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, AltScreenSearch, 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, SearchableSelectList, SelectItem, SelectList, SettingItem,
49    SettingsList, SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer,
50    StackChild, StackEntry, StatusIndicator, Text, ThemeManager, ThemePreset,
51    ToolExecutionComponent, ToolStatus, TuiAltScreen, UserMessageComponent, VStack, WorkingState, TUI,
52};
53use rpi_tui::{bold as tui_bold, theme as current_theme};
54
55#[allow(unused_imports)]
56use rpi_tui::BashStatus;
57
58use crate::args::Args;
59
60/// B5e: the markdown-transformer trait object the assistant-message render path
61/// applies to raw text BEFORE the [`Markdown`] renderer styles it. A plain
62/// `Fn(&str) -> String` (NO `rpi-extensions` types) so `rpi-tui` stays free of
63/// an `rpi-extensions` dep — `rpi-cli` (which already depends on
64/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot` and
65/// hands the trait object to `AssistantMessageComponent::set_markdown_transformer`.
66type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
67
68/// A synchronous rendezvous between the Node runtime-request thread and the
69/// blocking TUI key loop. Node's `ctx.ui.*` methods are promises, so the host
70/// request must remain pending while the user interacts with the native
71/// component. The key loop owns opening/closing components; this bridge only
72/// carries JSON results and cancellation state across the threads.
73#[derive(Clone, Default)]
74struct JsDialogBridge {
75    pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
76    active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
77    /// The one dialog currently installed in the TUI input slot. Other
78    /// requests may remain active while a command is waiting, but a cancel
79    /// notification must never close whichever dialog happens to be visible.
80    visible: Arc<Mutex<Option<String>>>,
81    cancelled_before_open: Arc<Mutex<HashSet<String>>>,
82    closed: Arc<Mutex<bool>>,
83}
84
85struct JsDialogPending {
86    request: JsDialogRequest,
87    result: std_mpsc::Sender<serde_json::Value>,
88}
89
90struct JsDialogActive {
91    result: std_mpsc::Sender<serde_json::Value>,
92    cancel_requested: bool,
93}
94
95#[derive(Clone, Debug)]
96struct JsDialogRequest {
97    id: String,
98    method: String,
99    title: String,
100    message: String,
101    options: Vec<String>,
102    placeholder: Option<String>,
103    prefill: Option<String>,
104}
105
106impl JsDialogRequest {
107    fn parse(args: &serde_json::Value) -> Result<Self, String> {
108        let id = args
109            .get("dialogId")
110            .and_then(serde_json::Value::as_str)
111            .filter(|value| !value.is_empty())
112            .ok_or("ui.dialog missing dialogId")?
113            .to_string();
114        let method = args
115            .get("method")
116            .and_then(serde_json::Value::as_str)
117            .ok_or("ui.dialog missing method")?
118            .to_string();
119        if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
120            return Err(format!("unsupported UI dialog method: {method}"));
121        }
122        let options = args
123            .get("options")
124            .and_then(serde_json::Value::as_array)
125            .map(|values| {
126                values
127                    .iter()
128                    .filter_map(serde_json::Value::as_str)
129                    .map(ToOwned::to_owned)
130                    .collect()
131            })
132            .unwrap_or_default();
133        Ok(Self {
134            id,
135            method,
136            title: args
137                .get("title")
138                .and_then(serde_json::Value::as_str)
139                .unwrap_or_default()
140                .to_string(),
141            message: args
142                .get("message")
143                .and_then(serde_json::Value::as_str)
144                .unwrap_or_default()
145                .to_string(),
146            options,
147            placeholder: args
148                .get("placeholder")
149                .and_then(serde_json::Value::as_str)
150                .map(ToOwned::to_owned),
151            prefill: args
152                .get("prefill")
153                .and_then(serde_json::Value::as_str)
154                .map(ToOwned::to_owned),
155        })
156    }
157}
158
159impl JsDialogBridge {
160    fn handle_runtime_request(
161        &self,
162        action: &str,
163        args: serde_json::Value,
164    ) -> Result<serde_json::Value, String> {
165        match action {
166            "ui.dialog" => self.wait_for_dialog(args),
167            "ui.dialog.cancel" => {
168                let id = args
169                    .get("dialogId")
170                    .and_then(serde_json::Value::as_str)
171                    .ok_or("ui.dialog.cancel missing dialogId")?;
172                self.cancel(id);
173                Ok(serde_json::json!(true))
174            }
175            _ => Err(format!("unsupported capability: {action}")),
176        }
177    }
178
179    fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
180        let request = JsDialogRequest::parse(&args)?;
181        let (sender, receiver) = std_mpsc::channel();
182        // Hold the closed flag while enqueueing. `cancel_all` takes this same
183        // lock before draining pending requests, so shutdown cannot observe
184        // an empty queue and then have this request arrive behind the drain.
185        let _closed = self
186            .closed
187            .lock()
188            .map_err(|_| "JS dialog bridge poisoned")?;
189        if *_closed {
190            return Ok(serde_json::json!({ "cancelled": true }));
191        }
192        let cancelled_before_open = self
193            .cancelled_before_open
194            .lock()
195            .map_err(|_| "JS dialog cancellation state poisoned")?
196            .remove(&request.id);
197        if cancelled_before_open {
198            return Ok(serde_json::json!({ "cancelled": true }));
199        }
200        // Do not hold the cancellation-state lock while taking `pending`:
201        // `take_pending` takes those locks in the opposite order.
202        self.pending
203            .lock()
204            .map_err(|_| "JS dialog pending state poisoned")?
205            .push_back(JsDialogPending {
206                request,
207                result: sender,
208            });
209        drop(_closed);
210        receiver
211            .recv()
212            .map_err(|_| "JS dialog closed before it received an answer".to_string())
213    }
214
215    /// Move one request to the active set. The caller invokes this only when
216    /// the TUI has no other modal occupying the editor slot.
217    fn take_pending(&self) -> Option<JsDialogRequest> {
218        loop {
219            let pending = self.pending.lock().ok()?.pop_front()?;
220            let mut active = self.active.lock().ok()?;
221            // Check cancellation while holding the active lock and insert the
222            // entry in the same critical section. `cancel()` checks `active`
223            // before recording a pre-open cancellation, so it will either see
224            // this entry or leave a marker that we consume here. Checking the
225            // marker before acquiring `active` had a small race where a cancel
226            // could land between the check and insertion and strand the dialog.
227            if self
228                .cancelled_before_open
229                .lock()
230                .ok()?
231                .remove(&pending.request.id)
232            {
233                drop(active);
234                let _ = pending
235                    .result
236                    .send(serde_json::json!({ "cancelled": true }));
237                continue;
238            }
239            active.insert(
240                pending.request.id.clone(),
241                JsDialogActive {
242                    result: pending.result,
243                    cancel_requested: false,
244                },
245            );
246            if let Ok(mut visible) = self.visible.lock() {
247                *visible = Some(pending.request.id.clone());
248            }
249            return Some(pending.request);
250        }
251    }
252
253    fn respond(&self, id: &str, result: serde_json::Value) {
254        if let Ok(mut active) = self.active.lock() {
255            if let Some(entry) = active.remove(id) {
256                let _ = entry.result.send(result);
257            }
258        }
259        if let Ok(mut visible) = self.visible.lock() {
260            if visible.as_deref() == Some(id) {
261                *visible = None;
262            }
263        }
264    }
265
266    fn cancel(&self, id: &str) {
267        if let Ok(mut pending) = self.pending.lock() {
268            if let Some(index) = pending.iter().position(|item| item.request.id == id) {
269                if let Some(item) = pending.remove(index) {
270                    let _ = item.result.send(serde_json::json!({ "cancelled": true }));
271                    return;
272                }
273            }
274        }
275        if let Ok(mut active) = self.active.lock() {
276            if let Some(entry) = active.get_mut(id) {
277                if !entry.cancel_requested {
278                    entry.cancel_requested = true;
279                    let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
280                }
281                return;
282            }
283        }
284        if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
285            cancelled.insert(id.to_string());
286        }
287    }
288
289    fn cancelled_active_ids(&self) -> Vec<String> {
290        self.active
291            .lock()
292            .map(|active| {
293                active
294                    .iter()
295                    .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
296                    .collect()
297            })
298            .unwrap_or_default()
299    }
300
301    fn is_visible(&self, id: &str) -> bool {
302        self.visible
303            .lock()
304            .map(|visible| visible.as_deref() == Some(id))
305            .unwrap_or(false)
306    }
307
308    fn finish(&self, id: &str) {
309        if let Ok(mut active) = self.active.lock() {
310            active.remove(id);
311        }
312        if let Ok(mut visible) = self.visible.lock() {
313            if visible.as_deref() == Some(id) {
314                *visible = None;
315            }
316        }
317    }
318
319    fn cancel_all(&self) {
320        // Keep the closed lock through the queue drains. `wait_for_dialog`
321        // holds it while enqueueing, making the shutdown check + enqueue an
322        // atomic operation with respect to this drain.
323        let Ok(mut closed) = self.closed.lock() else {
324            return;
325        };
326        *closed = true;
327        if let Ok(mut pending) = self.pending.lock() {
328            for item in pending.drain(..) {
329                let _ = item.result.send(serde_json::json!({ "cancelled": true }));
330            }
331        }
332        if let Ok(mut active) = self.active.lock() {
333            for (_, entry) in active.drain() {
334                let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
335            }
336        }
337        if let Ok(mut visible) = self.visible.lock() {
338            *visible = None;
339        }
340        drop(closed);
341    }
342
343    /// Cancel requests owned by one interrupted prompt preparation while
344    /// keeping the bridge available to a replacement Node host.
345    fn cancel_open_requests(&self) {
346        // Keep enqueueing closed until the old host and its preparation
347        // worker have stopped. Otherwise a late runtime request can land just
348        // after the drain and strand its handler thread.
349        let Ok(mut closed) = self.closed.lock() else {
350            return;
351        };
352        *closed = true;
353        if let Ok(mut pending) = self.pending.lock() {
354            for item in pending.drain(..) {
355                let _ = item.result.send(serde_json::json!({ "cancelled": true }));
356            }
357        }
358        if let Ok(mut active) = self.active.lock() {
359            for (_, entry) in active.drain() {
360                let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
361            }
362        }
363        if let Ok(mut visible) = self.visible.lock() {
364            *visible = None;
365        }
366        if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
367            cancelled.clear();
368        }
369        drop(closed);
370    }
371
372    fn reopen(&self) {
373        if let Ok(mut closed) = self.closed.lock() {
374            *closed = false;
375        }
376    }
377}
378
379/// B5e: build the `AssistantMessageComponent` markdown-transformer closure the
380/// render path applies to raw assistant text before styling. Wraps any plugin
381/// `register_markdown_transformer` handlers registered in `snapshot` (chained
382/// in registration order: each handler's output feeds the next). `None` when
383/// no markdown transformers are registered (the component defaults to the
384/// identity transform + this avoids a closure allocation on the hot render
385/// path).
386///
387/// The closure captures an `Arc<RegistrySnapshot>` clone so it outlives the
388/// borrow that built it (the snapshot's `active` flag guards dispatch in
389/// `emit_resources_discover`/event translation; a reloaded session's old
390/// snapshot flips false, so a stale closure no-ops rather than driving a
391/// half-swapped registry — the transformer falls back to the input unchanged
392/// on an inactive snapshot, matching the plugin's per-handler skip-on-error).
393///
394/// This is the cycle-free seam: `rpi-tui` takes a `Fn(&str) -> String` trait
395/// object (no `rpi-extensions` dep); `rpi-cli` (which already depends on
396/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot`. The
397/// calling pattern mirrors `plugin_stub_smoke.rs`'s direct `RenderFn` round-
398/// trip (input `{"markdown":…}` → `render_fn` → reclaim `out` via the plugin's
399/// `free_string` → parse `{"markdown":…}`).
400fn build_markdown_transformer(
401    snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
402) -> Option<MarkdownTransformer> {
403    let snapshot = snapshot?;
404    // Pre-check: if no markdown renderers are registered, return None so the
405    // component uses the identity path (no per-delta closure call). The
406    // renderers list is a per-call `renderers_of` clone; snapshotting it once
407    // here keeps the closure cheap on the hot path.
408    let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
409    if renderers.is_empty() {
410        return None;
411    }
412    Some(Arc::new(move |raw: &str| -> String {
413        transform_markdown_chain(&snapshot, &renderers, raw)
414    }))
415}
416
417/// Drive the markdown-transformer chain for one input string. Each registered
418/// handler receives the previous handler's output (or the raw input for the
419/// first), as a `{"markdown": <text>}` JSON envelope; its `RenderFn` returns
420/// `{"markdown": <transformed>}` (rc=0) or an error (rc!=0). On any failure —
421/// nonzero rc, a panic across the FFI (caught), a missing `markdown` field, or
422/// an inactive snapshot — the chain short-circuits to the current text
423/// unchanged (per-handler skip-on-error, mirroring pi's `runner.ts` fan-out).
424fn transform_markdown_chain(
425    snapshot: &rpi_extensions::RegistrySnapshot,
426    renderers: &[rpi_extensions::RegisteredRenderer],
427    raw: &str,
428) -> String {
429    // A stale snapshot (post-/reload) must not drive a swapped-out registry.
430    // The renderers were captured from this snapshot; if it has gone inactive,
431    // fall back to the raw input so the UI never renders stale-transformed text
432    // from a dead plugin.
433    if !snapshot.is_active() {
434        return raw.to_string();
435    }
436
437    let mut current = raw.to_string();
438    for renderer in renderers {
439        let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
440            Ok(s) => s,
441            Err(_) => return current, // serialize failure — keep current, stop chain
442        };
443        // SAFETY: `render_fn` is a plugin-provided `extern "C" fn` over a
444        // borrowed `StbStringRef` + an out-param. The plugin warrants
445        // `poll`/`render` are non-blocking + thread-safe (the same contract
446        // the tool adapter relies on). `user_data` is the plugin's opaque
447        // pointer, stable for the registry lifetime (the keepalive keeps the
448        // cdylib mapped). We reclaim `out` via the plugin's `free_string`
449        // exactly once. The whole call is `catch_unwind`-wrapped — a plugin
450        // panic must not unwind across the FFI boundary (same policy as the
451        // tool partial cb + the runtime_action trampoline).
452        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
453            let mut out = rpi_plugin_sdk::StbString::empty();
454            let rc = (renderer.render_fn)(
455                rpi_plugin_sdk::StbStringRef::from_str(&input),
456                &mut out as *mut rpi_plugin_sdk::StbString,
457                renderer.user_data,
458            );
459            let text = if rc == 0 {
460                let s = out.to_string_lossy();
461                Some(s)
462            } else {
463                None
464            };
465            // Reclaim the plugin-owned `out` regardless of rc (rc!=0 may still
466            // have written an error JSON the plugin allocated). `free_with` is
467            // idempotent on an empty `StbString`.
468            out.free_with(Some(renderer.plugin_free_string));
469            text
470        }));
471        let out_text = match outcome {
472            Ok(Some(s)) => s,
473            Ok(None) => return current, // rc != 0 — skip this handler, keep current
474            Err(_) => return current,   // panic — skip, keep current (do not abort: the
475                                         // render path is not the action trampoline; a panicking transformer
476                                         // degrades to identity rather than killing the process. Logged via
477                                         // the `tracing` crate's panic hook.)
478        };
479        // Parse `{"markdown": <text>}`; lenient — a missing/non-string field
480        // keeps the current text (skip this handler).
481        let next = serde_json::from_str::<serde_json::Value>(&out_text)
482            .ok()
483            .and_then(|v| {
484                v.get("markdown")
485                    .and_then(|m| m.as_str())
486                    .map(|s| s.to_string())
487            })
488            .unwrap_or(current);
489        current = next;
490    }
491    current
492}
493
494// ===========================================================================
495// Slash commands — trait + registry
496// ===========================================================================
497//
498// Each built-in slash command is one `impl SlashCommand`. The commands are
499// registered at startup into a [`CommandRegistry`] (one source of truth) that
500// serves both dispatch ("given this token, run the command") and autocomplete
501// ("list the visible commands"). This replaces the old two-list + sync-test
502// arrangement, where `handle_slash_command` and `v1_slash_commands()` had to be
503// kept in lock-step by hand.
504//
505// `execute` runs on the blocking key/compose thread (the editor `on_submit`
506// callback and the Ctrl+L hotkey both land there), so it MUST stay synchronous:
507//   - commands needing async (`set_model`/`set_thinking_level`/`set_active_tools`)
508//     `tokio::spawn` the work and return immediately;
509//   - commands needing the main async loop (`compact`/`copy`/`exit`/`clear`/
510//     `user-input`) signal it via `ctx.tx.send(TuiMessage::…)`;
511//   - everything else mutates the chat container + requests a render directly.
512
513/// The borrowed world a slash command runs against. All fields are `Arc` (or a
514/// cheap `String` snapshot), so one `CommandContext` clones freely into each
515/// command without per-capture ceremony — this struct is exactly the set of
516/// `*_for_cb` clones the old submit closure used to make individually.
517#[derive(Clone)]
518struct CommandContext {
519    chat: Arc<Container>,
520    tui: Arc<TuiAltScreen>,
521    tx: mpsc::UnboundedSender<TuiMessage>,
522    state: Arc<TuiState>,
523    editor: Arc<Editor>,
524    editor_container: Arc<Container>,
525    lane: Arc<dyn AgentLane>,
526    model_catalog: Arc<Vec<rpi_ai::Model>>,
527    /// Lane model id snapshot, read once via `lane.get_model().await` BEFORE the
528    /// blocking key loop starts. Selectors/key loop can't await, so they read
529    /// this owned string instead. Semantically unchanged from pre-refactor.
530    lane_model_id: String,
531    cwd: std::path::PathBuf,
532    /// Package resources resolved at startup. An empty set means Pi package
533    /// loading was not explicitly enabled and must remain disabled for all
534    /// interactive theme selectors.
535    package_resources: Arc<crate::packages::PackageResources>,
536    /// Harness resources snapshot (skills + prompt templates) for `/context`.
537    /// Captured once at TUI startup because the blocking submit thread can't
538    /// `.await get_resources()`.
539    resources: Arc<rpi_harness::types::AgentHarnessResources>,
540    /// B5d: the reload context `/reload` drives. `Arc<ReloadContext>` so the
541    /// blocking submit thread can cheaply clone it into the `ReloadCommand`
542    /// without an `.await` (the command can't drive reload directly — it signals
543    /// the main loop via `TuiMessage::ReloadExtensions`, which awaits the shared
544    /// `reload_extension_resources` routine on the async runtime).
545    reload_context: Arc<crate::session::ReloadContext>,
546    /// 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/// Current wall-clock time in milliseconds since the Unix epoch. Used for
683/// `bashExecution` message timestamps (agent-loop messages carry real times, so
684/// the transcript record must too).
685fn now_ms() -> i64 {
686    std::time::SystemTime::now()
687        .duration_since(std::time::UNIX_EPOCH)
688        .map(|d| d.as_millis() as i64)
689        .unwrap_or(0)
690}
691
692/// Parse a submitted line into a user shell command.
693///
694/// Mirrors native pi's `text.startsWith("!")` handling
695/// (`interactive-mode.ts:3225`): `!cmd` runs and is recorded in the session
696/// context, `!!cmd` is excluded from it. Returns `None` for a bare `!` / `!!`
697/// (or whitespace only), so the line falls through to the normal prompt path.
698fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
699    let rest = text.strip_prefix('!')?;
700    let (command, exclude_from_context) = match rest.strip_prefix('!') {
701        Some(command) => (command, true),
702        None => (rest, false),
703    };
704    let command = command.trim();
705    if command.is_empty() {
706        return None;
707    }
708    Some((command, exclude_from_context))
709}
710
711/// Run a `!command` submitted from the editor.
712///
713/// The command executes out-of-band — it never reaches the model. Output
714/// streams into a [`BashExecutionComponent`] in the transcript and the
715/// terminal result is handed to the main loop, which persists it as a
716/// `bashExecution` message (skipped from context for `!!`). Mirrors
717/// `handleBashCommand` (`interactive-mode.ts:6729`).
718fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
719    let comp = Arc::new(BashExecutionComponent::new_with_context(
720        command,
721        exclude_from_context,
722    ));
723    comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
724    ctx.chat.add_child(comp.clone());
725
726    // Register the cancellation slot before spawning so an Esc arriving on the
727    // key thread can never miss the run (native pi's `_bashAbortControllers`).
728    let cancel = ctx.state.begin_user_bash();
729    ctx.tui.request_render(false);
730
731    let env: Arc<dyn rpi_tools::ExecutionEnv> =
732        Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
733    let tui = ctx.tui.clone();
734    let tx = ctx.tx.clone();
735    let cwd = ctx.cwd.clone();
736    let command_owned = command.to_string();
737
738    tokio::spawn(async move {
739        let comp_chunk = comp.clone();
740        let tui_chunk = tui.clone();
741        let on_chunk: Box<
742            dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
743        > = Box::new(move |_chunk, get_progress| {
744            // The capture layer reports the complete tail captured so far, not
745            // a delta; `append_output` replaces its snapshot accordingly.
746            comp_chunk.append_output(&get_progress().output);
747            tui_chunk.request_render(false);
748        });
749        let options = rpi_tools::shell_output::ShellCaptureOptions {
750            cwd: Some(cwd),
751            cancel: Some(&cancel),
752            on_chunk: Some(on_chunk),
753            return_execution_errors: true,
754            ..Default::default()
755        };
756        let result =
757            rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
758                .await;
759
760        // `Ok` carries the real exit code/cancellation; `Err` is a transport
761        // level failure (spawn failure, invalid env) which we surface inline.
762        let (exit_code, cancelled, output, truncated, full_output_path) = match result {
763            Ok(capture) => (
764                capture.exit_code,
765                capture.cancelled,
766                capture.output.clone(),
767                capture.truncated,
768                capture
769                    .full_output_path
770                    .as_ref()
771                    .map(|path| path.to_string_lossy().into_owned()),
772            ),
773            Err(error) => (
774                None,
775                false,
776                format!("Bash command failed: {error}"),
777                false,
778                None,
779            ),
780        };
781        comp.append_output(&output);
782        comp.set_complete(
783            exit_code,
784            cancelled,
785            BashTruncation {
786                truncated,
787                full_output_path: full_output_path.clone(),
788            },
789        );
790        tui.render_now(false);
791
792        let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
793            command: command_owned,
794            output,
795            exit_code,
796            cancelled,
797            truncated,
798            full_output_path,
799            exclude_from_context,
800        }));
801    });
802}
803
804/// Encode a crossterm key into the raw key data consumed by the Node TUI
805/// compatibility layer. Plain keys retain the usual terminal sequences;
806/// modified functional keys use Kitty CSI-u so Shift/Alt/Ctrl combinations are
807/// not collapsed into their unmodified equivalent (notably Shift+Enter).
808fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
809    use crossterm::event::{KeyCode, KeyModifiers};
810
811    let modifiers = key.modifiers;
812    let ctrl = modifiers.contains(KeyModifiers::CONTROL);
813    let shift = modifiers.contains(KeyModifiers::SHIFT);
814    let alt = modifiers.contains(KeyModifiers::ALT);
815    let super_key = modifiers.contains(KeyModifiers::SUPER);
816
817    if modifiers == KeyModifiers::NONE {
818        return match key.code {
819            KeyCode::Char(ch) => ch.to_string(),
820            KeyCode::Enter => "\r".into(),
821            KeyCode::Esc => "\x1b".into(),
822            KeyCode::Backspace => "\x7f".into(),
823            KeyCode::Tab => "\t".into(),
824            // Crossterm represents Shift+Tab as `BackTab` on both Unix
825            // (`ESC[Z`) and Windows. Preserve the canonical terminal form
826            // so the Node keybinding matcher sees `shift+tab`.
827            KeyCode::BackTab => "\x1b[Z".into(),
828            KeyCode::Up => "\x1b[A".into(),
829            KeyCode::Down => "\x1b[B".into(),
830            KeyCode::Right => "\x1b[C".into(),
831            KeyCode::Left => "\x1b[D".into(),
832            KeyCode::Home => "\x1b[H".into(),
833            KeyCode::End => "\x1b[F".into(),
834            KeyCode::PageUp => "\x1b[5~".into(),
835            KeyCode::PageDown => "\x1b[6~".into(),
836            KeyCode::Delete => "\x1b[3~".into(),
837            KeyCode::Insert => "\x1b[2~".into(),
838            KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
839            _ => String::new(),
840        };
841    }
842
843    // Legacy control bytes are what the native `matchesKey` implementation
844    // expects for the common Ctrl+letter actions (Ctrl+C, Ctrl+O, Ctrl+J...).
845    if ctrl && !shift && !alt && !super_key {
846        if let KeyCode::Char(ch) = key.code {
847            if let Some(code) = control_code(ch) {
848                return char::from(code).to_string();
849            }
850        }
851    }
852
853    // Legacy Alt+character input is unambiguous when no other modifier is
854    // present and is accepted by pi's `matchesKey` fallback parser.
855    if alt && !ctrl && !shift && !super_key {
856        if let KeyCode::Char(ch) = key.code {
857            return format!("\x1b{ch}");
858        }
859    }
860
861    // Crossterm has already resolved the keyboard layout for character events
862    // (for example, Windows reports Shift+1 as `Char('!')`). Pass that actual
863    // character through unchanged so custom components receive text instead
864    // of a CSI-u escape sequence. Functional keys and combined modifiers use
865    // CSI-u below so their modifier identity remains available to keybindings.
866    if shift && !ctrl && !alt && !super_key {
867        if let KeyCode::Char(ch) = key.code {
868            return ch.to_string();
869        }
870    }
871
872    if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
873        return sequence;
874    }
875    let Some(codepoint) = key_codepoint(key.code, ctrl) else {
876        return String::new();
877    };
878    kitty_key_sequence(codepoint, modifiers)
879}
880
881fn control_code(ch: char) -> Option<u8> {
882    let ch = ch.to_ascii_lowercase();
883    Some(match ch {
884        '@' | ' ' => 0,
885        'a'..='z' => (ch as u8) & 0x1f,
886        '[' => 0x1b,
887        '\\' => 0x1c,
888        ']' => 0x1d,
889        '^' => 0x1e,
890        '_' | '-' => 0x1f,
891        _ => return None,
892    })
893}
894
895fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
896    use crossterm::event::KeyCode;
897    Some(match code {
898        KeyCode::Char(ch) => {
899            if ctrl {
900                ch.to_ascii_lowercase() as u32
901            } else {
902                ch as u32
903            }
904        }
905        KeyCode::Enter => 13,
906        KeyCode::Esc => 27,
907        KeyCode::Backspace => 127,
908        KeyCode::Tab => 9,
909        // Keep modified BackTab combinations representable through CSI-u;
910        // the unmodified/SHIFT form is handled as the legacy `ESC[Z` above.
911        KeyCode::BackTab => 9,
912        _ => return None,
913    })
914}
915
916fn modified_functional_sequence(
917    code: crossterm::event::KeyCode,
918    modifiers: crossterm::event::KeyModifiers,
919) -> Option<String> {
920    use crossterm::event::{KeyCode, KeyModifiers};
921    // `BackTab` is already a semantic Shift+Tab event. Crossterm normally
922    // includes SHIFT in its modifier bits, but preserving the legacy sequence
923    // for a synthetic event without that bit keeps the adapter portable.
924    if code == KeyCode::BackTab
925        && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
926    {
927        return Some("\x1b[Z".into());
928    }
929    let modifier = kitty_modifier(modifiers);
930    let sequence = match code {
931        KeyCode::Up => format!("\x1b[1;{modifier}A"),
932        KeyCode::Down => format!("\x1b[1;{modifier}B"),
933        KeyCode::Right => format!("\x1b[1;{modifier}C"),
934        KeyCode::Left => format!("\x1b[1;{modifier}D"),
935        KeyCode::Home => format!("\x1b[1;{modifier}H"),
936        KeyCode::End => format!("\x1b[1;{modifier}F"),
937        KeyCode::Insert => format!("\x1b[2;{modifier}~"),
938        KeyCode::Delete => format!("\x1b[3;{modifier}~"),
939        KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
940        KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
941        _ => return None,
942    };
943    Some(sequence)
944}
945
946fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
947    use crossterm::event::KeyModifiers;
948    let mut modifier = 1u8;
949    if modifiers.contains(KeyModifiers::SHIFT) {
950        modifier += 1;
951    }
952    if modifiers.contains(KeyModifiers::ALT) {
953        modifier += 2;
954    }
955    if modifiers.contains(KeyModifiers::CONTROL) {
956        modifier += 4;
957    }
958    if modifiers.contains(KeyModifiers::SUPER) {
959        modifier += 8;
960    }
961    modifier
962}
963
964fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
965    let modifier = kitty_modifier(modifiers);
966    format!("\x1b[{codepoint};{modifier}u")
967}
968
969/// A slash command registered by a native extension. The command metadata is
970/// captured for autocomplete, while the handler is looked up from the live
971/// session on every invocation so `/reload` takes effect without rebuilding
972/// the editor callback.
973struct ExtensionCommand {
974    name: String,
975    description: String,
976    session: crate::session::ExtensionSessionCell,
977}
978
979struct JsExtensionCommand {
980    name: String,
981    session: crate::js_extensions::JsExtensionSession,
982}
983
984impl SlashCommand for JsExtensionCommand {
985    fn name(&self) -> &str {
986        &self.name
987    }
988    fn description(&self) -> &'static str {
989        "JS extension command"
990    }
991    fn description_owned(&self) -> String {
992        "JS extension command".to_string()
993    }
994    fn execute(&self, ctx: &CommandContext, args: &str) {
995        // JS commands may own the terminal for their entire lifetime (for
996        // example pi-btw's fullscreen side thread). Running them inline here
997        // would block the crossterm key thread, so no input could reach the
998        // extension while it is waiting for `ui.custom()` to complete.
999        let session = self.session.clone();
1000        let command = self.name.trim_start_matches('/').to_string();
1001        let args = args.to_string();
1002        let ctx = ctx.clone();
1003        // The command runs off the key thread. Keep the startup snapshot so a
1004        // late editorText result cannot overwrite text typed while the command
1005        // was in flight.
1006        let initial_editor_text = ctx.editor.get_text();
1007        tokio::task::spawn_blocking(move || {
1008            match session.invoke_command_with_context(
1009                &command,
1010                &args,
1011                serde_json::json!({"editorText": initial_editor_text}),
1012            ) {
1013                Ok(value) => {
1014                    if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
1015                        if ctx.editor.get_text() == initial_editor_text
1016                            && editor_text != initial_editor_text
1017                        {
1018                            let cursor = editor_text.chars().count();
1019                            ctx.editor.set_text(editor_text);
1020                            ctx.editor.set_cursor(0, cursor);
1021                        }
1022                    }
1023                    if let Some(notifications) =
1024                        value.get("notifications").and_then(|v| v.as_array())
1025                    {
1026                        for notification in notifications {
1027                            let message = notification
1028                                .get("message")
1029                                .and_then(|v| v.as_str())
1030                                .unwrap_or_default();
1031                            if message.is_empty() {
1032                                continue;
1033                            }
1034                            match notification.get("level").and_then(|v| v.as_str()) {
1035                                Some("error") => add_error_message(&ctx.chat, message),
1036                                _ => add_note_message(&ctx.chat, message),
1037                            }
1038                        }
1039                    }
1040                    let result = value.get("result").unwrap_or(&value);
1041                    let text = result
1042                        .get("text")
1043                        .and_then(|item| item.as_str())
1044                        .map(str::to_string)
1045                        .or_else(|| result.as_str().map(str::to_string))
1046                        .filter(|text| !text.is_empty() && text != "null");
1047                    if let Some(text) = text {
1048                        add_note_message(&ctx.chat, &text);
1049                    }
1050                }
1051                Err(error) => {
1052                    add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
1053                }
1054            }
1055            ctx.tui.request_render(false);
1056        });
1057    }
1058}
1059
1060impl SlashCommand for ExtensionCommand {
1061    fn name(&self) -> &str {
1062        &self.name
1063    }
1064
1065    fn description(&self) -> &'static str {
1066        "extension command"
1067    }
1068
1069    fn description_owned(&self) -> String {
1070        self.description.clone()
1071    }
1072
1073    fn execute(&self, ctx: &CommandContext, args: &str) {
1074        let result = invoke_extension_command(&self.session, &self.name, args);
1075        handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
1076    }
1077}
1078
1079fn invoke_extension_command(
1080    session: &crate::session::ExtensionSessionCell,
1081    name: &str,
1082    args: &str,
1083) -> Option<serde_json::Value> {
1084    let command = session
1085        .lock()
1086        .ok()
1087        .and_then(|s| s.snapshot_arc())
1088        .and_then(|snap| {
1089            snap.commands()
1090                .iter()
1091                .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
1092                .cloned()
1093        })?;
1094    let input = serde_json::json!({ "args": args, "command": name });
1095    let input = serde_json::to_string(&input).ok()?;
1096    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1097        let mut out = rpi_plugin_sdk::StbString::empty();
1098        let rc = (command.handler)(
1099            rpi_plugin_sdk::StbStringRef::from_str(&input),
1100            &mut out as *mut rpi_plugin_sdk::StbString,
1101            command.user_data,
1102        );
1103        let text = if rc == 0 {
1104            Some(out.to_string_lossy())
1105        } else {
1106            None
1107        };
1108        rpi_extensions::host_free_string(out);
1109        text
1110    }))
1111    .ok()
1112    .flatten()?;
1113    serde_json::from_str(&outcome).ok()
1114}
1115
1116fn handle_extension_ui_result(
1117    result: Option<serde_json::Value>,
1118    ctx: &CommandContext,
1119    session: crate::session::ExtensionSessionCell,
1120    command_name: String,
1121) {
1122    let Some(value) = result else {
1123        add_error_message(&ctx.chat, "Extension command failed.");
1124        ctx.tui.request_render(false);
1125        return;
1126    };
1127    // A cancellation continuation may intentionally return JSON null. Native
1128    // pi resolves the pending promise with `undefined` and does not add a
1129    // visible "null" message to the transcript.
1130    if value.is_null() {
1131        ctx.tui.request_render(false);
1132        return;
1133    }
1134    match value.get("kind").and_then(|v| v.as_str()) {
1135        Some("message") | None => {
1136            let fallback = value.to_string();
1137            let text = value
1138                .get("text")
1139                .and_then(|v| v.as_str())
1140                .unwrap_or(&fallback)
1141                .to_string();
1142            if !text.is_empty() {
1143                add_note_message(&ctx.chat, &text);
1144            }
1145            ctx.tui.request_render(false);
1146        }
1147        Some("selector") => open_extension_selector(ctx, session, command_name, value),
1148        Some("editor") => open_extension_editor(ctx, session, command_name, value),
1149        // Native pi exposes `ctx.ui.input(title, placeholder)` separately
1150        // from the multiline editor. Render it as a focused single-line
1151        // dialog in the swapped input slot.
1152        Some("input") => open_extension_input(ctx, session, command_name, value),
1153        Some(other) => {
1154            add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1155            ctx.tui.request_render(false);
1156        }
1157    }
1158}
1159
1160/// Render the title used by the native pi extension dialogs.  Keeping it in
1161/// the swapped editor container makes the question stay visible while the
1162/// extension waits for the answer, instead of adding a transient chat note.
1163fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1164    let colors = current_theme().colors;
1165    let text = if bold {
1166        tui_bold(title)
1167    } else {
1168        title.to_string()
1169    };
1170    Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1171}
1172
1173fn extension_dialog_hint(label: &str) -> Arc<Text> {
1174    Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1175}
1176
1177/// Markdown-aware version of `extension_dialog_title` for extension dialogs
1178/// that may contain markdown content (tables, lists, bold, etc).
1179fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1180    let text = if bold {
1181        format!("**{}**", title)
1182    } else {
1183        title.to_string()
1184    };
1185    Arc::new(Markdown::new(text, 1, 0))
1186}
1187
1188/// Markdown-aware version of `extension_dialog_hint` for extension dialogs
1189/// that may contain markdown content.
1190fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1191    Arc::new(Markdown::new(label, 1, 0))
1192}
1193
1194/// Take and run the cancellation callback for the active extension dialog.
1195/// Taking it before invoking the callback breaks the temporary Arc cycle: the
1196/// callback owns the command context so it can process a follow-up result.
1197fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1198    let callback = state.active_extension_cancel.lock().unwrap().take();
1199    if let Some(callback) = callback {
1200        callback();
1201        true
1202    } else {
1203        false
1204    }
1205}
1206
1207fn open_extension_input(
1208    ctx: &CommandContext,
1209    session: crate::session::ExtensionSessionCell,
1210    command_name: String,
1211    value: serde_json::Value,
1212) {
1213    let title = value
1214        .get("title")
1215        .and_then(|v| v.as_str())
1216        .filter(|title| !title.is_empty())
1217        .unwrap_or("Input");
1218    let input = value
1219        .get("placeholder")
1220        .and_then(|v| v.as_str())
1221        .map(Input::with_placeholder)
1222        .unwrap_or_default();
1223    let input = Arc::new(input);
1224    if let Some(initial) = value
1225        .get("initialText")
1226        .or_else(|| value.get("text"))
1227        .and_then(|v| v.as_str())
1228    {
1229        input.set_value(initial);
1230    }
1231    input.set_focused(true);
1232
1233    let frame = Arc::new(Container::new());
1234    frame.add_child(Arc::new(DynamicBorder::new()));
1235    frame.add_child(Arc::new(Spacer::new(1)));
1236    frame.add_child(extension_dialog_title(title, false));
1237    frame.add_child(Arc::new(Spacer::new(1)));
1238    frame.add_child(input.clone());
1239    frame.add_child(Arc::new(Spacer::new(1)));
1240    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1241    frame.add_child(Arc::new(Spacer::new(1)));
1242    frame.add_child(Arc::new(DynamicBorder::new()));
1243
1244    *ctx.state.active_extension_editor.lock().unwrap() = None;
1245    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1246    ctx.editor_container.clear();
1247    ctx.editor_container.add_child(frame);
1248
1249    let state = ctx.state.clone();
1250    let ec = ctx.editor_container.clone();
1251    let original = ctx.editor.clone();
1252    let tui = ctx.tui.clone();
1253    let session_submit = session.clone();
1254    let command_submit = command_name.clone();
1255    let ctx_submit = ctx.clone();
1256    input.on_submit(Arc::new(move |text| {
1257        let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1258        let result = invoke_extension_command(
1259            &session_submit,
1260            &command_submit,
1261            &serde_json::to_string(&args).unwrap_or_default(),
1262        );
1263        close_extension_editor(&state, &ec, &original, &tui);
1264        handle_extension_ui_result(
1265            result,
1266            &ctx_submit,
1267            session_submit.clone(),
1268            command_submit.clone(),
1269        );
1270    }));
1271
1272    let state_cancel = ctx.state.clone();
1273    let ec_cancel = ctx.editor_container.clone();
1274    let original_cancel = ctx.editor.clone();
1275    let tui_cancel = ctx.tui.clone();
1276    let session_cancel = session.clone();
1277    let command_cancel = command_name.clone();
1278    let ctx_cancel = ctx.clone();
1279    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1280        let args = serde_json::json!({ "action": "cancel" });
1281        let result = invoke_extension_command(
1282            &session_cancel,
1283            &command_cancel,
1284            &serde_json::to_string(&args).unwrap_or_default(),
1285        );
1286        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1287        handle_extension_ui_result(
1288            result,
1289            &ctx_cancel,
1290            session_cancel.clone(),
1291            command_cancel.clone(),
1292        );
1293    }));
1294
1295    ctx.tui.set_focus(Some(input));
1296    ctx.tui.request_render(false);
1297}
1298
1299fn open_extension_selector(
1300    ctx: &CommandContext,
1301    session: crate::session::ExtensionSessionCell,
1302    command_name: String,
1303    value: serde_json::Value,
1304) {
1305    let items = value
1306        .get("items")
1307        .and_then(|v| v.as_array())
1308        .map(|items| {
1309            items
1310                .iter()
1311                .filter_map(|item| {
1312                    // Native pi's selector accepts `string[]`; the Rust ABI
1313                    // also permits `{value,label,description}` objects.
1314                    let value = if let Some(value) = item.as_str() {
1315                        value
1316                    } else {
1317                        item.get("value")?.as_str()?
1318                    };
1319                    let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1320                    let mut out = SelectItem::new(value, label);
1321                    if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1322                        out = out.with_description(desc);
1323                    }
1324                    Some(out)
1325                })
1326                .collect::<Vec<_>>()
1327        })
1328        .unwrap_or_default();
1329    if items.is_empty() {
1330        add_error_message(&ctx.chat, "Extension selector has no items.");
1331        ctx.tui.request_render(false);
1332        return;
1333    }
1334    let list = Arc::new(SelectList::new(items, 10));
1335    let title = value
1336        .get("title")
1337        .and_then(|v| v.as_str())
1338        .filter(|title| !title.is_empty())
1339        .unwrap_or("Select");
1340    let frame = Arc::new(Container::new());
1341    frame.add_child(Arc::new(DynamicBorder::new()));
1342    frame.add_child(Arc::new(Spacer::new(1)));
1343    frame.add_child(extension_dialog_title(title, true));
1344    frame.add_child(Arc::new(Spacer::new(1)));
1345    frame.add_child(list.clone());
1346    frame.add_child(Arc::new(Spacer::new(1)));
1347    frame.add_child(extension_dialog_hint(
1348        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1349    ));
1350    frame.add_child(Arc::new(Spacer::new(1)));
1351    frame.add_child(Arc::new(DynamicBorder::new()));
1352
1353    let state = ctx.state.clone();
1354    let ec = ctx.editor_container.clone();
1355    let editor = ctx.editor.clone();
1356    let tui = ctx.tui.clone();
1357    let session_select = session.clone();
1358    let command_select = command_name.clone();
1359    let ctx_select = ctx.clone();
1360    list.on_select(Arc::new(move |item| {
1361        let args = serde_json::json!({ "action": "select", "value": item.value });
1362        let result = invoke_extension_command(
1363            &session_select,
1364            &command_select,
1365            &serde_json::to_string(&args).unwrap_or_default(),
1366        );
1367        close_selector(&state, &ec, &editor, &tui);
1368        handle_extension_ui_result(
1369            result,
1370            &ctx_select,
1371            session_select.clone(),
1372            command_select.clone(),
1373        );
1374    }));
1375    let state_cancel = ctx.state.clone();
1376    let ec_cancel = ctx.editor_container.clone();
1377    let editor_cancel = ctx.editor.clone();
1378    let tui_cancel = ctx.tui.clone();
1379    list.on_cancel(Arc::new(move || {
1380        if !run_extension_cancel(&state_cancel) {
1381            close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1382        }
1383    }));
1384    let state_cancel = ctx.state.clone();
1385    let ec_cancel = ctx.editor_container.clone();
1386    let editor_cancel = ctx.editor.clone();
1387    let tui_cancel = ctx.tui.clone();
1388    let session_cancel = session.clone();
1389    let command_cancel = command_name.clone();
1390    let ctx_cancel = ctx.clone();
1391    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1392        let args = serde_json::json!({ "action": "cancel" });
1393        let result = invoke_extension_command(
1394            &session_cancel,
1395            &command_cancel,
1396            &serde_json::to_string(&args).unwrap_or_default(),
1397        );
1398        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1399        handle_extension_ui_result(
1400            result,
1401            &ctx_cancel,
1402            session_cancel.clone(),
1403            command_cancel.clone(),
1404        );
1405    }));
1406    open_selector_with_view(
1407        &ctx.state,
1408        &ctx.editor_container,
1409        &ctx.editor,
1410        &ctx.tui,
1411        SelectorView::List(list),
1412        frame,
1413        SelectorKind::Extension,
1414    );
1415}
1416
1417fn open_extension_editor(
1418    ctx: &CommandContext,
1419    session: crate::session::ExtensionSessionCell,
1420    command_name: String,
1421    value: serde_json::Value,
1422) {
1423    let initial = value
1424        .get("initialText")
1425        .or_else(|| value.get("text"))
1426        .and_then(|v| v.as_str())
1427        .unwrap_or_default()
1428        .to_string();
1429    let title = value
1430        .get("title")
1431        .and_then(|v| v.as_str())
1432        .filter(|title| !title.is_empty())
1433        .unwrap_or("Editor");
1434    let editor = Arc::new(Editor::new(
1435        EditorOptions {
1436            padding_x: 1,
1437            autocomplete_max_visible: 0,
1438            placeholder: value
1439                .get("placeholder")
1440                .and_then(|v| v.as_str())
1441                .map(str::to_string),
1442            initial_text: Some(initial),
1443        },
1444        EditorStyle {
1445            prompt: "> ".to_string(),
1446            placeholder: String::new(),
1447        },
1448        Arc::new(rpi_tui::Keybindings::new()),
1449    ));
1450    editor.set_focused(true);
1451    let frame = Arc::new(Container::new());
1452    frame.add_child(Arc::new(DynamicBorder::new()));
1453    frame.add_child(Arc::new(Spacer::new(1)));
1454    frame.add_child(extension_dialog_title(title, false));
1455    frame.add_child(Arc::new(Spacer::new(1)));
1456    frame.add_child(editor.clone());
1457    frame.add_child(Arc::new(Spacer::new(1)));
1458    frame.add_child(extension_dialog_hint(
1459        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1460    ));
1461    frame.add_child(Arc::new(Spacer::new(1)));
1462    frame.add_child(Arc::new(DynamicBorder::new()));
1463
1464    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1465    *ctx.state.active_extension_input.lock().unwrap() = None;
1466    ctx.editor_container.clear();
1467    ctx.editor_container.add_child(frame);
1468
1469    let state = ctx.state.clone();
1470    let ec = ctx.editor_container.clone();
1471    let original = ctx.editor.clone();
1472    let tui = ctx.tui.clone();
1473    let session_submit = session.clone();
1474    let command_submit = command_name.clone();
1475    let ctx_submit = ctx.clone();
1476    editor.on_submit(Arc::new(move |text| {
1477        let args = serde_json::json!({ "action": "edit", "text": text });
1478        let result = invoke_extension_command(
1479            &session_submit,
1480            &command_submit,
1481            &serde_json::to_string(&args).unwrap_or_default(),
1482        );
1483        close_extension_editor(&state, &ec, &original, &tui);
1484        handle_extension_ui_result(
1485            result,
1486            &ctx_submit,
1487            session_submit.clone(),
1488            command_submit.clone(),
1489        );
1490    }));
1491
1492    let state_cancel = ctx.state.clone();
1493    let ec_cancel = ctx.editor_container.clone();
1494    let original_cancel = ctx.editor.clone();
1495    let tui_cancel = ctx.tui.clone();
1496    let session_cancel = session.clone();
1497    let command_cancel = command_name.clone();
1498    let ctx_cancel = ctx.clone();
1499    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1500        let args = serde_json::json!({ "action": "cancel" });
1501        let result = invoke_extension_command(
1502            &session_cancel,
1503            &command_cancel,
1504            &serde_json::to_string(&args).unwrap_or_default(),
1505        );
1506        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1507        handle_extension_ui_result(
1508            result,
1509            &ctx_cancel,
1510            session_cancel.clone(),
1511            command_cancel.clone(),
1512        );
1513    }));
1514
1515    ctx.tui.set_focus(Some(editor));
1516    ctx.tui.request_render(false);
1517}
1518
1519fn close_extension_editor(
1520    state: &Arc<TuiState>,
1521    editor_container: &Arc<Container>,
1522    editor: &Arc<Editor>,
1523    tui: &Arc<TuiAltScreen>,
1524) {
1525    editor_container.clear();
1526    editor_container.add_child(editor.clone());
1527    state.autocomplete_container.clear();
1528    *state.active_extension_editor.lock().unwrap() = None;
1529    *state.active_extension_input.lock().unwrap() = None;
1530    *state.active_extension_cancel.lock().unwrap() = None;
1531    editor.set_focused(true);
1532    tui.set_focus(Some(editor.clone()));
1533    tui.request_render(false);
1534}
1535
1536/// Open one Node `ctx.ui.*` request in the native editor slot. The Node host
1537/// waits on the runtime response while these callbacks resolve the bridge on
1538/// Enter, selection, or cancellation.
1539fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1540    match request.method.as_str() {
1541        "select" => open_js_selector(ctx, bridge, request, false),
1542        "confirm" => open_js_selector(ctx, bridge, request, true),
1543        "input" => open_js_input(ctx, bridge, request),
1544        "editor" => open_js_editor(ctx, bridge, request),
1545        _ => {
1546            bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1547        }
1548    }
1549}
1550
1551fn open_js_selector(
1552    ctx: &CommandContext,
1553    bridge: Arc<JsDialogBridge>,
1554    request: JsDialogRequest,
1555    confirm: bool,
1556) {
1557    let values = if confirm {
1558        vec!["Yes".to_string(), "No".to_string()]
1559    } else {
1560        request.options.clone()
1561    };
1562    if values.is_empty() {
1563        bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1564        return;
1565    }
1566    let items = values
1567        .iter()
1568        .map(|value| SelectItem::new(value, value))
1569        .collect::<Vec<_>>();
1570    let list = Arc::new(SelectList::new(items, 10));
1571    let frame = Arc::new(Container::new());
1572    frame.add_child(Arc::new(DynamicBorder::new()));
1573    frame.add_child(Arc::new(Spacer::new(1)));
1574    frame.add_child(extension_dialog_title(
1575        if request.title.is_empty() {
1576            if confirm {
1577                "Confirm"
1578            } else {
1579                "Select"
1580            }
1581        } else {
1582            request.title.as_str()
1583        },
1584        true,
1585    ));
1586    if !request.message.is_empty() {
1587        frame.add_child(Arc::new(Spacer::new(1)));
1588        frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1589    }
1590    frame.add_child(Arc::new(Spacer::new(1)));
1591    frame.add_child(list.clone());
1592    frame.add_child(Arc::new(Spacer::new(1)));
1593    frame.add_child(extension_dialog_hint(
1594        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1595    ));
1596    frame.add_child(Arc::new(Spacer::new(1)));
1597    frame.add_child(Arc::new(DynamicBorder::new()));
1598
1599    let id = request.id.clone();
1600    let bridge_select = bridge.clone();
1601    let state_select = ctx.state.clone();
1602    let ec_select = ctx.editor_container.clone();
1603    let editor_select = ctx.editor.clone();
1604    let tui_select = ctx.tui.clone();
1605    list.on_select(Arc::new(move |item| {
1606        let result = if confirm {
1607            serde_json::json!({ "confirmed": item.value == "Yes" })
1608        } else {
1609            serde_json::json!({ "value": item.value })
1610        };
1611        bridge_select.respond(&id, result);
1612        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1613    }));
1614
1615    let id_cancel = request.id.clone();
1616    let bridge_cancel = bridge.clone();
1617    let state_cancel = ctx.state.clone();
1618    let ec_cancel = ctx.editor_container.clone();
1619    let editor_cancel = ctx.editor.clone();
1620    let tui_cancel = ctx.tui.clone();
1621    list.on_cancel(Arc::new(move || {
1622        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1623        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1624    }));
1625
1626    let id_abort = request.id.clone();
1627    let bridge_abort = bridge.clone();
1628    let state_abort = ctx.state.clone();
1629    let ec_abort = ctx.editor_container.clone();
1630    let editor_abort = ctx.editor.clone();
1631    let tui_abort = ctx.tui.clone();
1632    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1633        bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1634        close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1635    }));
1636
1637    open_selector_with_view(
1638        &ctx.state,
1639        &ctx.editor_container,
1640        &ctx.editor,
1641        &ctx.tui,
1642        SelectorView::List(list),
1643        frame,
1644        SelectorKind::Extension,
1645    );
1646}
1647
1648fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1649    let input = request
1650        .placeholder
1651        .as_deref()
1652        .map(Input::with_placeholder)
1653        .unwrap_or_default();
1654    let input = Arc::new(input);
1655    input.set_focused(true);
1656
1657    let frame = Arc::new(Container::new());
1658    frame.add_child(Arc::new(DynamicBorder::new()));
1659    frame.add_child(Arc::new(Spacer::new(1)));
1660    frame.add_child(extension_dialog_title(
1661        if request.title.is_empty() {
1662            "Input"
1663        } else {
1664            &request.title
1665        },
1666        false,
1667    ));
1668    frame.add_child(Arc::new(Spacer::new(1)));
1669    frame.add_child(input.clone());
1670    frame.add_child(Arc::new(Spacer::new(1)));
1671    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1672    frame.add_child(Arc::new(Spacer::new(1)));
1673    frame.add_child(Arc::new(DynamicBorder::new()));
1674
1675    *ctx.state.active_extension_editor.lock().unwrap() = None;
1676    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1677    ctx.editor_container.clear();
1678    ctx.editor_container.add_child(frame);
1679
1680    let id = request.id.clone();
1681    let bridge_submit = bridge.clone();
1682    let state_submit = ctx.state.clone();
1683    let ec_submit = ctx.editor_container.clone();
1684    let editor_submit = ctx.editor.clone();
1685    let tui_submit = ctx.tui.clone();
1686    input.on_submit(Arc::new(move |value| {
1687        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1688        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1689    }));
1690
1691    let id_cancel = request.id.clone();
1692    let bridge_cancel = bridge.clone();
1693    let state_cancel = ctx.state.clone();
1694    let ec_cancel = ctx.editor_container.clone();
1695    let editor_cancel = ctx.editor.clone();
1696    let tui_cancel = ctx.tui.clone();
1697    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1698        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1699        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1700    }));
1701    ctx.tui.set_focus(Some(input));
1702    ctx.tui.request_render(false);
1703}
1704
1705fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1706    let editor = Arc::new(Editor::new(
1707        EditorOptions {
1708            padding_x: 1,
1709            autocomplete_max_visible: 0,
1710            initial_text: request.prefill.clone(),
1711            ..Default::default()
1712        },
1713        EditorStyle {
1714            prompt: "> ".to_string(),
1715            placeholder: String::new(),
1716        },
1717        Arc::new(rpi_tui::Keybindings::new()),
1718    ));
1719    editor.set_focused(true);
1720
1721    let frame = Arc::new(Container::new());
1722    frame.add_child(Arc::new(DynamicBorder::new()));
1723    frame.add_child(Arc::new(Spacer::new(1)));
1724    frame.add_child(extension_dialog_title(
1725        if request.title.is_empty() {
1726            "Editor"
1727        } else {
1728            &request.title
1729        },
1730        false,
1731    ));
1732    frame.add_child(Arc::new(Spacer::new(1)));
1733    frame.add_child(editor.clone());
1734    frame.add_child(Arc::new(Spacer::new(1)));
1735    frame.add_child(extension_dialog_hint(
1736        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1737    ));
1738    frame.add_child(Arc::new(Spacer::new(1)));
1739    frame.add_child(Arc::new(DynamicBorder::new()));
1740
1741    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1742    *ctx.state.active_extension_input.lock().unwrap() = None;
1743    ctx.editor_container.clear();
1744    ctx.editor_container.add_child(frame);
1745
1746    let id = request.id.clone();
1747    let bridge_submit = bridge.clone();
1748    let state_submit = ctx.state.clone();
1749    let ec_submit = ctx.editor_container.clone();
1750    let editor_submit = ctx.editor.clone();
1751    let tui_submit = ctx.tui.clone();
1752    editor.on_submit(Arc::new(move |value| {
1753        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1754        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1755    }));
1756
1757    let id_cancel = request.id.clone();
1758    let bridge_cancel = bridge.clone();
1759    let state_cancel = ctx.state.clone();
1760    let ec_cancel = ctx.editor_container.clone();
1761    let editor_cancel = ctx.editor.clone();
1762    let tui_cancel = ctx.tui.clone();
1763    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1764        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1765        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1766    }));
1767    ctx.tui.set_focus(Some(editor));
1768    ctx.tui.request_render(false);
1769}
1770
1771fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1772    for id in bridge.cancelled_active_ids() {
1773        // Several commands can ask for a dialog concurrently. Only the id
1774        // currently occupying the TUI slot may close the visible component;
1775        // an older cancellation must leave a newer ask dialog untouched.
1776        if !bridge.is_visible(&id) {
1777            bridge.finish(&id);
1778            continue;
1779        }
1780        if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1781            selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1782        } else if ctx.state.extension_dialog_open() {
1783            if !run_extension_cancel(&ctx.state) {
1784                close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1785            }
1786        }
1787        // `respond` normally removes the active entry from the callback. The
1788        // fallback path above can run before a callback was installed, so
1789        // always discard the id after routing the cancellation.
1790        bridge.finish(&id);
1791    }
1792}
1793
1794// ===========================================================================
1795// ask_user — plugin UI-dialog bridge (runtime action 17)
1796// ===========================================================================
1797
1798/// Sentinel option value that switches a freeform-capable selector into the
1799/// single-line input slot (native `allowFreeform` affordance).
1800const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1801
1802/// One in-flight `ask_user` request being shown in the TUI. Unlike
1803/// [`JsDialogBridge`], the plugin side is a synchronous `poll` loop that reads
1804/// answers back through the shared [`rpi_extensions::UiDialogMailbox`], so this
1805/// bridge only tracks which request id currently occupies the input slot.
1806#[derive(Clone)]
1807struct AskUserBridge {
1808    mailbox: rpi_extensions::UiDialogMailbox,
1809    visible: Arc<Mutex<Option<String>>>,
1810}
1811
1812impl AskUserBridge {
1813    fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1814        Self {
1815            mailbox,
1816            visible: Arc::new(Mutex::new(None)),
1817        }
1818    }
1819
1820    /// Mark the interactive consumer as available. Idempotent.
1821    fn attach(&self) {
1822        self.mailbox.attach();
1823    }
1824
1825    /// Take the next pending request and mark it visible. Only the single key
1826    /// loop calls this while no other dialog is open, so the visible-id
1827    /// bookkeeping is race-free without a separate lock.
1828    fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1829        let mut visible = self.visible.lock().ok()?;
1830        if visible.is_some() {
1831            return None;
1832        }
1833        let request = self.mailbox.take_pending()?;
1834        *visible = Some(request.request_id.clone());
1835        Some(request)
1836    }
1837
1838    fn clear_visible(&self, id: &str) {
1839        if let Ok(mut visible) = self.visible.lock() {
1840            if visible.as_deref() == Some(id) {
1841                *visible = None;
1842            }
1843        }
1844    }
1845
1846    /// Record an answer for `id` and release the input slot.
1847    fn respond(&self, id: &str, answer: serde_json::Value) {
1848        let _ = self.mailbox.respond(id, answer);
1849        self.clear_visible(id);
1850    }
1851
1852    /// Cancel `id` and release the input slot.
1853    fn cancel(&self, id: &str) {
1854        let _ = self.mailbox.cancel(id);
1855        self.clear_visible(id);
1856    }
1857
1858    /// Cancel every outstanding request (run abort). Pending prompts from the
1859    /// aborted turn must not surface in a later turn.
1860    fn cancel_all(&self) {
1861        self.mailbox.cancel_all();
1862        if let Ok(mut visible) = self.visible.lock() {
1863            *visible = None;
1864        }
1865    }
1866
1867    /// Detach + cancel everything (TUI shutdown) so a parked plugin `poll`
1868    /// observes a terminal state instead of blocking on a dead session.
1869    fn shutdown(&self) {
1870        self.mailbox.detach();
1871        if let Ok(mut visible) = self.visible.lock() {
1872            *visible = None;
1873        }
1874    }
1875}
1876
1877/// Parsed presentation data for one `ask_user` question. Supports both the
1878/// flat native question contract and the earlier `questions[0]` alias, plus a
1879/// `confirm` summary.
1880#[derive(Clone)]
1881struct AskUserPrompt {
1882    question_id: String,
1883    question: String,
1884    header: Option<String>,
1885    context: Option<String>,
1886    options: Vec<(String, Option<String>)>,
1887    allow_multiple: bool,
1888    allow_freeform: bool,
1889    suggest: Option<String>,
1890    kind: String,
1891}
1892
1893fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1894    keys.iter().find_map(|key| {
1895        value
1896            .get(*key)
1897            .and_then(serde_json::Value::as_str)
1898            .map(str::trim)
1899            .filter(|text| !text.is_empty())
1900            .map(str::to_string)
1901    })
1902}
1903
1904fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1905    keys.iter()
1906        .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1907}
1908
1909fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1910    let mut options = Vec::new();
1911    let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1912        return options;
1913    };
1914    for item in items {
1915        if let Some(title) = item.as_str() {
1916            let title = title.trim();
1917            if !title.is_empty() {
1918                options.push((title.to_string(), None));
1919            }
1920            continue;
1921        }
1922        let Some(object) = item.as_object() else {
1923            continue;
1924        };
1925        let title = ["title", "label", "text", "value", "name"]
1926            .iter()
1927            .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1928            .map(str::trim)
1929            .filter(|title| !title.is_empty());
1930        let Some(title) = title else { continue };
1931        let description = object
1932            .get("description")
1933            .and_then(serde_json::Value::as_str)
1934            .map(str::trim)
1935            .filter(|text| !text.is_empty())
1936            .map(str::to_string);
1937        options.push((title.to_string(), description));
1938    }
1939    options
1940}
1941
1942fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1943    let ui = &request.ui;
1944    let kind = ui
1945        .get("kind")
1946        .and_then(serde_json::Value::as_str)
1947        .unwrap_or("selector")
1948        .to_string();
1949    // A request may carry a single flat question or a `questions[]` array. The
1950    // plugin emits one question per request, but accepting `questions[0]` keeps
1951    // the host compatible with hosts/plugins that batch.
1952    let nested = ui
1953        .get("questions")
1954        .and_then(serde_json::Value::as_array)
1955        .and_then(|items| items.first());
1956    let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1957
1958    let question = ask_user_str(source, &["question", "summary", "title"])
1959        .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1960        .unwrap_or_default();
1961    let question_id = ask_user_str(source, &["id", "questionId"])
1962        .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1963        .unwrap_or_else(|| request.request_id.clone());
1964    let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1965    let context = ask_user_str(source, &["context", "message"])
1966        .or_else(|| ask_user_str(ui, &["context", "message"]));
1967    let mut options = ask_user_options(source);
1968    if options.is_empty() {
1969        options = ask_user_options(ui);
1970    }
1971    if kind == "confirm" && options.is_empty() {
1972        options = vec![("Yes".to_string(), None), ("No".to_string(), None)];
1973    }
1974    let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1975        .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1976        .unwrap_or(false);
1977    // Freeform defaults on when there is nothing to pick from, matching the
1978    // "no options ⇒ free input" contract.
1979    let allow_freeform =
1980        ask_user_bool(source, &["allowFreeform", "allow_freeform", "allow_custom"])
1981            .or_else(|| ask_user_bool(ui, &["allowFreeform", "allow_freeform", "allow_custom"]))
1982            .unwrap_or(options.is_empty());
1983    let suggest = ask_user_str(source, &["suggest", "placeholder"])
1984        .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1985
1986    AskUserPrompt {
1987        question_id,
1988        question,
1989        header,
1990        context,
1991        options,
1992        allow_multiple,
1993        allow_freeform,
1994        suggest,
1995        kind,
1996    }
1997}
1998
1999/// Build the shared header frame for an ask-user prompt.
2000/// Uses markdown-aware rendering for question and context content.
2001fn ask_user_frame(prompt: &AskUserPrompt, body: Arc<dyn Component>, hint: &str) -> Arc<Container> {
2002    let frame = Arc::new(Container::new());
2003    frame.add_child(Arc::new(DynamicBorder::new()));
2004    frame.add_child(Arc::new(Spacer::new(1)));
2005    if let Some(header) = prompt.header.as_deref() {
2006        frame.add_child(extension_dialog_title_md(header, true));
2007    }
2008    if !prompt.question.trim().is_empty() {
2009        frame.add_child(extension_dialog_title_md(
2010            &prompt.question,
2011            prompt.header.is_none(),
2012        ));
2013    }
2014    if let Some(context) = prompt.context.as_deref() {
2015        frame.add_child(extension_dialog_hint_md(context));
2016    }
2017    frame.add_child(Arc::new(Spacer::new(1)));
2018    frame.add_child(body);
2019    frame.add_child(Arc::new(Spacer::new(1)));
2020    frame.add_child(extension_dialog_hint(hint));
2021    frame.add_child(Arc::new(Spacer::new(1)));
2022    frame.add_child(Arc::new(DynamicBorder::new()));
2023    frame
2024}
2025
2026/// Open one `ask_user` request in the TUI input slot.
2027fn open_ask_user_dialog(
2028    ctx: &CommandContext,
2029    bridge: AskUserBridge,
2030    request: rpi_extensions::UiDialogRequest,
2031) {
2032    let prompt = parse_ask_user_prompt(&request);
2033    if prompt.kind == "input" || prompt.options.is_empty() {
2034        open_ask_user_input(ctx, bridge, request, prompt);
2035    } else {
2036        open_ask_user_selector(ctx, bridge, request, prompt);
2037    }
2038}
2039
2040fn open_ask_user_selector(
2041    ctx: &CommandContext,
2042    bridge: AskUserBridge,
2043    request: rpi_extensions::UiDialogRequest,
2044    prompt: AskUserPrompt,
2045) {
2046    let multi = prompt.allow_multiple && prompt.kind != "confirm";
2047    let mut items: Vec<SelectItem> = Vec::new();
2048    for (title, description) in &prompt.options {
2049        let item = SelectItem::new(title, title);
2050        let item = match description.as_deref() {
2051            Some(description) => item.with_description(description),
2052            None => item,
2053        };
2054        items.push(item);
2055    }
2056    // A freeform-capable single selector gets an extra sentinel row that opens
2057    // the input slot for a typed answer.
2058    if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
2059        items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
2060    }
2061    let list = if multi {
2062        Arc::new(SelectList::new_multi(items, 10))
2063    } else {
2064        Arc::new(SelectList::new(items, 10))
2065    };
2066
2067    let request_id = request.request_id.clone();
2068    let state = ctx.state.clone();
2069    let editor_container = ctx.editor_container.clone();
2070    let editor = ctx.editor.clone();
2071    let tui = ctx.tui.clone();
2072
2073    // Single-select: respond with the chosen title, or fall through to the
2074    // input slot for the freeform sentinel.
2075    let bridge_select = bridge.clone();
2076    let request_for_freeform = request.clone();
2077    let prompt_for_freeform = AskUserPrompt {
2078        question_id: prompt.question_id.clone(),
2079        question: prompt.question.clone(),
2080        header: prompt.header.clone(),
2081        context: prompt.context.clone(),
2082        options: Vec::new(),
2083        allow_multiple: false,
2084        allow_freeform: true,
2085        suggest: prompt.suggest.clone(),
2086        kind: "input".to_string(),
2087    };
2088    let state_select = state.clone();
2089    let ec_select = editor_container.clone();
2090    let editor_select = editor.clone();
2091    let tui_select = tui.clone();
2092    let ctx_select = ctx.clone();
2093    list.on_select(Arc::new(move |item: &SelectItem| {
2094        if item.value == ASK_USER_FREEFORM {
2095            close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2096            open_ask_user_input(
2097                &ctx_select,
2098                bridge_select.clone(),
2099                request_for_freeform.clone(),
2100                prompt_for_freeform.clone(),
2101            );
2102            return;
2103        }
2104        bridge_select.respond(
2105            &request_id,
2106            serde_json::json!({
2107                "values": [item.display_value()],
2108                "kind": "selector",
2109            }),
2110        );
2111        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2112    }));
2113
2114    if multi {
2115        let bridge_multi = bridge.clone();
2116        let request_id_multi = request.request_id.clone();
2117        let state_multi = state.clone();
2118        let ec_multi = editor_container.clone();
2119        let editor_multi = editor.clone();
2120        let tui_multi = tui.clone();
2121        list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
2122            let values: Vec<String> = selected
2123                .iter()
2124                .map(|item| item.display_value().to_string())
2125                .collect();
2126            bridge_multi.respond(
2127                &request_id_multi,
2128                serde_json::json!({"values": values, "kind": "selector"}),
2129            );
2130            close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
2131        }));
2132    }
2133
2134    let bridge_cancel = bridge.clone();
2135    let request_id_cancel = request.request_id.clone();
2136    let state_cancel = state.clone();
2137    let ec_cancel = editor_container.clone();
2138    let editor_cancel = editor.clone();
2139    let tui_cancel = tui.clone();
2140    list.on_cancel(Arc::new(move || {
2141        bridge_cancel.cancel(&request_id_cancel);
2142        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2143    }));
2144
2145    let hint = if multi {
2146        "Space toggle · Enter submit · Esc cancel"
2147    } else {
2148        "Enter select · Esc cancel"
2149    };
2150    let frame = ask_user_frame(&prompt, list.clone(), hint);
2151    open_selector_with_view(
2152        &state,
2153        &editor_container,
2154        &editor,
2155        &tui,
2156        SelectorView::List(list),
2157        frame,
2158        SelectorKind::Extension,
2159    );
2160}
2161
2162fn open_ask_user_input(
2163    ctx: &CommandContext,
2164    bridge: AskUserBridge,
2165    request: rpi_extensions::UiDialogRequest,
2166    prompt: AskUserPrompt,
2167) {
2168    let placeholder = prompt
2169        .suggest
2170        .clone()
2171        .unwrap_or_else(|| "Type your answer…".to_string());
2172    let input = Arc::new(Input::with_placeholder(&placeholder));
2173    input.set_focused(true);
2174
2175    let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2176    *ctx.state.active_extension_editor.lock().unwrap() = None;
2177    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2178    ctx.editor_container.clear();
2179    ctx.editor_container.add_child(frame);
2180    ctx.tui.set_focus(Some(input.clone()));
2181    ctx.tui.request_render(false);
2182
2183    let state = ctx.state.clone();
2184    let ec = ctx.editor_container.clone();
2185    let editor = ctx.editor.clone();
2186    let tui = ctx.tui.clone();
2187    let bridge_submit = bridge.clone();
2188    let request_id = request.request_id.clone();
2189    input.on_submit(Arc::new(move |text: &str| {
2190        bridge_submit.respond(
2191            &request_id,
2192            serde_json::json!({
2193                "values": [text],
2194                "value": text,
2195                "text": text,
2196                "kind": "input",
2197            }),
2198        );
2199        close_extension_editor(&state, &ec, &editor, &tui);
2200    }));
2201
2202    let state_cancel = ctx.state.clone();
2203    let ec_cancel = ctx.editor_container.clone();
2204    let editor_cancel = ctx.editor.clone();
2205    let tui_cancel = ctx.tui.clone();
2206    let bridge_cancel = bridge.clone();
2207    let request_id_cancel = request.request_id.clone();
2208    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2209        bridge_cancel.cancel(&request_id_cancel);
2210        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2211    }));
2212}
2213
2214// ---- Built-in command implementations ----
2215
2216struct HelpCommand;
2217impl SlashCommand for HelpCommand {
2218    fn name(&self) -> &'static str {
2219        "/help"
2220    }
2221    fn aliases(&self) -> &'static [&'static str] {
2222        &["/?"]
2223    }
2224    fn description(&self) -> &'static str {
2225        "Show available commands"
2226    }
2227    fn execute(&self, ctx: &CommandContext, _args: &str) {
2228        add_help_message(&ctx.chat);
2229        ctx.tui.request_render(false);
2230    }
2231}
2232
2233struct ClearChatCommand;
2234impl SlashCommand for ClearChatCommand {
2235    fn name(&self) -> &'static str {
2236        "/clear"
2237    }
2238    fn aliases(&self) -> &'static [&'static str] {
2239        &["/new"]
2240    }
2241    // `/new` carries its own weight as a discoverable entry, so surface it.
2242    fn alias_visible(&self) -> &'static [&'static str] {
2243        &["/new"]
2244    }
2245    fn description(&self) -> &'static str {
2246        "Clear the conversation"
2247    }
2248    fn execute(&self, ctx: &CommandContext, _args: &str) {
2249        let _ = ctx.tx.send(TuiMessage::ClearChat);
2250    }
2251}
2252
2253struct ExitCommand;
2254impl SlashCommand for ExitCommand {
2255    fn name(&self) -> &'static str {
2256        "/exit"
2257    }
2258    fn aliases(&self) -> &'static [&'static str] {
2259        &["/quit", "/q"]
2260    }
2261    // `/quit` is surfaced (matches pi's BUILTIN list); `/q` stays a hidden alias.
2262    fn alias_visible(&self) -> &'static [&'static str] {
2263        &["/quit"]
2264    }
2265    fn description(&self) -> &'static str {
2266        "Exit the application"
2267    }
2268    fn execute(&self, ctx: &CommandContext, _args: &str) {
2269        ctx.state.cancel_js_preparation();
2270        let _ = ctx.tx.send(TuiMessage::Exit);
2271    }
2272}
2273
2274struct VersionCommand;
2275impl SlashCommand for VersionCommand {
2276    fn name(&self) -> &'static str {
2277        "/version"
2278    }
2279    fn aliases(&self) -> &'static [&'static str] {
2280        &["/v"]
2281    }
2282    fn description(&self) -> &'static str {
2283        "Show version information"
2284    }
2285    fn execute(&self, ctx: &CommandContext, _args: &str) {
2286        add_version_message(&ctx.chat);
2287        ctx.tui.request_render(false);
2288    }
2289}
2290
2291struct ChangelogCommand;
2292impl SlashCommand for ChangelogCommand {
2293    fn name(&self) -> &'static str {
2294        "/changelog"
2295    }
2296    fn description(&self) -> &'static str {
2297        "Show recent release changes"
2298    }
2299    fn execute(&self, ctx: &CommandContext, _args: &str) {
2300        add_changelog_message(&ctx.chat);
2301        ctx.tui.request_render(false);
2302    }
2303}
2304
2305struct HotkeysCommand;
2306impl SlashCommand for HotkeysCommand {
2307    fn name(&self) -> &'static str {
2308        "/hotkeys"
2309    }
2310    fn description(&self) -> &'static str {
2311        "Show keyboard shortcuts"
2312    }
2313    fn execute(&self, ctx: &CommandContext, _args: &str) {
2314        add_hotkeys_message(&ctx.chat);
2315        ctx.tui.request_render(false);
2316    }
2317}
2318
2319struct ModelCommand;
2320impl SlashCommand for ModelCommand {
2321    fn name(&self) -> &'static str {
2322        "/model"
2323    }
2324    fn aliases(&self) -> &'static [&'static str] {
2325        &["/m"]
2326    }
2327    fn description(&self) -> &'static str {
2328        "Choose a model (selector)"
2329    }
2330    fn execute(&self, ctx: &CommandContext, args: &str) {
2331        let term = args.trim();
2332        if !term.is_empty() {
2333            // /model <name> — direct switch by id (pi handleModelCommand).
2334            //
2335            // Native pi only switches on an EXACT match; a term that matches
2336            // nothing falls through to the selector with the query prefilled
2337            // instead of erroring out (`showModelSelector(searchTerm)`).
2338            if let Some(model) = find_model_selector_match(&ctx.model_catalog, term) {
2339                let model_id = model.id.clone();
2340                ctx.state.set_current_model(&model);
2341                let lane = ctx.lane.clone();
2342                tokio::spawn(async move {
2343                    let _ = lane.set_model(model).await;
2344                });
2345                add_note_message(
2346                    &ctx.chat,
2347                    &format!(
2348                        "Model set to {} — applies to the next message.",
2349                        short_model_name(&model_id)
2350                    ),
2351                );
2352                ctx.tui.request_render(false);
2353                return;
2354            }
2355        }
2356        open_model_selector(
2357            &ctx.state,
2358            &ctx.editor_container,
2359            &ctx.editor,
2360            &ctx.tui,
2361            &ctx.model_catalog,
2362            &ctx.lane,
2363            &ctx.lane_model_id,
2364            &ctx.chat,
2365            (!term.is_empty()).then_some(term),
2366        );
2367    }
2368}
2369
2370struct ThinkingCommand;
2371impl SlashCommand for ThinkingCommand {
2372    fn name(&self) -> &'static str {
2373        "/thinking"
2374    }
2375    fn aliases(&self) -> &'static [&'static str] {
2376        &["/think"]
2377    }
2378    fn description(&self) -> &'static str {
2379        "Set thinking level (selector)"
2380    }
2381    fn execute(&self, ctx: &CommandContext, args: &str) {
2382        let level_name = args.trim();
2383        if !level_name.is_empty() {
2384            // /thinking <level> — direct set (pi supports the param form).
2385            let Some(level) = thinking_level_from_name(level_name) else {
2386                add_error_message(
2387                    &ctx.chat,
2388                    &format!(
2389                        "Unknown thinking level \"{level_name}\". Valid: {}",
2390                        crate::args::VALID_THINKING_LEVELS.join(", ")
2391                    ),
2392                );
2393                ctx.tui.request_render(false);
2394                return;
2395            };
2396            let lane = ctx.lane.clone();
2397            let footer = ctx.state.footer.clone();
2398            tokio::spawn(async move {
2399                let _ = lane.set_thinking_level(level).await;
2400            });
2401            footer.set_thinking_level(Some(thinking_level_name(level)));
2402            add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2403            ctx.tui.request_render(false);
2404            return;
2405        }
2406        open_thinking_selector(
2407            &ctx.state,
2408            &ctx.editor_container,
2409            &ctx.editor,
2410            &ctx.tui,
2411            &ctx.lane,
2412            &ctx.model_catalog,
2413            &ctx.lane_model_id,
2414            &ctx.chat,
2415        );
2416    }
2417}
2418
2419struct ToolsCommand;
2420impl SlashCommand for ToolsCommand {
2421    fn name(&self) -> &'static str {
2422        "/tools"
2423    }
2424    fn description(&self) -> &'static str {
2425        "Toggle tools on/off"
2426    }
2427    fn execute(&self, ctx: &CommandContext, _args: &str) {
2428        open_tools_selector(
2429            &ctx.state,
2430            &ctx.editor_container,
2431            &ctx.editor,
2432            &ctx.tui,
2433            &ctx.lane,
2434            &ctx.chat,
2435        );
2436    }
2437}
2438
2439struct ImagesCommand;
2440impl SlashCommand for ImagesCommand {
2441    fn name(&self) -> &'static str {
2442        "/images"
2443    }
2444    fn description(&self) -> &'static str {
2445        "Toggle inline images"
2446    }
2447    fn execute(&self, ctx: &CommandContext, _args: &str) {
2448        open_images_selector(
2449            &ctx.state,
2450            &ctx.editor_container,
2451            &ctx.editor,
2452            &ctx.tui,
2453            &ctx.chat,
2454        );
2455    }
2456}
2457
2458struct SessionCommand;
2459impl SlashCommand for SessionCommand {
2460    fn name(&self) -> &'static str {
2461        "/session"
2462    }
2463    fn aliases(&self) -> &'static [&'static str] {
2464        &["/resume"]
2465    }
2466    fn description(&self) -> &'static str {
2467        "List saved sessions"
2468    }
2469    fn execute(&self, ctx: &CommandContext, _args: &str) {
2470        open_session_selector(
2471            &ctx.state,
2472            &ctx.editor_container,
2473            &ctx.editor,
2474            &ctx.tui,
2475            &ctx.cwd,
2476            &ctx.tx,
2477        );
2478    }
2479}
2480
2481struct ThemeCommand;
2482impl SlashCommand for ThemeCommand {
2483    fn name(&self) -> &'static str {
2484        "/theme"
2485    }
2486    fn description(&self) -> &'static str {
2487        "Choose a theme (selector)"
2488    }
2489    fn execute(&self, ctx: &CommandContext, args: &str) {
2490        let name = args.trim().to_ascii_lowercase();
2491        if !name.is_empty() {
2492            // /theme <name> — direct apply + persist (matches /settings Theme).
2493            let preset = match name.as_str() {
2494                "light" => ThemePreset::Light,
2495                "monochrome" => ThemePreset::Monochrome,
2496                "dark" => ThemePreset::Dark,
2497                _ => {
2498                    add_error_message(
2499                        &ctx.chat,
2500                        &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2501                    );
2502                    ctx.tui.request_render(false);
2503                    return;
2504                }
2505            };
2506            apply_theme_preset(preset);
2507            let mut settings = crate::settings::load_settings().unwrap_or_default();
2508            settings.theme = Some(name.clone());
2509            let _ = crate::settings::save_settings(&settings);
2510            add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2511            ctx.tui.request_render(false);
2512            ctx.tui.render_now(true);
2513            return;
2514        }
2515        open_theme_selector(
2516            &ctx.state,
2517            &ctx.editor_container,
2518            &ctx.editor,
2519            &ctx.tui,
2520            &ctx.cwd,
2521            &ctx.package_resources,
2522        );
2523    }
2524}
2525
2526struct CompactCommand;
2527impl SlashCommand for CompactCommand {
2528    fn name(&self) -> &'static str {
2529        "/compact"
2530    }
2531    fn description(&self) -> &'static str {
2532        "Compact the conversation"
2533    }
2534    fn execute(&self, ctx: &CommandContext, _args: &str) {
2535        let _ = ctx.tx.send(TuiMessage::Compact);
2536    }
2537}
2538
2539struct CopyCommand;
2540impl SlashCommand for CopyCommand {
2541    fn name(&self) -> &'static str {
2542        "/copy"
2543    }
2544    fn description(&self) -> &'static str {
2545        "Copy last reply to clipboard"
2546    }
2547    fn execute(&self, ctx: &CommandContext, _args: &str) {
2548        let _ = ctx.tx.send(TuiMessage::Copy);
2549    }
2550}
2551
2552struct ExportCommand;
2553impl SlashCommand for ExportCommand {
2554    fn name(&self) -> &'static str {
2555        "/export"
2556    }
2557    fn description(&self) -> &'static str {
2558        "Export session (supports: md, html, jsonl)"
2559    }
2560    fn execute(&self, ctx: &CommandContext, args: &str) {
2561        let format = args.trim().to_lowercase();
2562        if format.is_empty() || format == "md" || format == "markdown" {
2563            let _ = ctx.tx.send(TuiMessage::ExportSession);
2564        } else if format == "html" {
2565            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2566                crate::export::ExportFormat::Html,
2567            ));
2568        } else if format == "jsonl" {
2569            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2570                crate::export::ExportFormat::Jsonl,
2571            ));
2572        } else {
2573            // Invalid format, show error
2574            add_error_message(
2575                &ctx.chat,
2576                &format!(
2577                    "Unknown export format: {}. Supported: md, html, jsonl",
2578                    args.trim()
2579                ),
2580            );
2581            ctx.tui.request_render(false);
2582        }
2583    }
2584}
2585
2586struct ForkCommand;
2587impl SlashCommand for ForkCommand {
2588    fn name(&self) -> &'static str {
2589        "/fork"
2590    }
2591    fn description(&self) -> &'static str {
2592        "Fork the session into a new one"
2593    }
2594    fn execute(&self, ctx: &CommandContext, _args: &str) {
2595        let _ = ctx.tx.send(TuiMessage::ForkSession);
2596    }
2597}
2598
2599/// `/clone` is the native Pi spelling for duplicating the current session.
2600/// Reuse the same durable fork path as `/fork`; both create a child session
2601/// and rebind the live harness to it.
2602struct CloneCommand;
2603impl SlashCommand for CloneCommand {
2604    fn name(&self) -> &'static str {
2605        "/clone"
2606    }
2607    fn description(&self) -> &'static str {
2608        "Duplicate the current session"
2609    }
2610    fn execute(&self, ctx: &CommandContext, _args: &str) {
2611        let _ = ctx.tx.send(TuiMessage::ForkSession);
2612    }
2613}
2614
2615struct TreeCommand;
2616impl SlashCommand for TreeCommand {
2617    fn name(&self) -> &'static str {
2618        "/tree"
2619    }
2620    fn description(&self) -> &'static str {
2621        "Navigate the current session tree"
2622    }
2623    fn execute(&self, ctx: &CommandContext, _args: &str) {
2624        let _ = ctx.tx.send(TuiMessage::OpenTree);
2625    }
2626}
2627
2628struct LoginCommand;
2629impl SlashCommand for LoginCommand {
2630    fn name(&self) -> &'static str {
2631        "/login"
2632    }
2633    fn description(&self) -> &'static str {
2634        "Save an Anthropic API key"
2635    }
2636    fn execute(&self, ctx: &CommandContext, args: &str) {
2637        let key = args.trim();
2638        if key.is_empty() {
2639            add_note_message(&ctx.chat, "Usage: /login <api-key>");
2640        } else {
2641            let result = crate::config::upsert_credential(
2642                "anthropic",
2643                crate::config::Credential::ApiKey {
2644                    key: Some(key.to_string()),
2645                    env: None,
2646                },
2647            );
2648            match result {
2649                Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2650                Err(error) => {
2651                    add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2652                }
2653            }
2654        }
2655        ctx.tui.request_render(false);
2656    }
2657}
2658
2659struct LogoutCommand;
2660impl SlashCommand for LogoutCommand {
2661    fn name(&self) -> &'static str {
2662        "/logout"
2663    }
2664    fn description(&self) -> &'static str {
2665        "Remove saved Anthropic credentials"
2666    }
2667    fn execute(&self, ctx: &CommandContext, _args: &str) {
2668        match crate::config::delete_credential("anthropic") {
2669            Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2670            Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2671            Err(error) => {
2672                add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2673            }
2674        }
2675        ctx.tui.request_render(false);
2676    }
2677}
2678
2679fn set_project_trust_for_command(
2680    cwd: &std::path::Path,
2681    value: Option<bool>,
2682) -> Result<(), crate::config::ConfigError> {
2683    crate::config::set_project_trust(cwd, value)
2684}
2685
2686struct TrustCommand;
2687impl SlashCommand for TrustCommand {
2688    fn name(&self) -> &'static str {
2689        "/trust"
2690    }
2691    fn description(&self) -> &'static str {
2692        "Trust the current project"
2693    }
2694    fn execute(&self, ctx: &CommandContext, args: &str) {
2695        let value = match args.trim().to_ascii_lowercase().as_str() {
2696            "" | "yes" | "y" | "true" => Some(true),
2697            "no" | "n" | "false" => Some(false),
2698            "clear" | "reset" | "none" => None,
2699            _ => {
2700                add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2701                ctx.tui.request_render(false);
2702                return;
2703            }
2704        };
2705        match set_project_trust_for_command(&ctx.cwd, value) {
2706            Ok(()) => {
2707                let label = match value {
2708                    Some(true) => "trusted",
2709                    Some(false) => "untrusted",
2710                    None => "trust decision cleared",
2711                };
2712                add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2713            }
2714            Err(error) => add_error_message(
2715                &ctx.chat,
2716                &format!("Could not save trust decision: {error}"),
2717            ),
2718        }
2719        ctx.tui.request_render(false);
2720    }
2721}
2722
2723struct NameCommand;
2724impl SlashCommand for NameCommand {
2725    fn name(&self) -> &'static str {
2726        "/name"
2727    }
2728    fn description(&self) -> &'static str {
2729        "Set session display name"
2730    }
2731    fn execute(&self, ctx: &CommandContext, args: &str) {
2732        let name = args.trim();
2733        if name.is_empty() {
2734            add_note_message(
2735                &ctx.chat,
2736                "Usage: /name <display name> — sets the current session's name.",
2737            );
2738            ctx.tui.request_render(false);
2739            return;
2740        }
2741        let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2742    }
2743}
2744
2745struct ImportCommand;
2746impl SlashCommand for ImportCommand {
2747    fn name(&self) -> &'static str {
2748        "/import"
2749    }
2750    fn description(&self) -> &'static str {
2751        "Import a session file (path)"
2752    }
2753    fn execute(&self, ctx: &CommandContext, args: &str) {
2754        let path = args.trim();
2755        if path.is_empty() {
2756            add_note_message(
2757                &ctx.chat,
2758                "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2759            );
2760            ctx.tui.request_render(false);
2761            return;
2762        }
2763        let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2764    }
2765}
2766
2767struct SettingsCommand;
2768impl SlashCommand for SettingsCommand {
2769    fn name(&self) -> &'static str {
2770        "/settings"
2771    }
2772    fn description(&self) -> &'static str {
2773        "Open settings menu"
2774    }
2775    fn execute(&self, ctx: &CommandContext, _args: &str) {
2776        open_settings_selector(
2777            &ctx.state,
2778            &ctx.editor_container,
2779            &ctx.editor,
2780            &ctx.tui,
2781            &ctx.lane,
2782            &ctx.model_catalog,
2783            &ctx.lane_model_id,
2784            &ctx.chat,
2785            &ctx.cwd,
2786            &ctx.package_resources,
2787        );
2788    }
2789}
2790
2791struct ScopedModelsCommand;
2792impl SlashCommand for ScopedModelsCommand {
2793    fn name(&self) -> &'static str {
2794        "/scoped-models"
2795    }
2796    fn description(&self) -> &'static str {
2797        "Choose models for Ctrl+M cycling"
2798    }
2799    fn execute(&self, ctx: &CommandContext, _args: &str) {
2800        open_scoped_models_selector(
2801            &ctx.state,
2802            &ctx.editor_container,
2803            &ctx.editor,
2804            &ctx.tui,
2805            &ctx.model_catalog,
2806            &ctx.chat,
2807        );
2808    }
2809}
2810
2811struct ShareCommand;
2812impl SlashCommand for ShareCommand {
2813    fn name(&self) -> &'static str {
2814        "/share"
2815    }
2816    fn description(&self) -> &'static str {
2817        "Share session (gist via gh, or clipboard)"
2818    }
2819    fn execute(&self, ctx: &CommandContext, _args: &str) {
2820        let _ = ctx.tx.send(TuiMessage::ShareSession);
2821    }
2822}
2823
2824struct ArminCommand;
2825impl SlashCommand for ArminCommand {
2826    fn name(&self) -> &'static str {
2827        "/armin"
2828    }
2829    fn description(&self) -> &'static str {
2830        "??? (easter egg)"
2831    }
2832    fn execute(&self, ctx: &CommandContext, _args: &str) {
2833        crate::extras::add_armin(&ctx.chat);
2834        ctx.tui.request_render(false);
2835    }
2836}
2837
2838struct EarendilCommand;
2839impl SlashCommand for EarendilCommand {
2840    fn name(&self) -> &'static str {
2841        "/earendil"
2842    }
2843    fn description(&self) -> &'static str {
2844        "Announcement"
2845    }
2846    fn execute(&self, ctx: &CommandContext, _args: &str) {
2847        crate::extras::add_earendil(&ctx.chat);
2848        ctx.tui.request_render(false);
2849    }
2850}
2851
2852/// `/context` — lists discovered context files, skills, and prompt templates.
2853/// Hidden from autocomplete (needs the resources snapshot to be meaningful as a
2854/// discovery surface; like `/name`, it's recognized-v1 but kept off the list).
2855struct ContextCommand;
2856impl SlashCommand for ContextCommand {
2857    fn name(&self) -> &'static str {
2858        "/context"
2859    }
2860    fn visible(&self) -> bool {
2861        false
2862    }
2863    fn execute(&self, ctx: &CommandContext, _args: &str) {
2864        show_context_panel(&ctx.chat, &ctx.resources);
2865        // Live session stats are async, so ask the main loop to append them.
2866        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2867        ctx.tui.request_render(false);
2868    }
2869}
2870
2871/// `/usage` — token/cost/cache totals read through the product-layer
2872/// `AgentSession`. This is the same numbers the footer shows, but expanded and
2873/// with the cache-hit breakdown that the one-line footer cannot fit.
2874struct UsageCommand;
2875impl SlashCommand for UsageCommand {
2876    fn name(&self) -> &'static str {
2877        "/usage"
2878    }
2879    fn description(&self) -> &'static str {
2880        "Show token, cost, and cache totals for this session"
2881    }
2882    fn execute(&self, ctx: &CommandContext, _args: &str) {
2883        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2884        ctx.tui.request_render(false);
2885    }
2886}
2887
2888/// `/reload` — re-run extension + resource discovery into the LIVE harness
2889/// (B5d): reload the cdylib plugins, invalidate the old `ActionBridge` +
2890/// registry snapshot, rebuild skills/prompts/context/SYSTEM.md/APPEND_SYSTEM.md
2891/// + the `TeeEmitter`, and push the rebuilt state via the B5d harness setters.
2892/// The command itself runs on the blocking submit thread, so it can't drive
2893/// the async `reload_extension_resources` routine directly — it signals the main
2894/// loop via `TuiMessage::ReloadExtensions`, which awaits it on the async runtime.
2895/// (A plugin's `runtime_action(Reload)` signals the same loop via the
2896/// `ReloadMailbox` the TUI installs — the B5d async-reload design avoids the
2897/// self-unmapping race a synchronous plugin-initiated reload would have.)
2898struct ReloadCommand;
2899impl SlashCommand for ReloadCommand {
2900    fn name(&self) -> &'static str {
2901        "/reload"
2902    }
2903    fn description(&self) -> &'static str {
2904        "Reload extensions, skills, prompts"
2905    }
2906    fn execute(&self, ctx: &CommandContext, _args: &str) {
2907        // Signal the main loop. It owns the `&AgentHarness` borrow the
2908        // `reload_extension_resources` routine needs (the blocking submit thread
2909        // only has the context's `Arc<ReloadContext>` + the `Arc<dyn AgentLane>`).
2910        add_note_message(&ctx.chat, "Reloading extensions + resources…");
2911        ctx.tui.request_render(false);
2912        let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2913    }
2914}
2915
2916/// Build the full command registry: active built-ins first (so they win on a
2917/// fuzzy autocomplete tie), then the v1-out-of-scope stubs. Prompt-template
2918/// commands are merged in separately by the autocomplete builder (they dispatch
2919/// via template expansion, not this registry).
2920fn build_builtin_registry() -> CommandRegistry {
2921    let mut r = CommandRegistry::new();
2922    r.register(Arc::new(HelpCommand));
2923    r.register(Arc::new(ClearChatCommand));
2924    r.register(Arc::new(ExitCommand));
2925    r.register(Arc::new(VersionCommand));
2926    r.register(Arc::new(ChangelogCommand));
2927    r.register(Arc::new(ModelCommand));
2928    r.register(Arc::new(ThinkingCommand));
2929    r.register(Arc::new(ToolsCommand));
2930    r.register(Arc::new(ImagesCommand));
2931    r.register(Arc::new(SessionCommand));
2932    r.register(Arc::new(ThemeCommand));
2933    r.register(Arc::new(CompactCommand));
2934    r.register(Arc::new(CopyCommand));
2935    r.register(Arc::new(HotkeysCommand));
2936    r.register(Arc::new(ArminCommand));
2937    r.register(Arc::new(EarendilCommand));
2938    r.register(Arc::new(ContextCommand));
2939    r.register(Arc::new(UsageCommand));
2940    // Recognized but inert in v1 (one struct backs them all). The TS builtins
2941    // out of v1 scope; each carries a description so autocomplete surfaces its
2942    // existence even though running it reports "not supported".
2943    r.register(Arc::new(NameCommand));
2944    r.register(Arc::new(SettingsCommand));
2945    r.register(Arc::new(ScopedModelsCommand));
2946    r.register(Arc::new(ExportCommand));
2947    r.register(Arc::new(ImportCommand));
2948    r.register(Arc::new(ShareCommand));
2949    r.register(Arc::new(ForkCommand));
2950    r.register(Arc::new(CloneCommand));
2951    r.register(Arc::new(TreeCommand));
2952    r.register(Arc::new(TrustCommand));
2953    r.register(Arc::new(LoginCommand));
2954    r.register(Arc::new(LogoutCommand));
2955    r.register(Arc::new(ReloadCommand));
2956    r
2957}
2958
2959fn register_extension_commands(
2960    registry: &mut CommandRegistry,
2961    session: crate::session::ExtensionSessionCell,
2962) {
2963    let commands = session
2964        .lock()
2965        .ok()
2966        .and_then(|s| s.snapshot_arc())
2967        .map(|snap| snap.commands().to_vec())
2968        .unwrap_or_default();
2969    for command in commands {
2970        let name = if command.name.starts_with('/') {
2971            command.name.clone()
2972        } else {
2973            format!("/{}", command.name)
2974        };
2975        if registry.find(&name).is_some() {
2976            continue;
2977        }
2978        registry.register(Arc::new(ExtensionCommand {
2979            name,
2980            description: command.description,
2981            session: session.clone(),
2982        }));
2983    }
2984}
2985
2986fn register_js_extension_commands(
2987    registry: &mut CommandRegistry,
2988    session: Option<crate::js_extensions::JsExtensionSession>,
2989) {
2990    let Some(session) = session else {
2991        return;
2992    };
2993    for command in &session.commands {
2994        let name = if command.starts_with('/') {
2995            command.clone()
2996        } else {
2997            format!("/{command}")
2998        };
2999        if registry.find(&name).is_none() {
3000            registry.register(Arc::new(JsExtensionCommand {
3001                name,
3002                session: session.clone(),
3003            }));
3004        }
3005    }
3006}
3007
3008// ===========================================================================
3009// Channel + helpers
3010// ===========================================================================
3011
3012/// Message type for communication between the key/callback threads and the
3013/// main async loop.
3014enum TuiMessage {
3015    UserInput(String),
3016    OpenTree,
3017    NavigateTree(String),
3018    Exit,
3019    /// Clear the transcript (from `/clear`).
3020    ClearChat,
3021    /// Compact the conversation (from `/compact`).
3022    Compact,
3023    /// Copy the last assistant reply to the clipboard (from `/copy`).
3024    Copy,
3025    /// Hot-switch to another saved session (from the `/session` selector):
3026    /// the payload is the session id the selector's item value carried.
3027    SwitchSession(String),
3028    /// Export the current session to a markdown file (from `/export`).
3029    ExportSession,
3030    /// Export session with specific format
3031    ExportSessionWithFormat(crate::export::ExportFormat),
3032    /// Show live usage/context stats (from `/usage` and `/context`).
3033    ShowUsage,
3034    /// Fork the current session into a new one and switch to it (from `/fork`).
3035    ForkSession,
3036    /// Rename the current session (from `/name <name>`).
3037    SetSessionName(String),
3038    /// Import a JSONL session file into the session dir and switch to it
3039    /// (from `/import <path>`).
3040    ImportSession(String),
3041    /// Share the current session (`/share`): `gh gist create` when the gh CLI
3042    /// is available, otherwise copy the transcript to the clipboard.
3043    ShareSession,
3044    /// `/reload` — re-run extension + resource discovery into the live harness
3045    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
3046    /// mailbox) signal the main loop, which awaits
3047    /// `reload_extension_resources` on the async runtime.
3048    ReloadExtensions,
3049    /// Result returned after Ctrl+G edits a temporary file in an external
3050    /// editor. Handling it on the async loop keeps editor mutation single-
3051    /// threaded with the rest of the TUI state.
3052    ExternalEditorResult(Result<String, String>),
3053    /// A user-initiated `!command` / `!!command` shell run finished. Routed to
3054    /// the main loop so the `bashExecution` transcript record is persisted in
3055    /// order relative to agent runs (native pi's `recordBashResult` /
3056    /// `_pendingBashMessages`).
3057    UserBashFinished(UserBashReport),
3058}
3059
3060/// Terminal result of a user-initiated `!command` shell run.
3061///
3062/// Carries everything needed to build the persisted `bashExecution` message
3063/// plus the `!!` context-exclusion flag. See `start_user_bash`.
3064struct UserBashReport {
3065    command: String,
3066    output: String,
3067    exit_code: Option<i32>,
3068    cancelled: bool,
3069    truncated: bool,
3070    full_output_path: Option<String>,
3071    exclude_from_context: bool,
3072}
3073
3074/// Extract the concatenated text content from an assistant message (mirrors
3075/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
3076fn assistant_text(msg: &AssistantMessage) -> String {
3077    msg.content
3078        .iter()
3079        .filter_map(|c| match c {
3080            Content::Text(t) => Some(t.text.clone()),
3081            _ => None,
3082        })
3083        .collect()
3084}
3085
3086/// The user message's text (Text content or the text blocks of a Blocks
3087/// payload — images are skipped, consistent with the v1 text-only prompt path).
3088fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
3089    msg.content
3090        .iter()
3091        .filter_map(|c| match c {
3092            Content::Text(t) => Some(t.text.clone()),
3093            _ => None,
3094        })
3095        .collect::<Vec<_>>()
3096        .join("\n")
3097}
3098
3099fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
3100    match &msg.content {
3101        rpi_ai::types::UserContent::Text(s) => s.clone(),
3102        rpi_ai::types::UserContent::Blocks(blocks) => blocks
3103            .iter()
3104            .filter_map(|c| match c {
3105                Content::Text(t) => Some(t.text.clone()),
3106                _ => None,
3107            })
3108            .collect(),
3109    }
3110}
3111
3112/// The catalog allowed in the Ctrl+M cycle: the `/scoped-models` set from
3113/// settings.json when present, otherwise every model. The current model is
3114/// always included (fallback) so cycling can never strand the user off-scope.
3115fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
3116    let scoped = crate::settings::load_settings()
3117        .ok()
3118        .and_then(|s| s.scoped_models)
3119        .unwrap_or_default();
3120    if scoped.is_empty() {
3121        return catalog.to_vec();
3122    }
3123    let mut out: Vec<rpi_ai::Model> = catalog
3124        .iter()
3125        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
3126        .cloned()
3127        .collect();
3128    // Never strand the user: if the current model isn't in scope, keep it.
3129    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
3130        if let Some(cur) = catalog
3131            .iter()
3132            .find(|m| m.id.eq_ignore_ascii_case(current_id))
3133        {
3134            out.push(cur.clone());
3135        }
3136    }
3137    out
3138}
3139
3140/// Interactive `/settings` menu.
3141///
3142/// A [`SettingsList`] of editable settings (native pi's `SettingsSelectorComponent`):
3143/// Enter/Space cycles a row's value in place, and the theme/model/thinking/scope
3144/// rows open a sub-selector that replaces the active selector (the
3145/// `active_selector` slot is single). Value changes apply immediately where the
3146/// running TUI can honor them and are always persisted to `settings.json`, so
3147/// rows marked "applies on restart" take effect next launch.
3148fn open_settings_selector(
3149    state: &Arc<TuiState>,
3150    editor_container: &Arc<Container>,
3151    editor: &Arc<Editor>,
3152    tui: &Arc<TuiAltScreen>,
3153    lane: &Arc<dyn AgentLane>,
3154    catalog: &[rpi_ai::Model],
3155    lane_model_id: &str,
3156    chat: &Arc<Container>,
3157    cwd: &std::path::Path,
3158    package_resources: &Arc<crate::packages::PackageResources>,
3159) {
3160    let settings = crate::settings::load_settings().unwrap_or_default();
3161    let items = settings_menu_items(&settings, state, lane_model_id);
3162    let list = Arc::new(SettingsList::new(items));
3163
3164    // ---- submenu rows ----
3165    // These reuse the existing sub-selectors, which already persist their own
3166    // choices. The settings menu is replaced by the sub-selector (single
3167    // `active_selector` slot); cancelling the sub-selector returns to the editor.
3168    let state_sel = state.clone();
3169    let ec_sel = editor_container.clone();
3170    let editor_sel = editor.clone();
3171    let tui_sel = tui.clone();
3172    let lane_sel = lane.clone();
3173    let catalog_sel = catalog.to_vec();
3174    let lane_model_sel = lane_model_id.to_string();
3175    let chat_sel = chat.clone();
3176    let cwd_sel = cwd.to_path_buf();
3177    let package_resources_sel = package_resources.clone();
3178    list.on_select(Arc::new(move |item| match item.key.as_str() {
3179        "theme" => open_settings_theme_selector(
3180            &state_sel,
3181            &ec_sel,
3182            &editor_sel,
3183            &tui_sel,
3184            &chat_sel,
3185            &cwd_sel,
3186            &package_resources_sel,
3187        ),
3188        "model" => open_settings_model_selector(
3189            &state_sel,
3190            &ec_sel,
3191            &editor_sel,
3192            &tui_sel,
3193            &lane_sel,
3194            &catalog_sel,
3195            &lane_model_sel,
3196            &chat_sel,
3197        ),
3198        "thinking" => open_settings_thinking_selector(
3199            &state_sel,
3200            &ec_sel,
3201            &editor_sel,
3202            &tui_sel,
3203            &lane_sel,
3204            &catalog_sel,
3205            &lane_model_sel,
3206            &chat_sel,
3207        ),
3208        "scoped-models" => open_scoped_models_selector(
3209            &state_sel,
3210            &ec_sel,
3211            &editor_sel,
3212            &tui_sel,
3213            &catalog_sel,
3214            &chat_sel,
3215        ),
3216        _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3217    }));
3218
3219    // ---- value rows ----
3220    // Apply live where possible, then persist so the choice survives a restart.
3221    let state_change = state.clone();
3222    let chat_change = chat.clone();
3223    let tui_change = tui.clone();
3224    list.on_change(Arc::new(move |key, value| {
3225        let mut settings = crate::settings::load_settings().unwrap_or_default();
3226        let applied = apply_setting_change(&state_change, key, value, &mut settings);
3227        let saved = crate::settings::save_settings(&settings);
3228        if let Some(note) = applied {
3229            let suffix = if saved.is_ok() { "" } else { " (not saved)" };
3230            add_note_message(&chat_change, &format!("{note}{suffix}"));
3231        }
3232        tui_change.request_render(false);
3233    }));
3234
3235    let state_cancel = state.clone();
3236    let ec_cancel = editor_container.clone();
3237    let editor_cancel = editor.clone();
3238    let tui_cancel = tui.clone();
3239    list.on_cancel(Arc::new(move || {
3240        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3241    }));
3242
3243    open_selector_with_view(
3244        state,
3245        editor_container,
3246        editor,
3247        tui,
3248        SelectorView::Settings(list.clone()),
3249        list,
3250        SelectorKind::Settings,
3251    );
3252}
3253
3254/// Build the `/settings` rows from the loaded settings plus the live TUI state.
3255///
3256/// Descriptions carry "(applies on restart)" for values resolved once at
3257/// startup, so the menu never implies an effect the running session cannot
3258/// apply.
3259fn settings_menu_items(
3260    settings: &crate::settings::Settings,
3261    state: &Arc<TuiState>,
3262    lane_model_id: &str,
3263) -> Vec<SettingItem> {
3264    let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
3265    let current_model = settings
3266        .default_model
3267        .clone()
3268        .unwrap_or_else(|| lane_model_id.to_string());
3269    let current_thinking = settings
3270        .default_thinking_level
3271        .clone()
3272        .unwrap_or_else(|| "(default)".to_string());
3273    let scoped_desc = match &settings.scoped_models {
3274        Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
3275        _ => "all models".to_string(),
3276    };
3277    let hide_thinking = state.hide_thinking();
3278    let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
3279    let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
3280    let http_idle = settings
3281        .http_idle_timeout_ms()
3282        .map(|ms| {
3283            if ms == 0 {
3284                "disabled".to_string()
3285            } else {
3286                format!("{}s", ms / 1000)
3287            }
3288        })
3289        .unwrap_or_else(|| "5m".to_string());
3290
3291    vec![
3292        SettingItem::new("theme", "Theme", &current_theme)
3293            .with_description("Color theme for the interface")
3294            .with_submenu(),
3295        SettingItem::new("model", "Default model", &current_model)
3296            .with_description("Saved default model for new sessions")
3297            .with_submenu(),
3298        SettingItem::new("thinking", "Default thinking", &current_thinking)
3299            .with_description("Saved default thinking level")
3300            .with_submenu(),
3301        SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
3302            .with_description("Models enabled for Ctrl+M cycling")
3303            .with_submenu(),
3304        SettingItem::new(
3305            "hide-thinking",
3306            "Hide thinking",
3307            bool_setting_value(hide_thinking),
3308        )
3309        .with_description("Hide thinking blocks in assistant responses")
3310        .with_values(&["true", "false"]),
3311        SettingItem::new(
3312            "show-images",
3313            "Show images",
3314            bool_setting_value(show_images),
3315        )
3316        .with_description("Render images inline in the terminal")
3317        .with_values(&["true", "false"]),
3318        SettingItem::new(
3319            "cache-miss-notices",
3320            "Cache miss notices",
3321            bool_setting_value(show_cache_miss),
3322        )
3323        .with_description("Transcript notices for prompt-cache costs")
3324        .with_values(&["true", "false"]),
3325        SettingItem::new(
3326            "quiet-startup",
3327            "Quiet startup",
3328            bool_setting_value(settings.quiet_startup.unwrap_or(false)),
3329        )
3330        .with_description("Disable the verbose startup listing (applies on restart)")
3331        .with_values(&["true", "false"]),
3332        SettingItem::new(
3333            "terminal-progress",
3334            "Terminal progress",
3335            bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
3336        )
3337        .with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
3338        .with_values(&["true", "false"]),
3339        SettingItem::new(
3340            "fullscreen-copy-on-select",
3341            "Fullscreen copy on select",
3342            bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
3343        )
3344        .with_description("Copy selected text automatically in fullscreen mode")
3345        .with_values(&["true", "false"]),
3346        SettingItem::new(
3347            "double-escape-action",
3348            "Double-escape action",
3349            settings
3350                .double_escape_action
3351                .clone()
3352                .unwrap_or_else(|| "tree".to_string())
3353                .as_str(),
3354        )
3355        .with_description("Esc Esc with an empty editor (applies on restart)")
3356        .with_values(&["tree", "fork", "none"]),
3357        SettingItem::new(
3358            "editor-padding",
3359            "Editor padding",
3360            settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
3361        )
3362        .with_description("Horizontal editor padding, 0-3 (applies on restart)")
3363        .with_values(&["0", "1", "2", "3"]),
3364        SettingItem::new(
3365            "autocomplete-max-items",
3366            "Autocomplete max items",
3367            settings
3368                .autocomplete_max_visible
3369                .unwrap_or(5)
3370                .to_string()
3371                .as_str(),
3372        )
3373        .with_description("Max autocomplete rows, 3-20 (applies on restart)")
3374        .with_values(&["3", "5", "7", "10", "15", "20"]),
3375        SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
3376            .with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
3377            .with_values(&["30s", "1m", "5m", "disabled"]),
3378    ]
3379}
3380
3381/// `"true"` / `"false"` for a settings row value.
3382fn bool_setting_value(value: bool) -> &'static str {
3383    if value {
3384        "true"
3385    } else {
3386        "false"
3387    }
3388}
3389
3390/// Apply one `/settings` value change to the running TUI and to `settings`.
3391///
3392/// Returns a note to show in the transcript when the change is user-visible;
3393/// `None` for changes that only take effect on the next launch.
3394fn apply_setting_change(
3395    state: &Arc<TuiState>,
3396    key: &str,
3397    value: &str,
3398    settings: &mut crate::settings::Settings,
3399) -> Option<String> {
3400    let as_bool = || value == "true";
3401    match key {
3402        // Live: hide/show thinking blocks in the transcript immediately.
3403        "hide-thinking" => {
3404            state.set_hide_thinking(as_bool());
3405            settings.hide_thinking_block = Some(as_bool());
3406            Some(format!(
3407                "Thinking blocks {}.",
3408                if as_bool() { "hidden" } else { "shown" }
3409            ))
3410        }
3411        // Live: the image flag is consulted when images would be rendered.
3412        "show-images" => {
3413            if let Ok(mut guard) = state.show_images.lock() {
3414                *guard = as_bool();
3415            }
3416            settings.show_images = Some(as_bool());
3417            Some(format!(
3418                "Inline images {}.",
3419                if as_bool() { "enabled" } else { "disabled" }
3420            ))
3421        }
3422        // Live: read from `state` once per cache-miss notice.
3423        "cache-miss-notices" => {
3424            if let Ok(mut guard) = state.cache_miss_notices.lock() {
3425                *guard = as_bool();
3426            }
3427            settings.show_cache_miss_notices = Some(as_bool());
3428            Some(format!(
3429                "Cache miss notices {}.",
3430                if as_bool() { "enabled" } else { "disabled" }
3431            ))
3432        }
3433        // Live: read from settings on each selection mouse-up.
3434        "fullscreen-copy-on-select" => {
3435            settings.fullscreen_copy_on_select = Some(as_bool());
3436            Some(format!(
3437                "Copy on select {}.",
3438                if as_bool() { "enabled" } else { "disabled" }
3439            ))
3440        }
3441        // Persisted only: resolved once at startup.
3442        "quiet-startup" => {
3443            settings.quiet_startup = Some(as_bool());
3444            None
3445        }
3446        "terminal-progress" => {
3447            settings.show_terminal_progress = Some(as_bool());
3448            None
3449        }
3450        "double-escape-action" => {
3451            settings.double_escape_action = Some(value.to_string());
3452            None
3453        }
3454        "editor-padding" => {
3455            settings.editor_padding_x = value.parse().ok();
3456            None
3457        }
3458        "autocomplete-max-items" => {
3459            settings.autocomplete_max_visible = value.parse().ok();
3460            None
3461        }
3462        "http-idle-timeout" => {
3463            settings.http_idle_timeout = Some(match value {
3464                "disabled" => serde_json::Value::String("disabled".to_string()),
3465                "30s" => serde_json::json!(30_000),
3466                "1m" => serde_json::json!(60_000),
3467                _ => serde_json::json!(300_000),
3468            });
3469            None
3470        }
3471        _ => None,
3472    }
3473}
3474
3475/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
3476fn open_settings_theme_selector(
3477    state: &Arc<TuiState>,
3478    editor_container: &Arc<Container>,
3479    editor: &Arc<Editor>,
3480    tui: &Arc<TuiAltScreen>,
3481    chat: &Arc<Container>,
3482    cwd: &std::path::Path,
3483    package_resources: &Arc<crate::packages::PackageResources>,
3484) {
3485    let mut items = vec![
3486        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3487        SelectItem::new("light", "Light").with_description("Light background"),
3488        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3489    ];
3490    if state.themes_enabled {
3491        for path in package_resources.theme_files() {
3492            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3493                items.push(SelectItem::new(name, name).with_description("Package theme"));
3494            }
3495        }
3496    }
3497    let list = Arc::new(SelectList::new(items, 10));
3498
3499    let state_sel = state.clone();
3500    let ec_sel = editor_container.clone();
3501    let editor_sel = editor.clone();
3502    let tui_sel = tui.clone();
3503    let chat_sel = chat.clone();
3504    let cwd_sel = cwd.to_path_buf();
3505    let package_resources_sel = package_resources.clone();
3506    list.on_select(Arc::new(move |item| {
3507        let preset = match item.value.as_str() {
3508            "light" => Some(ThemePreset::Light),
3509            "monochrome" => Some(ThemePreset::Monochrome),
3510            "dark" => Some(ThemePreset::Dark),
3511            name => {
3512                if state_sel.themes_enabled {
3513                    if let Ok(custom) = crate::packages::load_theme_with_resources(
3514                        &cwd_sel,
3515                        name,
3516                        &package_resources_sel,
3517                    ) {
3518                        rpi_tui::global_theme_manager().set(custom.clone());
3519                        state_sel.theme_manager.set(custom);
3520                    }
3521                }
3522                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3523                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3524                tui_sel.render_now(true);
3525                return;
3526            }
3527        };
3528        let Some(preset) = preset else { return };
3529        apply_theme_preset(preset);
3530        state_sel.theme_manager.apply_preset(preset);
3531        let mut settings = crate::settings::load_settings().unwrap_or_default();
3532        settings.theme = Some(item.value.clone());
3533        let saved = crate::settings::save_settings(&settings);
3534        add_note_message(
3535            &chat_sel,
3536            &format!(
3537                "Theme set to {} (saved{})",
3538                item.label,
3539                if saved.is_ok() { "" } else { ", not saved" },
3540            ),
3541        );
3542        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3543        tui_sel.render_now(true);
3544    }));
3545    let state_cancel = state.clone();
3546    let ec_cancel = editor_container.clone();
3547    let editor_cancel = editor.clone();
3548    let tui_cancel = tui.clone();
3549    list.on_cancel(Arc::new(move || {
3550        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3551    }));
3552
3553    open_selector(
3554        state,
3555        editor_container,
3556        editor,
3557        tui,
3558        list,
3559        SelectorKind::Settings,
3560    );
3561}
3562
3563/// Choose the default model AND persist it (`/settings` → Default model):
3564/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
3565/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
3566fn open_settings_model_selector(
3567    state: &Arc<TuiState>,
3568    editor_container: &Arc<Container>,
3569    editor: &Arc<Editor>,
3570    tui: &Arc<TuiAltScreen>,
3571    lane: &Arc<dyn AgentLane>,
3572    catalog: &[rpi_ai::Model],
3573    lane_model_id: &str,
3574    chat: &Arc<Container>,
3575) {
3576    let items = model_selector_items(catalog, lane_model_id);
3577    if items.is_empty() {
3578        add_note_message(chat, "No models in the catalog.");
3579        tui.request_render(false);
3580        return;
3581    }
3582    let list = Arc::new(SelectList::new(items, 10));
3583
3584    let catalog_arc = catalog.to_vec();
3585    let state_sel = state.clone();
3586    let ec_sel = editor_container.clone();
3587    let editor_sel = editor.clone();
3588    let tui_sel = tui.clone();
3589    let chat_sel = chat.clone();
3590    let lane_sel = lane.clone();
3591    list.on_select(Arc::new(move |item| {
3592        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3593            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3594            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3595            return;
3596        };
3597        state_sel.set_current_model(&model);
3598        let lane = lane_sel.clone();
3599        tokio::spawn(async move {
3600            let _ = lane.set_model(model).await;
3601        });
3602        let mut settings = crate::settings::load_settings().unwrap_or_default();
3603        settings.default_model = Some(item.value.clone());
3604        let saved = crate::settings::save_settings(&settings);
3605        add_note_message(
3606            &chat_sel,
3607            &format!(
3608                "Default model set to {} (saved{}",
3609                short_model_name(&item.value),
3610                if saved.is_ok() { ")" } else { ", not saved)" },
3611            ),
3612        );
3613        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3614    }));
3615    let state_cancel = state.clone();
3616    let ec_cancel = editor_container.clone();
3617    let editor_cancel = editor.clone();
3618    let tui_cancel = tui.clone();
3619    list.on_cancel(Arc::new(move || {
3620        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3621    }));
3622
3623    open_searchable_selector(
3624        state,
3625        editor_container,
3626        editor,
3627        tui,
3628        Some("Default model"),
3629        Some("Type to filter by name, provider, or id"),
3630        list,
3631        SelectorKind::Settings,
3632    );
3633}
3634
3635/// Convert the authenticated runtime catalog into selector rows. Keep the
3636/// model id as the value so `/model <id>` and the selection callback share one
3637/// lookup path, while making the provider visible for OpenAI-compatible
3638/// gateways where the same model id may exist at multiple endpoints.
3639fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3640    let mut seen = std::collections::HashSet::new();
3641    catalog
3642        .iter()
3643        .filter(|m| {
3644            seen.insert((
3645                m.api.clone(),
3646                m.provider.to_ascii_lowercase(),
3647                m.id.to_ascii_lowercase(),
3648            ))
3649        })
3650        .map(|m| {
3651            let label = if m.name.is_empty() {
3652                short_model_name(&m.id)
3653            } else {
3654                m.name.clone()
3655            };
3656            let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3657                && m.provider.eq_ignore_ascii_case("anthropic")
3658            {
3659                m.id.clone()
3660            } else {
3661                format!("{}/{}", m.provider, m.id)
3662            };
3663            let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3664                " (current)"
3665            } else {
3666                ""
3667            };
3668            // Native pi ranks provider-prefixed queries first, so the bare id
3669            // is not the leading token (`getModelSelectorSearchText`).
3670            let name = if m.name.is_empty() {
3671                String::new()
3672            } else {
3673                format!(" {}", m.name)
3674            };
3675            let search_text = format!(
3676                "{} {}/{} {} {}{}",
3677                m.provider, m.provider, m.id, m.provider, m.id, name
3678            );
3679            SelectItem::new(&m.id, &label)
3680                .with_description(&format!("{identity}{marker}"))
3681                .with_search_text(&search_text)
3682        })
3683        .collect()
3684}
3685
3686/// Resolve a selector input by either bare model id or the qualified
3687/// `provider/model` identity shown for gateway models. This keeps manual
3688/// `/model ...` input consistent with the rows rendered by the selector.
3689fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3690    let (provider, id) = input
3691        .split_once('/')
3692        .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3693        .map_or((None, input), |(provider, id)| (Some(provider), id));
3694    catalog
3695        .iter()
3696        .find(|model| {
3697            model.id.eq_ignore_ascii_case(id)
3698                && provider.map_or(true, |provider| {
3699                    model.provider.eq_ignore_ascii_case(provider)
3700                        || (provider.eq_ignore_ascii_case("anthropic")
3701                            && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3702                })
3703        })
3704        .cloned()
3705}
3706
3707/// Choose the default thinking level AND persist it (`/settings` → Default
3708/// thinking): applies live via `lane.set_thinking_level` and saves
3709/// `defaultThinkingLevel` to settings.json.
3710fn open_settings_thinking_selector(
3711    state: &Arc<TuiState>,
3712    editor_container: &Arc<Container>,
3713    editor: &Arc<Editor>,
3714    tui: &Arc<TuiAltScreen>,
3715    lane: &Arc<dyn AgentLane>,
3716    catalog: &[rpi_ai::Model],
3717    lane_model_id: &str,
3718    chat: &Arc<Container>,
3719) {
3720    let model = catalog
3721        .iter()
3722        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3723    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3724        .map(|m| m.supported_thinking_levels())
3725        .unwrap_or_else(|| {
3726            use rpi_ai::types::ThinkingLevel::*;
3727            vec![Off, Minimal, Low, Medium, High]
3728        });
3729    let mut items: Vec<SelectItem> = Vec::new();
3730    for lvl in &levels {
3731        let name = thinking_level_name(*lvl);
3732        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3733    }
3734    if items.is_empty() {
3735        add_note_message(chat, "This model has no supported thinking levels.");
3736        tui.request_render(false);
3737        return;
3738    }
3739    let list = Arc::new(SelectList::new(items, 10));
3740
3741    let state_sel = state.clone();
3742    let ec_sel = editor_container.clone();
3743    let editor_sel = editor.clone();
3744    let tui_sel = tui.clone();
3745    let chat_sel = chat.clone();
3746    let lane_sel = lane.clone();
3747    list.on_select(Arc::new(move |item| {
3748        let Some(level) = thinking_level_from_name(&item.value) else {
3749            add_note_message(
3750                &chat_sel,
3751                &format!("Unknown thinking level: {}.", item.label),
3752            );
3753            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3754            return;
3755        };
3756        let lane = lane_sel.clone();
3757        let footer_sel = state_sel.footer.clone();
3758        tokio::spawn(async move {
3759            let _ = lane.set_thinking_level(level).await;
3760        });
3761        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3762        let mut settings = crate::settings::load_settings().unwrap_or_default();
3763        settings.default_thinking_level = Some(item.value.clone());
3764        let saved = crate::settings::save_settings(&settings);
3765        add_note_message(
3766            &chat_sel,
3767            &format!(
3768                "Default thinking set to {} (saved{}",
3769                item.label,
3770                if saved.is_ok() { ")" } else { ", not saved)" },
3771            ),
3772        );
3773        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3774    }));
3775    let state_cancel = state.clone();
3776    let ec_cancel = editor_container.clone();
3777    let editor_cancel = editor.clone();
3778    let tui_cancel = tui.clone();
3779    list.on_cancel(Arc::new(move || {
3780        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3781    }));
3782
3783    open_selector(
3784        state,
3785        editor_container,
3786        editor,
3787        tui,
3788        list,
3789        SelectorKind::Settings,
3790    );
3791}
3792
3793/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
3794/// item toggles it in the in-progress set (the selector stays open); Esc saves
3795/// the set to settings.json and closes. The active scoped set is echoed after
3796/// each toggle so the user sees the current selection.
3797fn open_scoped_models_selector(
3798    state: &Arc<TuiState>,
3799    editor_container: &Arc<Container>,
3800    editor: &Arc<Editor>,
3801    tui: &Arc<TuiAltScreen>,
3802    catalog: &[rpi_ai::Model],
3803    chat: &Arc<Container>,
3804) {
3805    if catalog.is_empty() {
3806        add_note_message(chat, "No models in the catalog.");
3807        tui.request_render(false);
3808        return;
3809    }
3810    // Seed the edit set from the saved scoped models.
3811    let seed: Vec<String> = crate::settings::load_settings()
3812        .ok()
3813        .and_then(|s| s.scoped_models)
3814        .unwrap_or_default();
3815    *state.scoped_edit.lock().unwrap() = Some(seed);
3816
3817    let mut items: Vec<SelectItem> = Vec::new();
3818    for m in catalog {
3819        items.push(SelectItem::new(&m.id, &m.id));
3820    }
3821    let list = Arc::new(SelectList::new(items, 10));
3822
3823    let state_sel = state.clone();
3824    let chat_sel = chat.clone();
3825    let tui_sel = tui.clone();
3826    list.on_select(Arc::new(move |item| {
3827        // Toggle the model in the in-progress set; the selector stays open.
3828        let mut set = state_sel.scoped_edit.lock().unwrap();
3829        let set = set.get_or_insert_with(Vec::new);
3830        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3831            set.remove(pos);
3832            add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3833        } else {
3834            set.push(item.value.clone());
3835            add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3836        }
3837        tui_sel.request_render(false);
3838    }));
3839    let state_cancel = state.clone();
3840    let ec_cancel = editor_container.clone();
3841    let editor_cancel = editor.clone();
3842    let tui_cancel = tui.clone();
3843    let chat_cancel = chat.clone();
3844    list.on_cancel(Arc::new(move || {
3845        // Save the edited set to settings.json and close.
3846        let set = state_cancel
3847            .scoped_edit
3848            .lock()
3849            .unwrap()
3850            .take()
3851            .unwrap_or_default();
3852        let mut settings = crate::settings::load_settings().unwrap_or_default();
3853        settings.scoped_models = if set.is_empty() {
3854            None
3855        } else {
3856            Some(set.clone())
3857        };
3858        match crate::settings::save_settings(&settings) {
3859            Ok(()) => {
3860                if set.is_empty() {
3861                    add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3862                } else {
3863                    add_note_message(
3864                        &chat_cancel,
3865                        &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3866                    );
3867                }
3868            }
3869            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3870        }
3871        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3872    }));
3873
3874    open_selector(
3875        state,
3876        editor_container,
3877        editor,
3878        tui,
3879        list,
3880        SelectorKind::ScopedModels,
3881    );
3882}
3883
3884/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
3885/// PATH, create a gist of the exported markdown; otherwise fall back to the
3886/// clipboard (best-effort) and note the local path.
3887async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3888    use std::process::Stdio;
3889
3890    // Reuse the export builder for the transcript text.
3891    let tree = harness.session().view("main");
3892    let entries = match tree
3893        .find_entries(&EntryQuery {
3894            entry_type: None,
3895            custom_type: None,
3896            // Exports append entries top-to-bottom, so use chronological order
3897            // instead of the session query default (newest-first).
3898            order: Some(EntryOrder::OldestFirst),
3899            limit: None,
3900            cursor: None,
3901        })
3902        .await
3903    {
3904        Ok(e) => e,
3905        Err(e) => {
3906            add_error_message(chat, &format!("Could not read session: {e}"));
3907            return;
3908        }
3909    };
3910    let mut md = String::from("# Session\n\n");
3911    for e in entries {
3912        let Entry::Message(me) = e else { continue };
3913        match &me.message {
3914            AgentMessage::User(u) => {
3915                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3916            }
3917            AgentMessage::Assistant(a) => {
3918                let text = assistant_text(a);
3919                if !text.is_empty() {
3920                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3921                }
3922            }
3923            _ => {}
3924        }
3925    }
3926
3927    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
3928    let gh = std::process::Command::new("gh")
3929        .arg("gist")
3930        .arg("create")
3931        .arg("--filename")
3932        .arg("session.md")
3933        .arg("-")
3934        .stdin(Stdio::piped())
3935        .stdout(Stdio::piped())
3936        .stderr(Stdio::null())
3937        .spawn();
3938    if let Ok(mut child) = gh {
3939        use std::io::Write;
3940        if let Some(mut stdin) = child.stdin.take() {
3941            let _ = stdin.write_all(md.as_bytes());
3942            let _ = stdin.flush();
3943        }
3944        let out = child.wait_with_output().ok();
3945        if let Some(out) = out {
3946            if out.status.success() {
3947                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3948                add_note_message(chat, &format!("Shared session: {url}"));
3949                return;
3950            }
3951        }
3952        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3953    } else {
3954        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3955    }
3956    // Clipboard fallback (or transcript echo when the clipboard feature is off).
3957    if copy_to_clipboard(&md) {
3958        add_note_message(chat, "Session transcript copied to the clipboard.");
3959    } else {
3960        add_note_message(
3961            chat,
3962            "Clipboard unavailable — use /export to write the transcript to a file.",
3963        );
3964    }
3965}
3966
3967// Session export is driven through the product-layer `AgentSession`
3968// (`crate::agent_session`), which owns format selection + default filenames —
3969// the TUI loop calls `AgentSession::export*` directly, so there is no separate
3970// helper here.
3971
3972/// Fork the current session into a new JSONL session and switch to it (TS
3973/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
3974/// session the user continues in). Uses the repo's `fork_typed`, then swaps
3975/// the harness backing and renders the (empty-ish) fork transcript.
3976/// Hot-switch the harness to another saved session: abort any in-flight run,
3977/// open the target session file, swap the durable backing, and re-render the
3978/// transcript from the new history (mirrors pi's `/session` resume-in-place).
3979/// Shared by the `/session` selector, `/import`, and `/fork`. The current
3980/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
3981async fn switch_to_session(
3982    harness: &AgentHarness,
3983    lane: &Arc<dyn AgentLane>,
3984    id: &str,
3985    cwd: &std::path::Path,
3986    chat: &Arc<Container>,
3987    state: &Arc<TuiState>,
3988) -> bool {
3989    if *state.status.lock().unwrap() == RunStatus::Working {
3990        state.set_status(RunStatus::Aborting);
3991        let _ = lane.abort().await;
3992    }
3993    let cwd_str = cwd.to_string_lossy().to_string();
3994    match crate::session::open_session_by_id(id, &cwd_str).await {
3995        Ok(new_session) => {
3996            let _ = harness.set_session(new_session).await;
3997            chat.clear();
3998            add_welcome_message(chat);
3999            render_session_history(
4000                harness,
4001                chat,
4002                state.markdown_transformer(),
4003                Some(state.extension_session.clone()),
4004            )
4005            .await;
4006            state.set_status(RunStatus::Idle);
4007            add_note_message(chat, &format!("Switched to session {id}."));
4008            true
4009        }
4010        Err(e) => {
4011            state.set_status(RunStatus::Idle);
4012            add_error_message(chat, &format!("Could not open session {id}: {e}"));
4013            false
4014        }
4015    }
4016}
4017
4018/// `/import <path>`: copy a JSONL session file into the default session dir,
4019/// then hot-switch to it (the file name becomes its id — matching the
4020/// selector/`open_session_by_id` containment rules).
4021async fn import_session(
4022    harness: &AgentHarness,
4023    lane: &Arc<dyn AgentLane>,
4024    path: &str,
4025    cwd: &std::path::Path,
4026    chat: &Arc<Container>,
4027    state: &Arc<TuiState>,
4028) {
4029    use std::path::Path as FsPath;
4030
4031    let src = FsPath::new(path);
4032    if !src.is_file() {
4033        add_error_message(chat, &format!("Import source not found: {path}"));
4034        return;
4035    }
4036    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
4037        add_error_message(chat, "Import source has no file name.");
4038        return;
4039    };
4040    if !fname.ends_with(".jsonl") {
4041        add_error_message(chat, "Import source must be a .jsonl session file.");
4042        return;
4043    }
4044    let dir = crate::session::default_session_dir(cwd);
4045    if let Err(e) = std::fs::create_dir_all(&dir) {
4046        add_error_message(chat, &format!("Could not create session dir: {e}"));
4047        return;
4048    }
4049    let dest = dir.join(fname);
4050    match std::fs::copy(src, &dest) {
4051        Ok(_) => {
4052            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
4053            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
4054                add_note_message(chat, &format!("Imported session from {path}"));
4055            }
4056        }
4057        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
4058    }
4059}
4060
4061async fn fork_session(
4062    harness: &AgentHarness,
4063    cwd: &std::path::Path,
4064    chat: &Arc<Container>,
4065    state: &Arc<TuiState>,
4066) {
4067    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
4068    use rpi_tools::FileSystem;
4069
4070    let cwd_str = cwd.to_string_lossy().to_string();
4071    let dir = crate::session::default_session_dir(cwd);
4072    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
4073    let fs: Arc<dyn FileSystem> = env.clone();
4074    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
4075        fs,
4076        sessions_root: dir.to_string_lossy().into_owned(),
4077        clock: Arc::new(rpi_harness::session::memory::SystemClock),
4078        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
4079    });
4080    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
4081    // it from the session list by the current session's id.
4082    let id = harness.session().storage().metadata().id.clone();
4083    let metas = match crate::session::list_session_metadata(&cwd_str).await {
4084        Ok(m) => m,
4085        Err(e) => {
4086            add_error_message(chat, &format!("Could not list sessions: {e}"));
4087            return;
4088        }
4089    };
4090    let Some(source) = metas.iter().find(|m| m.id == id) else {
4091        add_error_message(chat, &format!("Current session {id} not found on disk."));
4092        return;
4093    };
4094    let fork_storage = match repo
4095        .fork_typed(
4096            source,
4097            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
4098                id: None,
4099                parent_session_id: Some(source.id.clone()),
4100                cwd: cwd_str.clone(),
4101                metadata: None,
4102            },
4103            &rpi_harness::session::types::ForkOptions::default(),
4104        )
4105        .await
4106    {
4107        Ok(s) => s,
4108        Err(e) => {
4109            add_error_message(chat, &format!("Could not fork session: {e}"));
4110            return;
4111        }
4112    };
4113    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
4114    let _ = harness.set_session(new_session).await;
4115    chat.clear();
4116    add_welcome_message(chat);
4117    render_session_history(
4118        harness,
4119        chat,
4120        state.markdown_transformer(),
4121        Some(state.extension_session.clone()),
4122    )
4123    .await;
4124    state.set_status(RunStatus::Idle);
4125    add_note_message(chat, "Forked into a new session.");
4126}
4127
4128/// Render the restored session's prior transcript (user + assistant messages)
4129/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
4130/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
4131/// any session read failure just starts with an empty transcript.
4132///
4133/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
4134/// the identity path. Each restored assistant component installs it so replayed
4135/// history renders through the same `register_markdown_transformer` handlers
4136/// the live stream does.
4137async fn render_session_history(
4138    harness: &AgentHarness,
4139    chat: &Arc<Container>,
4140    transformer: Option<MarkdownTransformer>,
4141    extension_session: Option<crate::session::ExtensionSessionCell>,
4142) {
4143    let tree = harness.session().view("main");
4144    let entries = match tree
4145        .find_entries(&EntryQuery {
4146            entry_type: None,
4147            custom_type: None,
4148            // Session queries default to newest-first for selectors and
4149            // pagination. The transcript appends children top-to-bottom, so
4150            // restored history must explicitly be chronological.
4151            order: Some(EntryOrder::OldestFirst),
4152            limit: None,
4153            cursor: None,
4154        })
4155        .await
4156    {
4157        Ok(e) => e,
4158        Err(_) => return,
4159    };
4160    let mut rendered_any = false;
4161    for e in entries {
4162        match e {
4163            Entry::Message(me) => match &me.message {
4164                AgentMessage::User(u) => {
4165                    add_user_message(chat, &user_message_text(u));
4166                    rendered_any = true;
4167                }
4168                AgentMessage::Assistant(a) => {
4169                    let comp = Arc::new(AssistantMessageComponent::new(
4170                        AssistantMessageOptions::default(),
4171                    ));
4172                    if let Some(t) = &transformer {
4173                        comp.set_markdown_transformer(Some(t.clone()));
4174                    }
4175                    comp.update_blocks(&assistant_blocks(a));
4176                    chat.add_child(comp);
4177                    // Single trailing spacer: the next transcript entry (user or
4178                    // assistant) follows one blank line below.
4179                    chat.add_child(Arc::new(Spacer::new(1)));
4180                    if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
4181                        add_note_message(chat, &text);
4182                    }
4183                    if let Some(error) = assistant_error_text(a) {
4184                        add_error_message(chat, &error);
4185                    }
4186                    rendered_any = true;
4187                }
4188                AgentMessage::ToolResult(result) => {
4189                    // Tool results are persisted as separate message entries,
4190                    // not as part of the assistant text. Restore them as
4191                    // completed tool panels so resumed sessions show the
4192                    // command output as well as the user's prompts.
4193                    let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
4194                    comp.set_result(&tool_result_message_text(result), result.is_error);
4195                    chat.add_child(comp);
4196                    chat.add_child(Arc::new(Spacer::new(1)));
4197                    rendered_any = true;
4198                }
4199                AgentMessage::Custom(custom) => {
4200                    if let Some(session) = &extension_session {
4201                        if let Some(component) = extension_message_component(
4202                            session,
4203                            &custom.role,
4204                            &serde_json::json!({
4205                                "customType": custom.role,
4206                                "content": custom.content,
4207                                "details": custom.data,
4208                            }),
4209                            transformer.clone(),
4210                        ) {
4211                            chat.add_child(component);
4212                            chat.add_child(Arc::new(Spacer::new(1)));
4213                            rendered_any = true;
4214                            continue;
4215                        }
4216                    }
4217                    add_note_message(chat, &custom_message_fallback(&custom));
4218                    rendered_any = true;
4219                }
4220            },
4221            Entry::Compaction(compaction) => {
4222                add_note_message(
4223                    chat,
4224                    &format!(
4225                        "Compacted {} tokens: {}",
4226                        compaction.tokens_before, compaction.summary
4227                    ),
4228                );
4229                rendered_any = true;
4230            }
4231            Entry::BranchSummary(summary) => {
4232                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
4233                rendered_any = true;
4234            }
4235            Entry::Custom(custom) => {
4236                let rendered = extension_session.as_ref().and_then(|session| {
4237                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
4238                });
4239                if let Some(component) = rendered {
4240                    chat.add_child(component);
4241                    chat.add_child(Arc::new(Spacer::new(1)));
4242                    rendered_any = true;
4243                } else if let Some(text) =
4244                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
4245                {
4246                    add_note_message(chat, &text);
4247                    rendered_any = true;
4248                }
4249            }
4250            Entry::ModelChange(change) => {
4251                add_note_message(
4252                    chat,
4253                    &format!("Model changed to {}:{}", change.provider, change.model_id),
4254                );
4255                rendered_any = true;
4256            }
4257            Entry::ThinkingLevel(change) => {
4258                add_note_message(
4259                    chat,
4260                    &format!("Thinking level: {:?}", change.thinking_level),
4261                );
4262                rendered_any = true;
4263            }
4264            Entry::ActiveTools(change) => {
4265                add_note_message(
4266                    chat,
4267                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
4268                );
4269                rendered_any = true;
4270            }
4271        }
4272    }
4273    if rendered_any {
4274        // No trailing spacer here — each entry already adds its own trailing
4275        // Spacer(1), so an extra would double the bottom gap.
4276    }
4277}
4278
4279fn invoke_extension_renderer(
4280    session: &crate::session::ExtensionSessionCell,
4281    kind: rpi_extensions::RegisteredRendererKind,
4282    payload: &serde_json::Value,
4283) -> Option<serde_json::Value> {
4284    let snapshot = session.lock().ok()?.snapshot_arc()?;
4285    let input = serde_json::to_string(payload).ok()?;
4286    for renderer in snapshot.renderers_of(kind) {
4287        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4288            let mut out = rpi_plugin_sdk::StbString::empty();
4289            let rc = (renderer.render_fn)(
4290                rpi_plugin_sdk::StbStringRef::from_str(&input),
4291                &mut out as *mut rpi_plugin_sdk::StbString,
4292                renderer.user_data,
4293            );
4294            let text = if rc == 0 {
4295                Some(out.to_string_lossy())
4296            } else {
4297                None
4298            };
4299            out.free_with(Some(renderer.plugin_free_string));
4300            text
4301        }))
4302        .ok()
4303        .flatten();
4304        let Some(text) = outcome else { continue };
4305        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
4306            return Some(value);
4307        }
4308    }
4309    None
4310}
4311
4312fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
4313    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
4314        let text = lines
4315            .iter()
4316            .filter_map(|line| line.as_str())
4317            .collect::<Vec<_>>()
4318            .join("\n");
4319        return Some(Arc::new(Text::new(text, 0, 0)));
4320    }
4321    let text = value.get("text").and_then(|v| v.as_str())?;
4322    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4323        let component = Arc::new(AssistantMessageComponent::new(
4324            AssistantMessageOptions::default(),
4325        ));
4326        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4327        Some(component)
4328    } else {
4329        Some(Arc::new(Text::new(text, 0, 0)))
4330    }
4331}
4332
4333fn extension_message_component(
4334    session: &crate::session::ExtensionSessionCell,
4335    custom_type: &str,
4336    payload: &serde_json::Value,
4337    transformer: Option<MarkdownTransformer>,
4338) -> Option<Arc<dyn rpi_tui::Component>> {
4339    let value = invoke_extension_renderer(
4340        session,
4341        rpi_extensions::RegisteredRendererKind::Message,
4342        payload,
4343    )?;
4344    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4345        let text = value.get("text").and_then(|v| v.as_str())?;
4346        let component = Arc::new(AssistantMessageComponent::new(
4347            AssistantMessageOptions::default(),
4348        ));
4349        if let Some(transformer) = transformer {
4350            component.set_markdown_transformer(Some(transformer));
4351        }
4352        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4353        return Some(component);
4354    }
4355    extension_text_component(&value)
4356        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4357}
4358
4359/// Render usage from a completed assistant message through the registered
4360/// message renderers. Hosts without a token-usage renderer return `None`.
4361fn extension_usage_text(
4362    session: Option<&crate::session::ExtensionSessionCell>,
4363    usage: &rpi_ai::types::Usage,
4364) -> Option<String> {
4365    let session = session?;
4366    let payload = serde_json::json!({
4367        "customType": "token-usage",
4368        "usage": usage,
4369    });
4370    let value = invoke_extension_renderer(
4371        session,
4372        rpi_extensions::RegisteredRendererKind::Message,
4373        &payload,
4374    )?;
4375    value
4376        .get("text")
4377        .and_then(|value| value.as_str())
4378        .filter(|text| !text.trim().is_empty())
4379        .map(ToOwned::to_owned)
4380}
4381
4382fn extension_entry_component(
4383    session: &crate::session::ExtensionSessionCell,
4384    custom_type: &str,
4385    data: Option<serde_json::Value>,
4386) -> Option<Arc<dyn rpi_tui::Component>> {
4387    let payload = serde_json::json!({
4388        "customType": custom_type,
4389        "data": data,
4390    });
4391    let value = invoke_extension_renderer(
4392        session,
4393        rpi_extensions::RegisteredRendererKind::Entry,
4394        &payload,
4395    )?;
4396    extension_text_component(&value)
4397}
4398
4399/// Project an assistant message's content into the provider-free
4400/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
4401/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
4402/// are rendered by their own components in the transcript.
4403/// Whether startup intentionally opened a session that already has history.
4404fn launch_restores_history(args: &Args) -> bool {
4405    args.continue_session
4406        || args.resume
4407        || args.session.is_some()
4408        || args.session_id.is_some()
4409        || args.fork.is_some()
4410}
4411
4412fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4413    msg.content
4414        .iter()
4415        .filter_map(|c| match c {
4416            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4417            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4418            Content::Image(image) => base64::engine::general_purpose::STANDARD
4419                .decode(&image.data)
4420                .ok()
4421                .filter(|data| !data.is_empty())
4422                .map(AssistantBlock::Image),
4423            _ => None,
4424        })
4425        .collect()
4426}
4427
4428fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4429    let content = custom
4430        .content
4431        .iter()
4432        .filter_map(|item| match item {
4433            Content::Text(text) => Some(text.text.as_str()),
4434            _ => None,
4435        })
4436        .collect::<Vec<_>>()
4437        .join("\n");
4438    if content.is_empty() {
4439        format!("{}: {}", custom.role, custom.data)
4440    } else {
4441        format!("{}: {}", custom.role, content)
4442    }
4443}
4444
4445/// Path to the enclosing repository's `.git/HEAD`, if any.
4446fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
4447    let mut dir = Some(cwd);
4448    while let Some(current) = dir {
4449        let head = current.join(".git").join("HEAD");
4450        if head.exists() {
4451            return Some(head);
4452        }
4453        dir = current.parent();
4454    }
4455    None
4456}
4457
4458/// Current git branch for `cwd`, or `None` outside a repository / on error.
4459/// Reads `.git/HEAD` directly (no `git` subprocess) so the footer can call it
4460/// cheaply at startup.
4461fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
4462    let mut dir = Some(cwd);
4463    while let Some(current) = dir {
4464        let head = current.join(".git").join("HEAD");
4465        if let Ok(contents) = std::fs::read_to_string(&head) {
4466            let trimmed = contents.trim();
4467            if let Some(reference) = trimmed.strip_prefix("ref: ") {
4468                let branch = reference
4469                    .rsplit('/')
4470                    .next()
4471                    .unwrap_or(reference)
4472                    .trim()
4473                    .to_string();
4474                if !branch.is_empty() {
4475                    return Some(branch);
4476                }
4477            }
4478            if !trimmed.is_empty() {
4479                return Some(trimmed.chars().take(7).collect());
4480            }
4481        }
4482        dir = current.parent();
4483    }
4484    None
4485}
4486
4487/// The name displayed for a model id (last path segment / after the final
4488/// `:`), to keep the footer compact.
4489fn short_model_name(id: &str) -> String {
4490    id.rsplit([':', '/'])
4491        .next()
4492        .filter(|s| !s.is_empty())
4493        .unwrap_or(id)
4494        .to_string()
4495}
4496
4497// ===========================================================================
4498// Streaming run status
4499// ===========================================================================
4500
4501/// The live status of the agent run, fed to the footer + status slot.
4502#[derive(Clone, Copy, PartialEq, Eq)]
4503enum RunStatus {
4504    Idle,
4505    Working,
4506    Aborting,
4507}
4508
4509/// Which selector overlay (if any) is currently swapped into the editor slot.
4510#[derive(Clone, Copy, PartialEq, Eq)]
4511enum SelectorKind {
4512    /// `/model` — available models (live switch via `lane.set_model`).
4513    Model,
4514    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
4515    Thinking,
4516    /// `/tools` — toggle builtin tools on/off.
4517    Tools,
4518    /// `/images` — toggle inline image rendering.
4519    Images,
4520    /// `/session` — browse and switch saved JSONL sessions.
4521    Session,
4522    /// `/theme` — dark / light / monochrome presets applied live.
4523    Theme,
4524    /// `/scoped-models` — multi-toggle Ctrl+M cycle scope.
4525    ScopedModels,
4526    /// `/settings` — interactive settings menu (and its sub-selectors).
4527    Settings,
4528    /// `/tree` — navigate to an existing entry in the current session.
4529    Tree,
4530    /// Extension-provided selector; uses the same keyboard contract.
4531    Extension,
4532}
4533
4534/// The component receiving keys while a selector is open.
4535///
4536/// Plain selectors route straight to the [`SelectList`]; searchable ones route
4537/// through [`SearchableSelectList`] so typing filters instead of being ignored.
4538/// Both variants expose the underlying list for callback registration and
4539/// selection inspection.
4540#[derive(Clone)]
4541enum SelectorView {
4542    /// A bare `SelectList` (no search box).
4543    List(Arc<SelectList>),
4544    /// A `SelectList` wrapped with a fuzzy-filter input.
4545    Searchable(Arc<SearchableSelectList>),
4546    /// The `/settings` menu: value rows cycle in place, submenu rows open a
4547    /// nested selector.
4548    Settings(Arc<SettingsList>),
4549}
4550
4551impl SelectorView {
4552    /// Route a key to the appropriate child.
4553    fn handle_key(&self, key: KeyEvent) {
4554        match self {
4555            Self::List(list) => list.handle_key(key),
4556            Self::Searchable(searchable) => searchable.handle_key(key),
4557            Self::Settings(settings) => settings.handle_key(key),
4558        }
4559    }
4560}
4561
4562/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
4563/// and the render-tick task.
4564struct TuiState {
4565    /// The in-flight streaming assistant message (cleared on finalize).
4566    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4567    /// Tool-execution components keyed by `tool_call_id`.
4568    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4569    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
4570    /// generic tool map so bash output streams into a `BashExecutionComponent`
4571    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
4572    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4573    /// Whether package/custom themes may be selected in this session.
4574    themes_enabled: bool,
4575    /// Persisted display preference toggled by Ctrl+T.
4576    hide_thinking: std::sync::Mutex<bool>,
4577    /// Global tool-output expansion preference toggled by Ctrl+O.
4578    tool_outputs_expanded: std::sync::Mutex<bool>,
4579    /// Whether the native-style terminal progress indicator is enabled.
4580    show_terminal_progress: bool,
4581    /// Run status for the status indicator + interrupt routing.
4582    status: std::sync::Mutex<RunStatus>,
4583    /// Extension status registry (`SetStatus` runtime action). The render tick
4584    /// repaints the footer only when `ext_status_revision` moved.
4585    ext_status: rpi_extensions::ExtensionStatusMailbox,
4586    /// Revision of `ext_status` as of the last footer write.
4587    ext_status_revision: std::sync::atomic::AtomicU64,
4588    /// Cancellation signal for the short phase that starts the persistent JS
4589    /// host and runs `before_agent_start`. The key thread can trigger this
4590    /// directly while the async message loop is awaiting the blocking worker.
4591    js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4592    /// Cancellation signal for a user-initiated `!command` / `!!command` shell
4593    /// run. `Some` while the command executes; Esc/Ctrl+C cancels it. `None`
4594    /// when no user bash is running.
4595    user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
4596    /// `bashExecution` messages produced by `!command` while an agent run was
4597    /// in flight. Flushed to the lane on `AgentEnd` so transcript order matches
4598    /// native pi's `_pendingBashMessages` (never spliced mid-turn).
4599    pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
4600    /// The footer, updated live by the drain task.
4601    footer: Arc<FooterComponent>,
4602    /// The status-container (status slot in the dock) — cleared/filled with a
4603    /// loader while a run is active.
4604    status_container: Arc<Container>,
4605    /// The chat transcript container.
4606    chat_container: Arc<Container>,
4607    /// The active loader shown while `Working`.
4608    loader: Arc<Loader>,
4609    /// The editor, so status changes can drive its top-border working
4610    /// indicator (the spinner + elapsed time shown inside the input box border).
4611    editor: Arc<Editor>,
4612    /// The last finalized assistant text (for `/copy`). Updated by the drain
4613    /// task on `MessageEnd` / `AgentEnd`.
4614    last_assistant_text: std::sync::Mutex<String>,
4615    /// The active selector overlay, swapped into the editor slot. `Some` while
4616    /// a selector is open; the key loop routes to it first and restores the
4617    /// editor on done/cancel.
4618    active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
4619    /// Extension-provided editor currently occupying the input slot.
4620    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4621    /// Single-line input currently occupying the input slot for an extension.
4622    active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4623    /// Callback used to resolve an extension dialog with a cancellation action.
4624    active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4625    /// The autocomplete manager (slash + @file providers) consulted on every
4626    /// editor keystroke.
4627    autocomplete: AutocompleteManager,
4628    /// The container rendered above the editor holding the live autocomplete
4629    /// suggestion list (cleared when there are no suggestions).
4630    autocomplete_container: Arc<Container>,
4631    /// Maximum number of autocomplete rows rendered above the editor.
4632    autocomplete_max_visible: usize,
4633    /// Images queued from clipboard paste and attached to the next prompt.
4634    pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4635    /// The owned theme manager — `/theme` applies presets here. The global
4636    /// `theme()` is read-only after OnceLock init, so per-instance state is the
4637    /// only way to apply a preset at runtime.
4638    theme_manager: Arc<ThemeManager>,
4639    /// The alt-screen handle, held so `set_status` can reflect run state in the
4640    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
4641    /// that never call `set_status` with a title.
4642    tui: Option<Arc<TuiAltScreen>>,
4643    /// The model id currently shown in the footer + used as the Ctrl+M
4644    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
4645    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
4646    current_model_id: std::sync::Mutex<String>,
4647    /// Whether inline image rendering is enabled (`/images` toggle). Stored
4648    /// even though image wiring is minimal this pass — the flag is consulted
4649    /// where images would be shown and echoed back by `/images`.
4650    show_images: std::sync::Mutex<bool>,
4651    /// Transcript notices for prompt-cache costs and provider recovery
4652    /// diagnostics (native pi `showCacheMissNotices`, default `false`).
4653    cache_miss_notices: std::sync::Mutex<bool>,
4654    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
4655    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
4656    history: std::sync::Mutex<Vec<String>>,
4657    /// Browse index while recalling history: -1 = not browsing, 0 = most
4658    /// recent, 1 = older, … Reset to -1 on every submit.
4659    history_index: std::sync::Mutex<isize>,
4660    /// The editor text captured when entering browse mode, restored when the
4661    /// user navigates back past the newest entry (TS `historyDraft`).
4662    history_draft: std::sync::Mutex<Option<String>>,
4663    /// Full cache-waste tracker (pi `detectCacheMiss`/`cache-stats.ts`):
4664    /// counts and prices prompt-cache misses across turns using the same
4665    /// noise floor, idle-gap, and model-change logic as the batch scan.
4666    cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
4667    /// The in-progress scoped-models selection while the `/scoped-models`
4668    /// selector is open (toggle per item, Esc saves). `None` when not editing.
4669    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4670    /// B5e: the live assistant-markdown transformer, built from the current
4671    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
4672    /// when no markdown transformers are registered (identity render path).
4673    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
4674    /// closure no-ops once its snapshot's `active` flag flips false) and
4675    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
4676    /// transform takes effect on the visible streaming message immediately.
4677    /// New assistant components pick up whatever closure is current at
4678    /// construction time via [`install_markdown_transformer`].
4679    markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4680    /// Live extension registry used by message/entry renderer dispatch.
4681    extension_session: crate::session::ExtensionSessionCell,
4682    /// Transcript search handler (Ctrl+Shift+F).
4683    search: Arc<AltScreenSearch>,
4684    /// Search bar component shown when search is active.
4685    search_bar: Arc<SearchBar>,
4686    /// Dock slot listing queued steering/follow-up messages while a run is
4687    /// active (native pi's `pendingMessagesContainer`). Empty when nothing is
4688    /// queued, so it renders zero rows and never affects the layout.
4689    pending_container: Arc<Container>,
4690    /// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`),
4691    /// shown in the pending-messages hint row.
4692    dequeue_hint: String,
4693    /// Last queue snapshot rendered into `pending_container`. Lets
4694    /// `set_pending_queue` skip redundant rebuilds + frame requests when the
4695    /// polled queue is unchanged.
4696    pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
4697    /// Fullscreen selection tracking for auto-copy.
4698    selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4699    selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4700}
4701
4702/// How many submitted messages are kept for ↑ recall (mirrors the TS
4703/// editor's 100-entry cap).
4704const HISTORY_LIMIT: usize = 100;
4705
4706/// Keep a few rows of overlap so page scrolling preserves visual context,
4707/// matching the upstream fullscreen viewport behavior.
4708const PAGE_SCROLL_OVERLAP: usize = 4;
4709
4710/// Native pi scrolls a small chunk for each wheel notch rather than moving the
4711/// transcript one physical row at a time. Three lines stays precise while
4712/// avoiding the sluggish feel of the previous implementation.
4713const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4714
4715/// Parse the compact key notation used by native Pi settings (for example
4716/// `ctrl+g`, `shift+tab`, or `escape`) into crossterm's representation.
4717fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4718    let mut modifiers = KeyModifiers::NONE;
4719    let mut key = None;
4720    for part in value.trim().to_ascii_lowercase().split('+') {
4721        match part {
4722            "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4723            "shift" => modifiers |= KeyModifiers::SHIFT,
4724            "alt" | "option" => modifiers |= KeyModifiers::ALT,
4725            "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4726            part if !part.is_empty() => key = Some(part.to_string()),
4727            _ => {}
4728        }
4729    }
4730    let key = key?;
4731    let code = match key.as_str() {
4732        "esc" | "escape" => KeyCode::Esc,
4733        "enter" | "return" => KeyCode::Enter,
4734        "tab" => {
4735            if modifiers.contains(KeyModifiers::SHIFT) {
4736                return Some(rpi_tui::KeyCombo::new(
4737                    KeyCode::BackTab,
4738                    modifiers & !KeyModifiers::SHIFT,
4739                ));
4740            }
4741            KeyCode::Tab
4742        }
4743        "backspace" | "back" => KeyCode::Backspace,
4744        "delete" | "del" => KeyCode::Delete,
4745        "up" | "arrowup" => KeyCode::Up,
4746        "down" | "arrowdown" => KeyCode::Down,
4747        "left" | "arrowleft" => KeyCode::Left,
4748        "right" | "arrowright" => KeyCode::Right,
4749        "home" => KeyCode::Home,
4750        "end" => KeyCode::End,
4751        "pageup" | "page-up" => KeyCode::PageUp,
4752        "pagedown" | "page-down" => KeyCode::PageDown,
4753        "space" => KeyCode::Char(' '),
4754        "f1" => KeyCode::F(1),
4755        "f2" => KeyCode::F(2),
4756        "f3" => KeyCode::F(3),
4757        "f4" => KeyCode::F(4),
4758        "f5" => KeyCode::F(5),
4759        "f6" => KeyCode::F(6),
4760        "f7" => KeyCode::F(7),
4761        "f8" => KeyCode::F(8),
4762        "f9" => KeyCode::F(9),
4763        "f10" => KeyCode::F(10),
4764        "f11" => KeyCode::F(11),
4765        "f12" => KeyCode::F(12),
4766        value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4767        _ => return None,
4768    };
4769    Some(rpi_tui::KeyCombo::new(code, modifiers))
4770}
4771
4772fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4773    let mut bindings = rpi_tui::Keybindings::new();
4774    let settings = crate::settings::load_settings().unwrap_or_default();
4775    let Some(overrides) = settings.keybindings else {
4776        rpi_tui::set_keybindings(bindings.clone());
4777        return Arc::new(bindings);
4778    };
4779    let known: &[(&str, rpi_tui::KeybindingId)] = &[
4780        ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4781        ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4782        ("app.exit", rpi_tui::keybindings::keys::EXIT),
4783        ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4784        (
4785            "app.model.cycleForward",
4786            rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4787        ),
4788        ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4789        (
4790            "app.thinking.toggle",
4791            rpi_tui::keybindings::keys::THINKING_TOGGLE,
4792        ),
4793        (
4794            "app.editor.external",
4795            rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4796        ),
4797        (
4798            "app.thinking.cycle",
4799            rpi_tui::keybindings::keys::THINKING_CYCLE,
4800        ),
4801        (
4802            "app.clipboard.pasteImage",
4803            rpi_tui::keybindings::keys::PASTE_IMAGE,
4804        ),
4805        ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4806    ];
4807    for (name, id) in known {
4808        let Some(value) = overrides.get(*name) else {
4809            continue;
4810        };
4811        let values: Vec<String> = match value {
4812            serde_json::Value::String(value) => vec![value.clone()],
4813            serde_json::Value::Array(values) => values
4814                .iter()
4815                .filter_map(|v| v.as_str().map(str::to_string))
4816                .collect(),
4817            serde_json::Value::Null => Vec::new(),
4818            _ => continue,
4819        };
4820        let combos: Vec<_> = values
4821            .iter()
4822            .filter_map(|value| parse_configured_key(value))
4823            .collect();
4824        if values.is_empty() || !combos.is_empty() {
4825            bindings.set(id, combos);
4826        }
4827    }
4828    rpi_tui::set_keybindings(bindings.clone());
4829    Arc::new(bindings)
4830}
4831
4832fn keybinding_matches(
4833    bindings: &rpi_tui::Keybindings,
4834    event: &crossterm::event::KeyEvent,
4835    id: rpi_tui::KeybindingId,
4836) -> bool {
4837    if bindings.matches(event, id) {
4838        return true;
4839    }
4840    // crossterm reports Shift+Tab as BackTab on some terminals and as Tab
4841    // plus Shift on others. Treat both forms as the same configured action.
4842    if event.code == KeyCode::BackTab {
4843        let normalized =
4844            crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4845        bindings.matches(&normalized, id)
4846    } else {
4847        false
4848    }
4849}
4850
4851/// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`), used in
4852/// the pending-messages hint row. Mirrors native pi's `getAppKeyDisplay`;
4853/// falls back to native pi's platform default when nothing is configured.
4854fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
4855    let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
4856    if keys.is_empty() {
4857        return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
4858    }
4859    keys.iter()
4860        .map(|combo| combo.display())
4861        .collect::<Vec<_>>()
4862        .join("/")
4863}
4864
4865fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4866    last.is_some_and(|previous| {
4867        now.duration_since(previous) <= std::time::Duration::from_millis(500)
4868    })
4869}
4870
4871/// How long to wait for queued console input before treating a bare Enter as a
4872/// real submit. Pasted input is already in the queue, so this only needs to
4873/// cover scheduler latency — small enough that a human Enter feels instant.
4874const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4875
4876/// Maximum gap between a pasted character and the next pasted key. A human
4877/// cannot deliver two keypresses this fast, so anything tighter is paste.
4878const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4879
4880/// Whether a bare Enter is pasted content (insert a newline) rather than a
4881/// submit.
4882///
4883/// `crossterm` 0.27 only implements bracketed paste on Unix; on Windows the
4884/// console event source never produces `Event::Paste`, so a pasted block
4885/// arrives as plain key events with a bare Enter per line. Two timing signals
4886/// separate a pasted Enter from a typed one:
4887///
4888/// - `more_queued`: real key input is already sitting in the console input
4889///   queue. Callers must feed this through [`probe_paste_input`], which drops
4890///   the key's own Release events — Windows queues press+release together over
4891///   RDP, and a bare poll cannot tell that Release apart from paste content.
4892/// - `last_text_key_at`: the Enter lands within [`PASTE_BURST_GAP`] of the
4893///   preceding pasted character, which also catches a paste that *ends* with a
4894///   newline (nothing queued behind it, but it was not typed by hand).
4895///
4896/// NOTE: the caller gates this on `cfg!(windows)`. On Unix, bracketed paste
4897/// delivers real pastes as `Event::Paste`, and the timing heuristic would
4898/// misclassify a remote/mobile client's coalesced text+Enter burst as paste
4899/// ("回车变成了换行").
4900fn enter_is_paste_burst(
4901    last_text_key_at: Option<std::time::Instant>,
4902    now: std::time::Instant,
4903    more_queued: bool,
4904) -> bool {
4905    if more_queued {
4906        return true;
4907    }
4908    last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4909}
4910
4911/// Decide whether more *real* input is queued behind the current key, for the
4912/// Windows paste-burst check.
4913///
4914/// Windows emits a `KeyEventKind::Release` for every press, usually queued
4915/// right behind the `Press`. A bare non-blocking poll therefore reports "more
4916/// input" for a lone Enter — which made a remote/RDP Enter look like a paste
4917/// burst. This drains and discards release events and returns the first
4918/// *meaningful* event it read (stashed so the caller's loop still processes it)
4919/// plus whether any real input was queued. `next_event` returns `None` when
4920/// nothing is available within the probe window.
4921fn probe_paste_input(
4922    pending: Option<Event>,
4923    mut next_event: impl FnMut() -> Option<Event>,
4924) -> (bool, Option<Event>) {
4925    let mut pending = pending;
4926    let mut more_queued = pending.is_some();
4927    while !more_queued {
4928        match next_event() {
4929            Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
4930            Some(ev) => {
4931                pending = Some(ev);
4932                more_queued = true;
4933            }
4934            None => break,
4935        }
4936    }
4937    (more_queued, pending)
4938}
4939
4940/// Milliseconds since the last text key, for the [`enter_is_paste_burst`] trace
4941/// line (`-` when there was no preceding text key).
4942fn gap_ms(last_text_key_at: Option<std::time::Instant>) -> String {
4943    match last_text_key_at {
4944        Some(previous) => std::time::Instant::now()
4945            .duration_since(previous)
4946            .as_millis()
4947            .to_string(),
4948        None => "-".to_string(),
4949    }
4950}
4951
4952fn transcript_page_size(viewport_height: usize) -> i32 {
4953    viewport_height
4954        .saturating_sub(PAGE_SCROLL_OVERLAP)
4955        .max(1)
4956        .min(i32::MAX as usize) as i32
4957}
4958
4959fn should_dispatch_key(kind: KeyEventKind) -> bool {
4960    kind != KeyEventKind::Release
4961}
4962
4963/// Last-resort quit, run on the key worker thread.
4964///
4965/// The async loop owns `run_prompt_streaming(..).await` for the whole run, so a
4966/// queued [`TuiMessage::Exit`] is not read while a run is in flight. When the
4967/// user asks to quit a second time — the first Ctrl+C only reached `Aborting` —
4968/// treat the run as wedged, restore the terminal, and exit.
4969///
4970/// `130` is the conventional `128 + SIGINT` status, so a wrapper script can tell
4971/// a forced quit from a clean one.
4972fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
4973    // Best effort: leave the alternate buffer and restore cooked mode so the
4974    // user's shell is usable afterwards. `stop` also joins the render
4975    // scheduler, so no in-flight frame can repaint over the restored screen.
4976    tui.stop(Default::default());
4977    eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
4978    std::process::exit(130);
4979}
4980
4981/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
4982fn format_tokens(n: i64) -> String {
4983    if n >= 1_000_000 {
4984        format!("{:.1}M", n as f64 / 1_000_000.0)
4985    } else if n >= 1_000 {
4986        format!("{:.1}K", n as f64 / 1_000.0)
4987    } else {
4988        n.to_string()
4989    }
4990}
4991
4992/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
4993/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
4994/// resets the browse state so a fresh prompt never resumes mid-history.
4995fn push_history(state: &Arc<TuiState>, text: &str) {
4996    let trimmed = text.trim().to_string();
4997    if trimmed.is_empty() {
4998        return;
4999    }
5000    let mut history = state.history.lock().unwrap();
5001    if history.first() == Some(&trimmed) {
5002        return;
5003    }
5004    history.insert(0, trimmed);
5005    history.truncate(HISTORY_LIMIT);
5006    *state.history_index.lock().unwrap() = -1;
5007    *state.history_draft.lock().unwrap() = None;
5008}
5009
5010/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
5011/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
5012/// current editor text as the draft; navigating back past the newest entry
5013/// restores that draft.
5014fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
5015    let history = state.history.lock().unwrap();
5016    if history.is_empty() {
5017        return;
5018    }
5019    let mut index = state.history_index.lock().unwrap();
5020    let new_index = *index - direction as isize;
5021    if new_index < -1 || new_index >= history.len() as isize {
5022        return;
5023    }
5024    if *index == -1 && new_index >= 0 {
5025        // Entering browse mode: stash the current input.
5026        *state.history_draft.lock().unwrap() = Some(editor.get_text());
5027    }
5028    *index = new_index;
5029    if new_index == -1 {
5030        // Exited browse mode: restore the draft (or clear if there was none).
5031        let draft = state.history_draft.lock().unwrap().take();
5032        match draft {
5033            Some(d) => {
5034                let len = d.len();
5035                editor.set_text(&d);
5036                editor.set_cursor(0, len);
5037            }
5038            None => editor.set_text(""),
5039        }
5040    } else {
5041        let text = history[new_index as usize].clone();
5042        let len = text.len();
5043        editor.set_text(&text);
5044        editor.set_cursor(0, len);
5045    }
5046}
5047
5048impl TuiState {
5049    fn begin_js_preparation(&self) -> CancellationToken {
5050        let cancellation = CancellationToken::new();
5051        if let Some(previous) = self
5052            .js_preparation_cancel
5053            .lock()
5054            .unwrap()
5055            .replace(cancellation.clone())
5056        {
5057            previous.cancel();
5058        }
5059        cancellation
5060    }
5061
5062    fn finish_js_preparation(&self) {
5063        self.js_preparation_cancel.lock().unwrap().take();
5064    }
5065
5066    fn cancel_js_preparation(&self) -> bool {
5067        let cancellation = self.js_preparation_cancel.lock().unwrap().take();
5068        if let Some(cancellation) = cancellation {
5069            cancellation.cancel();
5070            true
5071        } else {
5072            false
5073        }
5074    }
5075
5076    /// Whether a user-initiated `!command` shell run is currently active.
5077    fn user_bash_running(&self) -> bool {
5078        self.user_bash_cancel.lock().unwrap().is_some()
5079    }
5080
5081    /// Register a cancellation token for a new user bash run, cancelling any
5082    /// previous one (mirrors `begin_js_preparation`).
5083    fn begin_user_bash(&self) -> CancellationToken {
5084        let cancellation = CancellationToken::new();
5085        if let Some(previous) = self
5086            .user_bash_cancel
5087            .lock()
5088            .unwrap()
5089            .replace(cancellation.clone())
5090        {
5091            previous.cancel();
5092        }
5093        cancellation
5094    }
5095
5096    /// Clear the user-bash slot. Called when the command finishes so the
5097    /// running guard and the Esc router both see idle state.
5098    fn finish_user_bash(&self) {
5099        self.user_bash_cancel.lock().unwrap().take();
5100    }
5101
5102    /// Cancel a running user bash command. Returns `true` when one was active
5103    /// (mirrors `cancel_js_preparation`). The spawned task clears the slot
5104    /// itself once `execute_shell_with_capture` returns.
5105    fn cancel_user_bash(&self) -> bool {
5106        let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
5107        if let Some(cancellation) = cancellation {
5108            cancellation.cancel();
5109            true
5110        } else {
5111            false
5112        }
5113    }
5114
5115    fn set_status(&self, status: RunStatus) {
5116        *self.status.lock().unwrap() = status;
5117        self.apply_status(status);
5118    }
5119
5120    /// The dock container listing queued messages (native pi's
5121    /// `pendingMessagesContainer`).
5122    fn pending_container(&self) -> &Arc<Container> {
5123        &self.pending_container
5124    }
5125
5126    /// Render the current queue into the pending dock slot. Mirrors native
5127    /// pi's `updatePendingMessagesDisplay`: `Steering: <text>` /
5128    /// `Follow-up: <text>` rows (dim) plus a `↳ <key> to edit all queued
5129    /// messages` hint. A no-op when the snapshot is unchanged, so the poll
5130    /// caller can invoke it freely without forcing frames.
5131    fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
5132        {
5133            let mut current = self.pending_snapshot.lock().unwrap();
5134            if *current == messages {
5135                return;
5136            }
5137            *current = messages.clone();
5138        }
5139        let container = &self.pending_container;
5140        container.clear();
5141        if messages.is_empty() {
5142            return;
5143        }
5144        let colors = current_theme().colors;
5145        container.add_child(Arc::new(Spacer::new(1)));
5146        for text in &messages.steering {
5147            container.add_child(Arc::new(Text::new(
5148                colors.muted.fg(&format!("Steering: {text}")),
5149                1,
5150                0,
5151            )));
5152        }
5153        for text in &messages.follow_up {
5154            container.add_child(Arc::new(Text::new(
5155                colors.muted.fg(&format!("Follow-up: {text}")),
5156                1,
5157                0,
5158            )));
5159        }
5160        container.add_child(Arc::new(Text::new(
5161            colors.muted.fg(&format!(
5162                "↳ {} to edit all queued messages",
5163                self.dequeue_hint
5164            )),
5165            1,
5166            0,
5167        )));
5168    }
5169
5170    /// Atomically reserve the single interactive run slot. The editor callback
5171    /// runs on a different thread from the async prompt loop, so checking and
5172    /// setting in separate steps would allow rapid Enter presses to queue more
5173    /// than one operation.
5174    fn try_start_working(&self) -> bool {
5175        let mut status = self.status.lock().unwrap();
5176        if *status != RunStatus::Idle {
5177            return false;
5178        }
5179        *status = RunStatus::Working;
5180        drop(status);
5181        self.apply_status(RunStatus::Working);
5182        true
5183    }
5184
5185    fn apply_status(&self, status: RunStatus) {
5186        match status {
5187            RunStatus::Working => {
5188                // The status dock already shows the active loader. Keep the
5189                // footer focused on the model and shortcuts instead of
5190                // repeating a second `Working…` at the bottom of the TUI.
5191                self.footer.set_status("");
5192                // Reflect the in-flight turn in the terminal window/tab title
5193                // (OSC 2). No-op when `tui` is absent (unit tests).
5194                if let Some(tui) = &self.tui {
5195                    tui.set_title("🦀π rpi ⟳");
5196                }
5197                self.status_container.clear();
5198                // The working indicator renders inside the editor's top border.
5199                // `show_terminal_progress` disables it entirely.
5200                if self.show_terminal_progress {
5201                    self.editor.set_working(Some(WorkingState {
5202                        frame: 0,
5203                        started_at: std::time::Instant::now(),
5204                        message: "Working…".to_string(),
5205                    }));
5206                } else {
5207                    self.editor.set_working(None);
5208                }
5209            }
5210            RunStatus::Aborting => {
5211                self.footer.set_status("Aborting…");
5212                // Do not leave a frozen "Working" spinner on screen after the
5213                // render tick intentionally stops advancing in this state.
5214                self.loader.stop();
5215                self.status_container.clear();
5216                self.editor.set_working(None);
5217            }
5218            RunStatus::Idle => {
5219                self.footer.set_status("");
5220                if let Some(tui) = &self.tui {
5221                    tui.set_title("🦀π rpi");
5222                }
5223                self.loader.stop();
5224                self.status_container.clear();
5225                self.editor.set_working(None);
5226            }
5227        }
5228    }
5229
5230    /// Push the extension status registry into the footer when it changed.
5231    /// Returns `true` when the footer was rewritten (the caller then requests a
5232    /// repaint — an idle session would otherwise never redraw).
5233    fn sync_extension_status(&self) -> bool {
5234        let revision = self.ext_status.revision();
5235        if self
5236            .ext_status_revision
5237            .load(std::sync::atomic::Ordering::SeqCst)
5238            == revision
5239        {
5240            return false;
5241        }
5242        self.ext_status_revision
5243            .store(revision, std::sync::atomic::Ordering::SeqCst);
5244        self.footer.set_extension_status(&self.ext_status.text());
5245        true
5246    }
5247
5248    /// The bash panel has its own `Running...` spinner. Keep the global
5249    /// `Working...` loader out of the status slot while any bash tool is active
5250    /// so the same operation is not presented as two simultaneous loaders.
5251    fn sync_working_loader_with_bash(&self) {
5252        if *self.status.lock().unwrap() != RunStatus::Working {
5253            return;
5254        }
5255
5256        // The working indicator now lives inside the editor's top border and
5257        // stays visible for the whole Working state, so no status-slot swap is
5258        // needed when bash panels (which render their own spinner in the
5259        // transcript) appear or disappear.
5260        self.status_container.clear();
5261    }
5262
5263    fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
5264        *self.status.lock().unwrap() = RunStatus::Working;
5265        self.footer.set_status("");
5266        if let Some(tui) = &self.tui {
5267            tui.set_title("🦀π rpi ↻");
5268        }
5269        self.loader.stop();
5270        self.status_container.clear();
5271        self.status_container
5272            .add_child(Arc::new(StatusIndicator::retry(
5273                attempt,
5274                max_retries,
5275                std::time::Duration::from_millis(delay_ms),
5276            )));
5277    }
5278
5279    /// Whether a selector overlay is currently open (routes keys to it first).
5280    fn selector_open(&self) -> bool {
5281        self.active_selector.lock().unwrap().is_some()
5282    }
5283
5284    fn extension_editor_open(&self) -> bool {
5285        self.active_extension_editor.lock().unwrap().is_some()
5286    }
5287
5288    fn extension_input_open(&self) -> bool {
5289        self.active_extension_input.lock().unwrap().is_some()
5290    }
5291
5292    fn extension_dialog_open(&self) -> bool {
5293        self.extension_editor_open() || self.extension_input_open()
5294    }
5295
5296    fn set_hide_thinking(&self, hide: bool) {
5297        *self.hide_thinking.lock().unwrap() = hide;
5298        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5299            comp.set_hide_thinking(hide);
5300        }
5301    }
5302
5303    fn hide_thinking(&self) -> bool {
5304        *self.hide_thinking.lock().unwrap()
5305    }
5306
5307    fn toggle_thinking(&self) -> bool {
5308        let next = !self.hide_thinking();
5309        self.set_hide_thinking(next);
5310        next
5311    }
5312
5313    fn toggle_tool_outputs(&self) -> bool {
5314        let next = !*self.tool_outputs_expanded.lock().unwrap();
5315        *self.tool_outputs_expanded.lock().unwrap() = next;
5316        for comp in self.tool_components.lock().unwrap().values() {
5317            comp.set_expanded(next);
5318        }
5319        for comp in self.bash_components.lock().unwrap().values() {
5320            comp.set_expanded(next);
5321        }
5322        next
5323    }
5324
5325    /// The model id currently tracked as active (footer + Ctrl+M anchor).
5326    fn current_model_id(&self) -> String {
5327        self.current_model_id.lock().unwrap().clone()
5328    }
5329
5330    /// Update the tracked model id + footer label after a switch (live or
5331    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
5332    fn set_current_model(&self, model: &rpi_ai::Model) {
5333        *self.current_model_id.lock().unwrap() = model.id.clone();
5334        self.footer.set_model(&short_model_name(&model.id));
5335        // A model switch changes the context window, so the `%/{window}` badge
5336        // must rescale against the same last-reported token count.
5337        self.footer.set_context_window(model.context_window as i64);
5338    }
5339
5340    /// B5e: read a clone of the current assistant-markdown transformer (if any).
5341    /// New assistant components call this at construction so they render with
5342    /// whatever plugin `register_markdown_transformer` handlers are live.
5343    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
5344        self.markdown_transformer.lock().unwrap().clone()
5345    }
5346
5347    /// B5e: swap the live transformer. Used at startup (install the first
5348    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
5349    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
5350    /// transform should take effect on the VISIBLE streaming message too, so
5351    /// this re-installs on the in-flight `current_assistant` component — its
5352    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
5353    /// `None` clears the transform (identity), e.g. a reload that unregisters
5354    /// every markdown transformer.
5355    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
5356        *self.markdown_transformer.lock().unwrap() = transformer.clone();
5357        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5358            comp.set_markdown_transformer(transformer);
5359        }
5360    }
5361
5362    fn queue_image(&self, image: rpi_ai::types::ImageContent) {
5363        self.pending_images.lock().unwrap().push(image);
5364    }
5365
5366    fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
5367        std::mem::take(&mut *self.pending_images.lock().unwrap())
5368    }
5369}
5370
5371// ===========================================================================
5372// interactive_tui — the entry point
5373// ===========================================================================
5374
5375#[derive(Debug, PartialEq)]
5376struct TuiStartupSettings {
5377    editor_padding_x: usize,
5378    autocomplete_max_visible: usize,
5379    hide_thinking: bool,
5380    quiet_startup: bool,
5381    show_terminal_progress: bool,
5382    /// Render images inline (`terminal.showImages` / flat `showImages`).
5383    show_images: bool,
5384    /// Transcript notices for prompt-cache costs (`showCacheMissNotices`).
5385    cache_miss_notices: bool,
5386}
5387
5388fn preferred_project_setting<T>(
5389    project_settings: &[crate::settings::Settings],
5390    field: impl Fn(&crate::settings::Settings) -> Option<T>,
5391) -> Option<T> {
5392    project_settings.iter().find_map(field)
5393}
5394
5395fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
5396    verbose || !quiet_startup
5397}
5398
5399fn git_only_update_resources(
5400    mut resources: crate::packages::PackageResources,
5401) -> crate::packages::PackageResources {
5402    resources
5403        .packages
5404        .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
5405    resources
5406}
5407
5408/// Resolve TUI-only settings with native Pi's project-over-global precedence.
5409/// `project_settings` must be ordered `.rpi` then `.pi`; an explicit `Some`
5410/// wins even when the value is `false` or zero.
5411fn resolve_tui_startup_settings(
5412    global: &crate::settings::Settings,
5413    project_settings: &[crate::settings::Settings],
5414    project_trusted: bool,
5415) -> TuiStartupSettings {
5416    let project_settings = if project_trusted {
5417        project_settings
5418    } else {
5419        &[]
5420    };
5421    let editor_padding_x =
5422        preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
5423            .or(global.editor_padding_x)
5424            .unwrap_or(1)
5425            .min(16);
5426    let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
5427        settings.autocomplete_max_visible
5428    })
5429    .or(global.autocomplete_max_visible)
5430    .unwrap_or(5)
5431    .clamp(1, 20);
5432    let hide_thinking =
5433        preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
5434            .or(global.hide_thinking_block)
5435            .unwrap_or(false);
5436    let quiet_startup =
5437        preferred_project_setting(project_settings, |settings| settings.quiet_startup)
5438            .or(global.quiet_startup)
5439            .unwrap_or(false);
5440    let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
5441        settings.show_terminal_progress()
5442    })
5443    .or_else(|| global.show_terminal_progress())
5444    .unwrap_or(true);
5445    let show_images =
5446        preferred_project_setting(project_settings, |settings| settings.show_images())
5447            .or_else(|| global.show_images())
5448            .unwrap_or(true);
5449    let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
5450        settings.show_cache_miss_notices
5451    })
5452    .or(global.show_cache_miss_notices)
5453    .unwrap_or(false);
5454
5455    TuiStartupSettings {
5456        editor_padding_x,
5457        autocomplete_max_visible,
5458        hide_thinking,
5459        quiet_startup,
5460        show_terminal_progress,
5461        show_images,
5462        cache_miss_notices,
5463    }
5464}
5465
5466/// TUI-based interactive mode.
5467///
5468/// `event_rx` carries the live `AgentEvent` stream (installed by
5469/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
5470/// fn), it falls back to a blocking, await-final-text path.
5471///
5472/// `model_catalog` is the read-only catalog the `/model` selector displays.
5473///
5474/// This implementation mirrors the TypeScript `InteractiveMode` class:
5475/// build the layout root once, drain `AgentEvent`s into UI mutations that
5476/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
5477/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
5478/// autocomplete are layered on via the editor-container swap pattern.
5479pub async fn interactive_tui(
5480    harness: &AgentHarness,
5481    event_rx: Option<broadcast::Receiver<AgentEvent>>,
5482    args: &Args,
5483    model_catalog: Vec<rpi_ai::Model>,
5484    initial: Option<String>,
5485    extra_messages: &[String],
5486    initial_images: Vec<rpi_ai::types::ImageContent>,
5487    theme: Option<&str>,
5488    no_themes: bool,
5489    reload_context: &crate::session::ReloadContext,
5490) -> i32 {
5491    let lane: Arc<dyn AgentLane> = harness.lane("main");
5492    // Reuse the startup snapshot captured before provider/session setup. An
5493    // extension may mutate the process cwd while loading; that must not change
5494    // which project settings or package update roots this TUI observes.
5495    let cwd = reload_context.cwd.clone();
5496    let saved_settings = crate::settings::load_settings().unwrap_or_default();
5497    let project_trusted = reload_context.project_trusted;
5498    let project_settings = if project_trusted {
5499        crate::settings::load_project_settings(&cwd)
5500    } else {
5501        Vec::new()
5502    };
5503    let tui_settings =
5504        resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5505    let editor_padding_x = tui_settings.editor_padding_x;
5506    let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5507    let mut hide_thinking = tui_settings.hide_thinking;
5508    let show_terminal_progress = tui_settings.show_terminal_progress;
5509    let show_images = tui_settings.show_images;
5510    let cache_miss_notices = tui_settings.cache_miss_notices;
5511    let quiet_startup = tui_settings.quiet_startup;
5512    let update_checks_enabled =
5513        !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
5514
5515    // Session display preferences (durable, latest-wins) override the
5516    // settings-file defaults. These are per-session facts stored as custom
5517    // entries (see `rpi_harness::session::values`), so resuming a session with
5518    // `-c`/`-r` restores the thinking/tool-output display the user last chose
5519    // there — independent of global settings. Absent keys leave the settings
5520    // value untouched.
5521    let session_values = rpi_harness::session::SessionValues::load(harness.session())
5522        .await
5523        .unwrap_or_default();
5524    if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5525        hide_thinking = persisted;
5526    }
5527
5528    // Resolve the active model once, up front. The full id feeds the TuiState
5529    // tracking field + the selectors/key loop (which run on a blocking thread
5530    // and can't await `lane.get_model()`); the short name feeds the footer.
5531    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5532    let model_name = short_model_name(&lane_model_id);
5533
5534    // Snapshot startup capabilities for the welcome screen. Both accessors
5535    // return defensive clones, so rendering this summary does not retain a
5536    // harness lock or trigger a second resource scan.
5537    let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5538    // AgentHarness uses an empty active-name list as the default "all tools"
5539    // state. Do not expose that implementation sentinel as `Tools (0) none`
5540    // in the welcome banner (it is especially visible on the first prompt).
5541    if active_tool_names.is_empty() {
5542        active_tool_names = harness
5543            .get_tools()
5544            .await
5545            .unwrap_or_default()
5546            .into_iter()
5547            .map(|tool| tool.tool.schema().name.clone())
5548            .collect();
5549    }
5550    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5551    let skill_names: Vec<String> = resources_snapshot
5552        .skills
5553        .as_deref()
5554        .unwrap_or(&[])
5555        .iter()
5556        .map(|skill| skill.name.clone())
5557        .collect();
5558
5559    // Resolve package resources for @file autocomplete + session discovery.
5560    let package_resources = reload_context.package_resources.clone();
5561
5562    // Channel between the key/callback threads and the main async loop.
5563    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
5564
5565    // Apply the saved theme before constructing transcript components. Some
5566    // components keep styled text, so doing this after the welcome banner left
5567    // the first screen in the dark palette until it was rebuilt.
5568    let theme_manager = Arc::new(ThemeManager::new());
5569    if let Some(preset) = match theme {
5570        Some("light") => Some(ThemePreset::Light),
5571        Some("monochrome") => Some(ThemePreset::Monochrome),
5572        Some("dark") => Some(ThemePreset::Dark),
5573        _ => None,
5574    } {
5575        apply_theme_preset(preset);
5576        theme_manager.apply_preset(preset);
5577    } else if !no_themes {
5578        if let Some(name) = theme {
5579            match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
5580                Ok(custom) => {
5581                    rpi_tui::global_theme_manager().set(custom.clone());
5582                    theme_manager.set(custom);
5583                }
5584                Err(error) => {
5585                    eprintln!("warning: could not load package theme `{name}`: {error}");
5586                }
5587            }
5588        }
5589    }
5590
5591    // ---- TUI + containers ----
5592    let terminal = Box::new(ProcessTerminal::new());
5593    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
5594    let js_dialog_bridge = Arc::new(JsDialogBridge::default());
5595    // Plugin `ask_user` prompts (runtime action 17). Attaching marks this
5596    // session as interactive so a detached/headless host fails loudly instead
5597    // of returning a fabricated answer. The mailbox is session-scoped (held in
5598    // `ReloadContext`) so in-flight prompts survive a `/reload`.
5599    let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
5600    ask_user_bridge.attach();
5601    tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
5602
5603    if let Some(js) = &reload_context.js_extension_session {
5604        if let Err(error) = js.install_ui_runtime(tui.clone()) {
5605            eprintln!("warning: could not enable JS custom UI bridge: {error}");
5606        } else if let Some(js_active) = js.active_tools() {
5607            // Apply the discovery-time JS subset while preserving Rust
5608            // built-ins already active in the harness lane. The real TUI
5609            // lifecycle reconciliation runs after the key worker starts below.
5610            let js_names = js.tool_names();
5611            let mut active = lane.get_active_tools().await.unwrap_or_default();
5612            active.retain(|name| {
5613                crate::session::tool_name_allowed(name, args)
5614                    && !js_names.iter().any(|js_name| js_name == name)
5615            });
5616            active.extend(js_active.into_iter().filter(|name| {
5617                js_names.iter().any(|js_name| js_name == name)
5618                    && crate::session::tool_name_allowed(name, args)
5619            }));
5620            active = crate::session::filter_active_tool_names(active, args);
5621            let _ = lane.set_active_tools(active).await;
5622            active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5623        }
5624    }
5625
5626    let chat_container = Arc::new(Container::new());
5627    if let Some(js) = &reload_context.js_extension_session {
5628        // Tool contexts do not have a command-result envelope. Route
5629        // `ctx.ui.notify()` through the live transcript so notifications from
5630        // tools such as ask_user_question are visible immediately.
5631        let chat_notify = chat_container.clone();
5632        let tui_notify = tui.clone();
5633        if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
5634            if action != "ui.notify" {
5635                return Err(format!("unsupported capability: {action}"));
5636            }
5637            let message = args
5638                .get("message")
5639                .and_then(serde_json::Value::as_str)
5640                .unwrap_or_default();
5641            if !message.is_empty() {
5642                if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
5643                    add_error_message(&chat_notify, message);
5644                } else {
5645                    add_note_message(&chat_notify, message);
5646                }
5647                tui_notify.request_render(false);
5648            }
5649            Ok(serde_json::json!(true))
5650        })) {
5651            if args.verbose {
5652                eprintln!("warning: could not enable JS notification bridge: {error}");
5653            }
5654        }
5655    }
5656    if should_show_startup_listing(args.verbose, quiet_startup) {
5657        add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5658    }
5659
5660    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
5661    // banner + the earendil announcement once, then write the sentinel. The TS
5662    // original is a multi-step dialog (theme picker + analytics opt-in); this
5663    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
5664    // analytics deferred — no telemetry wiring). See `extras.rs`.
5665    crate::extras::maybe_first_time_setup(&chat_container);
5666
5667    // A --continue/--resume/--session launch opens on an existing JSONL
5668    // session — render its prior user/assistant transcript so the user sees
5669    // where they left off (tool executions are skipped: their live display
5670    // belongs to the current run, and replaying old results would be noise).
5671    let initial_transformer = build_markdown_transformer(
5672        reload_context
5673            .extension_session
5674            .lock()
5675            .unwrap()
5676            .snapshot_arc(),
5677    );
5678    // A normal launch creates a fresh session and must not replay records from
5679    // another/project harness. Only explicit restore/fork modes render prior
5680    // conversation history. This fixes stale prompts appearing every startup.
5681    if launch_restores_history(args) {
5682        render_session_history(
5683            &harness,
5684            &chat_container,
5685            initial_transformer.clone(),
5686            Some(reload_context.extension_session.clone()),
5687        )
5688        .await;
5689    }
5690
5691    // `document_container` wraps the welcome header + chat so the scrollview
5692    // follows the whole transcript (mirrors TS `documentContainer`).
5693    let document_container = Arc::new(Container::new());
5694    document_container.add_child(chat_container.clone());
5695    // Add bottom padding to the output area for visual breathing room.
5696    document_container.add_child(Arc::new(Spacer::new(1)));
5697
5698    let scroll_view = Arc::new(ScrollView::new(
5699        document_container.clone(),
5700        ScrollViewOptions {
5701            follow: FollowMode::End,
5702            primary: true,
5703            overscroll: OverscrollMode::Chain,
5704            // Native pi keeps transcript chrome out of the way. Our Auto mode
5705            // has no hide timer yet and therefore became effectively permanent
5706            // after the first wheel event, unlike the upstream experience.
5707            scrollbar: ScrollbarMode::Hidden,
5708            ..Default::default()
5709        },
5710    ));
5711
5712    // ---- Editor ----
5713    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
5714    // editor renders full-width `─` top/bottom borders with padding-only lines
5715    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
5716    let keybindings = configured_keybindings();
5717    let editor = Arc::new(Editor::new(
5718        EditorOptions {
5719            padding_x: editor_padding_x,
5720            autocomplete_max_visible,
5721            ..Default::default()
5722        },
5723        EditorStyle::default(),
5724        keybindings.clone(),
5725    ));
5726
5727    // Bash-mode border: a `!`-prefixed draft recolors the editor border to the
5728    // bash accent (native pi's `updateEditorBorderColor` / `isBashMode`). The
5729    // editor fires `on_change` for keystrokes and for programmatic
5730    // `clear`/`set_text`, so the color also resets after a `!command` submits.
5731    {
5732        let editor_for_change = editor.clone();
5733        editor.on_change(Arc::new(move |text: &str| {
5734            let colors = current_theme().colors;
5735            let is_bash = text.trim_start().starts_with('!');
5736            editor_for_change.set_border_color(if is_bash {
5737                Some(colors.bash_mode)
5738            } else {
5739                None
5740            });
5741        }));
5742    }
5743
5744    // ---- Footer + status ----
5745    let footer = Arc::new(FooterComponent::new());
5746    footer.set_model(&model_name);
5747    footer.set_cwd(&cwd.to_string_lossy());
5748    footer.set_git_branch(git_branch_for(&cwd).as_deref());
5749    if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5750        footer.set_context_window(m.context_window as i64);
5751    }
5752    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");
5753
5754    // Live git-branch refresh (native fs-watch on `.git/HEAD`): a checkout or
5755    // commit updates the footer's branch without a manual refresh.
5756    let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5757        let footer_ref = footer.clone();
5758        let cwd_ref = cwd.clone();
5759        crate::fs_watch::watch_path(head, move || {
5760            footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5761        })
5762    });
5763
5764    let status_container = Arc::new(Container::new());
5765    let loader = Arc::new(Loader::with_text("Working…"));
5766
5767    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
5768    // Prompt templates discovered at session build (Part A2) are surfaced as
5769    // `/`-prefixed entries alongside the built-in slash commands: typing
5770    // `/<name>` in the editor expands the template (mirrors pi
5771    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
5772    // the template's frontmatter description (or a fallback) so the autocomplete
5773    // popover shows what each template does.
5774    //
5775    // We snapshot the full resources once (skills + prompt-templates): the
5776    // autocomplete builder consumes the templates, and the `/context` command
5777    // (fired from the blocking submit handler, which can't `.await`) reads the
5778    // snapshot to render the discovered-resources panel without touching the
5779    // harness async accessor.
5780    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5781        .prompt_templates
5782        .clone()
5783        .unwrap_or_default()
5784        .iter()
5785        .map(|t| SlashCommandEntry {
5786            name: format!("/{}", t.name),
5787            description: t
5788                .description
5789                .clone()
5790                .unwrap_or_else(|| "Expand prompt template".to_string()),
5791        })
5792        .collect();
5793    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5794        Arc::new(resources_snapshot);
5795    // Build the built-in command registry once — the single source of truth for
5796    // both dispatch and the built-in autocomplete entries. The discovered
5797    // prompt-template commands are merged into the autocomplete list separately
5798    // (they dispatch via template expansion, not the registry); built-ins come
5799    // first so they win on a fuzzy tie.
5800    let mut command_registry = build_builtin_registry();
5801    register_extension_commands(
5802        &mut command_registry,
5803        reload_context.extension_session.clone(),
5804    );
5805    register_js_extension_commands(
5806        &mut command_registry,
5807        reload_context.js_extension_session.clone(),
5808    );
5809    let registry = Arc::new(command_registry);
5810    let mut all_slash_commands = registry.visible_entries();
5811    all_slash_commands.extend(template_slash_commands);
5812    let autocomplete = AutocompleteManager::new();
5813    {
5814        let mut combined = CombinedAutocompleteProvider::new();
5815        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5816            all_slash_commands,
5817        )));
5818        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5819            cwd.clone(),
5820        )));
5821        autocomplete.set_provider(Arc::new(combined));
5822    }
5823    let autocomplete_container = Arc::new(Container::new());
5824
5825    let tool_outputs_expanded = session_values
5826        .get_as::<bool>("display.tool_outputs_expanded")
5827        .unwrap_or(false);
5828    let state = Arc::new(TuiState {
5829        current_assistant: std::sync::Mutex::new(None),
5830        tool_components: std::sync::Mutex::new(HashMap::new()),
5831        bash_components: std::sync::Mutex::new(HashMap::new()),
5832        themes_enabled: !no_themes,
5833        hide_thinking: std::sync::Mutex::new(hide_thinking),
5834        tool_outputs_expanded: std::sync::Mutex::new(tool_outputs_expanded),
5835        show_terminal_progress,
5836        status: std::sync::Mutex::new(RunStatus::Idle),
5837        ext_status: reload_context.ext_status.clone(),
5838        ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
5839        js_preparation_cancel: std::sync::Mutex::new(None),
5840        user_bash_cancel: std::sync::Mutex::new(None),
5841        pending_bash_messages: std::sync::Mutex::new(Vec::new()),
5842        footer: footer.clone(),
5843        status_container: status_container.clone(),
5844        chat_container: chat_container.clone(),
5845        loader: loader.clone(),
5846        editor: editor.clone(),
5847        last_assistant_text: std::sync::Mutex::new(String::new()),
5848        active_selector: std::sync::Mutex::new(None),
5849        active_extension_editor: std::sync::Mutex::new(None),
5850        active_extension_input: std::sync::Mutex::new(None),
5851        active_extension_cancel: std::sync::Mutex::new(None),
5852        autocomplete,
5853        autocomplete_container: autocomplete_container.clone(),
5854        autocomplete_max_visible,
5855        pending_images: std::sync::Mutex::new(Vec::new()),
5856        theme_manager,
5857        tui: Some(tui.clone()),
5858        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5859        show_images: std::sync::Mutex::new(show_images),
5860        cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
5861        history: std::sync::Mutex::new(Vec::new()),
5862        history_index: std::sync::Mutex::new(-1),
5863        history_draft: std::sync::Mutex::new(None),
5864        cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5865        scoped_edit: std::sync::Mutex::new(None),
5866        markdown_transformer: std::sync::Mutex::new(initial_transformer),
5867        extension_session: reload_context.extension_session.clone(),
5868        search: Arc::new(AltScreenSearch::new()),
5869        search_bar: Arc::new(SearchBar::new()),
5870        pending_container: Arc::new(Container::new()),
5871        dequeue_hint: pending_dequeue_hint(&keybindings),
5872        pending_snapshot: std::sync::Mutex::new(
5873            rpi_harness::agent_harness::QueuedMessages::default(),
5874        ),
5875        selection_start: std::sync::Mutex::new(None),
5876        selection_end: std::sync::Mutex::new(None),
5877    });
5878
5879    // Capture the model catalog + cwd for the selector builders + the key loop
5880    // (the callbacks fire on blocking threads and need owned data).
5881    let model_catalog_arc = Arc::new(model_catalog.clone());
5882    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5883
5884    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
5885    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
5886    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
5887    //
5888    // The scrollview gets `basis(0)` so the constrained stack allocator starts
5889    // it at zero height and grows it to fill the space the dock does not need
5890    // — this keeps the dock (editor borders + footer) pinned to the bottom and
5891    // never shrinks it below the editor's 3 rows (top + content + bottom). The
5892    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
5893    // never clip the input panel below its minimum.
5894    let editor_container = Arc::new(Container::new());
5895    editor_container.add_child(editor.clone());
5896
5897    let dock = Arc::new(VStack::from_children(vec![
5898        StackChild::Entry(StackEntry::new(state.pending_container().clone())),
5899        StackChild::Entry(StackEntry::new(status_container.clone())),
5900        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5901        // NOTE: no Spacer here. The scroll content already ends with a blank line
5902        // (`document_container`), and a second one in the dock stacked into a
5903        // two-row gap between the last message and the input box. One row of
5904        // breathing room is the whole safe area now; the transcript keeps its
5905        // own padding so a long message never touches the editor border.
5906        StackChild::Entry(
5907            StackEntry::new(editor_container.clone())
5908                .shrink(0)
5909                .min_size(3),
5910        ),
5911        StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5912        StackChild::Entry(StackEntry::new(footer.clone())),
5913    ]));
5914
5915    let root = VStack::from_children(vec![
5916        StackChild::Entry(
5917            StackEntry::new(scroll_view.clone())
5918                .basis(0)
5919                .grow(1)
5920                .shrink(1)
5921                .min_size(1),
5922        ),
5923        StackChild::Entry(StackEntry::new(dock).shrink(1)),
5924    ]);
5925
5926    tui.set_layout_root(Some(Arc::new(root)));
5927    tui.set_focus(Some(editor.clone()));
5928    editor.set_focused(true);
5929
5930    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
5931    //
5932    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
5933    // the old version made individually) + the registry, then routes `/`-text
5934    // through `dispatch_slash` and sends plain text directly. Each command's
5935    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
5936    // chat mutation) — the handler itself stays a thin router.
5937    //
5938    // One `CommandContext` is built and cloned for both the submit handler and
5939    // the key loop (Ctrl+L routes `/model` through the same registry); all
5940    // fields are `Arc`/cheap, so the clones are free.
5941    //
5942    // The product-layer `AgentSession` wraps a clone of the harness (the
5943    // harness handle is cheap and shares the session/bus), so `/usage`,
5944    // `/export`, and future product commands go through one surface.
5945    let scoped_models = args
5946        .model
5947        .as_deref()
5948        .map(crate::agent_session::ScopedModel::parse)
5949        .into_iter()
5950        .collect();
5951    let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5952        harness.clone(),
5953        model_catalog.clone(),
5954        scoped_models,
5955        cwd.clone(),
5956    ));
5957    let ctx = CommandContext {
5958        chat: chat_container.clone(),
5959        tui: tui.clone(),
5960        tx: tx.clone(),
5961        state: state.clone(),
5962        editor: editor.clone(),
5963        editor_container: editor_container.clone(),
5964        lane: lane.clone(),
5965        model_catalog: model_catalog_arc.clone(),
5966        lane_model_id: lane_model_id.clone(),
5967        cwd: cwd.clone(),
5968        package_resources: package_resources.clone(),
5969        resources: resources_arc.clone(),
5970        reload_context: Arc::new(reload_context.clone()),
5971        session: agent_session.clone(),
5972    };
5973
5974    if let Some(js) = &reload_context.js_extension_session {
5975        let bridge = js_dialog_bridge.clone();
5976        if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5977            bridge.handle_runtime_request(action, args)
5978        })) {
5979            eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5980        }
5981    }
5982
5983    let ctx_for_cb = ctx.clone();
5984    let registry_for_cb = registry.clone();
5985    editor.on_submit(Arc::new(move |text: &str| {
5986        let text = text.trim();
5987        if text.is_empty() {
5988            return;
5989        }
5990
5991        if text.starts_with('/') {
5992            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
5993            return;
5994        }
5995
5996        // User-initiated shell mode: `!command` runs in the background and is
5997        // recorded in the session context; `!!command` is excluded from it.
5998        // Handled before the agent prompt path so it never reaches the model.
5999        // Mirrors `interactive-mode.ts:3225`.
6000        if let Some((command, exclude_from_context)) = parse_user_bash(text) {
6001            if ctx_for_cb.state.user_bash_running() {
6002                add_error_message(
6003                    &ctx_for_cb.chat,
6004                    "A bash command is already running. Press Esc to cancel it first.",
6005                );
6006                ctx_for_cb.tui.request_render(false);
6007                return;
6008            }
6009            start_user_bash(&ctx_for_cb, command, exclude_from_context);
6010            return;
6011        }
6012
6013        let run_status = *ctx_for_cb.state.status.lock().unwrap();
6014        if run_status != RunStatus::Idle {
6015            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
6016            let lane = ctx_for_cb.lane.clone();
6017            let chat = ctx_for_cb.chat.clone();
6018            let tui = ctx_for_cb.tui.clone();
6019            let state = ctx_for_cb.state.clone();
6020            tokio::spawn(async move {
6021                // Queue immediately while the agent loop is still running.
6022                // Routing this through the TUI's main channel delayed it until
6023                // `prompt_text()` returned, after the loop's drain points had
6024                // passed, so the queued message appeared to disappear.
6025                //
6026                // Native pi's `steer()` enqueues unconditionally, even after
6027                // the run ends, and the message stays in the queue for the
6028                // next run. This matches the TS design and avoids the race
6029                // between `activeRun` clearing and the status check.
6030                if let Err(error) = lane.steer(message).await {
6031                    add_error_message(&chat, &format!("Could not queue message: {error}"));
6032                    tui.render_now(false);
6033                    return;
6034                }
6035                // Surface the queued entry in the pending dock immediately;
6036                // the drain-time refresh only fires on the next agent event.
6037                refresh_pending_messages(&state, &lane).await;
6038                tui.request_render(false);
6039            });
6040            // A queued prompt is echoed only once the loop actually consumes
6041            // it: the run's `AgentEvent::MessageStart` renders the user bubble
6042            // (native pi renders `message_start` for user messages). Until
6043            // then it is surfaced by the pending-messages display, so the
6044            // transcript never shows a message that is still waiting.
6045            ctx_for_cb.tui.request_render(false);
6046            return;
6047        }
6048
6049        if !ctx_for_cb.state.try_start_working() {
6050            return;
6051        }
6052
6053        // Render the user bubble here, NOT from `AgentEvent::MessageStart`.
6054        //
6055        // The harness persists the prompt itself and then drives
6056        // `run_agent_loop` with an EMPTY prompts vec (so the prompt is not
6057        // double-counted in the provider context), which means the loop emits
6058        // no `message_start` for it — see
6059        // `directly_sent_prompt_emits_no_user_message_start` in
6060        // `crates/pi-harness/tests/harness_run_e2e.rs`. Queued steering /
6061        // follow-up prompts DO get a `message_start` (the loop drains those
6062        // itself), so those are deliberately rendered from the event instead.
6063        add_user_message(&ctx_for_cb.chat, text);
6064        // A new prompt starts a fresh interaction at the tail even when the
6065        // user had scrolled up to inspect older output.
6066        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
6067            scroll.scroll_to_end();
6068        }
6069        ctx_for_cb.tui.request_render(false);
6070        // Remember the message for ↑ recall (slash commands are not part of
6071        // the replayable message history).
6072        push_history(&ctx_for_cb.state, text);
6073        if ctx_for_cb
6074            .tx
6075            .send(TuiMessage::UserInput(text.to_string()))
6076            .is_err()
6077        {
6078            ctx_for_cb.state.set_status(RunStatus::Idle);
6079        }
6080    }));
6081
6082    // Turn on the native-pi frame throttle before the first frame: from here on
6083    // `request_render(false)` parks a frame and the scheduler thread paints at
6084    // most one per `MIN_RENDER_INTERVAL_MS` (16 ms). A provider that streams one
6085    // `MessageUpdate` per delta would otherwise force a full transcript
6086    // re-render + full-viewport terminal write per token, which is what made
6087    // long output saturate the terminal (and its GPU compositor). Immediate
6088    // renders (selectors, the first frame, `render_now(..)`) are unaffected.
6089    tui.start_render_scheduler();
6090    tui.start_readerless();
6091
6092    // Consume local self-update results even when network checks are disabled.
6093    // Keep package discovery separate so package managers and Git remotes
6094    // cannot delay an rpi update notice or a prior helper failure.
6095    let mut update_check_handles = Vec::new();
6096    let rpi_chat = chat_container.clone();
6097    let rpi_tui = tui.clone();
6098    update_check_handles.push(tokio::spawn(async move {
6099        let report = crate::updates::check_rpi_startup().await;
6100        if !report.is_empty() {
6101            add_update_notices(&rpi_chat, &report);
6102        }
6103        rpi_tui.request_render(false);
6104    }));
6105
6106    if update_checks_enabled {
6107        let update_args = args.clone();
6108        let update_cwd = cwd.clone();
6109        let update_project_trusted = project_trusted;
6110        let chat = chat_container.clone();
6111        let tui = tui.clone();
6112        update_check_handles.push(tokio::spawn(async move {
6113            let resources = crate::session::package_resources_for_update_check(
6114                &update_args,
6115                &update_cwd,
6116                update_project_trusted,
6117            );
6118            let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
6119            {
6120                Ok(npm_command) => {
6121                    crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
6122                        Some(&resources),
6123                        &npm_command,
6124                        update_project_trusted.then_some(update_cwd.as_path()),
6125                    )
6126                    .await
6127                }
6128                Err(error) => {
6129                    add_error_message(
6130                        &chat,
6131                        &format!("npm package update checks disabled: {error}"),
6132                    );
6133                    let git_resources = git_only_update_resources(resources);
6134                    crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
6135                }
6136            };
6137            if !report.is_empty() {
6138                add_update_notices(&chat, &report);
6139            }
6140            tui.request_render(false);
6141        }));
6142    }
6143
6144    // ---- Streaming drain task ----
6145    let drain_handle = if let Some(rx) = event_rx {
6146        let tui_drain = tui.clone();
6147        let state_drain = state.clone();
6148        let chat_drain = chat_container.clone();
6149        let lane_drain = lane.clone();
6150        Some(tokio::spawn(async move {
6151            drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
6152        }))
6153    } else {
6154        None
6155    };
6156
6157    // ---- B5d: plugin→TUI reload bridge ----
6158    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
6159    // (its cdylib would be unmapped while the call frame is still on the stack).
6160    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
6161    // (an `UnboundedSender<()>`); this task drains those signals and forwards
6162    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
6163    // `reload_extension_resources` routine asynchronously. The mailbox is the
6164    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
6165    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
6166    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
6167    reload_context.mailbox.install(reload_sig_tx);
6168    let reload_tx = tx.clone();
6169    let reload_bridge_handle = tokio::spawn(async move {
6170        while reload_sig_rx.recv().await.is_some() {
6171            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
6172                break; // main loop gone — stop forwarding
6173            }
6174        }
6175    });
6176
6177    // ---- Render-tick task (advances the loader spinner while Working) ----
6178    //
6179    // The `Loader` only advances its frame on render; without a periodic
6180    // `request_render` the spinner visibly freezes between events.
6181    let tui_tick = tui.clone();
6182    let state_tick = state.clone();
6183    let tick_handle = tokio::spawn(async move {
6184        // 80ms per frame — native pi's `DEFAULT_INTERVAL_MS`, i.e. a full
6185        // 10-frame cycle every 800ms. The tick only *requests a repaint*; the
6186        // frame is advanced by `Editor::render` (mirroring `Loader::render`),
6187        // so streaming output — which schedules a frame per token — spins it
6188        // faster, and this interval is the floor when the model is silent.
6189        //
6190        // Advancing here as well would double-step every tick and make the idle
6191        // spinner run at 40ms/frame, twice native pi's rate.
6192        let mut interval = tokio::time::interval(std::time::Duration::from_millis(
6193            rpi_tui::loader::SPINNER_FRAME_MS,
6194        ));
6195        interval.tick().await; // discard immediate
6196        loop {
6197            interval.tick().await;
6198            // Extension status (langfuse ✓ …, …) — cheap revision check, and the
6199            // only reason an idle session repaints its footer.
6200            if state_tick.sync_extension_status() {
6201                tui_tick.request_render(false);
6202            }
6203            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
6204            if working {
6205                // A live transcript panel — a running bash command *or* a
6206                // running tool call — computes its elapsed readout from
6207                // `Instant::now()` at render time. A frame that reuses the
6208                // cached scroll content never rebuilds it, so the readout
6209                // froze at the second of the last full rebuild (reported: a
6210                // `search` call stuck at "2.0s" with no way to tell it was
6211                // still running). Rebuild while any such panel is live.
6212                let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
6213                let has_live_panel = {
6214                    let tools = state_tick.tool_components.lock().unwrap();
6215                    transcript_has_live_panel(bash_present, &tools)
6216                };
6217                if has_live_panel {
6218                    tui_tick.request_render(false);
6219                } else {
6220                    // Only the dock loader animates. Keep the already-rendered
6221                    // transcript instead of rebuilding a long history at 12.5
6222                    // frames per second.
6223                    tui_tick.request_render_reusing_scroll_content();
6224                }
6225            }
6226        }
6227    });
6228
6229    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
6230    let running = Arc::new(std::sync::Mutex::new(true));
6231    let running_key = running.clone();
6232    let tx_for_key = tx.clone();
6233    let tui_for_key = tui.clone();
6234    let editor_for_key = editor.clone();
6235    let scroll_for_key = scroll_view.clone();
6236    let lane_for_key = lane.clone();
6237    let state_for_key = state.clone();
6238    let model_catalog_for_key = model_catalog_arc.clone();
6239    let js_for_key = reload_context.js_extension_session.clone();
6240    let js_dialog_for_key = js_dialog_bridge.clone();
6241    let ask_user_for_key = ask_user_bridge.clone();
6242    // Ctrl+L routes through the same registry as `/model` (one path, not two),
6243    // so the key loop needs the same `CommandContext` + registry the submit
6244    // handler uses. All fields are `Arc`/cheap, so this clone is free.
6245    let ctx_for_key = ctx.clone();
6246    let registry_for_key = registry.clone();
6247    let keybindings_for_key = keybindings.clone();
6248    let double_escape_action = crate::settings::load_settings()
6249        .ok()
6250        .and_then(|settings| settings.double_escape_action)
6251        .unwrap_or_else(|| "tree".to_string())
6252        .to_ascii_lowercase();
6253
6254    let key_handle = tokio::task::spawn_blocking(move || {
6255        let mut last_escape_time = None;
6256        // Paste-burst tracking (see the bare-Enter guard below): the instant of
6257        // the most recent key event that could have been pasted text.
6258        let mut last_text_key_at: Option<std::time::Instant> = None;
6259        // One event peeked at by the paste-burst probe (which must discard a
6260        // key's Release but never lose a meaningful event it read). Restored
6261        // at the top of the next loop iteration.
6262        let mut pending_event: Option<Event> = None;
6263        if crate::key_trace::enabled() {
6264            crate::key_trace::note(&format!(
6265                "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
6266                std::process::id(),
6267                std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
6268                crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
6269            ));
6270        }
6271        loop {
6272            if !*running_key.lock().unwrap() {
6273                break;
6274            }
6275            if !state_for_key.selector_open()
6276                && !state_for_key.extension_dialog_open()
6277                && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
6278            {
6279                if let Some(request) = js_dialog_for_key.take_pending() {
6280                    open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
6281                } else if let Some(request) = ask_user_for_key.take_pending() {
6282                    open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
6283                }
6284            }
6285            cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
6286            let ev = if let Some(ev) = pending_event.take() {
6287                ev
6288            } else {
6289                // `event::read()` blocks indefinitely. Poll first so shutdown can
6290                // stop and join this worker even when no further key arrives.
6291                match crossterm::event::poll(std::time::Duration::from_millis(50)) {
6292                    Ok(true) => {}
6293                    Ok(false) => continue,
6294                    Err(_) => {
6295                        state_for_key.cancel_js_preparation();
6296                        let _ = tx_for_key.send(TuiMessage::Exit);
6297                        break;
6298                    }
6299                }
6300                let Ok(ev) = crossterm::event::read() else {
6301                    state_for_key.cancel_js_preparation();
6302                    let _ = tx_for_key.send(TuiMessage::Exit);
6303                    break;
6304                };
6305                ev
6306            };
6307            // `Event::Resize` is delivered as its own event (not a Key). With
6308            // `start_readerless` there is no competing terminal-reader thread to
6309            // handle it, so refresh the cached terminal size here and force a
6310            // full redraw so the constrained layout re-fits the new dimensions.
6311            if let Event::Resize(_cols, _rows) = ev {
6312                tui_for_key.refresh_size();
6313                if let Some(js) = &js_for_key {
6314                    if js.custom_active() {
6315                        let _ = js.send_custom_resize(_cols as usize, _rows as usize);
6316                        tui_for_key.request_render(false);
6317                    }
6318                }
6319                continue;
6320            }
6321            // Mouse wheel scrolls the transcript (pi supports wheel
6322            // scrolling). Previously every non-Key event was dropped, so a
6323            // wheel had zero effect — "滚动还是不行".
6324            if let Event::Mouse(m) = ev {
6325                use crossterm::event::MouseEventKind;
6326                match m.kind {
6327                    MouseEventKind::ScrollUp => {
6328                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
6329                        if scroll_for_key.scroll_by(delta) != delta {
6330                            tui_for_key.request_render_reusing_scroll_content();
6331                        }
6332                    }
6333                    MouseEventKind::ScrollDown => {
6334                        let delta = MOUSE_WHEEL_SCROLL_LINES;
6335                        if scroll_for_key.scroll_by(delta) != delta {
6336                            tui_for_key.request_render_reusing_scroll_content();
6337                        }
6338                    }
6339                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
6340                        // Start selection tracking. `selection_end` doubles as the
6341                        // "a real drag happened" marker: a plain tap never sets
6342                        // it, so it can never overwrite the clipboard.
6343                        *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
6344                        *state_for_key.selection_end.lock().unwrap() = None;
6345                    }
6346                    MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
6347                        // Motion with the button held = a genuine selection drag.
6348                        // (Terminals only report this with button-event mouse
6349                        // tracking, which `ProcessTerminal` enables.)
6350                        if state_for_key.selection_start.lock().unwrap().is_some() {
6351                            *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
6352                        }
6353                    }
6354                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
6355                        // End selection and auto-copy if enabled
6356                        if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
6357                            let end = (m.column, m.row);
6358
6359                            // Only auto-copy when the pointer actually MOVED with
6360                            // the button held (`Drag` set `selection_end`). A plain
6361                            // click/tap — even one whose Down/Up cells differ by a
6362                            // pixel over RDP — must not touch the clipboard: it used
6363                            // to fire `extract_selected_text`, which copies whatever
6364                            // rendered TUI line sat under the pointer, silently
6365                            // clobbering the user's real clipboard so the next paste
6366                            // returned that stale text.
6367                            let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
6368                            *state_for_key.selection_end.lock().unwrap() = Some(end);
6369
6370                            // Check if auto-copy is enabled
6371                            let auto_copy = crate::settings::load_settings()
6372                                .ok()
6373                                .and_then(|s| s.fullscreen_copy_on_select)
6374                                .unwrap_or(true);
6375
6376                            if auto_copy && is_drag {
6377                                // Try to extract selected text from chat container
6378                                if let Some(selected_text) =
6379                                    extract_selected_text(&state_for_key.chat_container, start, end)
6380                                {
6381                                    if !selected_text.trim().is_empty() {
6382                                        let _ = copy_to_clipboard(&selected_text);
6383                                    }
6384                                }
6385                            }
6386                        }
6387                        *state_for_key.selection_start.lock().unwrap() = None;
6388                        *state_for_key.selection_end.lock().unwrap() = None;
6389                    }
6390                    _ => {}
6391                }
6392                continue;
6393            }
6394            let Event::Key(key) = ev else {
6395                if let Event::Paste(text) = ev {
6396                    crate::key_trace::note(&format!(
6397                        "paste event ({} bytes, bracketed paste supported)",
6398                        text.len()
6399                    ));
6400                    let candidate = text.trim().trim_matches(['\"', '\'']);
6401                    let path = std::path::PathBuf::from(candidate);
6402                    if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
6403                        if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
6404                            add_image_preview(&state_for_key.chat_container, &image);
6405                            state_for_key.queue_image(image);
6406                            add_note_message(
6407                                &state_for_key.chat_container,
6408                                "Dropped image attached to the next prompt.",
6409                            );
6410                            tui_for_key.request_render(false);
6411                            continue;
6412                        }
6413                    }
6414                    editor_for_key.insert(&text);
6415                    refresh_autocomplete(&state_for_key, &editor_for_key);
6416                    tui_for_key.request_render_reusing_scroll_content();
6417                }
6418                continue;
6419            };
6420            // Drop releases but preserve Repeat so holding arrows, Backspace,
6421            // PageUp, etc. behaves naturally. Windows emits Press + Release
6422            // for a tap; terminals with keyboard enhancement may additionally
6423            // emit Repeat while a key is held.
6424            if !should_dispatch_key(key.kind) {
6425                continue;
6426            }
6427            // Diagnostic trace (off unless RPI_DEBUG_KEYS is set): records what
6428            // actually arrived before any routing decision, so a client that
6429            // sends LF for Enter is visible as `Char('j') mods=CONTROL`.
6430            crate::key_trace::key(&key, "");
6431
6432            // Prompt preparation runs on a blocking worker before the agent
6433            // lane owns the turn. Cancel it directly: an abort queued only to
6434            // the lane cannot wake a JS factory or lifecycle hook that never
6435            // resolves. This check precedes custom/dialog routing because
6436            // those components may themselves have been opened by the hook.
6437            let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
6438                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
6439                || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
6440            if prompt_abort && state_for_key.cancel_js_preparation() {
6441                state_for_key.set_status(RunStatus::Aborting);
6442                if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
6443                    close_extension_editor(
6444                        &state_for_key,
6445                        &ctx_for_key.editor_container,
6446                        &editor_for_key,
6447                        &tui_for_key,
6448                    );
6449                }
6450                js_dialog_for_key.cancel_open_requests();
6451                tui_for_key.set_render_suspended(false);
6452                tui_for_key.request_render(false);
6453                continue;
6454            }
6455
6456            if let Some(js) = &js_for_key {
6457                if js.custom_active() {
6458                    let visible = js.custom_accepts_input();
6459                    let data = key_event_to_input(key);
6460                    if !data.is_empty() {
6461                        // A visible custom owns the whole key stream, so its
6462                        // acknowledgement is unnecessary and would add a
6463                        // synchronous round-trip to every keystroke. Hidden
6464                        // overlays need the consume result to decide whether the
6465                        // outer editor should see the key.
6466                        if visible {
6467                            let _ = js.send_custom_input(&data);
6468                            continue;
6469                        }
6470                        let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
6471                        // A hidden component only keeps raw listeners alive (for
6472                        // example ask_user_question's reopen shortcut); an
6473                        // unconsumed key continues through the outer editor.
6474                        if consumed {
6475                            tui_for_key.request_render_reusing_scroll_content();
6476                            continue;
6477                        }
6478                    }
6479                }
6480            }
6481
6482            // Ctrl+C cancels an open selector before it reaches the global
6483            // abort/exit handler. Route through Esc so selector callbacks run.
6484            if key.modifiers == KeyModifiers::CONTROL
6485                && key.code == KeyCode::Char('c')
6486                && state_for_key.selector_open()
6487            {
6488                let selector = state_for_key
6489                    .active_selector
6490                    .lock()
6491                    .unwrap()
6492                    .clone()
6493                    .expect("selector_open guaranteed Some")
6494                    .0;
6495                selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6496                tui_for_key.request_render_reusing_scroll_content();
6497                continue;
6498            }
6499
6500            // Extension dialogs own the input slot while awaiting a result.
6501            // Esc and Ctrl+C both resolve the pending command with cancel;
6502            // all other keys go to the active native editor/input widget.
6503            if state_for_key.extension_dialog_open() {
6504                let cancel = key.code == KeyCode::Esc
6505                    || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
6506                if cancel {
6507                    if !run_extension_cancel(&state_for_key) {
6508                        close_extension_editor(
6509                            &state_for_key,
6510                            &ctx_for_key.editor_container,
6511                            &editor_for_key,
6512                            &tui_for_key,
6513                        );
6514                    }
6515                } else if let Some(extension_editor) = state_for_key
6516                    .active_extension_editor
6517                    .lock()
6518                    .unwrap()
6519                    .clone()
6520                {
6521                    extension_editor.handle_key(key);
6522                } else if let Some(extension_input) =
6523                    state_for_key.active_extension_input.lock().unwrap().clone()
6524                {
6525                    extension_input.handle_key(key);
6526                }
6527                tui_for_key.request_render_reusing_scroll_content();
6528                continue;
6529            }
6530
6531            // 0. Ctrl+C: copy the selection when the editor has one (pi
6532            //    `tui.input.copy`); otherwise abort an active run, or exit
6533            //    when idle. Open selectors and extension dialogs are handled
6534            //    above so their cancellation callbacks get first chance.
6535            if keybinding_matches(
6536                &keybindings_for_key,
6537                &key,
6538                rpi_tui::keybindings::keys::CLEAR,
6539            ) {
6540                if !state_for_key.selector_open() && editor_for_key.has_selection() {
6541                    editor_for_key.copy_selection();
6542                    continue;
6543                }
6544                let status = *state_for_key.status.lock().unwrap();
6545                match status {
6546                    RunStatus::Working => {
6547                        state_for_key.set_status(RunStatus::Aborting);
6548                        // Drop any outstanding ask_user prompt from this turn.
6549                        ask_user_for_key.cancel_all();
6550                        // Restore the editor if an extension dialog was occupying it.
6551                        if !run_extension_cancel(&state_for_key)
6552                            && state_for_key.extension_dialog_open()
6553                        {
6554                            close_extension_editor(
6555                                &state_for_key,
6556                                &ctx_for_key.editor_container,
6557                                &editor_for_key,
6558                                &tui_for_key,
6559                            );
6560                        }
6561                        let lane = lane_for_key.clone();
6562                        tokio::spawn(async move {
6563                            let _ = lane.abort().await;
6564                        });
6565                    }
6566                    // A held Ctrl+C can emit Repeat immediately after Press.
6567                    // Keep waiting for the in-flight cancellation instead of
6568                    // treating that repeat as a request to exit the process.
6569                    RunStatus::Aborting => {
6570                        // A second, *deliberate* Ctrl+C. The first press only
6571                        // got as far as `Aborting`, which means the abort has
6572                        // not landed yet — and the async loop is still inside
6573                        // `run_prompt_streaming(..).await`, so a queued
6574                        // `TuiMessage::Exit` would not be read until the run
6575                        // returns, which is exactly what is not happening.
6576                        //
6577                        // Exit here instead: the TUI must never be unquittable
6578                        // (a provider request or tool that never returns would
6579                        // otherwise trap the user forever). Auto-repeat is
6580                        // filtered out, so holding the key through a slow abort
6581                        // cannot kill the process by accident.
6582                        if key.kind != KeyEventKind::Repeat {
6583                            emergency_exit(&tui_for_key);
6584                        }
6585                    }
6586                    RunStatus::Idle => {
6587                        let _ = tx_for_key.send(TuiMessage::Exit);
6588                    }
6589                }
6590                continue;
6591            }
6592
6593            // 1. A selector overlay is open → route to it first. Only Esc
6594            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
6595            //    escape to the selector; on done/cancel the selector callbacks
6596            //    restore the editor and clear `active_selector`.
6597            if state_for_key.selector_open() {
6598                // Esc always cancels the selector (even with modifiers off).
6599                // Route through `SelectList::handle_key(Esc)` so the list's
6600                // `on_cancel` fires (the `/scoped-models` toggle selector saves
6601                // its edits there) — the old shortcut called `close_selector`
6602                // directly and skipped the callback.
6603                if key.code == KeyCode::Esc {
6604                    let (selector, _kind) = state_for_key
6605                        .active_selector
6606                        .lock()
6607                        .unwrap()
6608                        .clone()
6609                        .expect("selector_open guaranteed Some");
6610                    selector.handle_key(key);
6611                    continue;
6612                }
6613                let (selector, _kind) = state_for_key
6614                    .active_selector
6615                    .lock()
6616                    .unwrap()
6617                    .clone()
6618                    .expect("selector_open guaranteed Some");
6619                selector.handle_key(key);
6620                tui_for_key.request_render_reusing_scroll_content();
6621                continue;
6622            }
6623
6624            // 2a. Ctrl+D: pi's deleteCharForward inside the editor (mirrors
6625            //     `tui.editor.deleteCharForward`), and EOF-quit on an empty
6626            //     editor. With a run active, abort it first (same as Ctrl+C)
6627            //     so the key is never a no-op while a stuck command runs.
6628            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
6629                let status = *state_for_key.status.lock().unwrap();
6630                match status {
6631                    RunStatus::Working => {
6632                        state_for_key.set_status(RunStatus::Aborting);
6633                        ask_user_for_key.cancel_all();
6634                        if !run_extension_cancel(&state_for_key)
6635                            && state_for_key.extension_dialog_open()
6636                        {
6637                            close_extension_editor(
6638                                &state_for_key,
6639                                &ctx_for_key.editor_container,
6640                                &editor_for_key,
6641                                &tui_for_key,
6642                            );
6643                        }
6644                        let lane = lane_for_key.clone();
6645                        tokio::spawn(async move {
6646                            let _ = lane.abort().await;
6647                        });
6648                        continue;
6649                    }
6650                    RunStatus::Aborting => continue,
6651                    RunStatus::Idle => {}
6652                }
6653                if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
6654                    // Editor holds text — delete the char forward (pi parity).
6655                    editor_for_key.handle_key(key);
6656                    refresh_autocomplete(&state_for_key, &editor_for_key);
6657                    tui_for_key.request_render_reusing_scroll_content();
6658                    continue;
6659                }
6660                let _ = tx_for_key.send(TuiMessage::Exit);
6661                continue;
6662            }
6663
6664            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
6665            //     selector is open Esc already cancelled it above; when idle,
6666            //     Esc falls through to the editor (no-op-ish). Only fire while
6667            //     Working so an idle Esc doesn't abort a non-existent run.
6668            if keybinding_matches(
6669                &keybindings_for_key,
6670                &key,
6671                rpi_tui::keybindings::keys::INTERRUPT,
6672            ) {
6673                let status = *state_for_key.status.lock().unwrap();
6674                if status == RunStatus::Working {
6675                    state_for_key.set_status(RunStatus::Aborting);
6676                    ask_user_for_key.cancel_all();
6677                    if !run_extension_cancel(&state_for_key)
6678                        && state_for_key.extension_dialog_open()
6679                    {
6680                        close_extension_editor(
6681                            &state_for_key,
6682                            &ctx_for_key.editor_container,
6683                            &editor_for_key,
6684                            &tui_for_key,
6685                        );
6686                    }
6687                    let lane = lane_for_key.clone();
6688                    tokio::spawn(async move {
6689                        let _ = lane.abort().await;
6690                    });
6691                    continue;
6692                }
6693                // A running `!command` takes Esc next (native pi's `onEscape`
6694                // precedence: streaming → bash → bash-mode editor →
6695                // double-escape). The run slot stays occupied until the capture
6696                // resolves, so a second Esc is a no-op rather than a
6697                // double-escape trigger.
6698                if state_for_key.user_bash_running() {
6699                    state_for_key.cancel_user_bash();
6700                    tui_for_key.request_render(false);
6701                    continue;
6702                }
6703                if status == RunStatus::Idle
6704                    && editor_for_key.get_text().trim().is_empty()
6705                    && double_escape_action != "none"
6706                {
6707                    let now = std::time::Instant::now();
6708                    if double_escape_trigger(last_escape_time, now) {
6709                        last_escape_time = None;
6710                        match double_escape_action.as_str() {
6711                            "tree" => {
6712                                let _ = tx_for_key.send(TuiMessage::OpenTree);
6713                            }
6714                            "fork" => {
6715                                let _ = tx_for_key.send(TuiMessage::ForkSession);
6716                            }
6717                            _ => {}
6718                        }
6719                    } else {
6720                        last_escape_time = Some(now);
6721                    }
6722                }
6723                continue;
6724            }
6725
6726            // 2c. Ctrl+G: edit the current draft in the user's external
6727            // editor, matching native Pi's VISUAL/EDITOR fallback chain.
6728            if keybinding_matches(
6729                &keybindings_for_key,
6730                &key,
6731                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
6732            ) {
6733                launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
6734                continue;
6735            }
6736
6737            // 2d. Ctrl+O: toggle all tool output panels between compact and
6738            // expanded rendering (native Pi's global output toggle).
6739            if keybinding_matches(
6740                &keybindings_for_key,
6741                &key,
6742                rpi_tui::keybindings::keys::TOOLS_EXPAND,
6743            ) {
6744                let expanded = state_for_key.toggle_tool_outputs();
6745                // Persist per-session so a later `-c`/`-r` restores it.
6746                let session = ctx_for_key.session.clone();
6747                tokio::spawn(async move {
6748                    let _ = session
6749                        .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
6750                        .await;
6751                });
6752                tui_for_key.request_render(false);
6753                continue;
6754            }
6755
6756            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
6757            if keybinding_matches(
6758                &keybindings_for_key,
6759                &key,
6760                rpi_tui::keybindings::keys::THINKING_TOGGLE,
6761            ) {
6762                let hide = state_for_key.toggle_thinking();
6763                // Persist per-session so a later `-c`/`-r` restores it.
6764                let session = ctx_for_key.session.clone();
6765                tokio::spawn(async move {
6766                    let _ = session
6767                        .set_value("display.hide_thinking", serde_json::json!(hide))
6768                        .await;
6769                });
6770                tui_for_key.request_render(false);
6771                continue;
6772            }
6773
6774            // 2f. Ctrl+M: cycle to the next model in the catalog after the one
6775            //     currently tracked in `current_model_id`, apply it live via
6776            //     `lane.set_model` (takes effect on the next user message — the
6777            //     in-flight run's config is already snapshotted), and update the
6778            //     footer. `set_model` is async so it runs on a spawned task.
6779            if keybinding_matches(
6780                &keybindings_for_key,
6781                &key,
6782                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6783            ) {
6784                let current = state_for_key.current_model_id();
6785                // Cycle within the `/scoped-models` set (settings.json) when
6786                // configured; otherwise the full catalog.
6787                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
6788                if let Some(next) = cycle_next_model(&scope, &current) {
6789                    state_for_key.set_current_model(&next);
6790                    let lane = lane_for_key.clone();
6791                    tokio::spawn(async move {
6792                        let _ = lane.set_model(next).await;
6793                    });
6794                    tui_for_key.request_render_reusing_scroll_content();
6795                }
6796                continue;
6797            }
6798
6799            // 2f. Shift+Tab / BackTab: cycle the current model's supported
6800            // thinking levels, matching native Pi's thinking-level shortcut.
6801            if keybinding_matches(
6802                &keybindings_for_key,
6803                &key,
6804                rpi_tui::keybindings::keys::THINKING_CYCLE,
6805            ) {
6806                let lane = lane_for_key.clone();
6807                let catalog = model_catalog_for_key.clone();
6808                let state = state_for_key.clone();
6809                tokio::spawn(async move {
6810                    let Ok(current_model) = lane.get_model().await else {
6811                        return;
6812                    };
6813                    let levels = catalog
6814                        .iter()
6815                        .find(|model| {
6816                            model.provider == current_model.provider && model.id == current_model.id
6817                        })
6818                        .map(|model| model.supported_thinking_levels())
6819                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6820                    if levels.is_empty() {
6821                        return;
6822                    }
6823                    let current = lane
6824                        .get_thinking_level()
6825                        .await
6826                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6827                    let next = levels
6828                        .iter()
6829                        .position(|level| *level == current)
6830                        .map(|index| levels[(index + 1) % levels.len()])
6831                        .unwrap_or(levels[0]);
6832                    if lane.set_thinking_level(next).await.is_ok() {
6833                        state
6834                            .footer
6835                            .set_thinking_level(Some(thinking_level_name(next)));
6836                        state.tui.as_ref().map(|tui| tui.request_render(false));
6837                    }
6838                });
6839                continue;
6840            }
6841
6842            // Ctrl+V (or a configured paste-image key): if the system
6843            // clipboard holds an image, queue it for the next prompt;
6844            // otherwise paste the clipboard TEXT into the editor. Previously
6845            // a text-only clipboard fell through to the editor's Ctrl+V,
6846            // which yanks from the internal KILL RING — not the system
6847            // clipboard. On platforms without bracketed-paste `Event::Paste`
6848            // (Windows console, some remote clients) that meant Ctrl+V pasted
6849            // stale/other content instead of the user's real clipboard
6850            // ("不是系统的粘贴的内容").
6851            if keybinding_matches(
6852                &keybindings_for_key,
6853                &key,
6854                rpi_tui::keybindings::keys::PASTE_IMAGE,
6855            ) {
6856                match read_clipboard_image() {
6857                    Ok(Some(image)) => {
6858                        add_image_preview(&state_for_key.chat_container, &image);
6859                        state_for_key.queue_image(image);
6860                        add_note_message(
6861                            &state_for_key.chat_container,
6862                            "Clipboard image attached to the next prompt.",
6863                        );
6864                        tui_for_key.request_render(false);
6865                        continue;
6866                    }
6867                    Ok(None) | Err(_) => {}
6868                }
6869                // The arboard text fallback is Windows-only. On Unix a real
6870                // paste arrives as `Event::Paste` (bracketed paste is enabled
6871                // by `ProcessTerminal::enter_raw_mode`), so reading the
6872                // SERVER's clipboard here would paste stale TUI content on a
6873                // remote/mobile client — copy-on-select can have written the
6874                // welcome "Skills (…)" line into the server clipboard, and
6875                // Ctrl+V on the client returns that instead of the user's own
6876                // clipboard ("不是系统的粘贴的内容"). On Windows the console
6877                // never emits `Event::Paste`, so the arboard read is the only
6878                // paste source and must stay.
6879                if cfg!(windows) {
6880                    if let Some(text) = read_clipboard_text() {
6881                        if !text.is_empty() {
6882                            editor_for_key.insert(&text);
6883                            refresh_autocomplete(&state_for_key, &editor_for_key);
6884                            tui_for_key.request_render_reusing_scroll_content();
6885                            continue;
6886                        }
6887                    }
6888                }
6889            }
6890
6891            // 3. Ctrl+Shift+F: open transcript search
6892            if key
6893                .modifiers
6894                .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6895                && key.code == KeyCode::Char('F')
6896            {
6897                state_for_key.search.activate();
6898                state_for_key.search_bar.set_visible(true);
6899                state_for_key.search_bar.set_query("");
6900                state_for_key.search_bar.set_match_info(0, 0);
6901                tui_for_key.request_render(false);
6902                continue;
6903            }
6904
6905            // Handle search mode input
6906            if state_for_key.search.is_active() {
6907                let search_bar = state_for_key.search_bar.clone();
6908                match key.code {
6909                    KeyCode::Esc => {
6910                        state_for_key.search.deactivate();
6911                        search_bar.set_visible(false);
6912                        tui_for_key.request_render(false);
6913                        continue;
6914                    }
6915                    KeyCode::Enter => {
6916                        if key.modifiers.contains(KeyModifiers::SHIFT) {
6917                            state_for_key.search.previous_match();
6918                        } else {
6919                            state_for_key.search.next_match();
6920                        }
6921                        // Update search bar with current match info
6922                        let match_index = state_for_key.search.get_match_index();
6923                        let match_count = state_for_key.search.get_match_count();
6924                        search_bar.set_match_info(match_index, match_count);
6925                        tui_for_key.request_render(false);
6926                        continue;
6927                    }
6928                    KeyCode::Backspace => {
6929                        state_for_key.search.backspace();
6930                        // Re-search with updated query
6931                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6932                        state_for_key.search.find_matches(&lines);
6933                        // Update search bar
6934                        let query = state_for_key.search.get_query();
6935                        let match_count = state_for_key.search.get_match_count();
6936                        search_bar.set_query(&query);
6937                        search_bar.set_match_info(0, match_count);
6938                        tui_for_key.request_render(false);
6939                        continue;
6940                    }
6941                    KeyCode::Char(c) => {
6942                        state_for_key.search.append_char(c);
6943                        // Re-search with updated query
6944                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6945                        state_for_key.search.find_matches(&lines);
6946                        // Update search bar
6947                        let query = state_for_key.search.get_query();
6948                        let match_count = state_for_key.search.get_match_count();
6949                        search_bar.set_query(&query);
6950                        search_bar.set_match_info(0, match_count);
6951                        tui_for_key.request_render(false);
6952                        continue;
6953                    }
6954                    _ => continue,
6955                }
6956            }
6957
6958            // 3. Ctrl+L: open the model selector. Routed through the `/model`
6959            //    command so the hotkey and the slash command share one path
6960            //    (TS binds Ctrl+L to model-select).
6961            if keybinding_matches(
6962                &keybindings_for_key,
6963                &key,
6964                rpi_tui::keybindings::keys::MODEL_SELECT,
6965            ) {
6966                if let Some(cmd) = registry_for_key.find("/model") {
6967                    cmd.execute(&ctx_for_key, "");
6968                }
6969                continue;
6970            }
6971
6972            // 4. Tab: accept the top autocomplete suggestion (if any).
6973            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6974                if accept_top_suggestion(&state_for_key, &editor_for_key) {
6975                    tui_for_key.request_render_reusing_scroll_content();
6976                }
6977                continue;
6978            }
6979
6980            // 5. Global transcript scroll. PageUp/PageDown use the actual
6981            // viewport height with four rows of overlap (upstream behavior),
6982            // while Home/End jump to the transcript boundaries.
6983            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6984                let delta = -transcript_page_size(scroll_for_key.viewport_height());
6985                if scroll_for_key.scroll_by(delta) != delta {
6986                    tui_for_key.request_render_reusing_scroll_content();
6987                }
6988                continue;
6989            }
6990            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6991                let delta = transcript_page_size(scroll_for_key.viewport_height());
6992                if scroll_for_key.scroll_by(delta) != delta {
6993                    tui_for_key.request_render_reusing_scroll_content();
6994                }
6995                continue;
6996            }
6997            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
6998                scroll_for_key.scroll_to_start();
6999                tui_for_key.request_render_reusing_scroll_content();
7000                continue;
7001            }
7002            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
7003                scroll_for_key.scroll_to_end();
7004                tui_for_key.request_render_reusing_scroll_content();
7005                continue;
7006            }
7007
7008            // 5b. ↑/↓ browse submitted-message history when the editor is
7009            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
7010            //     without the surprise of replacing typed text. When the
7011            //     editor holds content, ↑/↓ fall through to cursor movement
7012            //     (typing "hello", pressing ↑ at the start, must never swap
7013            //     the draft for a history entry — reported as "text
7014            //     disappeared"). Once browsing, ↓ walks back and restores the
7015            //     draft.
7016            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
7017                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7018                if editor_for_key.get_text().is_empty() || browsing {
7019                    navigate_history(&state_for_key, &editor_for_key, -1);
7020                    tui_for_key.request_render_reusing_scroll_content();
7021                    continue;
7022                }
7023            }
7024            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
7025                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7026                if editor_for_key.get_text().is_empty() || browsing {
7027                    navigate_history(&state_for_key, &editor_for_key, 1);
7028                    tui_for_key.request_render_reusing_scroll_content();
7029                    continue;
7030                }
7031            }
7032
7033            // `app.message.dequeue` (Alt+Q on Windows, Alt+Up elsewhere):
7034            // restore every queued steering/follow-up message into the editor
7035            // so it can be edited before resending. Mirrors native pi's
7036            // `handleDequeue`; the pending-messages display shows the hint.
7037            if keybinding_matches(
7038                &keybindings_for_key,
7039                &key,
7040                rpi_tui::keybindings::keys::DEQUEUE,
7041            ) {
7042                // Handled here, on the key worker — NOT via the mailbox. The
7043                // async main loop parks inside `run_prompt_streaming(..).await`
7044                // for the whole run, and a queue only exists *while* a run is
7045                // in flight, so a `TuiMessage::Dequeue` sat unread until the
7046                // run finished (after the lane had already consumed the queue).
7047                // Alt+Q therefore looked like a no-op while it was most needed.
7048                // Spawn the drain so it lands while the user is still looking at
7049                // the queue, exactly like the Alt+Enter `follow_up` path above.
7050                tokio::spawn(restore_queued_messages_to_editor(
7051                    lane_for_key.clone(),
7052                    editor_for_key.clone(),
7053                    state_for_key.clone(),
7054                    tui_for_key.clone(),
7055                ));
7056                continue;
7057            }
7058
7059            // Alt+Enter queues a follow-up while a run is active. It is
7060            // handled here because Editor treats only a bare Enter as submit;
7061            // idle Alt+Enter keeps the normal prompt behavior.
7062            if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
7063                let prompt = editor_for_key.get_text().trim().to_string();
7064                if prompt.is_empty() {
7065                    continue;
7066                }
7067                editor_for_key.clear();
7068                let status = *state_for_key.status.lock().unwrap();
7069                if status == RunStatus::Idle {
7070                    if state_for_key.try_start_working() {
7071                        // Direct send: the harness emits no user `message_start`
7072                        // for it, so render the bubble here (see the submit
7073                        // handler in `interactive_tui`).
7074                        add_user_message(&state_for_key.chat_container, &prompt);
7075                        push_history(&state_for_key, &prompt);
7076                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
7077                    }
7078                } else {
7079                    // Follow-ups are echoed by the loop's
7080                    // `AgentEvent::MessageStart` once they are consumed; while
7081                    // they wait, the pending-messages display shows them.
7082                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
7083                    let lane = lane_for_key.clone();
7084                    let chat = state_for_key.chat_container.clone();
7085                    let tui = tui_for_key.clone();
7086                    let state = state_for_key.clone();
7087                    tokio::spawn(async move {
7088                        if let Err(error) = lane.follow_up(message).await {
7089                            add_error_message(&chat, &format!("Could not queue message: {error}"));
7090                            tui.request_render(false);
7091                            return;
7092                        }
7093                        refresh_pending_messages(&state, &lane).await;
7094                        tui.request_render(false);
7095                    });
7096                }
7097                tui_for_key.request_render(false);
7098                continue;
7099            }
7100
7101            // ---- Paste-burst coalescing (Windows only) ----------------------
7102            // `crossterm` 0.27 implements bracketed paste ONLY on Unix: its
7103            // Windows console event source (`ReadConsoleInputW`) never emits
7104            // `Event::Paste`, so `?2004h` buys nothing there and a pasted block
7105            // arrives as ordinary key events with a bare Enter per line. The
7106            // editor treats a bare Enter as "submit", so one paste became N
7107            // messages. See [`enter_is_paste_burst`] for the discriminator.
7108            //
7109            // On Unix the timing heuristic is both unnecessary AND harmful: a
7110            // remote/mobile client (SSH, soft keyboard) coalesces typed text +
7111            // Enter into one network burst, so the Enter lands within
7112            // [`PASTE_BURST_GAP`] of the last character and the heuristic
7113            // misclassifies it as pasted content — "回车变成了换行". Real pastes
7114            // on Unix arrive as `Event::Paste` (bracketed paste is enabled by
7115            // `ProcessTerminal::enter_raw_mode`), so gating to Windows restores
7116            // remote Enter without losing the Windows paste fix.
7117            if cfg!(windows)
7118                && key.modifiers.is_empty()
7119                && matches!(
7120                    key.code,
7121                    KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
7122                )
7123            {
7124                // Probe for a *real* queued key. Windows queues each key's
7125                // Release event directly behind its Press, and a bare
7126                // non-blocking poll cannot tell the two apart — treating that
7127                // Release as "more input" made every Enter on a remote/RDP
7128                // console look like a paste burst ("回车变成了换行"). See
7129                // [`probe_paste_input`].
7130                let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
7131                    if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
7132                        crossterm::event::read().ok()
7133                    } else {
7134                        None
7135                    }
7136                });
7137                pending_event = stashed;
7138                if enter_is_paste_burst(last_text_key_at, std::time::Instant::now(), more_queued) {
7139                    crate::key_trace::note(&format!(
7140                        "enter-decision -> newline (paste burst) gap_ms={} more_queued={more_queued}",
7141                        gap_ms(last_text_key_at),
7142                    ));
7143                    // Pasted newline: insert it and keep the remaining queued
7144                    // events flowing through this same path.
7145                    editor_for_key.insert("\n");
7146                    last_text_key_at = Some(std::time::Instant::now());
7147                    refresh_autocomplete(&state_for_key, &editor_for_key);
7148                    tui_for_key.request_render_reusing_scroll_content();
7149                    continue;
7150                }
7151                crate::key_trace::note(&format!(
7152                    "enter-decision -> submit gap_ms={} more_queued={more_queued}",
7153                    gap_ms(last_text_key_at),
7154                ));
7155                // A real submit ends the burst so a follow-up Enter is not
7156                // mistaken for paste continuation.
7157                last_text_key_at = None;
7158            } else if matches!(
7159                key.code,
7160                KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
7161            ) && !key
7162                .modifiers
7163                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
7164            {
7165                // Unmodified text keys extend the burst. Ctrl/Alt chords
7166                // (Ctrl+C, Alt+Enter, …) are commands, not paste content.
7167                last_text_key_at = Some(std::time::Instant::now());
7168            } else {
7169                // Arrows, Esc, Ctrl/Alt chords, PageUp/Down, … — not paste
7170                // content; break the burst.
7171                last_text_key_at = None;
7172            }
7173
7174            // 6. Otherwise forward to the editor + refresh autocomplete. The
7175            //    editor maps unmodified Enter and the encodings a remote client
7176            //    may send for it (`Char('\n')`/`Char('\r')`, and the raw LF
7177            //    crossterm reports as `Ctrl+J`) to submit, so a remote Enter
7178            //    sends instead of inserting a newline.
7179            editor_for_key.handle_key(key);
7180            refresh_autocomplete(&state_for_key, &editor_for_key);
7181            tui_for_key.request_render_reusing_scroll_content();
7182        }
7183    });
7184
7185    // ---- Session lifecycle (P1): SessionStart ----
7186    // The TUI + key worker are up and the session is ready to accept input.
7187    // Notify subscribed extensions now so they can initialize session-scoped
7188    // state. A veto here is **advisory** (the session is already up) — warn and
7189    // continue. No-op when no extension subscribes.
7190    if let Some(reason) = crate::session::dispatch_session_event_async(
7191        reload_context,
7192        rpi_plugin_sdk::EventTag::SessionStart,
7193    )
7194    .await
7195    {
7196        tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
7197    }
7198
7199    // ---- Initial prompts (run before reading from the channel) ----
7200    let mut prompts: Vec<String> = Vec::new();
7201    if let Some(init) = initial {
7202        prompts.push(init);
7203    }
7204    for m in extra_messages {
7205        prompts.push(m.clone());
7206    }
7207    // ---- Continue a run that recovery repaired, the way native pi does ----
7208    // This happens before any user input, and only when nothing was passed on the
7209    // command line: an explicit `-p` means the user has already said what they
7210    // want next. Driven here rather than inside the harness so the run's output
7211    // goes through the same streaming/render path as any other run, and so
7212    // startup is never blocked by a run the user cannot see.
7213    if prompts.is_empty() && lane.has_pending_resume().await {
7214        run_prompt_streaming(
7215            &lane,
7216            "",
7217            true,
7218            &tui,
7219            &state,
7220            drain_handle.is_some(),
7221            reload_context.js_extension_session.as_ref(),
7222            &js_dialog_bridge,
7223            args,
7224            Vec::new(),
7225        )
7226        .await;
7227    }
7228
7229    let mut images = initial_images;
7230    for prompt in prompts {
7231        if !*running.lock().unwrap() {
7232            break;
7233        }
7234        // The harness emits no user `message_start` for a directly-sent prompt
7235        // (it drives the loop with an empty prompts vec), so the initial
7236        // `-p` / `--prompt` bubbles are rendered here.
7237        add_user_message(&chat_container, &prompt);
7238        tui.request_render(false);
7239        run_prompt_streaming(
7240            &lane,
7241            &prompt,
7242            false,
7243            &tui,
7244            &state,
7245            drain_handle.is_some(),
7246            reload_context.js_extension_session.as_ref(),
7247            &js_dialog_bridge,
7248            args,
7249            std::mem::take(&mut images),
7250        )
7251        .await;
7252    }
7253
7254    // ---- Main loop: process submitted input + lifecycle messages ----
7255    loop {
7256        if !*running.lock().unwrap() {
7257            break;
7258        }
7259        match rx.recv().await {
7260            Some(TuiMessage::UserInput(prompt)) => {
7261                // Clear the editor so the next prompt starts fresh (the submit
7262                // handler runs on the blocking key thread and can't mutate the
7263                // editor state safely there; clearing here, on the async loop,
7264                // keeps it on one thread).
7265                editor.clear();
7266                let prompt_images = state.take_pending_images();
7267                if !prompt_images.is_empty() {
7268                    add_note_message(
7269                        &chat_container,
7270                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
7271                    );
7272                }
7273                run_prompt_streaming(
7274                    &lane,
7275                    &prompt,
7276                    false,
7277                    &tui,
7278                    &state,
7279                    drain_handle.is_some(),
7280                    reload_context.js_extension_session.as_ref(),
7281                    &js_dialog_bridge,
7282                    args,
7283                    prompt_images,
7284                )
7285                .await;
7286            }
7287            Some(TuiMessage::ExternalEditorResult(result)) => {
7288                match result {
7289                    Ok(text) => {
7290                        let cursor = text.chars().count();
7291                        editor.set_text(&text);
7292                        editor.set_cursor(0, cursor);
7293                        add_note_message(&chat_container, "Draft updated from external editor.");
7294                    }
7295                    Err(error) => add_error_message(&chat_container, &error),
7296                }
7297                tui.request_render(false);
7298            }
7299            Some(TuiMessage::UserBashFinished(report)) => {
7300                // Free the run slot first: the guard and the Esc router both
7301                // read it, so a finished command must never block the next one.
7302                state.finish_user_bash();
7303                let message = rpi_harness::messages::bash_execution_message(
7304                    report.command,
7305                    report.output,
7306                    report.exit_code,
7307                    report.cancelled,
7308                    report.truncated,
7309                    report.full_output_path,
7310                    Some(report.exclude_from_context),
7311                    now_ms(),
7312                );
7313                // While a run is in flight the message is deferred so it is not
7314                // spliced into the middle of a turn's tool sequence; it is
7315                // flushed at `AgentEnd` (native pi `_pendingBashMessages`).
7316                if *state.status.lock().unwrap() == RunStatus::Idle {
7317                    if let Err(error) = lane.append_message(message).await {
7318                        add_error_message(
7319                            &chat_container,
7320                            &format!("Could not record bash result: {error}"),
7321                        );
7322                        tui.request_render(false);
7323                    }
7324                } else {
7325                    state.pending_bash_messages.lock().unwrap().push(message);
7326                }
7327            }
7328            Some(TuiMessage::OpenTree) => {
7329                if *state.status.lock().unwrap() != RunStatus::Idle {
7330                    add_note_message(
7331                        &chat_container,
7332                        "Wait for the current run to finish before opening the tree.",
7333                    );
7334                    tui.request_render(false);
7335                } else {
7336                    open_tree_selector(
7337                        &harness,
7338                        &state,
7339                        &editor_container,
7340                        &editor,
7341                        &tui,
7342                        &chat_container,
7343                        &tx,
7344                    )
7345                    .await;
7346                }
7347            }
7348            Some(TuiMessage::NavigateTree(entry_id)) => {
7349                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
7350                    Ok(result) => match result.outcome {
7351                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
7352                            chat_container.clear();
7353                            add_welcome_message(&chat_container);
7354                            render_session_history(
7355                                &harness,
7356                                &chat_container,
7357                                state.markdown_transformer(),
7358                                Some(state.extension_session.clone()),
7359                            )
7360                            .await;
7361                            add_note_message(
7362                                &chat_container,
7363                                "Moved to the selected session entry.",
7364                            );
7365                        }
7366                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
7367                            add_error_message(&chat_container, &error.message);
7368                        }
7369                        _ => add_note_message(
7370                            &chat_container,
7371                            "The selected entry could not be opened.",
7372                        ),
7373                    },
7374                    Err(error) => add_error_message(
7375                        &chat_container,
7376                        &format!("Could not navigate session tree: {error}"),
7377                    ),
7378                }
7379                tui.request_render(false);
7380            }
7381            Some(TuiMessage::ClearChat) => {
7382                chat_container.clear();
7383                add_welcome_message(&chat_container);
7384                tui.request_render(false);
7385            }
7386            Some(TuiMessage::Compact) => {
7387                run_compact(&lane, &tui, &state).await;
7388            }
7389            Some(TuiMessage::Copy) => {
7390                copy_last_assistant(&state, &chat_container);
7391                tui.request_render(false);
7392            }
7393            Some(TuiMessage::Exit) => {
7394                *running.lock().unwrap() = false;
7395                break;
7396            }
7397            Some(TuiMessage::SwitchSession(id)) => {
7398                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
7399                tui.request_render(false);
7400            }
7401            Some(TuiMessage::ImportSession(path)) => {
7402                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
7403                tui.request_render(false);
7404            }
7405            Some(TuiMessage::ShareSession) => {
7406                share_session(&harness, &chat_container).await;
7407                tui.request_render(false);
7408            }
7409            Some(TuiMessage::SetSessionName(name)) => {
7410                let outcome = harness.session().set_name(Some(&name)).await;
7411                match outcome {
7412                    Ok(_) => add_note_message(
7413                        &chat_container,
7414                        &format!("Session renamed to \"{name}\"."),
7415                    ),
7416                    Err(e) => add_error_message(
7417                        &chat_container,
7418                        &format!("Could not rename session: {e}"),
7419                    ),
7420                }
7421                tui.request_render(false);
7422            }
7423            Some(TuiMessage::ExportSession) => {
7424                let file_name = agent_session
7425                    .default_export_file_name(crate::export::ExportFormat::Markdown)
7426                    .await;
7427                let path = cwd.join(&file_name);
7428                match agent_session.export_to_markdown(&path).await {
7429                    Ok(_) => add_note_message(
7430                        &chat_container,
7431                        &format!("Exported session to {}", path.display()),
7432                    ),
7433                    Err(e) => {
7434                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
7435                    }
7436                }
7437                tui.request_render(false);
7438            }
7439            Some(TuiMessage::ExportSessionWithFormat(format)) => {
7440                let file_name = agent_session.default_export_file_name(format.clone()).await;
7441                let path = cwd.join(&file_name);
7442                match agent_session.export(format, &path).await {
7443                    Ok(_) => add_note_message(
7444                        &chat_container,
7445                        &format!("Exported session to {}", path.display()),
7446                    ),
7447                    Err(e) => {
7448                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
7449                    }
7450                }
7451                tui.request_render(false);
7452            }
7453            Some(TuiMessage::ShowUsage) => {
7454                let stats = agent_session.usage_stats().await.ok();
7455                let total = agent_session.total_usage().await.ok();
7456                let snapshot = agent_session.snapshot().await.ok();
7457                match stats {
7458                    Some(stats) => {
7459                        let mut lines = vec![
7460                            "## Usage".to_string(),
7461                            String::new(),
7462                            format!("- messages: {}", stats.message_count),
7463                            format!("- total tokens: {}", stats.total_tokens),
7464                            format!("- cached tokens: {}", stats.cached_tokens),
7465                            format!("- uncached tokens: {}", stats.uncached_tokens),
7466                            format!("- cost: ${:.4}", stats.cost_total),
7467                        ];
7468                        match stats.cache_hit_ratio() {
7469                            Some(ratio) => {
7470                                lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
7471                            }
7472                            None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
7473                        }
7474                        if let Some(total) = total {
7475                            lines.push(String::new());
7476                            lines.push(format!(
7477                                "- last cumulative input/output: {} / {}",
7478                                total.input, total.output
7479                            ));
7480                        }
7481                        if let Some(snapshot) = snapshot {
7482                            lines.push(String::new());
7483                            lines.push(format!("- lane: `{}`", snapshot.lane));
7484                            lines.push(format!(
7485                                "- leaf: `{}`",
7486                                snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
7487                            ));
7488                            if let Some(active) = &snapshot.active {
7489                                lines.push(format!(
7490                                    "- active operation: {} (`{}`)",
7491                                    active.kind.as_str(),
7492                                    active.run_id
7493                                ));
7494                            }
7495                        }
7496                        add_note_message(&chat_container, &lines.join("\n"));
7497                    }
7498                    None => {
7499                        add_error_message(&chat_container, "Could not read session usage stats.")
7500                    }
7501                }
7502                tui.request_render(false);
7503            }
7504            Some(TuiMessage::ForkSession) => {
7505                fork_session(&harness, &cwd, &chat_container, &state).await;
7506                tui.request_render(false);
7507            }
7508            Some(TuiMessage::ReloadExtensions) => {
7509                // B5d: drive the shared reload routine on the async runtime,
7510                // then surface the outcome. `reload_context` was passed into
7511                // `interactive_tui` and is the same `Arc<ReloadContext>` the
7512                // `ReloadCommand` + the plugin mailbox both route through —
7513                // clone the `Arc` out so the borrow of `harness` (the main
7514                // loop's `&AgentHarness`) lives across the await.
7515                let reload_ctx = ctx.reload_context.clone();
7516                add_note_message(&chat_container, "Reloading extensions + resources…");
7517                tui.request_render(false);
7518                let outcome =
7519                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
7520                // B5e: the reload swapped a fresh `ExtensionSession` into the
7521                // context's cell. Rebuild the markdown transformer from that
7522                // fresh snapshot and install it on the in-flight streaming
7523                // component (so a reloaded plugin's transformer takes effect on
7524                // the visible message immediately) + future components (they
7525                // read `state.markdown_transformer()` at construction). The old
7526                // closure no-ops once its snapshot's `active` flag flips false
7527                // (reload already did that before the swap).
7528                let fresh_transformer = build_markdown_transformer(
7529                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
7530                );
7531                state.set_markdown_transformer_with_reinstall(fresh_transformer);
7532                if outcome.had_errors {
7533                    // Hard failure: the summary itself carries the error detail
7534                    // (e.g. "Settings reload failed: …"), so render it as an
7535                    // error without an extra stderr pointer.
7536                    add_error_message(&chat_container, &outcome.summary);
7537                } else if outcome.had_warnings {
7538                    // Reload succeeded but produced soft diagnostics (skill
7539                    // shadowing, name validation, …). The details are printed
7540                    // to stderr; keep this as an informational note so a
7541                    // successful reload isn't shown as a red ✗ error.
7542                    add_note_message(
7543                        &chat_container,
7544                        &format!(
7545                            "{} (with warnings — see stderr for details).",
7546                            outcome.summary
7547                        ),
7548                    );
7549                } else {
7550                    add_note_message(&chat_container, &outcome.summary);
7551                }
7552                tui.request_render(false);
7553            }
7554            None => break,
7555        }
7556    }
7557
7558    // ---- Shutdown ----
7559    // Wake any Node `ctx.ui.*` request that is still waiting on the dialog
7560    // bridge before joining the key worker and restoring the terminal.
7561    js_dialog_bridge.cancel_all();
7562    ask_user_bridge.shutdown();
7563    *running.lock().unwrap() = false;
7564    for handle in update_check_handles {
7565        handle.abort();
7566        let _ = handle.await;
7567    }
7568    // A hidden custom input listener can leave the key worker blocked on a
7569    // synchronous Node response. Stop the host first so transport shutdown
7570    // wakes that request before we join the worker.
7571    if let Some(js) = &reload_context.js_extension_session {
7572        js.shutdown();
7573    }
7574    // The input worker checks `running` at least every 50ms. Join it before
7575    // restoring cooked mode so no late event read races terminal cleanup.
7576    let _ = key_handle.await;
7577    tick_handle.abort();
7578    if let Some(handle) = drain_handle {
7579        handle.abort();
7580    }
7581    // Drop the reload bridge: clearing the mailbox closes the signal channel,
7582    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
7583    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
7584    reload_context.mailbox.clear();
7585    reload_bridge_handle.abort();
7586    tui.stop(Default::default());
7587
7588    // Native pi prints how to resume the session after an interactive exit so
7589    // a terminal that scrolled away can be recovered. Mirrors
7590    // `formatResumeCommand` (id + `--session-dir` only when non-default).
7591    let session_id = harness.session().storage().metadata().id;
7592    if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
7593        println!("\x1b[2mTo resume this session:\x1b[0m {command}");
7594    }
7595    println!("\nGoodbye!");
7596    let _ = args;
7597
7598    0
7599}
7600
7601// ===========================================================================
7602// Run a single prompt (streaming or blocking)
7603// ===========================================================================
7604
7605/// Prepare the session's persistent Node host immediately before a real prompt
7606/// enters the agent loop. The first call starts the lazy host; later calls run
7607/// `before_agent_start` again on that host so each prompt sees current state.
7608/// Keeping startup here leaves an idle TUI free of a Node child while still
7609/// giving the lifecycle hook the fully installed UI bridge.
7610async fn ensure_js_runtime_before_prompt(
7611    js: Option<&crate::js_extensions::JsExtensionSession>,
7612    lane: &Arc<dyn AgentLane>,
7613    state: &Arc<TuiState>,
7614    dialog_bridge: &JsDialogBridge,
7615    args: &Args,
7616) -> bool {
7617    let Some(js) = js else {
7618        return true;
7619    };
7620    let cancellation = state.begin_js_preparation();
7621    let worker_cancellation = cancellation.clone();
7622    let js_for_start = js.clone();
7623    let mut worker = tokio::task::spawn_blocking(move || {
7624        js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
7625    });
7626    let result = tokio::select! {
7627        result = &mut worker => result,
7628        _ = cancellation.cancelled() => {
7629            dialog_bridge.cancel_open_requests();
7630            let js_for_cancel = js.clone();
7631            let _ = tokio::task::spawn_blocking(move || {
7632                js_for_cancel.cancel_prompt_preparation();
7633            }).await;
7634            worker.await
7635        }
7636    };
7637    let was_cancelled = cancellation.is_cancelled();
7638    if was_cancelled {
7639        dialog_bridge.cancel_open_requests();
7640        let js_for_cancel = js.clone();
7641        let _ = tokio::task::spawn_blocking(move || {
7642            js_for_cancel.cancel_prompt_preparation();
7643        })
7644        .await;
7645        state.finish_js_preparation();
7646        dialog_bridge.reopen();
7647        return false;
7648    }
7649    state.finish_js_preparation();
7650    match result {
7651        Ok(Ok(())) => {
7652            // The lifecycle hook can change the JS-only active tool set once
7653            // it sees the real TUI context. Merge that subset with the Rust
7654            // built-ins while applying the command-line tool policy.
7655            if let Some(js_active) = js.active_tools() {
7656                let js_names = js.tool_names();
7657                let mut active = lane.get_active_tools().await.unwrap_or_default();
7658                active.retain(|name| {
7659                    crate::session::tool_name_allowed(name, args)
7660                        && !js_names.iter().any(|js_name| js_name == name)
7661                });
7662                active.extend(js_active.into_iter().filter(|name| {
7663                    js_names.iter().any(|js_name| js_name == name)
7664                        && crate::session::tool_name_allowed(name, args)
7665                }));
7666                active = crate::session::filter_active_tool_names(active, args);
7667                let _ = lane.set_active_tools(active).await;
7668            }
7669        }
7670        Ok(Err(error)) => {
7671            if args.verbose {
7672                eprintln!("warning: could not start JS extension runtime: {error}");
7673            }
7674        }
7675        Err(error) => {
7676            if args.verbose {
7677                eprintln!("warning: JS extension runtime worker failed: {error}");
7678            }
7679        }
7680    }
7681    true
7682}
7683
7684fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
7685    std::thread::spawn(move || {
7686        let file = match tempfile::Builder::new()
7687            .prefix("rpi-draft-")
7688            .suffix(".md")
7689            .tempfile()
7690        {
7691            Ok(file) => file,
7692            Err(error) => {
7693                let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7694                    "Could not create editor file: {error}"
7695                ))));
7696                return;
7697            }
7698        };
7699        if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
7700            let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7701                "Could not write editor file: {error}"
7702            ))));
7703            return;
7704        }
7705        let editor = std::env::var("RPI_EXTERNAL_EDITOR")
7706            .ok()
7707            .filter(|value| !value.trim().is_empty())
7708            .or_else(|| std::env::var("VISUAL").ok())
7709            .or_else(|| std::env::var("EDITOR").ok())
7710            .unwrap_or_else(|| {
7711                if cfg!(windows) {
7712                    "notepad".to_string()
7713                } else {
7714                    "nano".to_string()
7715                }
7716            });
7717        let status = std::process::Command::new(&editor)
7718            .arg(file.path())
7719            .status();
7720        let result = match status {
7721            Ok(status) if status.success() => std::fs::read_to_string(file.path())
7722                .map_err(|error| format!("Could not read editor file: {error}")),
7723            Ok(status) => Err(format!("External editor exited with {status}")),
7724            Err(error) => Err(format!(
7725                "Could not launch external editor `{editor}`: {error}"
7726            )),
7727        };
7728        let _ = tx.send(TuiMessage::ExternalEditorResult(result));
7729    });
7730}
7731
7732/// Apply the authoritative run result when it wins the race with the async
7733/// event drain, then detach the live component from further partial updates.
7734fn reconcile_streamed_assistant_completion(
7735    current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
7736    last_assistant_text: &Mutex<String>,
7737    final_message: Option<&AssistantMessage>,
7738) {
7739    let final_blocks = final_message.map(assistant_blocks);
7740    let final_text = final_message.map(assistant_text);
7741    let component = current_assistant.lock().unwrap().take();
7742
7743    if let Some(component) = component {
7744        if let Some(blocks) = final_blocks.as_deref() {
7745            component.update_blocks(blocks);
7746        }
7747        component.set_streaming(false);
7748    }
7749
7750    if let Some(text) = final_text.filter(|text| !text.is_empty()) {
7751        *last_assistant_text.lock().unwrap() = text;
7752    }
7753}
7754
7755/// Drive a single prompt through the lane. When `streaming` is true, the
7756/// `AgentEvent` drain task renders the response live and the completed outcome
7757/// reconciles its final snapshot. When false (no `event_rx`), this falls back
7758/// to the blocking await-final-text path.
7759async fn run_prompt_streaming(
7760    lane: &Arc<dyn AgentLane>,
7761    prompt: &str,
7762    resume: bool,
7763    tui: &Arc<TuiAltScreen>,
7764    state: &Arc<TuiState>,
7765    streaming: bool,
7766    js: Option<&crate::js_extensions::JsExtensionSession>,
7767    dialog_bridge: &JsDialogBridge,
7768    args: &Args,
7769    images: Vec<rpi_ai::types::ImageContent>,
7770) {
7771    // The persistent Node host is intentionally started at the first real
7772    // prompt. By this point the TUI key worker and all UI/runtime handlers are
7773    // live, so a `before_agent_start` hook may safely open a native dialog. A
7774    // session with no prompt never starts Node merely to render its welcome
7775    // screen; JS commands/tools still trigger the same lazy ensure path.
7776    // Preparation is part of the active turn. Mark it working before Node can
7777    // block so Ctrl+C, Ctrl+D, and Esc all retain their documented abort
7778    // semantics for initial argv prompts as well as editor submissions.
7779    state.set_status(RunStatus::Working);
7780    tui.request_render(false);
7781    if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
7782        state.set_status(RunStatus::Idle);
7783        tui.request_render(false);
7784        return;
7785    }
7786
7787    let outcome = if resume {
7788        // Continuing an interrupted run: no new user turn, just the provider call
7789        // the interrupted run never reached. Native pi behaves the same way.
7790        match lane.resume_pending().await {
7791            Ok(Some(result)) => Ok(result),
7792            // Nothing to resume after all: leave the status alone and let the
7793            // normal prompt path take over.
7794            Ok(None) => {
7795                state.set_status(RunStatus::Idle);
7796                tui.request_render(false);
7797                return;
7798            }
7799            Err(error) => Err(error),
7800        }
7801    } else {
7802        lane.prompt_text(prompt, images).await
7803    };
7804
7805    if streaming {
7806        // Broadcast delivery is asynchronous: the harness result can resolve
7807        // before the drain task processes MessageEnd. Reconcile from the
7808        // authoritative outcome before detaching the component so the final
7809        // streamed tail cannot be left at an earlier partial snapshot.
7810        let final_message = match &outcome {
7811            Ok(result) => match &result.outcome {
7812                HarnessRunOutcome::Completed { final_message, .. }
7813                | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7814                HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7815                HarnessRunOutcome::Suspended { .. } => None,
7816            },
7817            Err(_) => None,
7818        };
7819        reconcile_streamed_assistant_completion(
7820            &state.current_assistant,
7821            &state.last_assistant_text,
7822            final_message,
7823        );
7824    }
7825
7826    state.set_status(RunStatus::Idle);
7827    // Guarantee the pending-messages display reconciles at the end of every
7828    // run/prompt — covers aborts and the non-streaming path, where no
7829    // `AgentEnd` event necessarily reaches the drain task.
7830    refresh_pending_messages(state, lane).await;
7831
7832    // Authoritative context-badge fallback. The streaming drain normally
7833    // updates it from `MessageEnd`, but a `Lagged` broadcast or the blocking
7834    // path can skip that event; reconcile from the run outcome so the badge
7835    // always reflects the newest real response.
7836    if let Ok(result) = &outcome {
7837        let final_message: Option<&AssistantMessage> = match &result.outcome {
7838            HarnessRunOutcome::Completed { final_message, .. }
7839            | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7840            HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7841            HarnessRunOutcome::Suspended { .. } => None,
7842        };
7843        if let Some(a) = final_message {
7844            record_context_usage(&state.footer, a);
7845        }
7846    }
7847
7848    match outcome {
7849        Ok(result) => match &result.outcome {
7850            HarnessRunOutcome::Failed {
7851                error,
7852                final_message,
7853                ..
7854            } => {
7855                // Only add an error line if the stream did NOT already render
7856                // an assistant message for it (drain task leaves
7857                // current_assistant Some only on an abrupt end).
7858                // A final assistant error is emitted by the event drain only
7859                // in streaming mode. In regular mode there is no drain task,
7860                // so suppressing this branch merely hides 405/auth/network
7861                // diagnostics from the user.
7862                let already_rendered = streaming && final_message.is_some();
7863                if !already_rendered {
7864                    let msg = final_message
7865                        .as_ref()
7866                        .and_then(|m| m.error_message.clone())
7867                        .unwrap_or_else(|| format!("{error:?}"));
7868                    add_error_message(&state.chat_container, &msg);
7869                }
7870            }
7871            HarnessRunOutcome::Suspended { .. } => {
7872                add_error_message(
7873                    &state.chat_container,
7874                    "Run suspended (deferred) — resume is not supported in v1.",
7875                );
7876            }
7877            HarnessRunOutcome::Aborted { final_message, .. } => {
7878                // Aborted runs render their own partial/final message via the
7879                // stream; only add a note on the blocking fallback path.
7880                if !streaming {
7881                    add_error_message(&state.chat_container, "Request aborted.");
7882                    let _ = final_message; // (rendered by the stream in streaming mode)
7883                }
7884            }
7885            HarnessRunOutcome::Completed { final_message, .. } => {
7886                if !streaming {
7887                    let text = assistant_text(final_message);
7888                    if !text.is_empty() {
7889                        add_assistant_message_blocking(
7890                            &state.chat_container,
7891                            &text,
7892                            state.markdown_transformer(),
7893                        );
7894                        *state.last_assistant_text.lock().unwrap() = text;
7895                    }
7896                }
7897            }
7898        },
7899        Err(e) => {
7900            add_error_message(&state.chat_container, &e.to_string());
7901        }
7902    }
7903
7904    tui.request_render(false);
7905}
7906
7907/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
7908/// Reports the outcome as a transcript note; v1's compaction summarizes the
7909/// session in place, so no streaming display is wired (compaction emits no
7910/// `AgentEvent`s — only the harness bus `RunEnd`).
7911async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
7912    state.set_status(RunStatus::Working);
7913    tui.request_render(false);
7914    match lane.compact(None).await {
7915        Ok(_) => {
7916            add_note_message(&state.chat_container, "Conversation compacted.");
7917            // The last assistant usage describes the pre-compaction context;
7918            // reset the badge to `?` until the next response reports the new
7919            // (much smaller) prompt size — mirrors pi's `percent: null`.
7920            state.footer.set_context_tokens(None);
7921        }
7922        Err(e) => {
7923            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
7924        }
7925    }
7926    state.set_status(RunStatus::Idle);
7927    tui.request_render(false);
7928}
7929
7930/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
7931/// when no clipboard is available (or the `clipboard` feature is off), prints a
7932/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
7933fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
7934    let text = state.last_assistant_text.lock().unwrap().clone();
7935    if text.is_empty() {
7936        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
7937        return;
7938    }
7939    if copy_to_clipboard(&text) {
7940        add_note_message(chat, "Copied last reply to the clipboard.");
7941    } else {
7942        // Clipboard unavailable — print the text to the transcript so the user
7943        // can select/copy it manually (degrades gracefully in headless envs).
7944        let preview: String = text.chars().take(200).collect();
7945        add_note_message(
7946            chat,
7947            &format!(
7948                "Clipboard unavailable. Last reply: {preview}{}",
7949                if text.chars().count() > 200 {
7950                    "…"
7951                } else {
7952                    ""
7953                }
7954            ),
7955        );
7956    }
7957}
7958
7959/// Extract text from the chat container within the given screen coordinates.
7960/// Returns None if the selection is invalid or empty.
7961fn extract_selected_text(
7962    chat_container: &Arc<Container>,
7963    start: (u16, u16),
7964    end: (u16, u16),
7965) -> Option<String> {
7966    use rpi_tui::Component;
7967
7968    // Get the rendered lines from the chat container
7969    let lines = chat_container.render(80); // Use a reasonable width
7970    if lines.is_empty() {
7971        return None;
7972    }
7973
7974    // Normalize coordinates (ensure start is before end)
7975    let (start_row, end_row) = if start.1 <= end.1 {
7976        (start.1 as usize, end.1 as usize)
7977    } else {
7978        (end.1 as usize, start.1 as usize)
7979    };
7980
7981    // Clamp to valid range
7982    let start_row = start_row.min(lines.len().saturating_sub(1));
7983    let end_row = end_row.min(lines.len().saturating_sub(1));
7984
7985    if start_row > end_row {
7986        return None;
7987    }
7988
7989    // Extract the selected lines
7990    let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7991
7992    if selected_lines.is_empty() {
7993        return None;
7994    }
7995
7996    Some(selected_lines.join("\n"))
7997}
7998
7999/// Best-effort clipboard write. Enabled only with the `clipboard` feature
8000/// (`arboard`) on non-Android platforms; otherwise returns `false` so the
8001/// caller degrades to a hint.
8002#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8003fn copy_to_clipboard(text: &str) -> bool {
8004    match arboard::Clipboard::new() {
8005        Ok(mut cb) => cb.set_text(text).is_ok(),
8006        Err(_) => false,
8007    }
8008}
8009
8010#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8011fn copy_to_clipboard(_text: &str) -> bool {
8012    false
8013}
8014
8015/// Best-effort clipboard text read for Ctrl+V paste. The optional `clipboard`
8016/// feature keeps headless builds free of platform clipboard dependencies;
8017/// on Android the OS owns clipboard access so this degrades to `None`.
8018#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8019fn read_clipboard_text() -> Option<String> {
8020    let mut clipboard = arboard::Clipboard::new().ok()?;
8021    clipboard.get_text().ok()
8022}
8023
8024#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8025fn read_clipboard_text() -> Option<String> {
8026    None
8027}
8028
8029/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
8030/// `ImageContent` contract. The optional clipboard feature keeps headless
8031/// builds free of platform clipboard dependencies.
8032#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8033fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8034    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
8035    let image = match clipboard.get_image() {
8036        Ok(image) => image,
8037        Err(_) => return Ok(None),
8038    };
8039    let width =
8040        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
8041    let height =
8042        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
8043    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
8044        return Err("clipboard image dimensions are outside the supported range".into());
8045    }
8046    let mut bytes = Vec::new();
8047    {
8048        let mut encoder = png::Encoder::new(&mut bytes, width, height);
8049        encoder.set_color(png::ColorType::Rgba);
8050        encoder.set_depth(png::BitDepth::Eight);
8051        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
8052        writer
8053            .write_image_data(&image.bytes)
8054            .map_err(|e| e.to_string())?;
8055    }
8056    Ok(Some(rpi_ai::types::ImageContent {
8057        kind: rpi_ai::types::ImageContentType,
8058        data: base64::engine::general_purpose::STANDARD.encode(bytes),
8059        mime_type: "image/png".into(),
8060    }))
8061}
8062
8063fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
8064    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
8065        let mut options = ImageOptions::default();
8066        options.width = Some(48);
8067        options.alt_text = Some("Attached image".into());
8068        chat.add_child(Arc::new(Image::from_data(bytes, options)));
8069        chat.add_child(Arc::new(Spacer::new(1)));
8070    }
8071}
8072
8073#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8074fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8075    Ok(None)
8076}
8077
8078/// Blocking fallback (no `event_rx`): render the final assistant text as a
8079/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
8080/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
8081/// the identity path. The blocking path only fires when `event_rx` is absent,
8082/// so it shares the same transformer the streaming path installs on its
8083/// components.
8084fn add_assistant_message_blocking(
8085    container: &Arc<Container>,
8086    text: &str,
8087    transformer: Option<MarkdownTransformer>,
8088) {
8089    if text.is_empty() {
8090        return;
8091    }
8092    let msg = Arc::new(AssistantMessageComponent::new(
8093        AssistantMessageOptions::default(),
8094    ));
8095    if let Some(t) = &transformer {
8096        msg.set_markdown_transformer(Some(t.clone()));
8097    }
8098    msg.update_text(text);
8099    container.add_child(msg);
8100    container.add_child(Arc::new(Spacer::new(1)));
8101}
8102
8103// ===========================================================================
8104// AgentEvent drain task — the streaming core
8105// ===========================================================================
8106
8107/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
8108/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
8109/// lifetime of the TUI.
8110/// Re-read the lane's queue and update the pending-messages dock slot.
8111/// Cheap (two short mutex locks) and a no-op when the snapshot is unchanged,
8112/// so it is safe to call on every relevant event.
8113async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
8114    if let Ok(queued) = lane.queued_messages().await {
8115        state.set_pending_queue(queued);
8116    }
8117}
8118
8119/// Whether the transcript holds a panel that repaints its elapsed readout from
8120/// the clock on every frame: a running bash command, or a tool call still in
8121/// [`ToolStatus::Running`]. Such a panel needs a real transcript rebuild each
8122/// render tick — a frame that reuses the cached scroll content would leave its
8123/// timer frozen at the second of the last full rebuild.
8124fn transcript_has_live_panel(
8125    bash_components_present: bool,
8126    tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
8127) -> bool {
8128    bash_components_present
8129        || tool_components
8130            .values()
8131            .any(|component| component.status() == ToolStatus::Running)
8132}
8133
8134/// Prepend restored queued messages to the draft the user may already have
8135/// typed (native pi's `restoreQueuedMessagesToEditor` places them first).
8136fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
8137    if current.trim().is_empty() {
8138        queued_text.to_string()
8139    } else {
8140        format!("{queued_text}\n\n{current}")
8141    }
8142}
8143
8144/// `app.message.dequeue`: drain every queued steering/follow-up message out of
8145/// the lane and restore it to the editor so the user can edit before resending
8146/// (native pi's `restoreQueuedMessagesToEditor`).
8147///
8148/// Runs on a spawned task rather than through the [`TuiMessage`] mailbox: the
8149/// async main loop owns `run_prompt_streaming(..).await` for the whole run, and
8150/// a queue only exists *while* a run is in flight — so a `TuiMessage::Dequeue`
8151/// was never serviced until the run ended, after the lane had already consumed
8152/// the queue. That made Alt+Q a no-op while it was most needed.
8153async fn restore_queued_messages_to_editor(
8154    lane: Arc<dyn AgentLane>,
8155    editor: Arc<Editor>,
8156    state: Arc<TuiState>,
8157    tui: Arc<TuiAltScreen>,
8158) {
8159    match lane.clear_queue().await {
8160        Ok(queued) => {
8161            let all: Vec<String> = queued
8162                .steering
8163                .iter()
8164                .chain(queued.follow_up.iter())
8165                .cloned()
8166                .collect();
8167            if !all.is_empty() {
8168                let queued_text = all.join("\n\n");
8169                let current = editor.get_text();
8170                let combined = merge_queued_into_draft(&queued_text, &current);
8171                let cursor = combined.chars().count();
8172                editor.set_text(&combined);
8173                editor.set_cursor(0, cursor);
8174            }
8175            // The lane drained the queue; refresh so the dock drops the rows.
8176            refresh_pending_messages(&state, &lane).await;
8177        }
8178        Err(error) => {
8179            add_error_message(
8180                &state.chat_container,
8181                &format!("Could not restore queued messages: {error}"),
8182            );
8183        }
8184    }
8185    tui.request_render(false);
8186}
8187
8188async fn drain_agent_events(
8189    mut rx: broadcast::Receiver<AgentEvent>,
8190    tui: Arc<TuiAltScreen>,
8191    state: Arc<TuiState>,
8192    chat: Arc<Container>,
8193    lane: Arc<dyn AgentLane>,
8194) {
8195    loop {
8196        match rx.recv().await {
8197            Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
8198            Err(broadcast::error::RecvError::Lagged(_)) => {
8199                // We dropped some intermediate deltas; the next MessageUpdate/
8200                // MessageEnd carries a full partial snapshot so the UI re-syncs.
8201                continue;
8202            }
8203            Err(broadcast::error::RecvError::Closed) => break,
8204        }
8205    }
8206}
8207
8208/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
8209/// (`interactive-mode.ts:3068-3396`).
8210async fn handle_agent_event(
8211    event: AgentEvent,
8212    tui: &Arc<TuiAltScreen>,
8213    state: &Arc<TuiState>,
8214    chat: &Arc<Container>,
8215    lane: &Arc<dyn AgentLane>,
8216) {
8217    match event {
8218        AgentEvent::AgentStart => {
8219            state.set_status(RunStatus::Working);
8220            tui.request_render(false);
8221        }
8222
8223        AgentEvent::AgentEnd { .. } => {
8224            // Finalize any still-streaming assistant message.
8225            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8226                comp.set_streaming(false);
8227            }
8228            state.set_status(RunStatus::Idle);
8229            // Flush any `!command` results that completed mid-run: the run's
8230            // tool sequence is closed, so appending now keeps transcript order
8231            // intact (native pi's `flushPendingBashMessages`).
8232            let pending: Vec<AgentMessage> =
8233                std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
8234            for message in pending {
8235                if let Err(error) = lane.append_message(message).await {
8236                    add_error_message(chat, &format!("Could not record bash result: {error}"));
8237                }
8238            }
8239            // The run drained the queue; refresh so consumed entries drop out
8240            // of the pending-messages display.
8241            refresh_pending_messages(state, lane).await;
8242            tui.request_render(false);
8243        }
8244
8245        AgentEvent::RetryScheduled {
8246            attempt,
8247            max_retries,
8248            delay_ms,
8249            ..
8250        } => {
8251            state.show_retry(attempt, max_retries, delay_ms);
8252            tui.request_render(false);
8253        }
8254
8255        AgentEvent::TurnStart => {
8256            // A new turn: reset the streaming-assistant guard so the next
8257            // MessageStart creates a fresh component.
8258            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8259                comp.set_streaming(false);
8260            }
8261        }
8262
8263        AgentEvent::TurnEnd {
8264            message,
8265            tool_results,
8266        } => {
8267            // Finalize the assistant message for this turn.
8268            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8269                if let AgentMessage::Assistant(a) = &message {
8270                    comp.update_blocks(&assistant_blocks(a));
8271                }
8272                comp.set_streaming(false);
8273            }
8274            // Any tool results whose components were never ended by a
8275            // ToolExecutionEnd get a static rendering here (best-effort). The
8276            // normal path removes the component via ToolExecutionEnd; this is
8277            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
8278            let tools = state.tool_components.lock().unwrap();
8279            for tr in &tool_results {
8280                if tools.contains_key(&tr.tool_call_id) {
8281                    // Will be removed below via ToolExecutionEnd in the normal
8282                    // path; leave as-is if still present.
8283                    let _ = tr;
8284                }
8285            }
8286            drop(tools);
8287            tui.request_render(false);
8288        }
8289
8290        AgentEvent::MessageStart { message } => match message {
8291            AgentMessage::Assistant(a) => {
8292                let comp = Arc::new(AssistantMessageComponent::new(
8293                    AssistantMessageOptions::default(),
8294                ));
8295                // B5e: install the live markdown transformer so the plugin's
8296                // `register_markdown_transformer` handlers apply from the very
8297                // first streamed delta. `set_streaming` before the transform
8298                // install is fine (transform fires on `update_blocks`, below).
8299                if let Some(t) = state.markdown_transformer() {
8300                    comp.set_markdown_transformer(Some(t));
8301                }
8302                comp.set_hide_thinking(state.hide_thinking());
8303                comp.set_streaming(true);
8304                // Render text AND thinking blocks in order (the old path fed
8305                // only the concatenated text, so thinking blocks never showed).
8306                comp.update_blocks(&assistant_blocks(&a));
8307                chat.add_child(comp.clone());
8308                // Spacer(1) separates this assistant turn from the next entry;
8309                // the component itself adds no leading spacer.
8310                chat.add_child(Arc::new(Spacer::new(1)));
8311                *state.current_assistant.lock().unwrap() = Some(comp);
8312                tui.request_render(false);
8313            }
8314            AgentMessage::Custom(custom) => {
8315                let payload = serde_json::json!({
8316                    "customType": custom.role,
8317                    "content": custom.content,
8318                    "details": custom.data,
8319                    "expanded": false,
8320                    "outputPad": 1,
8321                });
8322                if let Some(component) = extension_message_component(
8323                    &state.extension_session,
8324                    &custom.role,
8325                    &payload,
8326                    state.markdown_transformer(),
8327                ) {
8328                    chat.add_child(component);
8329                    chat.add_child(Arc::new(Spacer::new(1)));
8330                    tui.request_render(false);
8331                } else {
8332                    add_note_message(chat, &custom_message_fallback(&custom));
8333                    tui.request_render(false);
8334                }
8335            }
8336            // Queued (steering / follow-up) user messages are rendered here,
8337            // not at queue time, so a message that is still waiting never looks
8338            // delivered. The loop drains those itself and emits
8339            // `message_start` for them (`agent_loop.rs` pending-message drain).
8340            //
8341            // A DIRECTLY-sent prompt does NOT reach this arm: the harness
8342            // persists it up front and runs the loop with an empty prompts vec,
8343            // so no `message_start` is emitted for it — the submit handler
8344            // renders that bubble instead.
8345            AgentMessage::User(user) => {
8346                add_user_message(chat, &user_message_text(&user));
8347                // A consumed entry must drop out of the pending display.
8348                refresh_pending_messages(state, lane).await;
8349                tui.request_render(false);
8350            }
8351            // ToolResult / other starts are echoed via the tool-execution
8352            // components; ignore the dupes.
8353            _ => {}
8354        },
8355
8356        AgentEvent::MessageUpdate {
8357            message,
8358            assistant_message_event,
8359        } => {
8360            {
8361                // `message` is the shared partial snapshot
8362                // (`Arc<AssistantMessage>`), so read through it instead of
8363                // cloning the whole growing message once per delta. The bare
8364                // block preserves the original nesting.
8365                let a: &rpi_ai::types::AssistantMessage = &message;
8366                let text = assistant_text(a);
8367                let mut saw_bash_tool_call = false;
8368                // Scan content for finalized tool calls → proactively create
8369                // tool components (TS shows the tool as soon as the assistant
8370                // emits the ToolCall; ToolExecutionStart coalesces if it
8371                // already exists).
8372                for c in &a.content {
8373                    if let Content::ToolCall(tc) = c {
8374                        // Streaming providers may expose a placeholder tool
8375                        // call before its name has arrived. It is not a real
8376                        // tool panel and must not leave an empty first row.
8377                        if tc.name.trim().is_empty() {
8378                            continue;
8379                        }
8380                        if tc.name == "bash" {
8381                            // Bash has a dedicated component. Create it here as
8382                            // well as on ToolExecutionStart because the tool
8383                            // call can become visible in a MessageUpdate first.
8384                            // Keeping it in the bash map lets Start coalesce
8385                            // with this panel instead of appending a second one.
8386                            let command = tc
8387                                .arguments
8388                                .get("command")
8389                                .and_then(|v| v.as_str())
8390                                .unwrap_or("");
8391                            // Streaming tool-call arguments may still be `{}`
8392                            // here. Do not create a running bash panel until
8393                            // the lifecycle start event provides the command;
8394                            // otherwise the spinner renders first and the
8395                            // actual `$ command` header appears one frame
8396                            // later.
8397                            if command.trim().is_empty() {
8398                                continue;
8399                            }
8400                            saw_bash_tool_call = true;
8401                            let mut bash = state.bash_components.lock().unwrap();
8402                            if let Some(existing) = bash.get(&tc.id) {
8403                                // Tool-call arguments arrive incrementally.
8404                                // Refresh the running panel from every full
8405                                // assistant snapshot instead of leaving its
8406                                // header on the first partial command.
8407                                existing.set_command(command);
8408                            } else {
8409                                let comp = Arc::new(BashExecutionComponent::new(command));
8410                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8411                                chat.add_child(comp.clone());
8412                                bash.insert(tc.id.clone(), comp);
8413                            }
8414                        } else {
8415                            let mut tools = state.tool_components.lock().unwrap();
8416                            let display_args = if is_ask_user_tool(&tc.name) {
8417                                ask_user_args_display(&tc.arguments)
8418                            } else {
8419                                tc.arguments.to_string()
8420                            };
8421                            if let Some(existing) = tools.get(&tc.id) {
8422                                // Regular tool arguments stream incrementally
8423                                // too, so refresh their live header as soon as
8424                                // a more complete snapshot arrives.
8425                                if !display_args.trim().is_empty() && display_args.trim() != "{}" {
8426                                    existing.set_args(&display_args);
8427                                }
8428                            } else {
8429                                let comp =
8430                                    Arc::new(ToolExecutionComponent::new(&tc.name, &display_args));
8431                                if is_ask_user_tool(&tc.name) {
8432                                    comp.set_display_title("ASK USER");
8433                                }
8434                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8435                                comp.set_running();
8436                                chat.add_child(comp.clone());
8437                                tools.insert(tc.id.clone(), comp);
8438                            }
8439                        }
8440                    }
8441                }
8442                // MessageUpdate can expose the finalized bash call before
8443                // ToolExecutionStart arrives. Hide the global `Working…`
8444                // loader immediately when creating that bash panel; otherwise
8445                // it briefly appears alongside the panel's `Running…` spinner.
8446                if saw_bash_tool_call {
8447                    state.sync_working_loader_with_bash();
8448                }
8449                let _ = assistant_message_event; // snapshot already applied via `a`
8450                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
8451                    // Stream the full block list (text + thinking) each update
8452                    // so thinking blocks render live as they arrive.
8453                    comp.update_blocks(&assistant_blocks(a));
8454                }
8455                *state.last_assistant_text.lock().unwrap() = text;
8456                tui.request_render(false);
8457            }
8458        }
8459
8460        AgentEvent::MessageEnd { message } => {
8461            if let AgentMessage::Assistant(a) = &message {
8462                let text = assistant_text(a);
8463                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8464                    comp.update_blocks(&assistant_blocks(a));
8465                    comp.set_streaming(false);
8466                }
8467                // Cache the finalized text for `/copy`.
8468                if !text.is_empty() {
8469                    *state.last_assistant_text.lock().unwrap() = text;
8470                }
8471                // Cache-miss notice (`maybeShowCacheMissNotice`): the previous
8472                // turn's input established a cacheable prefix; a prompt that
8473                // re-reads less than it should means the prefix was re-billed.
8474                // Uses the shared cache-stats logic (noise floor, idle gap,
8475                // model change) and prices the miss when known.
8476                let usage = &a.usage;
8477                // Footer usage totals + cache hit rate (pi footer.ts).
8478                state.footer.add_usage(
8479                    usage.input,
8480                    usage.output,
8481                    usage.cache_read,
8482                    usage.cache_write,
8483                    usage.cost.total,
8484                );
8485                let denom = usage.input + usage.cache_read + usage.cache_write;
8486                if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
8487                    state
8488                        .footer
8489                        .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
8490                }
8491                // Context-window badge (`?/512k` → `63.2%/512k`): the provider
8492                // reports the prompt token count for this request, which is the
8493                // context size the footer displays.
8494                record_context_usage(&state.footer, a);
8495                let miss = state
8496                    .cache_tracker
8497                    .lock()
8498                    .unwrap()
8499                    .observe(a, &rpi_harness::cache_stats::NoPrices);
8500                // Native pi gates these behind `showCacheMissNotices`
8501                // (default `false`); the tracker still runs so the footer's
8502                // cache-hit rate stays accurate either way.
8503                let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
8504                if let Some(miss) = miss.filter(|_| cache_miss_notices) {
8505                    let cost = if miss.missed_cost > 0.0 {
8506                        format!(" (~${:.4})", miss.missed_cost)
8507                    } else {
8508                        String::new()
8509                    };
8510                    add_note_message(
8511                        &state.chat_container,
8512                        &format!(
8513                            "Cache miss: {} tokens re-billed{}",
8514                            format_tokens(miss.missed_tokens),
8515                            cost
8516                        ),
8517                    );
8518                }
8519                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
8520                    add_note_message(chat, &text);
8521                }
8522                // Error assistant messages carry the provider diagnostic in
8523                // `error_message`, not in text content. The assistant
8524                // component is empty for these messages, so surface the
8525                // diagnostic as a visible error row in the transcript.
8526                if let Some(error) = assistant_error_text(a) {
8527                    add_error_message(chat, &error);
8528                }
8529            }
8530            tui.request_render(false);
8531        }
8532
8533        AgentEvent::ToolExecutionStart {
8534            tool_call_id,
8535            tool_name,
8536            args,
8537        } => {
8538            // Ignore placeholder lifecycle events emitted before the
8539            // provider has supplied a tool name.
8540            if tool_name.trim().is_empty() {
8541                return;
8542            }
8543            if tool_name == "bash" {
8544                // Bash streams into a dedicated BashExecutionComponent (command
8545                // header + live preview + exit/truncation status) rather than a
8546                // generic ToolExecutionComponent. The command comes from the
8547                // `command` field of the bash tool args.
8548                let command = args
8549                    .get("command")
8550                    .and_then(|v| v.as_str())
8551                    .unwrap_or("")
8552                    .to_string();
8553                // Bash arguments can still be `{}` when the lifecycle event
8554                // races the streamed tool-call argument finalization. Defer
8555                // the panel until a later start event carries the command.
8556                if command.trim().is_empty() {
8557                    return;
8558                }
8559                let mut bash_map = state.bash_components.lock().unwrap();
8560                if let Some(existing) = bash_map.get(&tool_call_id) {
8561                    // A ToolExecutionUpdate already created the panel (fast
8562                    // command — Update can arrive before Start); backfill the
8563                    // command header instead of adding a SECOND panel, which
8564                    // used to stack an empty "$ " box above the real one.
8565                    existing.set_command(&command);
8566                } else {
8567                    let comp = Arc::new(BashExecutionComponent::new(command));
8568                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8569                    chat.add_child(comp.clone());
8570                    bash_map.insert(tool_call_id.clone(), comp);
8571                }
8572            } else {
8573                let _comp = {
8574                    let mut tools = state.tool_components.lock().unwrap();
8575                    if let Some(existing) = tools.get(&tool_call_id) {
8576                        if is_ask_user_tool(&tool_name) {
8577                            existing.set_display_title("ASK USER");
8578                            existing.set_args(&ask_user_args_display(&args));
8579                        } else {
8580                            existing.set_args(&args.to_string());
8581                        }
8582                        existing.clone()
8583                    } else {
8584                        let display_args = if is_ask_user_tool(&tool_name) {
8585                            ask_user_args_display(&args)
8586                        } else {
8587                            args.to_string()
8588                        };
8589                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8590                        if is_ask_user_tool(&tool_name) {
8591                            comp.set_display_title("ASK USER");
8592                        }
8593                        comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8594                        // A `read` of a SKILL.md renders as native Pi's
8595                        // `[skill] <name>` invocation box (custom-message
8596                        // background, collapsed to one line, Ctrl+O expands the
8597                        // skill markdown) instead of a generic READ tool panel.
8598                        if let Some(skill) = skill_tool_name(&tool_name, &args) {
8599                            comp.set_skill_name(skill);
8600                        }
8601                        comp.set_running();
8602                        chat.add_child(comp.clone());
8603                        tools.insert(tool_call_id.clone(), comp.clone());
8604                        comp
8605                    }
8606                };
8607            }
8608            state.sync_working_loader_with_bash();
8609            tui.request_render(false);
8610        }
8611
8612        AgentEvent::ToolExecutionUpdate {
8613            tool_call_id,
8614            tool_name,
8615            args,
8616            partial_result,
8617        } => {
8618            if tool_name.trim().is_empty() {
8619                return;
8620            }
8621            let partial_text = if is_ask_user_tool(&tool_name) {
8622                ask_user_progress_text(&args, &partial_result)
8623            } else {
8624                tool_result_text(&partial_result)
8625            };
8626            let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
8627            if tool_name == "bash" {
8628                // Append the streamed chunk to the bash component's preview.
8629                // RAW text (no single-line collapsing) — the old
8630                // `summarize_tool_result` folded every newline into a `⏎`
8631                // glyph, cramming e.g. `ls -la`'s listing onto one line.
8632                let chunk = partial_text;
8633                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
8634                    // Lifecycle updates can contain a more complete args object
8635                    // than the snapshot that created this running panel.
8636                    if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
8637                        bash.set_command(command);
8638                    }
8639                    if has_partial_payload {
8640                        bash.append_output(&chunk);
8641                    }
8642                } else if has_partial_payload {
8643                    // ToolExecutionStart is emitted before a tool can run.
8644                    // Ignore an out-of-order partial until that event gives us
8645                    // the real command, rather than showing a spinner above an
8646                    // empty `$ ` header. Normal updates are handled by the
8647                    // component created in ToolExecutionStart.
8648                }
8649            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
8650                if let Some(skill) = skill_tool_name(&tool_name, &args) {
8651                    comp.set_skill_name(skill);
8652                }
8653                if is_ask_user_tool(&tool_name) {
8654                    comp.set_display_title("ASK USER");
8655                    comp.set_args(&ask_user_args_display(&args));
8656                } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
8657                    comp.set_args(&args.to_string());
8658                }
8659                // Raw multi-line text — read/ls-style tools must show their
8660                // full content, not the single-line ⏎-folded summary.
8661                if has_partial_payload {
8662                    comp.set_result(&partial_text, false);
8663                    apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
8664                }
8665            } else if has_partial_payload {
8666                // No component yet — create a running one so the partial shows.
8667                // Empty callbacks are common before ToolExecutionStart; wait
8668                // for Start so the first panel has the real arguments instead
8669                // of an empty `TOOLS` box.
8670                let display_args = if is_ask_user_tool(&tool_name) {
8671                    ask_user_args_display(&args)
8672                } else {
8673                    args.to_string()
8674                };
8675                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8676                if is_ask_user_tool(&tool_name) {
8677                    comp.set_display_title("ASK USER");
8678                }
8679                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8680                if let Some(skill) = skill_tool_name(&tool_name, &args) {
8681                    comp.set_skill_name(skill);
8682                }
8683                comp.set_running();
8684                comp.set_result(&partial_text, false);
8685                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
8686                chat.add_child(comp.clone());
8687                state
8688                    .tool_components
8689                    .lock()
8690                    .unwrap()
8691                    .insert(tool_call_id.clone(), comp.clone());
8692            }
8693            state.sync_working_loader_with_bash();
8694            tui.request_render(false);
8695        }
8696
8697        AgentEvent::ToolExecutionEnd {
8698            tool_call_id,
8699            tool_name,
8700            result,
8701            is_error,
8702        } => {
8703            if tool_name.trim().is_empty() {
8704                return;
8705            }
8706            if tool_name == "bash" {
8707                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
8708                if let Some(bash) = bash {
8709                    finalize_bash(&bash, &result, is_error);
8710                } else {
8711                    // Bash ended without a Start/Update — render a finalized
8712                    // component directly from the result text.
8713                    let command = result
8714                        .details
8715                        .get("command")
8716                        .and_then(|v| v.as_str())
8717                        .unwrap_or("")
8718                        .to_string();
8719                    if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
8720                        state.sync_working_loader_with_bash();
8721                        // Edge case: empty bash result. Still render immediately
8722                        // to clear the panel state for the user.
8723                        tui.render_now(false);
8724                        return;
8725                    }
8726                    let comp = Arc::new(BashExecutionComponent::new(command));
8727                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8728                    comp.append_output(&tool_result_text(&result));
8729                    finalize_bash(&comp, &result, is_error);
8730                    chat.add_child(comp);
8731                }
8732            } else {
8733                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
8734                if let Some(comp) = comp {
8735                    let result_text = if is_ask_user_tool(&tool_name) {
8736                        ask_user_result_text(&result)
8737                    } else {
8738                        tool_result_text(&result)
8739                    };
8740                    comp.set_result(&result_text, is_error);
8741                    if !is_ask_user_tool(&tool_name) {
8742                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8743                    }
8744                } else {
8745                    // Tool ended without a Start/Update (e.g. a very fast tool):
8746                    // render a finalized component directly.
8747                    let comp = Arc::new(ToolExecutionComponent::new(
8748                        &tool_name,
8749                        &if is_ask_user_tool(&tool_name) {
8750                            ask_user_args_display(&serde_json::Value::Null)
8751                        } else {
8752                            "".to_string()
8753                        },
8754                    ));
8755                    if is_ask_user_tool(&tool_name) {
8756                        comp.set_display_title("ASK USER");
8757                    }
8758                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8759                    let result_text = if is_ask_user_tool(&tool_name) {
8760                        ask_user_result_text(&result)
8761                    } else {
8762                        tool_result_text(&result)
8763                    };
8764                    comp.set_result(&result_text, is_error);
8765                    if !is_ask_user_tool(&tool_name) {
8766                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8767                    }
8768                    chat.add_child(comp.clone());
8769                }
8770            }
8771            state.sync_working_loader_with_bash();
8772            // Bash tool completion is user-visible: immediate render ensures the
8773            // result is displayed even when the scheduler thread is busy or the
8774            // throttle window has not elapsed. This single immediate frame does
8775            // not regress the streaming-output throttle that keeps CPU/GPU use
8776            // low (bash completions are discrete events, not per-token bursts).
8777            tui.render_now(false);
8778        }
8779    }
8780}
8781
8782/// Update the footer's context-window badge from one assistant response.
8783///
8784/// `Usage::context_tokens()` is the prompt size the provider reported for that
8785/// request — the value pi's footer renders as `{percent}%/{window}`. Aborted
8786/// and errored turns are skipped (their usage is not a real context
8787/// measurement, mirroring pi's `lastAssistantUsageInfo`) so the last valid
8788/// count stays on screen instead of flickering to `?`.
8789fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
8790    if message.stop_reason == rpi_ai::StopReason::Aborted
8791        || message.stop_reason == rpi_ai::StopReason::Error
8792    {
8793        return;
8794    }
8795    let tokens = message.usage.context_tokens();
8796    if tokens > 0 {
8797        footer.set_context_tokens(Some(tokens));
8798    }
8799}
8800
8801/// Return the diagnostic carried by a failed or aborted provider request.
8802/// Providers may omit `error_message`; keep a stable fallback so a terminal
8803/// request failure can never render as an empty transcript turn.
8804fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
8805    let fallback = match message.stop_reason {
8806        rpi_ai::StopReason::Error => "Provider request failed.",
8807        rpi_ai::StopReason::Aborted => "Request aborted.",
8808        _ => return None,
8809    };
8810    Some(
8811        message
8812            .error_message
8813            .as_deref()
8814            .filter(|text| !text.trim().is_empty())
8815            .unwrap_or(fallback)
8816            .to_string(),
8817    )
8818}
8819
8820/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
8821/// tool result and mark the component complete. Mirrors the TS bash finalize
8822/// path; only the fields `BashExecutionComponent` needs are read.
8823fn finalize_bash(
8824    comp: &Arc<BashExecutionComponent>,
8825    result: &rpi_agent::AgentToolResult,
8826    is_error: bool,
8827) {
8828    // The exit code isn't in details directly (TS carries it elsewhere); use
8829    // `is_error` as the error signal and 0/1 as a best-effort exit code.
8830    let exit_code = if is_error { Some(1) } else { Some(0) };
8831    let truncated = result
8832        .details
8833        .get("truncation")
8834        .and_then(|t| t.get("truncated"))
8835        .and_then(|v| v.as_bool())
8836        .unwrap_or(false);
8837    let full_output_path = result
8838        .details
8839        .get("full_output_path")
8840        .and_then(|v| v.as_str())
8841        .map(|s| s.to_string());
8842    let truncation = BashTruncation {
8843        truncated,
8844        full_output_path,
8845    };
8846    // Some tool backends attach the authoritative command to result details.
8847    // Backfill it before completing so this is a fallback, never the first
8848    // opportunity for the UI to show the whole command.
8849    if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
8850        comp.set_command(command);
8851    }
8852    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
8853    comp.set_complete(exit_code, cancelled, truncation);
8854}
8855
8856/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
8857/// display-diff string, render it with colors and attach to the component so
8858/// the changes show in the transcript. `write` has no diff (details: Null) and
8859/// stays a plain summary.
8860fn apply_edit_diff(
8861    comp: &Arc<ToolExecutionComponent>,
8862    tool_name: &str,
8863    details: &serde_json::Value,
8864    tui: &Arc<TuiAltScreen>,
8865) {
8866    if tool_name != "edit" {
8867        return;
8868    }
8869    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
8870        return;
8871    };
8872    if diff_text.is_empty() {
8873        return;
8874    }
8875    let width = tui.width();
8876    let lines = render_diff(diff_text, width);
8877    comp.set_diff(lines);
8878}
8879
8880/// The skill name when `tool_name` is a `read` of a `SKILL.md` file, else
8881/// `None`. The name is the `SKILL.md` parent directory's basename (matching
8882/// native Pi's skill-file convention). Ordinary markdown/document reads
8883/// return `None` and remain regular `READ` tool panels.
8884fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
8885    if tool_name != "read" {
8886        return None;
8887    }
8888    let path = args.get("path").and_then(|value| value.as_str())?;
8889    let normalized = path.replace('\\', "/");
8890    let file_name = normalized.rsplit('/').next()?;
8891    if !file_name.eq_ignore_ascii_case("SKILL.md") {
8892        return None;
8893    }
8894    normalized
8895        .trim_end_matches('/')
8896        .rsplit('/')
8897        .nth(1)
8898        .filter(|name| !name.is_empty())
8899        .map(str::to_string)
8900}
8901
8902/// The raw multi-line text of a tool result (no single-line collapsing). The
8903/// bash panel needs the original line structure — the old path fed it through
8904/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
8905/// crammed e.g. `ls -la`'s whole listing onto one line.
8906fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
8907    use rpi_agent::TextContentOrImage;
8908    let mut parts: Vec<String> = Vec::new();
8909    for c in &result.content {
8910        if let TextContentOrImage::Text(t) = c {
8911            parts.push(t.text.clone());
8912        }
8913    }
8914    parts.join("\n")
8915}
8916
8917/// Empty progress callbacks are valid (notably before a tool's start event),
8918/// but they do not contain anything useful to render. Defer those callbacks so
8919/// the first tool panel is created from `ToolExecutionStart` with real args.
8920fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
8921    !text.trim().is_empty() || !result.details.is_null()
8922}
8923
8924/// Tool names rendered through the dedicated `ASK USER` panel instead of a
8925/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
8926fn is_ask_user_tool(tool_name: &str) -> bool {
8927    matches!(tool_name, "ask_user" | "ask_user_question")
8928}
8929
8930/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
8931/// transport JSON must never leak into the transcript.
8932fn ask_user_args_display(args: &serde_json::Value) -> String {
8933    if args.is_null() {
8934        return String::new();
8935    }
8936    if let Some(summary) = args
8937        .get("summary")
8938        .and_then(serde_json::Value::as_str)
8939        .map(str::trim)
8940        .filter(|text| !text.is_empty())
8941    {
8942        return format!("Confirmation requested: {summary}");
8943    }
8944
8945    let mut lines: Vec<String> = Vec::new();
8946    let questions: Vec<&serde_json::Value> = args
8947        .get("questions")
8948        .and_then(serde_json::Value::as_array)
8949        .filter(|items| !items.is_empty())
8950        .map(|items| items.iter().collect())
8951        .unwrap_or_else(|| vec![args]);
8952
8953    for question in questions {
8954        let prompt = question
8955            .get("question")
8956            .and_then(serde_json::Value::as_str)
8957            .or_else(|| args.get("question").and_then(serde_json::Value::as_str))
8958            .map(str::trim)
8959            .unwrap_or_default();
8960        if prompt.is_empty() {
8961            continue;
8962        }
8963        if let Some(header) = question
8964            .get("header")
8965            .and_then(serde_json::Value::as_str)
8966            .map(str::trim)
8967            .filter(|text| !text.is_empty())
8968        {
8969            lines.push(format!("{header}: {prompt}"));
8970        } else {
8971            lines.push(prompt.to_string());
8972        }
8973        let context = question
8974            .get("context")
8975            .and_then(serde_json::Value::as_str)
8976            .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
8977            .map(str::trim)
8978            .filter(|text| !text.is_empty());
8979        if let Some(context) = context {
8980            lines.push(format!("  {context}"));
8981        }
8982        let options = question
8983            .get("options")
8984            .and_then(serde_json::Value::as_array)
8985            .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
8986        if let Some(options) = options {
8987            let labels: Vec<String> = options
8988                .iter()
8989                .filter_map(|option| {
8990                    option
8991                        .get("title")
8992                        .and_then(serde_json::Value::as_str)
8993                        .or_else(|| option.as_str())
8994                        .map(str::trim)
8995                        .filter(|label| !label.is_empty())
8996                        .map(str::to_string)
8997                })
8998                .collect();
8999            if !labels.is_empty() {
9000                lines.push(format!("Choices: {}", labels.join(", ")));
9001            }
9002        }
9003        if let Some(suggest) = question
9004            .get("suggest")
9005            .and_then(serde_json::Value::as_str)
9006            .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
9007            .map(str::trim)
9008            .filter(|text| !text.is_empty())
9009        {
9010            lines.push(format!("Suggestions: {suggest}"));
9011        }
9012    }
9013
9014    lines.join("\n")
9015}
9016
9017/// Progress text while an ask_user tool is pending. Prefer the plugin's own
9018/// readable `content`, otherwise show the question so the panel is not blank.
9019fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
9020    let text = tool_result_text(result);
9021    if !text.trim().is_empty() {
9022        return text;
9023    }
9024    let args_text = ask_user_args_display(args);
9025    if args_text.trim().is_empty() {
9026        "Waiting for your answer…".to_string()
9027    } else {
9028        args_text
9029    }
9030}
9031
9032/// Final result text for an ask_user tool. The plugin returns a human-readable
9033/// answer summary in `content`; never surface the transport JSON.
9034fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
9035    let text = tool_result_text(result);
9036    if text.trim().is_empty() {
9037        "Answer recorded.".to_string()
9038    } else {
9039        text
9040    }
9041}
9042
9043// ===========================================================================
9044// Selectors — editor-container swap (TS showSelector pattern)
9045// ===========================================================================
9046
9047/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
9048/// hiding the editor while the selector is open. Records the selector in
9049/// `state.active_selector` so the key loop routes to it.
9050fn open_selector(
9051    state: &Arc<TuiState>,
9052    editor_container: &Arc<Container>,
9053    editor: &Arc<Editor>,
9054    tui: &Arc<TuiAltScreen>,
9055    list: Arc<SelectList>,
9056    kind: SelectorKind,
9057) {
9058    open_selector_with_view(
9059        state,
9060        editor_container,
9061        editor,
9062        tui,
9063        SelectorView::List(list.clone()),
9064        list,
9065        kind,
9066    );
9067}
9068
9069/// Open a searchable selector: the list plus a fuzzy-filter search box.
9070///
9071/// Returns the wrapper so callers can prefill the query (native pi's
9072/// `initialSearchInput`). Mirrors `SelectSubmenu` with `searchable: true`.
9073fn open_searchable_selector(
9074    state: &Arc<TuiState>,
9075    editor_container: &Arc<Container>,
9076    editor: &Arc<Editor>,
9077    tui: &Arc<TuiAltScreen>,
9078    title: Option<&str>,
9079    description: Option<&str>,
9080    list: Arc<SelectList>,
9081    kind: SelectorKind,
9082) -> Arc<SearchableSelectList> {
9083    let searchable = Arc::new(SearchableSelectList::new(title, description, list));
9084    open_selector_with_view(
9085        state,
9086        editor_container,
9087        editor,
9088        tui,
9089        SelectorView::Searchable(searchable.clone()),
9090        searchable.clone(),
9091        kind,
9092    );
9093    // `TuiAltScreen::set_focus` only records the focused component; components
9094    // manage their own focus flag. Without this the search caret never renders
9095    // (and IME candidate positioning stays on the editor's old caret).
9096    searchable.set_focused(true);
9097    searchable
9098}
9099
9100/// Open a selector with an optional framed view. Native extension selectors
9101/// wrap the list with a title and hint while built-in selectors keep the list
9102/// as the complete view.
9103///
9104/// `view_router` receives keys (a bare list, or a searchable wrapper) while
9105/// `view` is what renders — the two are the same object for every built-in
9106/// selector, but extension selectors frame a plain list.
9107fn open_selector_with_view<V: Component + 'static>(
9108    state: &Arc<TuiState>,
9109    editor_container: &Arc<Container>,
9110    editor: &Arc<Editor>,
9111    tui: &Arc<TuiAltScreen>,
9112    view_router: SelectorView,
9113    view: Arc<V>,
9114    kind: SelectorKind,
9115) {
9116    // Dispatch ui_prompt_start event
9117    dispatch_ui_prompt_event(state);
9118
9119    // Unfocus the editor so its cursor marker doesn't render behind the list.
9120    editor.set_focused(false);
9121    // A selector replaces the editor slot. Drop stale slash/@file
9122    // suggestions so they cannot reappear after the selector closes.
9123    state.autocomplete_container.clear();
9124    // Swap: clear the container and add the selector view.
9125    editor_container.clear();
9126    editor_container.add_child(view.clone());
9127    *state.active_selector.lock().unwrap() = Some((view_router, kind));
9128    let focused: Arc<dyn Component> = view;
9129    tui.set_focus(Some(focused));
9130    tui.request_render(false);
9131}
9132
9133/// Restore the editor into the `editor_container` and clear the active
9134/// selector. Called by selector `on_cancel` and the Esc handler.
9135fn close_selector(
9136    state: &Arc<TuiState>,
9137    editor_container: &Arc<Container>,
9138    editor: &Arc<Editor>,
9139    tui: &Arc<TuiAltScreen>,
9140) {
9141    // Dispatch ui_prompt_end event
9142    dispatch_ui_prompt_event_end(state);
9143
9144    editor_container.clear();
9145    editor_container.add_child(editor.clone());
9146    state.autocomplete_container.clear();
9147    editor.set_focused(true);
9148    *state.active_selector.lock().unwrap() = None;
9149    *state.active_extension_cancel.lock().unwrap() = None;
9150    tui.set_focus(Some(editor.clone()));
9151    tui.request_render(false);
9152}
9153
9154/// Build + open the `/model` selector. Items are the resolved catalog (display
9155/// label = model name; description = id), with the current model marked.
9156/// Selecting applies the model **live** via `lane.set_model` (takes effect on
9157/// the next user message — the in-flight run's config is already snapshotted),
9158/// updates the footer, and notes the next-prompt effect.
9159fn open_model_selector(
9160    state: &Arc<TuiState>,
9161    editor_container: &Arc<Container>,
9162    editor: &Arc<Editor>,
9163    tui: &Arc<TuiAltScreen>,
9164    catalog: &[rpi_ai::Model],
9165    lane: &Arc<dyn AgentLane>,
9166    lane_model_id: &str,
9167    chat: &Arc<Container>,
9168    initial_search: Option<&str>,
9169) -> Option<Arc<SearchableSelectList>> {
9170    let items = model_selector_items(catalog, lane_model_id);
9171    if items.is_empty() {
9172        add_note_message(
9173            chat,
9174            "No models in the catalog. Use --model at startup to select one.",
9175        );
9176        tui.request_render(false);
9177        return None;
9178    }
9179    let list = Arc::new(SelectList::new(items, 10));
9180
9181    // Capture the catalog + lane so the on_select closure can resolve the
9182    // chosen Model and apply it. `on_select` fires on the blocking key thread,
9183    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
9184    let catalog_arc = catalog.to_vec();
9185    let state_sel = state.clone();
9186    let ec_sel = editor_container.clone();
9187    let editor_sel = editor.clone();
9188    let tui_sel = tui.clone();
9189    let chat_sel = chat.clone();
9190    let lane_sel = lane.clone();
9191    list.on_select(Arc::new(move |item| {
9192        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
9193            add_note_message(
9194                &chat_sel,
9195                &format!("Model {} not found in catalog.", item.label),
9196            );
9197            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9198            return;
9199        };
9200        state_sel.set_current_model(&model);
9201        let lane = lane_sel.clone();
9202        tokio::spawn(async move {
9203            let _ = lane.set_model(model).await;
9204        });
9205        add_note_message(
9206            &chat_sel,
9207            &format!(
9208                "Model set to {} — applies to the next message.",
9209                short_model_name(&item.value)
9210            ),
9211        );
9212        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9213    }));
9214    let state_cancel = state.clone();
9215    let ec_cancel = editor_container.clone();
9216    let editor_cancel = editor.clone();
9217    let tui_cancel = tui.clone();
9218    list.on_cancel(Arc::new(move || {
9219        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9220    }));
9221
9222    let searchable = open_searchable_selector(
9223        state,
9224        editor_container,
9225        editor,
9226        tui,
9227        Some("Select model"),
9228        Some("Type to filter by name, provider, or id"),
9229        list,
9230        SelectorKind::Model,
9231    );
9232    if let Some(term) = initial_search {
9233        searchable.set_search_text(term);
9234    }
9235    Some(searchable)
9236}
9237
9238/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
9239/// Returns `None` only when the catalog is empty or the current id isn't
9240/// found (in which case the first entry is returned — a no-op if it IS the
9241/// current). Used by the Ctrl+M model-cycle hotkey.
9242fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9243    if catalog.is_empty() {
9244        return None;
9245    }
9246    let idx = catalog
9247        .iter()
9248        .position(|m| m.id.eq_ignore_ascii_case(current_id));
9249    match idx {
9250        Some(i) => {
9251            let next = (i + 1) % catalog.len();
9252            Some(catalog[next].clone())
9253        }
9254        None => Some(catalog[0].clone()),
9255    }
9256}
9257
9258/// Build + open the `/session` selector. Lists JSONL session files under the
9259/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
9260/// fallback). Selecting reports "restore not
9261/// implemented in v1" (existing constraint) but shows the list for
9262/// discoverability.
9263fn open_session_selector(
9264    state: &Arc<TuiState>,
9265    editor_container: &Arc<Container>,
9266    editor: &Arc<Editor>,
9267    tui: &Arc<TuiAltScreen>,
9268    cwd: &std::path::Path,
9269    tx: &mpsc::UnboundedSender<TuiMessage>,
9270) {
9271    let dir = crate::session::default_session_dir(cwd);
9272    let mut items: Vec<SelectItem> = Vec::new();
9273    if let Ok(entries) = std::fs::read_dir(&dir) {
9274        for entry in entries.flatten() {
9275            let path = entry.path();
9276            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
9277                continue;
9278            }
9279            let stem = path
9280                .file_stem()
9281                .and_then(|s| s.to_str())
9282                .unwrap_or("(unnamed)")
9283                .to_string();
9284            let display = path
9285                .file_name()
9286                .and_then(|s| s.to_str())
9287                .unwrap_or(&stem)
9288                .to_string();
9289            items.push(SelectItem::new(&stem, &display));
9290        }
9291    }
9292    if items.is_empty() {
9293        add_note_message(
9294            &state.chat_container,
9295            "No saved sessions found. Sessions are created automatically in interactive mode.",
9296        );
9297        tui.request_render(false);
9298        return;
9299    }
9300    let list = Arc::new(SelectList::new(items, 10));
9301
9302    let state_sel = state.clone();
9303    let ec_sel = editor_container.clone();
9304    let editor_sel = editor.clone();
9305    let tui_sel = tui.clone();
9306    let tx_sel = tx.clone();
9307    list.on_select(Arc::new(move |item| {
9308        // Close the selector first, then ask the async loop to hot-switch:
9309        // opening the session file + swapping the harness backing is async
9310        // (repo list/open) and must not run on the blocking key thread.
9311        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9312        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
9313    }));
9314    let state_cancel = state.clone();
9315    let ec_cancel = editor_container.clone();
9316    let editor_cancel = editor.clone();
9317    let tui_cancel = tui.clone();
9318    list.on_cancel(Arc::new(move || {
9319        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9320    }));
9321
9322    open_searchable_selector(
9323        state,
9324        editor_container,
9325        editor,
9326        tui,
9327        Some("Resume session"),
9328        Some("Type to filter by session id"),
9329        list,
9330        SelectorKind::Session,
9331    );
9332}
9333
9334fn custom_entry_display_text(
9335    custom_type: &str,
9336    data: Option<&serde_json::Value>,
9337) -> Option<String> {
9338    let data = data?;
9339    let text = data
9340        .get("summary")
9341        .or_else(|| data.get("text"))
9342        .or_else(|| data.get("output"))
9343        .and_then(|value| value.as_str())
9344        .filter(|value| !value.trim().is_empty())?;
9345    let label = match custom_type {
9346        "compactionSummary" => "Compaction summary",
9347        "branchSummary" => "Branch summary",
9348        "bashExecution" => "Command output",
9349        other => other,
9350    };
9351    Some(format!("{label}: {text}"))
9352}
9353
9354/// Open a selector for the current session's persisted entry tree. Selecting a
9355/// message moves the main lane leaf to that entry, then the caller reloads the
9356/// visible branch from durable storage.
9357async fn open_tree_selector(
9358    harness: &AgentHarness,
9359    state: &Arc<TuiState>,
9360    editor_container: &Arc<Container>,
9361    editor: &Arc<Editor>,
9362    tui: &Arc<TuiAltScreen>,
9363    chat: &Arc<Container>,
9364    tx: &mpsc::UnboundedSender<TuiMessage>,
9365) {
9366    let entries = match harness
9367        .session()
9368        .view("main")
9369        .find_entries(&EntryQuery {
9370            order: Some(EntryOrder::OldestFirst),
9371            ..Default::default()
9372        })
9373        .await
9374    {
9375        Ok(entries) => entries,
9376        Err(error) => {
9377            add_error_message(chat, &format!("Could not read session tree: {error}"));
9378            tui.request_render(false);
9379            return;
9380        }
9381    };
9382    let current = harness.session().get_leaf_id().await.ok().flatten();
9383    let items: Vec<SelectItem> = entries
9384        .iter()
9385        .map(|entry| {
9386            let label = if current.as_deref() == Some(entry.id()) {
9387                format!("{} #{} (current)", entry.entry_type(), entry.seq())
9388            } else {
9389                format!("{} #{}", entry.entry_type(), entry.seq())
9390            };
9391            let short_id = &entry.id()[..entry.id().len().min(12)];
9392            // Search on the entry type, sequence, and id so `/tree` stays
9393            // usable in long sessions.
9394            let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
9395            SelectItem::new(entry.id(), &label)
9396                .with_description(short_id)
9397                .with_search_text(&search_text)
9398        })
9399        .collect();
9400    if items.is_empty() {
9401        add_note_message(chat, "The current session has no entries to navigate.");
9402        tui.request_render(false);
9403        return;
9404    }
9405    let list = Arc::new(SelectList::new(items, 12));
9406    let state_sel = state.clone();
9407    let ec_sel = editor_container.clone();
9408    let editor_sel = editor.clone();
9409    let tui_sel = tui.clone();
9410    let tx_sel = tx.clone();
9411    list.on_select(Arc::new(move |item| {
9412        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
9413        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9414    }));
9415    let state_cancel = state.clone();
9416    let ec_cancel = editor_container.clone();
9417    let editor_cancel = editor.clone();
9418    let tui_cancel = tui.clone();
9419    list.on_cancel(Arc::new(move || {
9420        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9421    }));
9422    open_searchable_selector(
9423        state,
9424        editor_container,
9425        editor,
9426        tui,
9427        Some("Session tree"),
9428        Some("Type to filter by entry type, sequence, or id"),
9429        list,
9430        SelectorKind::Tree,
9431    );
9432}
9433
9434/// Build + open the `/theme` selector. Built-in presets and enabled package
9435/// themes are shown; selecting applies the theme live and re-renders.
9436fn open_theme_selector(
9437    state: &Arc<TuiState>,
9438    editor_container: &Arc<Container>,
9439    editor: &Arc<Editor>,
9440    tui: &Arc<TuiAltScreen>,
9441    cwd: &std::path::Path,
9442    package_resources: &Arc<crate::packages::PackageResources>,
9443) {
9444    let mut items = vec![
9445        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
9446        SelectItem::new("light", "Light").with_description("Light background"),
9447        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
9448    ];
9449    if state.themes_enabled {
9450        for path in package_resources.theme_files() {
9451            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
9452                items.push(SelectItem::new(name, name).with_description("Package theme"));
9453            }
9454        }
9455    }
9456    let list = Arc::new(SelectList::new(items, 10));
9457
9458    let state_sel = state.clone();
9459    let ec_sel = editor_container.clone();
9460    let editor_sel = editor.clone();
9461    let tui_sel = tui.clone();
9462    let chat_sel = state.chat_container.clone();
9463    let cwd_sel = cwd.to_path_buf();
9464    let package_resources_sel = package_resources.clone();
9465    list.on_select(Arc::new(move |item| {
9466        let preset = match item.value.as_str() {
9467            "light" => Some(ThemePreset::Light),
9468            "monochrome" => Some(ThemePreset::Monochrome),
9469            "dark" => Some(ThemePreset::Dark),
9470            name => {
9471                if state_sel.themes_enabled {
9472                    if let Ok(custom) = crate::packages::load_theme_with_resources(
9473                        &cwd_sel,
9474                        name,
9475                        &package_resources_sel,
9476                    ) {
9477                        rpi_tui::global_theme_manager().set(custom.clone());
9478                        state_sel.theme_manager.set(custom);
9479                    }
9480                }
9481                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9482                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9483                tui_sel.render_now(true);
9484                return;
9485            }
9486        };
9487        let Some(preset) = preset else { return };
9488        apply_theme_preset(preset);
9489        state_sel.theme_manager.apply_preset(preset);
9490        // A quick accent note so the user sees the change registered even if
9491        // the terminal's own colors mask the preset difference.
9492        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9493        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9494        tui_sel.render_now(true);
9495    }));
9496    let state_cancel = state.clone();
9497    let ec_cancel = editor_container.clone();
9498    let editor_cancel = editor.clone();
9499    let tui_cancel = tui.clone();
9500    list.on_cancel(Arc::new(move || {
9501        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9502    }));
9503
9504    open_selector(
9505        state,
9506        editor_container,
9507        editor,
9508        tui,
9509        list,
9510        SelectorKind::Theme,
9511    );
9512}
9513
9514// ===========================================================================
9515// Feasible selectors — /thinking, /tools, /images
9516// ===========================================================================
9517
9518/// One-line descriptions for each thinking level, ported from
9519/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
9520fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9521    use rpi_ai::types::ThinkingLevel::*;
9522    match level {
9523        Off => "Off — No reasoning",
9524        Minimal => "Minimal — Brief reasoning (~1k tokens)",
9525        Low => "Low — Light reasoning (~1k tokens)",
9526        Medium => "Medium — Moderate reasoning (~80% of max)",
9527        High => "High — Extensive reasoning (~95% of max)",
9528        Xhigh => "Xhigh — Near-maximal reasoning",
9529        Max => "Max — Maximum reasoning",
9530    }
9531}
9532
9533/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
9534/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
9535fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9536    use rpi_ai::types::ThinkingLevel::*;
9537    match level {
9538        Off => "off",
9539        Minimal => "minimal",
9540        Low => "low",
9541        Medium => "medium",
9542        High => "high",
9543        Xhigh => "xhigh",
9544        Max => "max",
9545    }
9546}
9547
9548/// Parse a thinking-level name back to the enum (case-insensitive). Returns
9549/// `None` for an unknown name; used by the `/thinking` selector callback.
9550fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
9551    use rpi_ai::types::ThinkingLevel::*;
9552    match name.to_ascii_lowercase().as_str() {
9553        "off" => Some(Off),
9554        "minimal" => Some(Minimal),
9555        "low" => Some(Low),
9556        "medium" => Some(Medium),
9557        "high" => Some(High),
9558        "xhigh" => Some(Xhigh),
9559        "max" => Some(Max),
9560        _ => None,
9561    }
9562}
9563
9564/// Build + open the `/thinking` selector. Items are the levels the current
9565/// model supports (`Model::supported_thinking_levels`), each with a
9566/// description; the current level (read beforehand via `lane.get_thinking_level`)
9567/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
9568///
9569/// `on_select` fires on the blocking key thread, so it can't await
9570/// `lane.get_thinking_level()` to know the current level — the opener resolves
9571/// it first (best-effort) and preselects; the toggle on_select just applies
9572/// whatever was picked.
9573fn open_thinking_selector(
9574    state: &Arc<TuiState>,
9575    editor_container: &Arc<Container>,
9576    editor: &Arc<Editor>,
9577    tui: &Arc<TuiAltScreen>,
9578    lane: &Arc<dyn AgentLane>,
9579    catalog: &[rpi_ai::Model],
9580    lane_model_id: &str,
9581    chat: &Arc<Container>,
9582) {
9583    // Find the current model in the catalog to read its supported levels. If
9584    // absent, fall back to all levels so the selector still opens.
9585    let model = catalog
9586        .iter()
9587        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
9588    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
9589        .map(|m| m.supported_thinking_levels())
9590        .unwrap_or_else(|| {
9591            use rpi_ai::types::ThinkingLevel::*;
9592            vec![Off, Minimal, Low, Medium, High]
9593        });
9594    let mut items: Vec<SelectItem> = Vec::new();
9595    for lvl in &levels {
9596        let name = thinking_level_name(*lvl);
9597        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
9598    }
9599    if items.is_empty() {
9600        add_note_message(chat, "This model has no supported thinking levels.");
9601        tui.request_render(false);
9602        return;
9603    }
9604    let list = Arc::new(SelectList::new(items, 10));
9605
9606    let state_sel = state.clone();
9607    let ec_sel = editor_container.clone();
9608    let editor_sel = editor.clone();
9609    let tui_sel = tui.clone();
9610    let chat_sel = chat.clone();
9611    let lane_sel = lane.clone();
9612    list.on_select(Arc::new(move |item| {
9613        let Some(level) = thinking_level_from_name(&item.value) else {
9614            add_note_message(
9615                &chat_sel,
9616                &format!("Unknown thinking level: {}.", item.label),
9617            );
9618            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9619            return;
9620        };
9621        let lane = lane_sel.clone();
9622        let footer_sel = state_sel.footer.clone();
9623        tokio::spawn(async move {
9624            let _ = lane.set_thinking_level(level).await;
9625        });
9626        // Reflect the chosen level in the footer's model suffix (pi parity:
9627        // `model • thinking off` / `model • medium`). The shown text for the
9628        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
9629        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
9630        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
9631        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9632    }));
9633    let state_cancel = state.clone();
9634    let ec_cancel = editor_container.clone();
9635    let editor_cancel = editor.clone();
9636    let tui_cancel = tui.clone();
9637    list.on_cancel(Arc::new(move || {
9638        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9639    }));
9640
9641    open_selector(
9642        state,
9643        editor_container,
9644        editor,
9645        tui,
9646        list,
9647        SelectorKind::Thinking,
9648    );
9649}
9650
9651/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
9652/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
9653/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
9654/// — the blocking key thread can't await) and selecting a tool **toggles** it
9655/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
9656fn open_tools_selector(
9657    state: &Arc<TuiState>,
9658    editor_container: &Arc<Container>,
9659    editor: &Arc<Editor>,
9660    tui: &Arc<TuiAltScreen>,
9661    lane: &Arc<dyn AgentLane>,
9662    chat: &Arc<Container>,
9663) {
9664    // Best-effort read of the current active set. The opener runs on the async
9665    // runtime (it's called from the main loop's channel dispatch or the submit
9666    // closure that lives on the blocking thread — but `handle.block_on` is safe
9667    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
9668    let mut active = match tokio::runtime::Handle::try_current() {
9669        Ok(h) => h
9670            .block_on(async { lane.get_active_tools().await })
9671            .unwrap_or_default(),
9672        Err(_) => Vec::new(),
9673    };
9674    // An empty active set is the harness sentinel for "all registered tools"
9675    // (the selector only exposes built-ins). Expand it before rendering and
9676    // toggling so the first `/tools` visit does not show every tool as off or
9677    // accidentally reduce the active set to the one item selected.
9678    if active.is_empty() {
9679        active = crate::session::BUILTIN_TOOL_NAMES
9680            .iter()
9681            .map(|name| (*name).to_string())
9682            .collect();
9683    }
9684    let mut items: Vec<SelectItem> = Vec::new();
9685    for name in crate::session::BUILTIN_TOOL_NAMES {
9686        let on = active.iter().any(|a| a == name);
9687        let label = if on {
9688            format!("{name} (on)")
9689        } else {
9690            (*name).to_string()
9691        };
9692        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
9693    }
9694    let list = Arc::new(SelectList::new(items, 10));
9695
9696    // Capture the active set so on_select can toggle without re-reading.
9697    let active_captured = active.clone();
9698    let state_sel = state.clone();
9699    let ec_sel = editor_container.clone();
9700    let editor_sel = editor.clone();
9701    let tui_sel = tui.clone();
9702    let chat_sel = chat.clone();
9703    let lane_sel = lane.clone();
9704    list.on_select(Arc::new(move |item| {
9705        let mut next = active_captured.clone();
9706        if let Some(pos) = next.iter().position(|a| a == &item.value) {
9707            next.remove(pos);
9708        } else {
9709            next.push(item.value.clone());
9710        }
9711        let on = next.iter().any(|a| a == &item.value);
9712        let lane = lane_sel.clone();
9713        let next_clone = next.clone();
9714        tokio::spawn(async move {
9715            let _ = lane.set_active_tools(next_clone).await;
9716        });
9717        let list_str = if next.is_empty() {
9718            "(none)".to_string()
9719        } else {
9720            next.join(", ")
9721        };
9722        add_note_message(
9723            &chat_sel,
9724            &format!(
9725                "{} {} — active tools: {}",
9726                item.value,
9727                if on { "enabled" } else { "disabled" },
9728                list_str
9729            ),
9730        );
9731        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9732    }));
9733    let state_cancel = state.clone();
9734    let ec_cancel = editor_container.clone();
9735    let editor_cancel = editor.clone();
9736    let tui_cancel = tui.clone();
9737    list.on_cancel(Arc::new(move || {
9738        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9739    }));
9740
9741    open_selector(
9742        state,
9743        editor_container,
9744        editor,
9745        tui,
9746        list,
9747        SelectorKind::Tools,
9748    );
9749}
9750
9751/// Build + open the `/images` selector (Yes/No). Stores the choice in
9752/// `state.show_images` and notes it. Image wiring is minimal this pass — the
9753/// flag is consulted where images would be shown and echoed back here.
9754fn open_images_selector(
9755    state: &Arc<TuiState>,
9756    editor_container: &Arc<Container>,
9757    editor: &Arc<Editor>,
9758    tui: &Arc<TuiAltScreen>,
9759    chat: &Arc<Container>,
9760) {
9761    let current = *state.show_images.lock().unwrap();
9762    let items = vec![
9763        SelectItem::new("yes", "Yes").with_description(if current {
9764            "Inline images (current)"
9765        } else {
9766            "Inline images"
9767        }),
9768        SelectItem::new("no", "No").with_description(if current {
9769            "Placeholder only"
9770        } else {
9771            "Placeholder only (current)"
9772        }),
9773    ];
9774    let list = Arc::new(SelectList::new(items, 5));
9775
9776    let state_sel = state.clone();
9777    let ec_sel = editor_container.clone();
9778    let editor_sel = editor.clone();
9779    let tui_sel = tui.clone();
9780    let chat_sel = chat.clone();
9781    list.on_select(Arc::new(move |item| {
9782        let on = item.value == "yes";
9783        *state_sel.show_images.lock().unwrap() = on;
9784        add_note_message(
9785            &chat_sel,
9786            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
9787        );
9788        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9789    }));
9790    let state_cancel = state.clone();
9791    let ec_cancel = editor_container.clone();
9792    let editor_cancel = editor.clone();
9793    let tui_cancel = tui.clone();
9794    list.on_cancel(Arc::new(move || {
9795        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9796    }));
9797
9798    open_selector(
9799        state,
9800        editor_container,
9801        editor,
9802        tui,
9803        list,
9804        SelectorKind::Images,
9805    );
9806}
9807
9808// ===========================================================================
9809// Autocomplete
9810// ===========================================================================
9811
9812/// Refresh the autocomplete suggestion list from the current editor text +
9813/// cursor. Renders the suggestions into `autocomplete_container` (above the
9814/// editor) or clears it when there are none.
9815fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
9816    let text = editor.get_text();
9817    let cursor = editor_cursor_offset(editor, &text);
9818    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
9819    render_autocomplete(state, suggestions);
9820}
9821
9822/// Convert the editor's logical `(row, byte-column)` caret into the absolute
9823/// byte offset expected by autocomplete providers.
9824fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
9825    let (row, col) = editor.cursor_position();
9826    let mut offset = 0;
9827    for (index, line) in text.split('\n').enumerate() {
9828        if index == row {
9829            return (offset + col.min(line.len())).min(text.len());
9830        }
9831        offset = offset.saturating_add(line.len() + 1);
9832    }
9833    text.len()
9834}
9835
9836/// Restore an editor caret from an absolute byte offset after autocomplete
9837/// replaces a span in a multi-line draft.
9838fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
9839    let offset = offset.min(text.len());
9840    let mut consumed = 0;
9841    for (row, line) in text.split('\n').enumerate() {
9842        let end = consumed + line.len();
9843        if offset <= end {
9844            editor.set_cursor(row, offset - consumed);
9845            return;
9846        }
9847        consumed = end + 1;
9848    }
9849    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
9850    editor.set_cursor(
9851        last_row,
9852        text.rsplit('\n').next().map(str::len).unwrap_or(0),
9853    );
9854}
9855
9856/// Render (or clear) the autocomplete suggestion list into the container.
9857fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
9858    state.autocomplete_container.clear();
9859    let Some(sugg) = suggestions else {
9860        return;
9861    };
9862    if sugg.items.is_empty() {
9863        return;
9864    }
9865    // Build a compact list: top item marked with `→`, rest with `  `.
9866    // Cap the list so the dock doesn't swallow the transcript.
9867    let accent = state.theme_manager.get().colors.accent;
9868    let muted = state.theme_manager.get().colors.muted;
9869    for (i, item) in sugg
9870        .items
9871        .iter()
9872        .take(state.autocomplete_max_visible)
9873        .enumerate()
9874    {
9875        let prefix = if i == 0 { "→ " } else { "  " };
9876        let label = item.display_text();
9877        let line = if i == 0 {
9878            format!(
9879                "{prefix}{} {}",
9880                accent.fg(label),
9881                muted.fg(item.description.as_deref().unwrap_or(""))
9882            )
9883        } else {
9884            format!(
9885                "{prefix}{} {}",
9886                muted.fg(label),
9887                muted.fg(item.description.as_deref().unwrap_or(""))
9888            )
9889        };
9890        state
9891            .autocomplete_container
9892            .add_child(Arc::new(Text::new(line, 1, 0)));
9893    }
9894}
9895
9896/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
9897/// suggestion text, reposition the caret, and clear the suggestion list.
9898/// Returns `true` if a suggestion was accepted.
9899fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
9900    let text = editor.get_text();
9901    let cursor = editor_cursor_offset(editor, &text);
9902    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
9903        return false;
9904    };
9905    let Some(top) = sugg.items.first() else {
9906        return false;
9907    };
9908    // Replace the [start, end) span with the suggestion text. `start`/`end`
9909    // are byte offsets emitted by the providers on char boundaries, so the
9910    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
9911    let start = sugg.start.min(text.len());
9912    let end = sugg.end.min(text.len());
9913    let mut replaced = String::with_capacity(text.len() + top.text.len());
9914    replaced.push_str(&text[..start]);
9915    replaced.push_str(&top.text);
9916    // Keep the text AFTER the replaced span (mid-line completion: replacing
9917    // `[start, end)` must not drop the rest of the line).
9918    replaced.push_str(&text[end..]);
9919    if top.insert_space && !replaced.ends_with('/') {
9920        replaced.push(' ');
9921    }
9922    // New caret position: after the inserted text (byte offset; the editor
9923    // snaps `set_cursor` to a char boundary as a safety net).
9924    let new_cursor = replaced.len().min(
9925        start
9926            + top.text.len()
9927            + if top.insert_space && !top.text.ends_with('/') {
9928                1
9929            } else {
9930                0
9931            },
9932    );
9933    editor.set_text(&replaced);
9934    set_editor_cursor_offset(editor, &replaced, new_cursor);
9935    state.autocomplete_container.clear();
9936    true
9937}
9938
9939// ===========================================================================
9940// Transcript message helpers
9941// ===========================================================================
9942
9943/// Add the welcome header to the chat container.
9944fn add_welcome_message(container: &Arc<Container>) {
9945    add_welcome_message_with_capabilities(container, &[], &[]);
9946}
9947
9948/// Add the startup welcome header and a compact snapshot of active tools and
9949/// discovered skills. The snapshot reflects the harness configuration used by
9950/// the first turn, including tools contributed by extensions.
9951fn add_welcome_message_with_capabilities(
9952    container: &Arc<Container>,
9953    active_tools: &[String],
9954    skills: &[String],
9955) {
9956    let c = current_theme().colors;
9957    // ASCII art logo combining Rust crab and Pi
9958    let logo = format!(
9959        "{}\n{}\n{}\n{}",
9960        c.accent.fg("    🦀"),
9961        c.accent.fg("  ╭─π─╮"),
9962        c.accent.fg("  │") + &c.accent.fg(&tui_bold("rpi")) + &c.accent.fg("│"),
9963        c.accent.fg("  ╰───╯")
9964    );
9965    container.add_child(Arc::new(Text::new(logo, 1, 0)));
9966    container.add_child(Arc::new(Spacer::new(1)));
9967    // Accent logotype + a dim tagline, separated from the rest by a thin
9968    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
9969    // to the body text, so the header didn't read as a header.
9970    let title = format!(
9971        "{} {}",
9972        c.accent.fg(&tui_bold("rpi")),
9973        c.muted.fg("interactive TUI")
9974    );
9975    container.add_child(Arc::new(Text::new(title, 1, 0)));
9976    container.add_child(Arc::new(Spacer::new(1)));
9977    container.add_child(Arc::new(Text::new(
9978        c.dim.fg("Type your message and press Enter to send."),
9979        1,
9980        0,
9981    )));
9982    let hint = c
9983        .dim
9984        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
9985    container.add_child(Arc::new(Text::new(hint, 1, 0)));
9986    container.add_child(Arc::new(Spacer::new(1)));
9987    container.add_child(Arc::new(Text::new(
9988        welcome_capability_line("Tools", active_tools),
9989        1,
9990        0,
9991    )));
9992    container.add_child(Arc::new(Text::new(
9993        welcome_capability_line("Skills", skills),
9994        1,
9995        0,
9996    )));
9997    container.add_child(Arc::new(DynamicBorder::new()));
9998}
9999
10000/// Append update notices inside the live transcript. Fullscreen mode clears
10001/// pre-TUI stdout/stderr, so update state must be represented by components.
10002fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
10003    let colors = current_theme().colors;
10004    let group = Arc::new(Container::new());
10005
10006    for warning in &report.warnings {
10007        let body = format!(
10008            "{}\n{} {}{}",
10009            colors.error.fg(&warning.message),
10010            colors.muted.fg("Run"),
10011            colors.accent.fg(&warning.command),
10012            colors.muted.fg(" to retry.")
10013        );
10014        add_update_panel(&group, "Update Failed", &body, colors.error);
10015    }
10016
10017    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
10018        let body = format!(
10019            "{} {}{}",
10020            colors
10021                .muted
10022                .fg(&format!("New version {} is available. Run", notice.latest)),
10023            colors.accent.fg(&notice.command),
10024            colors.muted.fg(".")
10025        );
10026        add_update_panel(&group, "Update Available", &body, colors.warning);
10027    }
10028
10029    let package_notices = report
10030        .notices
10031        .iter()
10032        .filter(|notice| notice.name != "rpi")
10033        .collect::<Vec<_>>();
10034    if !package_notices.is_empty() {
10035        let command = package_notices[0].command.as_str();
10036        let mut lines = vec![format!(
10037            "{} {}{}",
10038            colors.muted.fg("Package updates are available. Run"),
10039            colors.accent.fg(command),
10040            colors.muted.fg(".")
10041        )];
10042        lines.push(colors.muted.fg("Packages:"));
10043        lines.extend(
10044            package_notices
10045                .into_iter()
10046                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
10047        );
10048        add_update_panel(
10049            &group,
10050            "Package Updates Available",
10051            &lines.join("\n"),
10052            colors.warning,
10053        );
10054    }
10055
10056    // Other transcript producers append concurrently. Add the fully built
10057    // group in one operation so card borders and content cannot interleave
10058    // with user, assistant, tool, or extension messages.
10059    if group.child_count() > 0 {
10060        container.add_child(group);
10061    }
10062}
10063
10064fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
10065    container.add_child(Arc::new(Spacer::new(1)));
10066    container.add_child(Arc::new(DynamicBorder::with_color(color)));
10067    container.add_child(Arc::new(Text::new(
10068        format!("{}\n{body}", color.fg(&tui_bold(title))),
10069        1,
10070        0,
10071    )));
10072    container.add_child(Arc::new(DynamicBorder::with_color(color)));
10073}
10074
10075fn welcome_capability_line(label: &str, names: &[String]) -> String {
10076    let c = current_theme().colors;
10077    let value = if names.is_empty() {
10078        "none".to_string()
10079    } else {
10080        names.join(" · ")
10081    };
10082    format!(
10083        "{} {}",
10084        c.accent.fg(&format!("{label} ({})", names.len())),
10085        c.muted.fg(&value)
10086    )
10087}
10088
10089/// Add the `/help` command listing to the chat container.
10090fn add_help_message(container: &Arc<Container>) {
10091    let c = current_theme().colors;
10092    // Section header + a thin themed rule, then a two-column command table:
10093    // `cmd` in accent, `— desc` in muted. The old single-space layout made
10094    // the description column wander depending on command length.
10095    container.add_child(Arc::new(Text::new(
10096        c.md_heading.fg(&tui_bold("📚 Available Commands")),
10097        1,
10098        0,
10099    )));
10100    container.add_child(Arc::new(Spacer::new(1)));
10101
10102    let cmds: &[(&str, &str)] = &[
10103        ("/help, /?", "Show this help message"),
10104        ("/clear, /new", "Clear the conversation"),
10105        ("/exit, /quit, /q", "Exit the application"),
10106        ("/version, /v", "Show version information"),
10107        ("/changelog", "Show recent release changes"),
10108        ("/model, /m", "Choose a model (live switch)"),
10109        ("/thinking, /think", "Set reasoning depth (selector)"),
10110        ("/tools", "Toggle built-in tools on/off"),
10111        ("/images", "Toggle inline image rendering"),
10112        ("/session", "List saved sessions"),
10113        ("/theme", "Choose a theme (selector)"),
10114        ("/compact", "Compact the conversation"),
10115        ("/copy", "Copy last reply to clipboard"),
10116        ("/hotkeys", "Show keyboard shortcuts"),
10117        ("/armin", "🐾 Easter egg"),
10118        ("/earendil", "Earendil announcement"),
10119    ];
10120    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10121    for (cmd, desc) in cmds {
10122        let row = format!(
10123            "  {:<cmd_w$}  {}  {}",
10124            c.accent.fg(cmd),
10125            c.dim.fg("—"),
10126            c.muted.fg(desc)
10127        );
10128        container.add_child(Arc::new(Text::new(row, 1, 0)));
10129    }
10130    container.add_child(Arc::new(Spacer::new(1)));
10131}
10132
10133/// Add the `/version` block to the chat container.
10134fn add_version_message(container: &Arc<Container>) {
10135    let c = current_theme().colors;
10136    container.add_child(Arc::new(Text::new(
10137        c.md_heading.fg(&tui_bold("📦 Version Information")),
10138        1,
10139        0,
10140    )));
10141    container.add_child(Arc::new(Spacer::new(1)));
10142    // Use the crate version (kept in sync via `version.workspace = true`)
10143    // instead of the stale hardcoded "v0.1.2".
10144    container.add_child(Arc::new(Text::new(
10145        format!(
10146            "  {} {}",
10147            c.muted.fg("rpi-cli"),
10148            c.text.fg(&format!("v{}", crate::VERSION))
10149        ),
10150        1,
10151        0,
10152    )));
10153    container.add_child(Arc::new(Text::new(
10154        format!(
10155            "  {}",
10156            c.dim.fg("Rust implementation of pi coding agent TUI")
10157        ),
10158        1,
10159        0,
10160    )));
10161    container.add_child(Arc::new(Spacer::new(1)));
10162}
10163
10164/// Add a compact `/changelog` block to the chat container. Keep this local to
10165/// the binary so the command remains useful in installed builds without a
10166/// source checkout or a network request.
10167fn add_changelog_message(container: &Arc<Container>) {
10168    let c = current_theme().colors;
10169    container.add_child(Arc::new(Text::new(
10170        c.md_heading.fg(&tui_bold("Recent Changes")),
10171        1,
10172        0,
10173    )));
10174    container.add_child(Arc::new(Spacer::new(1)));
10175    let entries = [
10176        (
10177            "Native parity phase 1",
10178            "models, images, trust, export, and JSON events",
10179        ),
10180        (
10181            "TUI controls",
10182            "external editor, thinking levels, and tool output toggles",
10183        ),
10184        (
10185            "Provider auth",
10186            "OpenAI-compatible API key aliases and gateway headers",
10187        ),
10188    ];
10189    for (release, summary) in entries {
10190        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
10191        container.add_child(Arc::new(Text::new(row, 1, 0)));
10192    }
10193    container.add_child(Arc::new(Text::new(
10194        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
10195        1,
10196        0,
10197    )));
10198    container.add_child(Arc::new(Spacer::new(1)));
10199}
10200
10201/// Add the `/hotkeys` block to the chat container.
10202fn add_hotkeys_message(container: &Arc<Container>) {
10203    let c = current_theme().colors;
10204    container.add_child(Arc::new(Text::new(
10205        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
10206        1,
10207        0,
10208    )));
10209    container.add_child(Arc::new(Spacer::new(1)));
10210    let keys: &[(&str, &str)] = &[
10211        ("Enter", "Send message"),
10212        ("Shift+Enter", "New line"),
10213        ("Tab", "Accept autocomplete suggestion"),
10214        ("Ctrl+A / Ctrl+E", "Line start / end"),
10215        (
10216            "Ctrl+K / Ctrl+U",
10217            "Kill to end / start of line (Ctrl+Y yanks)",
10218        ),
10219        ("Ctrl+- / Ctrl+R", "Undo / redo"),
10220        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
10221        ("Alt+Backspace", "Kill previous word"),
10222        ("Ctrl+C", "Abort a run, or exit when idle"),
10223        ("Esc", "Abort a running prompt"),
10224        ("Ctrl+L", "Open model selector"),
10225        ("Ctrl+M", "Cycle to the next model (live)"),
10226        ("Ctrl+O", "Expand/collapse all tool output"),
10227        ("Ctrl+T", "Show/hide reasoning blocks"),
10228        ("PageUp/Down", "Scroll transcript by one page"),
10229        ("Home / End", "Jump to transcript start / latest output"),
10230    ];
10231    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10232    for (key, desc) in keys {
10233        let row = format!(
10234            "  {:<key_w$}  {}  {}",
10235            c.accent.fg(key),
10236            c.dim.fg("—"),
10237            c.muted.fg(desc)
10238        );
10239        container.add_child(Arc::new(Text::new(row, 1, 0)));
10240    }
10241    container.add_child(Arc::new(Spacer::new(1)));
10242}
10243
10244/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
10245/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
10246/// plain `> text` echo. A trailing Spacer(1) separates it from the next
10247// transcript entry (every entry contributes one trailing spacer so
10248// consecutive turns are separated by exactly one blank line).
10249fn add_user_message(container: &Arc<Container>, text: &str) {
10250    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
10251    container.add_child(Arc::new(Spacer::new(1)));
10252}
10253
10254/// Add an error message to the chat container.
10255/// Collect all rendered lines from the chat container for search.
10256fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
10257    let width = 80; // Default width for search; actual width varies by terminal
10258    chat_container.render(width)
10259}
10260
10261/// Build the shell command that resumes `session_id` in this project, or
10262/// `None` when there is nothing to resume. Mirrors native pi's
10263/// `formatResumeCommand`: `rpi --session <id>`, prefixing
10264/// `--session-dir <dir>` only when a non-default directory was requested.
10265fn format_resume_command(
10266    session_id: &str,
10267    cwd: &std::path::Path,
10268    session_dir: Option<&std::path::Path>,
10269) -> Option<String> {
10270    if session_id.is_empty() {
10271        return None;
10272    }
10273    let mut args = vec![crate::APP_NAME.to_string()];
10274    if let Some(dir) = session_dir {
10275        let default = crate::session::default_session_dir(cwd);
10276        if dir != default.as_path() {
10277            args.push("--session-dir".to_string());
10278            args.push(quote_shell_arg(&dir.to_string_lossy()));
10279        }
10280    }
10281    args.push("--session".to_string());
10282    args.push(session_id.to_string());
10283    Some(args.join(" "))
10284}
10285
10286/// Quote a shell argument when it contains whitespace or shell metacharacters
10287/// (mirrors native pi's `quoteIfNeeded`).
10288fn quote_shell_arg(value: &str) -> String {
10289    let needs_quoting = value.is_empty()
10290        || value
10291            .chars()
10292            .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
10293    if needs_quoting {
10294        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
10295    } else {
10296        value.to_string()
10297    }
10298}
10299
10300fn add_error_message(container: &Arc<Container>, text: &str) {
10301    let c = current_theme().colors;
10302    let text = sanitize_error_message(text);
10303    container.add_child(Arc::new(Text::new(
10304        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
10305        1,
10306        0,
10307    )));
10308    container.add_child(Arc::new(Spacer::new(1)));
10309}
10310
10311/// Keep provider diagnostics printable in the transcript. HTTP error bodies
10312/// may contain carriage returns, terminal escapes, or an unexpectedly large
10313/// JSON payload; letting those bytes reach the renderer can corrupt the input
10314/// row or make the whole frame exceed terminal limits.
10315fn sanitize_error_message(text: &str) -> String {
10316    const MAX_ERROR_CHARS: usize = 16 * 1024;
10317    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
10318    let mut count = 0;
10319    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
10320    let mut escape_mode = 0u8;
10321    for ch in text.chars() {
10322        if escape_mode != 0 {
10323            match escape_mode {
10324                1 if ch == '[' => escape_mode = 2,
10325                1 if ch == ']' => escape_mode = 3,
10326                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
10327                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
10328                3 if ch == '\x07' => escape_mode = 0,
10329                _ => {}
10330            }
10331            continue;
10332        }
10333        if ch == '\x1b' {
10334            escape_mode = 1;
10335            continue;
10336        }
10337        if count >= MAX_ERROR_CHARS {
10338            result.push_str("…");
10339            break;
10340        }
10341        match ch {
10342            '\n' | '\t' => {
10343                result.push(ch);
10344                count += 1;
10345            }
10346            '\r' => {}
10347            c if c.is_control() => {}
10348            c => {
10349                result.push(c);
10350                count += 1;
10351            }
10352        }
10353    }
10354    if result.trim().is_empty() {
10355        "Provider request failed.".to_string()
10356    } else {
10357        result
10358    }
10359}
10360
10361/// Add a neutral note (e.g. unsupported-command message) to the chat container.
10362fn add_note_message(container: &Arc<Container>, text: &str) {
10363    let c = current_theme().colors;
10364    container.add_child(Arc::new(Text::new(
10365        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
10366        1,
10367        0,
10368    )));
10369    container.add_child(Arc::new(Spacer::new(1)));
10370}
10371
10372/// Render the `/context` panel: a transcript message listing the discovered
10373/// context files, skills, and prompt templates loaded for this session
10374/// (Part A resource discovery). Reads the harness resources snapshot captured
10375/// at TUI startup (the blocking submit handler can't `await get_resources()`.
10376///
10377/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
10378/// via `/reload`); here it's a transcript note rather than an overlay since the
10379/// resource set is session-static between `/reload`s (deferred).
10380fn show_context_panel(
10381    chat: &Arc<Container>,
10382    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
10383) {
10384    let skills = resources.skills.as_deref().unwrap_or(&[]);
10385    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
10386    let mut lines: Vec<String> = Vec::new();
10387    lines.push("📂 Discovered resources for this session:".into());
10388
10389    if skills.is_empty() {
10390        lines.push(
10391            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
10392        );
10393    } else {
10394        lines.push(format!("  Skills ({}):", skills.len()));
10395        for s in skills {
10396            let marker = if s.disable_model_invocation == Some(true) {
10397                " [hidden]"
10398            } else {
10399                ""
10400            };
10401            let desc: String = s.description.chars().take(72).collect();
10402            lines.push(format!("    • {}{marker} — {desc}", s.name));
10403        }
10404    }
10405
10406    if templates.is_empty() {
10407        lines.push(
10408            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
10409        );
10410    } else {
10411        lines.push(format!("  Prompt templates ({}):", templates.len()));
10412        for t in templates {
10413            let desc = t
10414                .description
10415                .as_deref()
10416                .unwrap_or("(no description)")
10417                .chars()
10418                .take(72)
10419                .collect::<String>();
10420            lines.push(format!("    • /{} — {desc}", t.name));
10421        }
10422    }
10423    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
10424    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
10425    lines.push(
10426        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
10427            .into(),
10428    );
10429    let body = lines.join("\n");
10430    container_note_block(chat, &body);
10431}
10432
10433/// Append a multi-line neutral note (header line + body) to the chat container.
10434fn container_note_block(container: &Arc<Container>, body: &str) {
10435    for line in body.lines() {
10436        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
10437    }
10438    container.add_child(Arc::new(Spacer::new(1)));
10439}
10440
10441// ===========================================================================
10442// TUI support + entry detection
10443// ===========================================================================
10444
10445/// Check if the terminal supports TUI mode.
10446pub fn is_tui_supported() -> bool {
10447    std::io::stdout().is_terminal()
10448}
10449
10450// Keep the `Color` import used (theme accent rendering in autocomplete).
10451#[allow(unused_imports)]
10452use rpi_tui::Color as _Color;
10453
10454#[cfg(test)]
10455mod tests {
10456    use super::*;
10457    use rpi_tui::Component;
10458
10459    /// Minimal `TuiState` for unit tests that only touch state flags. Mirrors
10460    /// the explicit literals other tests build, but keeps one copy in sync.
10461    fn test_tui_state() -> Arc<TuiState> {
10462        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            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10472            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10473            js_preparation_cancel: std::sync::Mutex::new(None),
10474            user_bash_cancel: std::sync::Mutex::new(None),
10475            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10476            footer: Arc::new(FooterComponent::new()),
10477            status_container: Arc::new(Container::new()),
10478            chat_container: Arc::new(Container::new()),
10479            loader: Arc::new(Loader::new()),
10480            editor: Arc::new(Editor::simple()),
10481            last_assistant_text: std::sync::Mutex::new(String::new()),
10482            active_selector: std::sync::Mutex::new(None),
10483            active_extension_editor: std::sync::Mutex::new(None),
10484            active_extension_input: std::sync::Mutex::new(None),
10485            active_extension_cancel: std::sync::Mutex::new(None),
10486            autocomplete: AutocompleteManager::new(),
10487            autocomplete_container: Arc::new(Container::new()),
10488            autocomplete_max_visible: 5,
10489            pending_images: std::sync::Mutex::new(Vec::new()),
10490            theme_manager: Arc::new(ThemeManager::new()),
10491            tui: None,
10492            current_model_id: std::sync::Mutex::new(String::new()),
10493            show_images: std::sync::Mutex::new(true),
10494            cache_miss_notices: std::sync::Mutex::new(false),
10495            history: std::sync::Mutex::new(Vec::new()),
10496            history_index: std::sync::Mutex::new(-1),
10497            history_draft: std::sync::Mutex::new(None),
10498            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10499            scoped_edit: std::sync::Mutex::new(None),
10500            markdown_transformer: std::sync::Mutex::new(None),
10501            extension_session: Arc::new(std::sync::Mutex::new(
10502                rpi_extensions::ExtensionSession::none(),
10503            )),
10504            search: Arc::new(AltScreenSearch::new()),
10505            search_bar: Arc::new(SearchBar::new()),
10506            pending_container: Arc::new(Container::new()),
10507            dequeue_hint: String::new(),
10508            pending_snapshot: std::sync::Mutex::new(
10509                rpi_harness::agent_harness::QueuedMessages::default(),
10510            ),
10511            selection_start: std::sync::Mutex::new(None),
10512            selection_end: std::sync::Mutex::new(None),
10513        })
10514    }
10515
10516    #[test]
10517    fn live_panel_repaints_while_a_tool_or_bash_is_running() {
10518        // No panels → only the dock loader animates (reuse the cached
10519        // transcript instead of rebuilding a long history).
10520        let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
10521        assert!(!transcript_has_live_panel(false, &empty));
10522
10523        // A running tool call is a live panel: its elapsed readout is computed
10524        // at render time, so it must force a transcript rebuild every tick.
10525        let mut tools = HashMap::new();
10526        let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
10527        tool.set_running();
10528        tools.insert("tc1".to_string(), tool);
10529        assert!(transcript_has_live_panel(false, &tools));
10530
10531        // A finished tool no longer needs the repaint, but a running bash
10532        // command still does.
10533        tools.get("tc1").unwrap().set_result("done", false);
10534        assert!(!transcript_has_live_panel(false, &tools));
10535        assert!(transcript_has_live_panel(true, &tools));
10536    }
10537
10538    #[test]
10539    fn dequeue_merge_preserves_a_typed_draft() {
10540        assert_eq!(merge_queued_into_draft("queued", ""), "queued");
10541        assert_eq!(merge_queued_into_draft("queued", "   "), "queued");
10542        assert_eq!(
10543            merge_queued_into_draft("queued", "typed"),
10544            "queued\n\ntyped"
10545        );
10546    }
10547
10548    #[test]
10549    fn settings_menu_covers_the_wired_settings_surface() {
10550        let state = test_tui_state();
10551        let settings = crate::settings::Settings::default();
10552        let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
10553        let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
10554        assert_eq!(
10555            keys,
10556            [
10557                "theme",
10558                "model",
10559                "thinking",
10560                "scoped-models",
10561                "hide-thinking",
10562                "show-images",
10563                "cache-miss-notices",
10564                "quiet-startup",
10565                "terminal-progress",
10566                "fullscreen-copy-on-select",
10567                "double-escape-action",
10568                "editor-padding",
10569                "autocomplete-max-items",
10570                "http-idle-timeout",
10571            ]
10572        );
10573        // Submenu rows must not cycle their displayed value.
10574        for item in items.iter().filter(|item| item.has_submenu) {
10575            assert!(
10576                item.values.is_empty(),
10577                "{} should be submenu-only",
10578                item.key
10579            );
10580        }
10581        // Value rows advertise a cycle that contains the current value.
10582        for item in items.iter().filter(|item| !item.has_submenu) {
10583            assert!(!item.values.is_empty(), "{} has no values", item.key);
10584            assert!(
10585                item.values.iter().any(|value| value == &item.value),
10586                "{} current value {} not in {:?}",
10587                item.key,
10588                item.value,
10589                item.values
10590            );
10591        }
10592        // The default model row falls back to the running lane's model.
10593        assert_eq!(items[1].value, "claude-sonnet-5");
10594        // Native defaults: cache-miss notices off, terminal progress on.
10595        assert_eq!(items[6].value, "false");
10596        assert_eq!(items[8].value, "true");
10597    }
10598
10599    #[test]
10600    fn apply_setting_change_updates_settings_and_live_state() {
10601        let state = test_tui_state();
10602        let mut settings = crate::settings::Settings::default();
10603
10604        // Live: hide-thinking flips both the runtime flag and the persisted one.
10605        let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
10606        assert!(state.hide_thinking());
10607        assert_eq!(settings.hide_thinking_block, Some(true));
10608        assert!(note.expect("user-visible note").contains("hidden"));
10609
10610        // Live: show-images writes the runtime mutex.
10611        apply_setting_change(&state, "show-images", "false", &mut settings);
10612        assert!(!*state.show_images.lock().unwrap());
10613        assert_eq!(settings.show_images, Some(false));
10614
10615        // Live: cache-miss notices.
10616        apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
10617        assert!(*state.cache_miss_notices.lock().unwrap());
10618        assert_eq!(settings.show_cache_miss_notices, Some(true));
10619
10620        // Persist-only rows change settings without a transcript note.
10621        assert_eq!(
10622            apply_setting_change(&state, "editor-padding", "3", &mut settings),
10623            None
10624        );
10625        assert_eq!(settings.editor_padding_x, Some(3));
10626        apply_setting_change(&state, "double-escape-action", "none", &mut settings);
10627        assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
10628        apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
10629        assert_eq!(settings.autocomplete_max_visible, Some(20));
10630
10631        // HTTP idle timeout maps labels onto the numeric/`disabled` forms.
10632        apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
10633        assert_eq!(settings.http_idle_timeout_ms(), Some(0));
10634        apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
10635        assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
10636
10637        // Unknown keys are ignored rather than panicking.
10638        assert_eq!(
10639            apply_setting_change(&state, "nope", "x", &mut settings),
10640            None
10641        );
10642    }
10643
10644    #[test]
10645    fn add_user_message_renders_a_visible_user_bubble() {
10646        // The submit handler renders directly-sent prompts through this helper
10647        // (the harness emits no user `message_start` for them), so a silent
10648        // failure here would make every prompt vanish from the transcript.
10649        let chat = Arc::new(Container::new());
10650        add_user_message(&chat, "hello from the user");
10651        let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
10652        let plain = strip_ansi(&rendered.join("\n"));
10653        assert!(
10654            plain.contains("hello from the user"),
10655            "user bubble must show the prompt text; got: {plain:?}"
10656        );
10657    }
10658
10659    #[test]
10660    fn verbose_overrides_quiet_startup_listing() {
10661        assert!(should_show_startup_listing(false, false));
10662        assert!(should_show_startup_listing(true, false));
10663        assert!(should_show_startup_listing(true, true));
10664        assert!(!should_show_startup_listing(false, true));
10665    }
10666
10667    #[test]
10668    fn resume_command_omits_the_default_session_dir() {
10669        let cwd = std::path::Path::new("/proj/demo");
10670        assert_eq!(format_resume_command("", cwd, None), None);
10671        assert_eq!(
10672            format_resume_command("abc-123", cwd, None).as_deref(),
10673            Some("rpi --session abc-123")
10674        );
10675        // The default dir carries no flag (mirrors native pi).
10676        let default = crate::session::default_session_dir(cwd);
10677        assert_eq!(
10678            format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
10679            Some("rpi --session abc-123")
10680        );
10681        // A custom dir is echoed, quoting when needed.
10682        let custom = std::path::Path::new("/tmp/my sessions");
10683        assert_eq!(
10684            format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
10685            Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
10686        );
10687    }
10688
10689    #[test]
10690    fn quote_shell_arg_quotes_only_when_needed() {
10691        assert_eq!(quote_shell_arg("plain"), "plain");
10692        assert_eq!(quote_shell_arg("with space"), "\"with space\"");
10693        assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
10694    }
10695
10696    #[test]
10697    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
10698        let tmp = tempfile::tempdir().unwrap();
10699        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
10700        let local_root = tmp.path().join("local-package");
10701        std::fs::create_dir_all(git_root.join(".git")).unwrap();
10702        std::fs::create_dir_all(&local_root).unwrap();
10703        std::fs::write(
10704            git_root.join("package.json"),
10705            r#"{"name":"git-demo","version":"1.0.0"}"#,
10706        )
10707        .unwrap();
10708        std::fs::write(
10709            local_root.join("package.json"),
10710            r#"{"name":"local-demo","version":"1.0.0"}"#,
10711        )
10712        .unwrap();
10713        let resources = crate::packages::discover(
10714            tmp.path(),
10715            &[
10716                "git:github.com/example/repo".to_string(),
10717                local_root.to_string_lossy().into_owned(),
10718            ],
10719        );
10720        assert_eq!(resources.packages.len(), 2);
10721
10722        let filtered = git_only_update_resources(resources);
10723
10724        assert_eq!(filtered.packages.len(), 1);
10725        assert!(matches!(
10726            filtered.packages[0].source,
10727            crate::packages::PackageSource::Git
10728        ));
10729    }
10730
10731    #[test]
10732    fn trust_command_uses_the_session_cwd_after_process_chdir() {
10733        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
10734        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
10735        const TEST_NAME: &str =
10736            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
10737
10738        if std::env::var_os(CHILD_ENV).is_some() {
10739            let session_cwd = std::path::PathBuf::from(
10740                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
10741            );
10742            let changed_cwd = std::env::current_dir().unwrap();
10743
10744            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
10745
10746            assert_eq!(
10747                crate::config::project_trust_decision(&session_cwd).unwrap(),
10748                Some(true)
10749            );
10750            assert_eq!(
10751                crate::config::project_trust_decision(&changed_cwd).unwrap(),
10752                None
10753            );
10754            return;
10755        }
10756
10757        let tmp = tempfile::tempdir().unwrap();
10758        let session_cwd = tmp.path().join("session-project");
10759        let changed_cwd = tmp.path().join("extension-cwd");
10760        let agent_dir = tmp.path().join("agent");
10761        std::fs::create_dir_all(&session_cwd).unwrap();
10762        std::fs::create_dir_all(&changed_cwd).unwrap();
10763        std::fs::create_dir_all(&agent_dir).unwrap();
10764
10765        let status = std::process::Command::new(std::env::current_exe().unwrap())
10766            .arg("--exact")
10767            .arg(TEST_NAME)
10768            .arg("--nocapture")
10769            .env(CHILD_ENV, "1")
10770            .env(SESSION_CWD_ENV, &session_cwd)
10771            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
10772            .current_dir(&changed_cwd)
10773            .status()
10774            .unwrap();
10775
10776        assert!(status.success(), "child test process failed: {status}");
10777    }
10778
10779    #[test]
10780    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
10781        let global = crate::settings::Settings {
10782            editor_padding_x: Some(3),
10783            autocomplete_max_visible: Some(6),
10784            hide_thinking_block: Some(false),
10785            quiet_startup: Some(true),
10786            show_terminal_progress: Some(true),
10787            ..Default::default()
10788        };
10789        let rpi_project = crate::settings::Settings {
10790            editor_padding_x: Some(0),
10791            hide_thinking_block: Some(true),
10792            quiet_startup: Some(false),
10793            show_terminal_progress: Some(false),
10794            ..Default::default()
10795        };
10796        let pi_project = crate::settings::Settings {
10797            editor_padding_x: Some(9),
10798            autocomplete_max_visible: Some(12),
10799            quiet_startup: Some(true),
10800            ..Default::default()
10801        };
10802
10803        assert_eq!(
10804            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
10805            TuiStartupSettings {
10806                editor_padding_x: 0,
10807                autocomplete_max_visible: 12,
10808                hide_thinking: true,
10809                quiet_startup: false,
10810                show_terminal_progress: false,
10811                show_images: true,
10812                cache_miss_notices: false,
10813            }
10814        );
10815    }
10816
10817    #[test]
10818    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
10819        let global = crate::settings::Settings {
10820            quiet_startup: Some(false),
10821            ..Default::default()
10822        };
10823        let rpi_project = crate::settings::Settings::default();
10824        let pi_project = crate::settings::Settings {
10825            quiet_startup: Some(true),
10826            ..Default::default()
10827        };
10828
10829        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
10830
10831        assert!(resolved.quiet_startup);
10832    }
10833
10834    #[test]
10835    fn tui_startup_settings_use_global_values_without_project_fields() {
10836        let global = crate::settings::Settings {
10837            editor_padding_x: Some(7),
10838            autocomplete_max_visible: Some(8),
10839            hide_thinking_block: Some(true),
10840            quiet_startup: Some(true),
10841            show_terminal_progress: Some(false),
10842            show_images: Some(false),
10843            show_cache_miss_notices: Some(true),
10844            ..Default::default()
10845        };
10846
10847        assert_eq!(
10848            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
10849            TuiStartupSettings {
10850                editor_padding_x: 7,
10851                autocomplete_max_visible: 8,
10852                hide_thinking: true,
10853                quiet_startup: true,
10854                show_terminal_progress: false,
10855                show_images: false,
10856                cache_miss_notices: true,
10857            }
10858        );
10859    }
10860
10861    #[test]
10862    fn tui_startup_settings_read_native_nested_terminal_block() {
10863        // Native pi nests these under `terminal`; rpi's flat keys stay
10864        // compatible and the nested block is the fallback.
10865        let global = crate::settings::Settings {
10866            terminal: Some(crate::settings::TerminalSettings {
10867                show_images: Some(false),
10868                show_terminal_progress: Some(false),
10869                clear_on_shrink: None,
10870            }),
10871            ..Default::default()
10872        };
10873        let resolved =
10874            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10875        assert!(!resolved.show_images);
10876        assert!(!resolved.show_terminal_progress);
10877    }
10878
10879    #[test]
10880    fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
10881        let global = crate::settings::Settings {
10882            show_images: Some(true),
10883            terminal: Some(crate::settings::TerminalSettings {
10884                show_images: Some(false),
10885                show_terminal_progress: None,
10886                clear_on_shrink: None,
10887            }),
10888            ..Default::default()
10889        };
10890        let resolved =
10891            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10892        assert!(resolved.show_images);
10893    }
10894
10895    #[test]
10896    fn tui_startup_settings_ignore_untrusted_project_values() {
10897        let global = crate::settings::Settings {
10898            quiet_startup: Some(false),
10899            show_terminal_progress: Some(true),
10900            ..Default::default()
10901        };
10902        let project = crate::settings::Settings {
10903            quiet_startup: Some(true),
10904            show_terminal_progress: Some(false),
10905            ..Default::default()
10906        };
10907
10908        let resolved = resolve_tui_startup_settings(&global, &[project], false);
10909
10910        assert!(!resolved.quiet_startup);
10911        assert!(resolved.show_terminal_progress);
10912    }
10913
10914    #[test]
10915    fn transcript_page_uses_viewport_with_overlap() {
10916        assert_eq!(transcript_page_size(24), 20);
10917        assert_eq!(transcript_page_size(4), 1);
10918        assert_eq!(transcript_page_size(0), 1);
10919    }
10920
10921    #[test]
10922    fn parse_user_bash_distinguishes_single_and_double_bang() {
10923        // `!cmd` runs and stays in context.
10924        assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
10925        // `!!cmd` runs but is excluded from context.
10926        assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
10927        // Surrounding whitespace on the command is trimmed, the `!` prefix is not.
10928        assert_eq!(parse_user_bash("!  echo hi  "), Some(("echo hi", false)));
10929        assert_eq!(parse_user_bash("!!  echo hi"), Some(("echo hi", true)));
10930    }
10931
10932    #[test]
10933    fn parse_user_bash_ignores_bare_bang_and_plain_text() {
10934        // A bare `!` / `!!` has no command: fall through to the prompt path
10935        // instead of executing an empty shell line.
10936        assert_eq!(parse_user_bash("!"), None);
10937        assert_eq!(parse_user_bash("!!"), None);
10938        assert_eq!(parse_user_bash("!   "), None);
10939        assert_eq!(parse_user_bash("hello"), None);
10940        assert_eq!(parse_user_bash(""), None);
10941        // `/` keeps routing to the slash-command registry.
10942        assert_eq!(parse_user_bash("/model"), None);
10943    }
10944
10945    #[test]
10946    fn user_bash_run_slot_guards_and_cancels() {
10947        let state = test_tui_state();
10948        assert!(!state.user_bash_running());
10949        let cancel = state.begin_user_bash();
10950        assert!(state.user_bash_running());
10951        // Esc cancels without freeing the slot; the spawned task clears it when
10952        // the capture resolves (native pi's `isBashRunning`).
10953        assert!(state.cancel_user_bash());
10954        assert!(state.user_bash_running());
10955        assert!(cancel.is_cancelled());
10956        state.finish_user_bash();
10957        assert!(!state.user_bash_running());
10958        // Nothing to cancel once idle.
10959        assert!(!state.cancel_user_bash());
10960    }
10961
10962    #[test]
10963    fn key_repeat_is_dispatched_but_release_is_not() {
10964        assert!(should_dispatch_key(KeyEventKind::Press));
10965        assert!(should_dispatch_key(KeyEventKind::Repeat));
10966        assert!(!should_dispatch_key(KeyEventKind::Release));
10967    }
10968
10969    #[test]
10970    fn key_event_encoding_matches_pi_keybinding_protocol() {
10971        let key = |code, modifiers| KeyEvent::new(code, modifiers);
10972        assert_eq!(
10973            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
10974            "\r"
10975        );
10976        assert_eq!(
10977            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
10978            "\x1b[13;2u"
10979        );
10980        assert_eq!(
10981            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
10982            "\x1b[9;2u"
10983        );
10984        assert_eq!(
10985            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
10986            "\x1b[Z"
10987        );
10988        assert_eq!(
10989            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
10990            "\x1b[Z"
10991        );
10992        assert_eq!(
10993            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
10994            "\x03"
10995        );
10996        assert_eq!(
10997            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
10998            "\x0f"
10999        );
11000        assert_eq!(
11001            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
11002            "!"
11003        );
11004        assert_eq!(
11005            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
11006            "1"
11007        );
11008    }
11009
11010    #[test]
11011    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
11012        let bridge = JsDialogBridge::default();
11013        let (sender, receiver) = std_mpsc::channel();
11014        bridge.pending.lock().unwrap().push_back(JsDialogPending {
11015            request: JsDialogRequest {
11016                id: "dialog-1".into(),
11017                method: "input".into(),
11018                title: String::new(),
11019                message: String::new(),
11020                options: Vec::new(),
11021                placeholder: None,
11022                prefill: None,
11023            },
11024            result: sender,
11025        });
11026
11027        // Model the cancellation arriving after the queue entry has been
11028        // removed but before the TUI has installed the native widget.
11029        bridge.cancel("dialog-1");
11030        assert!(bridge.take_pending().is_none());
11031        assert_eq!(
11032            receiver.recv().unwrap(),
11033            serde_json::json!({ "cancelled": true })
11034        );
11035        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
11036    }
11037
11038    #[test]
11039    fn test_layout_renders_welcome_message() {
11040        let chat = Arc::new(Container::new());
11041        add_welcome_message(&chat);
11042
11043        let scroll = Arc::new(ScrollView::new(
11044            chat.clone(),
11045            ScrollViewOptions {
11046                follow: FollowMode::End,
11047                primary: true,
11048                ..Default::default()
11049            },
11050        ));
11051
11052        let editor = Arc::new(Editor::new(
11053            EditorOptions {
11054                padding_x: 1,
11055                ..Default::default()
11056            },
11057            EditorStyle::default(),
11058            Arc::new(rpi_tui::Keybindings::new()),
11059        ));
11060        let dock = Arc::new(Container::new());
11061        dock.add_child(editor);
11062
11063        let footer = Arc::new(FooterComponent::new());
11064
11065        let root = VStack::from_children(vec![
11066            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
11067            StackChild::Entry(StackEntry::new(dock)),
11068            StackChild::Entry(StackEntry::new(footer)),
11069        ]);
11070
11071        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
11072
11073        let all: String = frame.lines.join("\n");
11074        assert!(
11075            all.contains("rpi"),
11076            "Welcome message not found. Rendered: {}",
11077            all
11078        );
11079        assert!(
11080            all.contains("Type your message"),
11081            "Help text not found. Rendered: {}",
11082            all
11083        );
11084    }
11085
11086    #[test]
11087    fn test_chat_container_has_welcome_content() {
11088        let chat = Arc::new(Container::new());
11089        add_welcome_message_with_capabilities(
11090            &chat,
11091            &["read".into(), "bash".into(), "web_fetch".into()],
11092            &["rust-review".into(), "release".into()],
11093        );
11094
11095        let lines = chat.render(80);
11096        let all: String = lines.join("\n");
11097        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
11098        // joined by an ANSI reset — strip ANSI before checking the substring.
11099        let plain = strip_ansi(&all);
11100        assert!(
11101            plain.contains("rpi"),
11102            "Welcome message not in chat container: {:?}",
11103            lines
11104        );
11105        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
11106        assert!(
11107            plain.contains("read · bash · web_fetch"),
11108            "Tool names missing: {plain}"
11109        );
11110        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
11111        assert!(
11112            plain.contains("rust-review · release"),
11113            "Skill names missing: {plain}"
11114        );
11115    }
11116
11117    #[test]
11118    fn update_notices_render_inside_the_transcript() {
11119        let chat = Arc::new(Container::new());
11120        let report = crate::updates::UpdateReport {
11121            notices: vec![
11122                crate::updates::UpdateNotice {
11123                    name: "rpi".into(),
11124                    current: "0.1.10".into(),
11125                    latest: "0.1.11".into(),
11126                    command: "rpi update".into(),
11127                },
11128                crate::updates::UpdateNotice {
11129                    name: "rpi-search".into(),
11130                    current: "0.1.0".into(),
11131                    latest: "0.1.1".into(),
11132                    command: "rpi pi-package update".into(),
11133                },
11134            ],
11135            warnings: vec![crate::updates::UpdateWarning {
11136                message: "The previously scheduled rpi update failed: access denied".into(),
11137                command: "rpi update".into(),
11138            }],
11139        };
11140
11141        add_update_notices(&chat, &report);
11142
11143        assert_eq!(chat.child_count(), 1);
11144        let plain = strip_ansi(&chat.render(80).join("\n"));
11145        assert!(plain.contains("Update Failed"), "{plain}");
11146        assert!(
11147            plain.contains("rpi update failed: access denied"),
11148            "{plain}"
11149        );
11150        assert!(plain.contains("Update Available"), "{plain}");
11151        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
11152        assert!(plain.contains("rpi update"), "{plain}");
11153        assert!(plain.contains("Package Updates Available"), "{plain}");
11154        assert!(plain.contains("rpi pi-package update"), "{plain}");
11155        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
11156    }
11157
11158    #[test]
11159    fn empty_update_report_does_not_add_transcript_content() {
11160        let chat = Arc::new(Container::new());
11161
11162        add_update_notices(&chat, &crate::updates::UpdateReport::default());
11163
11164        assert_eq!(chat.child_count(), 0);
11165    }
11166
11167    #[test]
11168    fn skill_reads_are_detected_by_path() {
11169        let name = skill_tool_name(
11170            "read",
11171            &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
11172        );
11173        assert_eq!(name.as_deref(), Some("release"));
11174
11175        let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
11176        assert!(name.is_none());
11177
11178        // Only `read` (not other tools) triggers the skill box.
11179        assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
11180    }
11181
11182    #[test]
11183    fn welcome_capabilities_show_empty_state() {
11184        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
11185        assert_eq!(plain, "Skills (0) none");
11186    }
11187
11188    #[test]
11189    fn empty_tool_progress_is_deferred_until_start() {
11190        let empty = rpi_agent::AgentToolResult::default();
11191        assert!(!tool_update_has_payload("", &empty));
11192
11193        let text = rpi_agent::AgentToolResult::text("partial output");
11194        assert!(tool_update_has_payload("partial output", &text));
11195
11196        let details = rpi_agent::AgentToolResult {
11197            details: serde_json::json!({"path": "src/lib.rs"}),
11198            ..Default::default()
11199        };
11200        assert!(tool_update_has_payload("", &details));
11201    }
11202
11203    #[test]
11204    fn completed_stream_reconciles_the_final_tail_before_detaching() {
11205        let component = Arc::new(AssistantMessageComponent::new(
11206            AssistantMessageOptions::default(),
11207        ));
11208        component.set_streaming(true);
11209        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
11210
11211        let current = Mutex::new(Some(component.clone()));
11212        let cached = Mutex::new("partial response".to_string());
11213        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
11214        final_message.content = vec![Content::text(
11215            "partial response with the previously missing final tail",
11216        )];
11217        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
11218
11219        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
11220
11221        assert!(current.lock().unwrap().is_none());
11222        assert_eq!(
11223            cached.lock().unwrap().as_str(),
11224            "partial response with the previously missing final tail"
11225        );
11226        let rendered = strip_ansi(&component.render(100).join("\n"));
11227        assert!(
11228            rendered.contains("previously missing final tail"),
11229            "{rendered}"
11230        );
11231    }
11232
11233    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
11234    /// replaces the editor text, the NEXT rendered frame must show the
11235    /// completed text (" /model " with the caret after it), not the old
11236    /// prefix. Mirrors the real dock layout (autocomplete_container above the
11237    /// bordered editor) and drives the same accept path the Tab handler uses.
11238    #[test]
11239    fn tab_accept_suggestion_reflects_in_next_render() {
11240        use rpi_tui::render_layout_frame;
11241
11242        let editor = Arc::new(Editor::new(
11243            EditorOptions {
11244                padding_x: 1,
11245                ..Default::default()
11246            },
11247            EditorStyle::default(),
11248            Arc::new(rpi_tui::Keybindings::new()),
11249        ));
11250        editor.set_focused(true);
11251        let editor_container = Arc::new(Container::new());
11252        editor_container.add_child(editor.clone());
11253        let autocomplete_container = Arc::new(Container::new());
11254        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
11255        let dock = Arc::new(VStack::from_children(vec![
11256            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
11257            StackChild::Entry(
11258                StackEntry::new(editor_container.clone())
11259                    .shrink(0)
11260                    .min_size(3),
11261            ),
11262            StackChild::Entry(StackEntry::new(footer)),
11263        ]));
11264
11265        // Simulate the user typing "/mo" (the popup shows suggestions).
11266        let manager = AutocompleteManager::new();
11267        let mut combined = CombinedAutocompleteProvider::new();
11268        combined.add_provider(Arc::new(
11269            SlashCommandAutocompleteProvider::with_default_commands(),
11270        ));
11271        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
11272        manager.set_provider(Arc::new(combined));
11273        // Simulate typing "/mo" via the real insert path (advances the caret
11274        // by char length, like `handle_key` does).
11275        editor.insert("/mo");
11276        assert_eq!(editor.cursor_position(), (0, 3));
11277
11278        let frame_before = render_layout_frame(dock.clone(), 80, 10);
11279        assert!(
11280            frame_before.lines.iter().any(|l| l.contains("/mo")),
11281            "precondition: editor shows the typed prefix. Frame rows:\n{}",
11282            frame_before
11283                .lines
11284                .iter()
11285                .map(|l| format!("  [{l}]"))
11286                .collect::<Vec<_>>()
11287                .join("\n")
11288        );
11289
11290        // Tab: accept the top suggestion (the same code path as the key loop).
11291        let text = editor.get_text();
11292        let cursor = editor_cursor_offset(&editor, &text);
11293        let sugg = manager
11294            .get_suggestions(&text, cursor)
11295            .expect("slash suggestions for /mo");
11296        let top = sugg.items.first().expect("at least one suggestion");
11297        let start = sugg.start.min(text.len());
11298        let end = sugg.end.min(text.len());
11299        let mut replaced = String::new();
11300        replaced.push_str(&text[..start]);
11301        replaced.push_str(&top.text);
11302        replaced.push_str(&text[end..]);
11303        if top.insert_space && !replaced.ends_with('/') {
11304            replaced.push(' ');
11305        }
11306        let new_cursor = start + top.text.len();
11307        editor.set_text(&replaced);
11308        set_editor_cursor_offset(&editor, &replaced, new_cursor);
11309        autocomplete_container.clear();
11310        assert_eq!(editor.get_text(), "/model");
11311
11312        // The next render MUST display the completed text.
11313        let frame_after = render_layout_frame(dock, 80, 10);
11314        let all: String = frame_after.lines.join("\n");
11315        assert!(
11316            all.contains("/model"),
11317            "completed text missing from next render. Got:\n{all}"
11318        );
11319        // The caret must sit AFTER the completed command (the snap_boundary
11320        // regression put it one char early: "/mode|l" with the final char
11321        // dangling past the caret).
11322        let editor_line = frame_after
11323            .lines
11324            .iter()
11325            .find(|l| l.contains("/model"))
11326            .expect("editor row with completed text");
11327        assert!(
11328            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
11329            "caret must follow the full completed text. Got: {editor_line:?}"
11330        );
11331    }
11332
11333    #[test]
11334    fn multiline_autocomplete_preserves_row_and_column() {
11335        let editor = Arc::new(Editor::simple());
11336        editor.set_text("first\n/mo");
11337        editor.set_cursor(1, 3);
11338
11339        let text = editor.get_text();
11340        assert_eq!(editor_cursor_offset(&editor, &text), 9);
11341
11342        let replaced = "first\n/model";
11343        editor.set_text(replaced);
11344        set_editor_cursor_offset(&editor, replaced, 12);
11345        assert_eq!(editor.cursor_position(), (1, 6));
11346    }
11347
11348    #[test]
11349    fn test_slash_command_dispatch() {
11350        // The registry is the single source of truth for dispatch: `find(token)`
11351        // returns the command (by name or alias) whose `name()` is the canonical
11352        // form, or `None` for an unknown token. This replaces the old enum-based
11353        // `handle_slash_command` assertions with equivalent registry lookups.
11354        let registry = build_builtin_registry();
11355
11356        // Helper: a token resolves to the command with this canonical name.
11357        let resolves_to = |token: &str, canonical: &str| {
11358            let found = registry.find(token).expect("{token} should resolve");
11359            assert_eq!(
11360                found.name(),
11361                canonical,
11362                "{token} resolved to {} (expected {canonical})",
11363                found.name()
11364            );
11365        };
11366
11367        resolves_to("/help", "/help");
11368        resolves_to("/?", "/help"); // alias → canonical
11369        resolves_to("/clear", "/clear");
11370        resolves_to("/new", "/clear"); // alias
11371        resolves_to("/q", "/exit"); // alias
11372        resolves_to("/quit", "/exit"); // alias
11373        resolves_to("/version", "/version");
11374        resolves_to("/v", "/version"); // alias
11375        resolves_to("/changelog", "/changelog");
11376        resolves_to("/hotkeys", "/hotkeys");
11377        resolves_to("/model", "/model");
11378        resolves_to("/m", "/model"); // alias
11379        resolves_to("/theme", "/theme");
11380        resolves_to("/session", "/session");
11381        resolves_to("/resume", "/session"); // alias
11382        resolves_to("/compact", "/compact");
11383        resolves_to("/copy", "/copy");
11384        resolves_to("/thinking", "/thinking");
11385        resolves_to("/think", "/thinking"); // alias
11386        resolves_to("/tools", "/tools");
11387        resolves_to("/images", "/images");
11388        resolves_to("/armin", "/armin");
11389        resolves_to("/earendil", "/earendil");
11390        resolves_to("/context", "/context");
11391        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
11392        resolves_to("/settings", "/settings");
11393        resolves_to("/name", "/name");
11394        resolves_to("/export", "/export");
11395
11396        // Unknown token → not found.
11397        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
11398    }
11399
11400    #[test]
11401
11402    fn test_registry_visible_entries_cover_dispatch() {
11403        // The autocomplete list is derived from the registry, so every visible
11404        // command the dispatcher recognizes must appear in it — by construction,
11405        // but this guards against a future command being registered with
11406        // `visible()` / a non-empty description that the builder drops.
11407        let registry = build_builtin_registry();
11408        let names: Vec<String> = registry
11409            .visible_entries()
11410            .iter()
11411            .map(|c| c.name.clone())
11412            .collect();
11413        for recognized in [
11414            "/help",
11415            "/clear",
11416            "/new",
11417            "/exit",
11418            "/quit",
11419            "/version",
11420            "/changelog",
11421            "/model",
11422            "/session",
11423            "/theme",
11424            "/compact",
11425            "/copy",
11426            "/hotkeys",
11427            "/tools",
11428            "/images",
11429            "/thinking",
11430            "/usage",
11431            "/armin",
11432            "/earendil",
11433        ] {
11434            assert!(
11435                names.contains(&recognized.to_string()),
11436                "{recognized} missing from autocomplete list"
11437            );
11438        }
11439        // Hidden commands stay off the list.
11440        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
11441            assert!(
11442                !names.contains(&hidden.to_string()),
11443                "{hidden} should be hidden from autocomplete"
11444            );
11445        }
11446    }
11447
11448    #[test]
11449    fn test_agent_event_mapping_creates_assistant_and_tool() {
11450        // Synthetic AgentEvent sequence → UI mutations, exercised against the
11451        // real drain handler with a no-op TUI stand-in.
11452        use rpi_ai::types::{
11453            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
11454            ToolCall, ToolCallType, Usage,
11455        };
11456
11457        let state = Arc::new(TuiState {
11458            current_assistant: std::sync::Mutex::new(None),
11459            tool_components: std::sync::Mutex::new(HashMap::new()),
11460            bash_components: std::sync::Mutex::new(HashMap::new()),
11461            themes_enabled: true,
11462            hide_thinking: std::sync::Mutex::new(false),
11463            tool_outputs_expanded: std::sync::Mutex::new(false),
11464            show_terminal_progress: true,
11465            status: std::sync::Mutex::new(RunStatus::Idle),
11466            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11467            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11468            js_preparation_cancel: std::sync::Mutex::new(None),
11469            user_bash_cancel: std::sync::Mutex::new(None),
11470            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11471            footer: Arc::new(FooterComponent::new()),
11472            status_container: Arc::new(Container::new()),
11473            chat_container: Arc::new(Container::new()),
11474            loader: Arc::new(Loader::new()),
11475            editor: Arc::new(Editor::simple()),
11476            last_assistant_text: std::sync::Mutex::new(String::new()),
11477            active_selector: std::sync::Mutex::new(None),
11478            active_extension_editor: std::sync::Mutex::new(None),
11479            active_extension_input: std::sync::Mutex::new(None),
11480            active_extension_cancel: std::sync::Mutex::new(None),
11481            autocomplete: AutocompleteManager::new(),
11482            autocomplete_container: Arc::new(Container::new()),
11483            autocomplete_max_visible: 5,
11484            pending_images: std::sync::Mutex::new(Vec::new()),
11485            theme_manager: Arc::new(ThemeManager::new()),
11486            tui: None,
11487            current_model_id: std::sync::Mutex::new(String::new()),
11488            show_images: std::sync::Mutex::new(true),
11489            cache_miss_notices: std::sync::Mutex::new(false),
11490            history: std::sync::Mutex::new(Vec::new()),
11491            history_index: std::sync::Mutex::new(-1),
11492            history_draft: std::sync::Mutex::new(None),
11493            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11494            scoped_edit: std::sync::Mutex::new(None),
11495            markdown_transformer: std::sync::Mutex::new(None),
11496            extension_session: Arc::new(std::sync::Mutex::new(
11497                rpi_extensions::ExtensionSession::none(),
11498            )),
11499            search: Arc::new(AltScreenSearch::new()),
11500            search_bar: Arc::new(SearchBar::new()),
11501            pending_container: Arc::new(Container::new()),
11502            dequeue_hint: String::new(),
11503            pending_snapshot: std::sync::Mutex::new(
11504                rpi_harness::agent_harness::QueuedMessages::default(),
11505            ),
11506            selection_start: std::sync::Mutex::new(None),
11507            selection_end: std::sync::Mutex::new(None),
11508        });
11509
11510        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
11511        // terminal; instead, exercise the *mutation* half directly against a
11512        // captured chat container via a synthetic message-start event's data.
11513        let assistant = AssistantMessage {
11514            role: rpi_ai::types::AssistantRole,
11515            content: vec![
11516                Content::Thinking(ThinkingContent {
11517                    kind: ThinkingContentType,
11518                    thinking: "Reasoning about the reply.".into(),
11519                    thinking_signature: None,
11520                    redacted: false,
11521                }),
11522                Content::Text(TextContent {
11523                    kind: TextContentType,
11524                    text: "Hello.".into(),
11525                    text_signature: None,
11526                }),
11527                Content::ToolCall(ToolCall {
11528                    kind: ToolCallType,
11529                    id: "tc1".into(),
11530                    name: "bash".into(),
11531                    arguments: serde_json::json!({"command": "echo hi"}),
11532                    thought_signature: None,
11533                    namespace: None,
11534                }),
11535            ],
11536            api: rpi_ai::Api::AnthropicMessages,
11537            provider: "anthropic".into(),
11538            model: "claude-sonnet-5".into(),
11539            response_model: None,
11540            response_id: None,
11541            usage: Usage::zero(),
11542            stop_reason: StopReason::Stop,
11543            deferred: None,
11544            error_message: None,
11545            raw_stop_reason: None,
11546            end_turn: None,
11547            timestamp: 0,
11548        };
11549
11550        // Manually apply the MessageStart assistant branch logic (mirrors the
11551        // drain handler, without needing a TuiAltScreen).
11552        let comp = Arc::new(AssistantMessageComponent::new(
11553            AssistantMessageOptions::default(),
11554        ));
11555        comp.set_streaming(true);
11556        comp.update_blocks(&assistant_blocks(&assistant));
11557        let chat = Arc::new(Container::new());
11558        chat.add_child(comp.clone());
11559        *state.current_assistant.lock().unwrap() = Some(comp);
11560
11561        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
11562        for c in &assistant.content {
11563            if let Content::ToolCall(tc) = c {
11564                let mut tools = state.tool_components.lock().unwrap();
11565                if !tools.contains_key(&tc.id) {
11566                    let tc_comp = Arc::new(ToolExecutionComponent::new(
11567                        &tc.name,
11568                        &tc.arguments.to_string(),
11569                    ));
11570                    tc_comp.set_running();
11571                    chat.add_child(tc_comp.clone());
11572                    tools.insert(tc.id.clone(), tc_comp);
11573                }
11574            }
11575        }
11576
11577        // Assert: the assistant component rendered the text + the thinking
11578        // block (the update_blocks path keeps thinking visible), and a tool
11579        // component was registered.
11580        let rendered = chat.render(80);
11581        let joined: String = rendered.join("\n");
11582        assert!(
11583            joined.contains("Hello."),
11584            "assistant text not rendered: {joined}"
11585        );
11586        assert!(
11587            joined.contains("Reasoning about the reply."),
11588            "thinking block not rendered: {joined}"
11589        );
11590        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
11591        assert!(state.current_assistant.lock().unwrap().is_some());
11592
11593        // Manually apply ToolExecutionEnd (mirrors drain).
11594        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
11595        ended.set_result("hi", false);
11596        assert!(state.tool_components.lock().unwrap().is_empty());
11597
11598        // A running bash panel owns the visible spinner. The global loader is
11599        // hidden until the last concurrent bash tool completes, then restored
11600        // while the agent remains in the Working state.
11601        assert!(state.try_start_working());
11602        assert!(
11603            !state.try_start_working(),
11604            "a second submit must be rejected"
11605        );
11606        state.set_status(RunStatus::Idle);
11607        state.set_status(RunStatus::Working);
11608        // The active loader now renders inside the editor's top border.
11609        assert!(state.editor.working().is_some());
11610        assert_eq!(state.status_container.child_count(), 0);
11611        assert!(state.footer.get_status().is_empty());
11612        state.show_retry(3, 10, 8_000);
11613        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
11614        assert!(retry_status.contains("Retrying (3/10)"));
11615        state.set_status(RunStatus::Working);
11616        {
11617            let mut bash = state.bash_components.lock().unwrap();
11618            bash.insert(
11619                "bash-1".into(),
11620                Arc::new(BashExecutionComponent::new("one")),
11621            );
11622            bash.insert(
11623                "bash-2".into(),
11624                Arc::new(BashExecutionComponent::new("two")),
11625            );
11626        }
11627        state.sync_working_loader_with_bash();
11628        assert_eq!(state.status_container.child_count(), 0);
11629        state.bash_components.lock().unwrap().remove("bash-1");
11630        state.sync_working_loader_with_bash();
11631        assert_eq!(state.status_container.child_count(), 0);
11632        state.bash_components.lock().unwrap().remove("bash-2");
11633        state.sync_working_loader_with_bash();
11634        // The editor-border indicator stays visible across bash panels, so the
11635        // status container stays empty.
11636        assert_eq!(state.status_container.child_count(), 0);
11637        assert!(state.editor.working().is_some());
11638
11639        state.set_status(RunStatus::Aborting);
11640        assert_eq!(state.status_container.child_count(), 0);
11641        assert!(!state.loader.is_running());
11642    }
11643
11644    #[test]
11645    fn fresh_launch_does_not_restore_old_history() {
11646        let fresh = Args::default();
11647        assert!(!launch_restores_history(&fresh));
11648
11649        let continued = Args {
11650            continue_session: true,
11651            ..Args::default()
11652        };
11653        assert!(launch_restores_history(&continued));
11654
11655        let selected = Args {
11656            session: Some("session-id".into()),
11657            ..Args::default()
11658        };
11659        assert!(launch_restores_history(&selected));
11660    }
11661
11662    #[test]
11663    fn context_badge_ignores_aborted_and_error_turns() {
11664        use rpi_ai::types::{Api, StopReason, Usage};
11665
11666        let footer = Arc::new(FooterComponent::new());
11667        footer.set_context_window(100_000);
11668
11669        // A real response fills in the percent (`?` -> `25.0%/100k`).
11670        let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11671        ok.stop_reason = StopReason::Stop;
11672        ok.usage = Usage {
11673            input: 25_000,
11674            ..Usage::zero()
11675        };
11676        record_context_usage(&footer, &ok);
11677        let stats = strip_ansi(&footer.render(120).join("\n"));
11678        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11679
11680        // An aborted turn must not clobber the last good value.
11681        let mut aborted =
11682            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11683        aborted.stop_reason = StopReason::Aborted;
11684        aborted.usage = Usage {
11685            input: 90_000,
11686            ..Usage::zero()
11687        };
11688        record_context_usage(&footer, &aborted);
11689        let stats = strip_ansi(&footer.render(120).join("\n"));
11690        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11691
11692        // An errored turn likewise (its usage is not a real measurement).
11693        let mut errored =
11694            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11695        errored.stop_reason = StopReason::Error;
11696        errored.usage = Usage {
11697            input: 90_000,
11698            ..Usage::zero()
11699        };
11700        record_context_usage(&footer, &errored);
11701        let stats = strip_ansi(&footer.render(120).join("\n"));
11702        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11703    }
11704
11705    #[test]
11706    fn test_short_model_name() {
11707        assert_eq!(
11708            short_model_name("anthropic:claude-sonnet-5"),
11709            "claude-sonnet-5"
11710        );
11711        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
11712    }
11713
11714    #[test]
11715    fn model_selector_items_are_deduplicated_and_provider_qualified() {
11716        use rpi_ai::{Api, Model};
11717
11718        let mut gateway = Model::new(
11719            "gpt-5.6-sol",
11720            "GPT 5.6 Sol",
11721            Api::OpenaiCompletions,
11722            "routeryo-copy",
11723            "https://gateway.example.com",
11724        );
11725        let duplicate = gateway.clone();
11726        let anthropic = Model::new(
11727            "claude-sonnet-5",
11728            "Claude Sonnet 5",
11729            Api::AnthropicMessages,
11730            "anthropic",
11731            "https://api.anthropic.com",
11732        );
11733        gateway.headers = Some(std::collections::BTreeMap::from([(
11734            "authorization".into(),
11735            "Bearer test".into(),
11736        )]));
11737
11738        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
11739        assert_eq!(items.len(), 2);
11740        assert_eq!(items[0].value, "gpt-5.6-sol");
11741        assert_eq!(items[0].label, "GPT 5.6 Sol");
11742        assert_eq!(
11743            items[0].description.as_deref(),
11744            Some("routeryo-copy/gpt-5.6-sol (current)")
11745        );
11746        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
11747    }
11748
11749    #[test]
11750    fn model_selector_search_text_covers_provider_name_and_qualified_id() {
11751        use rpi_ai::{Api, Model};
11752
11753        let anthropic = Model::new(
11754            "claude-sonnet-5",
11755            "Claude Sonnet 5",
11756            Api::AnthropicMessages,
11757            "anthropic",
11758            "https://api.anthropic.com",
11759        );
11760        let items = model_selector_items(&[anthropic], "");
11761        let search = items[0].search_key();
11762        // Mirrors native `getModelSelectorSearchText`: provider-prefixed first,
11763        // then the display name.
11764        assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
11765        assert!(search.contains("Claude Sonnet 5"), "{search}");
11766
11767        // A provider query and a display-name query both match through the
11768        // real fuzzy filter used by the searchable selector.
11769        use rpi_tui::fuzzy_filter;
11770        for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
11771            let matched = fuzzy_filter(&items, query, |item| item.search_key());
11772            assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
11773        }
11774    }
11775
11776    #[test]
11777    fn model_selector_match_accepts_bare_and_qualified_ids() {
11778        use rpi_ai::{Api, Model};
11779
11780        let gateway = Model::new(
11781            "gpt-5.6-sol",
11782            "GPT 5.6 Sol",
11783            Api::OpenaiCompletions,
11784            "routeryo-copy",
11785            "https://gateway.example.com",
11786        );
11787        let anthropic = Model::new(
11788            "claude-sonnet-5",
11789            "Claude Sonnet 5",
11790            Api::AnthropicMessages,
11791            "anthropic",
11792            "https://api.anthropic.com",
11793        );
11794        let catalog = [gateway, anthropic];
11795        assert_eq!(
11796            find_model_selector_match(&catalog, "gpt-5.6-sol")
11797                .unwrap()
11798                .provider,
11799            "routeryo-copy"
11800        );
11801        assert_eq!(
11802            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
11803                .unwrap()
11804                .id,
11805            "gpt-5.6-sol"
11806        );
11807        assert_eq!(
11808            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
11809                .unwrap()
11810                .id,
11811            "claude-sonnet-5"
11812        );
11813        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
11814    }
11815
11816    #[test]
11817    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
11818        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
11819
11820        let failed = AssistantMessage {
11821            role: AssistantRole,
11822            content: Vec::new(),
11823            api: rpi_ai::Api::AnthropicMessages,
11824            provider: "anthropic".into(),
11825            model: "claude-sonnet-5".into(),
11826            response_model: None,
11827            response_id: None,
11828            usage: Usage::zero(),
11829            stop_reason: StopReason::Error,
11830            deferred: None,
11831            error_message: Some("upstream returned 401".into()),
11832            raw_stop_reason: None,
11833            end_turn: None,
11834            timestamp: 0,
11835        };
11836        assert_eq!(
11837            assistant_error_text(&failed).as_deref(),
11838            Some("upstream returned 401")
11839        );
11840
11841        let mut no_detail = failed;
11842        no_detail.error_message = Some("  ".into());
11843        assert_eq!(
11844            assistant_error_text(&no_detail).as_deref(),
11845            Some("Provider request failed.")
11846        );
11847
11848        let mut aborted = no_detail;
11849        aborted.stop_reason = StopReason::Aborted;
11850        aborted.error_message = Some("abort error: Request aborted".into());
11851        assert_eq!(
11852            assistant_error_text(&aborted).as_deref(),
11853            Some("abort error: Request aborted")
11854        );
11855
11856        aborted.error_message = None;
11857        assert_eq!(
11858            assistant_error_text(&aborted).as_deref(),
11859            Some("Request aborted.")
11860        );
11861    }
11862
11863    #[test]
11864    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
11865        assert_eq!(
11866            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
11867            "405\nMethod Not Allowed"
11868        );
11869        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
11870        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
11871        let long = "x".repeat(20_000);
11872        let cleaned = sanitize_error_message(&long);
11873        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
11874        assert!(cleaned.ends_with('…'));
11875    }
11876
11877    #[test]
11878    fn test_cycle_next_model_wraps_around() {
11879        use rpi_ai::{Api, Model};
11880        let mk = |id: &str| {
11881            Model::new(
11882                id,
11883                id,
11884                Api::AnthropicMessages,
11885                "anthropic",
11886                "https://api.anthropic.com",
11887            )
11888        };
11889        let catalog = [mk("a"), mk("b"), mk("c")];
11890        // Next after "a" is "b"; after "c" wraps to "a".
11891        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
11892        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
11893        // An unknown current id falls back to the first model.
11894        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
11895        // Empty catalog yields None.
11896        let empty: Vec<Model> = vec![];
11897        assert!(cycle_next_model(&empty, "a").is_none());
11898    }
11899
11900    #[test]
11901    fn test_autocomplete_slash_suggestions_render() {
11902        // The autocomplete container should render at least one suggestion
11903        // line when the editor holds a `/` prefix, and clear when it doesn't.
11904        let state = Arc::new(TuiState {
11905            current_assistant: std::sync::Mutex::new(None),
11906            tool_components: std::sync::Mutex::new(HashMap::new()),
11907            bash_components: std::sync::Mutex::new(HashMap::new()),
11908            themes_enabled: true,
11909            hide_thinking: std::sync::Mutex::new(false),
11910            tool_outputs_expanded: std::sync::Mutex::new(false),
11911            show_terminal_progress: true,
11912            status: std::sync::Mutex::new(RunStatus::Idle),
11913            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11914            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11915            js_preparation_cancel: std::sync::Mutex::new(None),
11916            user_bash_cancel: std::sync::Mutex::new(None),
11917            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11918            footer: Arc::new(FooterComponent::new()),
11919            status_container: Arc::new(Container::new()),
11920            chat_container: Arc::new(Container::new()),
11921            loader: Arc::new(Loader::new()),
11922            editor: Arc::new(Editor::simple()),
11923            last_assistant_text: std::sync::Mutex::new(String::new()),
11924            active_selector: std::sync::Mutex::new(None),
11925            active_extension_editor: std::sync::Mutex::new(None),
11926            active_extension_input: std::sync::Mutex::new(None),
11927            active_extension_cancel: std::sync::Mutex::new(None),
11928            autocomplete: AutocompleteManager::new(),
11929            autocomplete_container: Arc::new(Container::new()),
11930            autocomplete_max_visible: 5,
11931            pending_images: std::sync::Mutex::new(Vec::new()),
11932            theme_manager: Arc::new(ThemeManager::new()),
11933            tui: None,
11934            current_model_id: std::sync::Mutex::new(String::new()),
11935            show_images: std::sync::Mutex::new(true),
11936            cache_miss_notices: std::sync::Mutex::new(false),
11937            history: std::sync::Mutex::new(Vec::new()),
11938            history_index: std::sync::Mutex::new(-1),
11939            history_draft: std::sync::Mutex::new(None),
11940            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11941            scoped_edit: std::sync::Mutex::new(None),
11942            markdown_transformer: std::sync::Mutex::new(None),
11943            extension_session: Arc::new(std::sync::Mutex::new(
11944                rpi_extensions::ExtensionSession::none(),
11945            )),
11946            search: Arc::new(AltScreenSearch::new()),
11947            search_bar: Arc::new(SearchBar::new()),
11948            pending_container: Arc::new(Container::new()),
11949            dequeue_hint: String::new(),
11950            pending_snapshot: std::sync::Mutex::new(
11951                rpi_harness::agent_harness::QueuedMessages::default(),
11952            ),
11953            selection_start: std::sync::Mutex::new(None),
11954            selection_end: std::sync::Mutex::new(None),
11955        });
11956        {
11957            let mut combined = CombinedAutocompleteProvider::new();
11958            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
11959                build_builtin_registry().visible_entries(),
11960            )));
11961            state.autocomplete.set_provider(Arc::new(combined));
11962        }
11963
11964        let editor = Arc::new(Editor::simple());
11965        editor.set_text("/he");
11966        editor.set_cursor(0, 3);
11967        refresh_autocomplete(&state, &editor);
11968        let lines = state.autocomplete_container.render(80);
11969        let joined: String = lines.join("\n");
11970        assert!(
11971            joined.contains("/help"),
11972            "slash suggestions not rendered: {joined}"
11973        );
11974
11975        // Clear: no suggestions for plain text.
11976        editor.set_text("hello");
11977        editor.set_cursor(0, 5);
11978        refresh_autocomplete(&state, &editor);
11979        assert!(state.autocomplete_container.render(80).is_empty());
11980    }
11981
11982    #[test]
11983    fn test_select_list_swap_restores_editor() {
11984        // The editor-container swap: opening a selector replaces the editor
11985        // child; closing restores it. Verify the container child count + the
11986        // active_selector flag round-trip.
11987        let state = Arc::new(TuiState {
11988            current_assistant: std::sync::Mutex::new(None),
11989            tool_components: std::sync::Mutex::new(HashMap::new()),
11990            bash_components: std::sync::Mutex::new(HashMap::new()),
11991            themes_enabled: true,
11992            hide_thinking: std::sync::Mutex::new(false),
11993            tool_outputs_expanded: std::sync::Mutex::new(false),
11994            show_terminal_progress: true,
11995            status: std::sync::Mutex::new(RunStatus::Idle),
11996            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11997            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11998            js_preparation_cancel: std::sync::Mutex::new(None),
11999            user_bash_cancel: std::sync::Mutex::new(None),
12000            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12001            footer: Arc::new(FooterComponent::new()),
12002            status_container: Arc::new(Container::new()),
12003            chat_container: Arc::new(Container::new()),
12004            loader: Arc::new(Loader::new()),
12005            editor: Arc::new(Editor::simple()),
12006            last_assistant_text: std::sync::Mutex::new(String::new()),
12007            active_selector: std::sync::Mutex::new(None),
12008            active_extension_editor: std::sync::Mutex::new(None),
12009            active_extension_input: std::sync::Mutex::new(None),
12010            active_extension_cancel: std::sync::Mutex::new(None),
12011            autocomplete: AutocompleteManager::new(),
12012            autocomplete_container: Arc::new(Container::new()),
12013            autocomplete_max_visible: 5,
12014            pending_images: std::sync::Mutex::new(Vec::new()),
12015            theme_manager: Arc::new(ThemeManager::new()),
12016            tui: None,
12017            current_model_id: std::sync::Mutex::new(String::new()),
12018            show_images: std::sync::Mutex::new(true),
12019            cache_miss_notices: std::sync::Mutex::new(false),
12020            history: std::sync::Mutex::new(Vec::new()),
12021            history_index: std::sync::Mutex::new(-1),
12022            history_draft: std::sync::Mutex::new(None),
12023            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12024            scoped_edit: std::sync::Mutex::new(None),
12025            markdown_transformer: std::sync::Mutex::new(None),
12026            extension_session: Arc::new(std::sync::Mutex::new(
12027                rpi_extensions::ExtensionSession::none(),
12028            )),
12029            search: Arc::new(AltScreenSearch::new()),
12030            search_bar: Arc::new(SearchBar::new()),
12031            pending_container: Arc::new(Container::new()),
12032            dequeue_hint: String::new(),
12033            pending_snapshot: std::sync::Mutex::new(
12034                rpi_harness::agent_harness::QueuedMessages::default(),
12035            ),
12036            selection_start: std::sync::Mutex::new(None),
12037            selection_end: std::sync::Mutex::new(None),
12038        });
12039        let editor_container = Arc::new(Container::new());
12040        let editor = Arc::new(Editor::simple());
12041        editor_container.add_child(editor.clone());
12042        assert!(!state.selector_open());
12043
12044        let tui_terminal = Box::new(ProcessTerminal::new());
12045        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
12046        let list = Arc::new(SelectList::new(
12047            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
12048            5,
12049        ));
12050        open_selector(
12051            &state,
12052            &editor_container,
12053            &editor,
12054            &tui,
12055            list,
12056            SelectorKind::Theme,
12057        );
12058        assert!(state.selector_open());
12059        // list only (editor swapped out).
12060        assert_eq!(editor_container.child_count(), 1);
12061
12062        close_selector(&state, &editor_container, &editor, &tui);
12063        assert!(!state.selector_open());
12064        // editor restored.
12065        assert_eq!(editor_container.child_count(), 1);
12066    }
12067
12068    #[test]
12069    fn test_message_history_browse_restores_draft() {
12070        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
12071        // messages, browse older → newer → back past the newest restores the
12072        // draft the user was typing.
12073        let state = Arc::new(TuiState {
12074            current_assistant: std::sync::Mutex::new(None),
12075            tool_components: std::sync::Mutex::new(HashMap::new()),
12076            bash_components: std::sync::Mutex::new(HashMap::new()),
12077            themes_enabled: true,
12078            hide_thinking: std::sync::Mutex::new(false),
12079            tool_outputs_expanded: std::sync::Mutex::new(false),
12080            show_terminal_progress: true,
12081            status: std::sync::Mutex::new(RunStatus::Idle),
12082            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12083            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12084            js_preparation_cancel: std::sync::Mutex::new(None),
12085            user_bash_cancel: std::sync::Mutex::new(None),
12086            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12087            footer: Arc::new(FooterComponent::new()),
12088            status_container: Arc::new(Container::new()),
12089            chat_container: Arc::new(Container::new()),
12090            loader: Arc::new(Loader::new()),
12091            editor: Arc::new(Editor::simple()),
12092            last_assistant_text: std::sync::Mutex::new(String::new()),
12093            active_selector: std::sync::Mutex::new(None),
12094            active_extension_editor: std::sync::Mutex::new(None),
12095            active_extension_input: std::sync::Mutex::new(None),
12096            active_extension_cancel: std::sync::Mutex::new(None),
12097            autocomplete: AutocompleteManager::new(),
12098            autocomplete_container: Arc::new(Container::new()),
12099            autocomplete_max_visible: 5,
12100            pending_images: std::sync::Mutex::new(Vec::new()),
12101            theme_manager: Arc::new(ThemeManager::new()),
12102            tui: None,
12103            current_model_id: std::sync::Mutex::new(String::new()),
12104            show_images: std::sync::Mutex::new(true),
12105            cache_miss_notices: std::sync::Mutex::new(false),
12106            history: std::sync::Mutex::new(Vec::new()),
12107            history_index: std::sync::Mutex::new(-1),
12108            history_draft: std::sync::Mutex::new(None),
12109            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12110            scoped_edit: std::sync::Mutex::new(None),
12111            markdown_transformer: std::sync::Mutex::new(None),
12112            extension_session: Arc::new(std::sync::Mutex::new(
12113                rpi_extensions::ExtensionSession::none(),
12114            )),
12115            search: Arc::new(AltScreenSearch::new()),
12116            search_bar: Arc::new(SearchBar::new()),
12117            pending_container: Arc::new(Container::new()),
12118            dequeue_hint: String::new(),
12119            pending_snapshot: std::sync::Mutex::new(
12120                rpi_harness::agent_harness::QueuedMessages::default(),
12121            ),
12122            selection_start: std::sync::Mutex::new(None),
12123            selection_end: std::sync::Mutex::new(None),
12124        });
12125        let editor = Arc::new(Editor::simple());
12126
12127        push_history(&state, "first message");
12128        push_history(&state, "second message");
12129        // Consecutive duplicate is skipped.
12130        push_history(&state, "second message");
12131        push_history(&state, "   "); // empty → skipped
12132        assert_eq!(state.history.lock().unwrap().len(), 2);
12133        assert_eq!(state.history.lock().unwrap()[0], "second message");
12134
12135        // User starts typing a fresh prompt.
12136        editor.set_text("half-typed");
12137        editor.set_cursor(0, 11);
12138
12139        // ↑ → most recent.
12140        navigate_history(&state, &editor, -1);
12141        assert_eq!(editor.get_text(), "second message");
12142        assert_eq!(*state.history_index.lock().unwrap(), 0);
12143        // ↑ → older.
12144        navigate_history(&state, &editor, -1);
12145        assert_eq!(editor.get_text(), "first message");
12146        assert_eq!(*state.history_index.lock().unwrap(), 1);
12147        // ↑ past the oldest → stays (no wrap).
12148        navigate_history(&state, &editor, -1);
12149        assert_eq!(editor.get_text(), "first message");
12150        // ↓ → newer.
12151        navigate_history(&state, &editor, 1);
12152        assert_eq!(editor.get_text(), "second message");
12153        // ↓ past the newest → restores the draft.
12154        navigate_history(&state, &editor, 1);
12155        assert_eq!(editor.get_text(), "half-typed");
12156        assert_eq!(*state.history_index.lock().unwrap(), -1);
12157    }
12158
12159    #[test]
12160    fn test_accept_top_suggestion_replaces_prefix() {
12161        // `/he` + Tab → `/help ` (slash command provider inserts a space).
12162        let state = Arc::new(TuiState {
12163            current_assistant: std::sync::Mutex::new(None),
12164            tool_components: std::sync::Mutex::new(HashMap::new()),
12165            bash_components: std::sync::Mutex::new(HashMap::new()),
12166            themes_enabled: true,
12167            hide_thinking: std::sync::Mutex::new(false),
12168            tool_outputs_expanded: std::sync::Mutex::new(false),
12169            show_terminal_progress: true,
12170            status: std::sync::Mutex::new(RunStatus::Idle),
12171            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12172            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12173            js_preparation_cancel: std::sync::Mutex::new(None),
12174            user_bash_cancel: std::sync::Mutex::new(None),
12175            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12176            footer: Arc::new(FooterComponent::new()),
12177            status_container: Arc::new(Container::new()),
12178            chat_container: Arc::new(Container::new()),
12179            loader: Arc::new(Loader::new()),
12180            editor: Arc::new(Editor::simple()),
12181            last_assistant_text: std::sync::Mutex::new(String::new()),
12182            active_selector: std::sync::Mutex::new(None),
12183            active_extension_editor: std::sync::Mutex::new(None),
12184            active_extension_input: std::sync::Mutex::new(None),
12185            active_extension_cancel: std::sync::Mutex::new(None),
12186            autocomplete: AutocompleteManager::new(),
12187            autocomplete_container: Arc::new(Container::new()),
12188            autocomplete_max_visible: 5,
12189            pending_images: std::sync::Mutex::new(Vec::new()),
12190            theme_manager: Arc::new(ThemeManager::new()),
12191            tui: None,
12192            current_model_id: std::sync::Mutex::new(String::new()),
12193            show_images: std::sync::Mutex::new(true),
12194            cache_miss_notices: std::sync::Mutex::new(false),
12195            history: std::sync::Mutex::new(Vec::new()),
12196            history_index: std::sync::Mutex::new(-1),
12197            history_draft: std::sync::Mutex::new(None),
12198            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12199            scoped_edit: std::sync::Mutex::new(None),
12200            markdown_transformer: std::sync::Mutex::new(None),
12201            extension_session: Arc::new(std::sync::Mutex::new(
12202                rpi_extensions::ExtensionSession::none(),
12203            )),
12204            search: Arc::new(AltScreenSearch::new()),
12205            search_bar: Arc::new(SearchBar::new()),
12206            pending_container: Arc::new(Container::new()),
12207            dequeue_hint: String::new(),
12208            pending_snapshot: std::sync::Mutex::new(
12209                rpi_harness::agent_harness::QueuedMessages::default(),
12210            ),
12211            selection_start: std::sync::Mutex::new(None),
12212            selection_end: std::sync::Mutex::new(None),
12213        });
12214        {
12215            let mut combined = CombinedAutocompleteProvider::new();
12216            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12217                build_builtin_registry().visible_entries(),
12218            )));
12219            state.autocomplete.set_provider(Arc::new(combined));
12220        }
12221        let editor = Arc::new(Editor::simple());
12222        editor.set_text("/he");
12223        editor.set_cursor(0, 3);
12224        refresh_autocomplete(&state, &editor);
12225        let accepted = accept_top_suggestion(&state, &editor);
12226        assert!(accepted, "should accept the top suggestion");
12227        let text = editor.get_text();
12228        assert!(
12229            text.starts_with("/help"),
12230            "editor text should start with /help, got {text}"
12231        );
12232    }
12233
12234    #[test]
12235    fn configured_key_parser_supports_native_notation() {
12236        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
12237        assert_eq!(combo.code, KeyCode::Char('g'));
12238        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
12239        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
12240        assert_eq!(combo.code, KeyCode::BackTab);
12241    }
12242
12243    #[test]
12244    fn double_escape_trigger_has_half_second_window() {
12245        let now = std::time::Instant::now();
12246        assert!(!double_escape_trigger(None, now));
12247        assert!(double_escape_trigger(
12248            Some(now - std::time::Duration::from_millis(500)),
12249            now
12250        ));
12251        assert!(!double_escape_trigger(
12252            Some(now - std::time::Duration::from_millis(501)),
12253            now
12254        ));
12255    }
12256
12257    #[test]
12258    fn pasted_multi_line_block_becomes_one_prompt_not_many() {
12259        // End-to-end shape of the fix: replay the key stream a Windows paste
12260        // produces ("l1\nl2\nl3" + a trailing Enter) through the same pieces
12261        // the key loop uses — the burst discriminator for each Enter and the
12262        // editor for the text — and assert that *no* submit fired while the
12263        // block arrived, then exactly one submit on the user's own Enter.
12264        use std::sync::atomic::{AtomicUsize, Ordering};
12265
12266        let editor = rpi_tui::Editor::simple();
12267        let submits = Arc::new(AtomicUsize::new(0));
12268        let submits_for_cb = submits.clone();
12269        editor.on_submit(Arc::new(move |_text: &str| {
12270            submits_for_cb.fetch_add(1, Ordering::SeqCst);
12271        }));
12272
12273        // Every pasted key is delivered back-to-back, so `now` is unchanged and
12274        // the console queue is never empty until the paste is drained.
12275        let now = std::time::Instant::now();
12276        let mut last_text_key_at: Option<std::time::Instant> = None;
12277        let block = ["l1", "l2", "l3"];
12278        for (index, line) in block.iter().enumerate() {
12279            for ch in line.chars() {
12280                editor.insert(&ch.to_string());
12281                last_text_key_at = Some(now);
12282            }
12283            if index + 1 < block.len() {
12284                // The pasted newline: more input is still queued behind it.
12285                let more_queued = true;
12286                assert!(
12287                    enter_is_paste_burst(last_text_key_at, now, more_queued),
12288                    "pasted newline {index} must not submit"
12289                );
12290                editor.insert("\n");
12291                last_text_key_at = Some(now);
12292            }
12293        }
12294        assert_eq!(editor.get_text(), "l1\nl2\nl3");
12295        assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
12296
12297        // The user's own Enter: a real gap, nothing queued.
12298        let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
12299        assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
12300        editor.handle_key(crossterm::event::KeyEvent::new(
12301            KeyCode::Enter,
12302            KeyModifiers::NONE,
12303        ));
12304        assert_eq!(
12305            submits.load(Ordering::SeqCst),
12306            1,
12307            "typed Enter submits once"
12308        );
12309    }
12310
12311    #[test]
12312    fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
12313        // A Windows paste delivers CRLF; the editor must fold it to LF so the
12314        // rendered block is clean and the cursor arithmetic stays sound.
12315        let editor = rpi_tui::Editor::simple();
12316        editor.insert("alpha\r\nbeta\r\n");
12317        assert_eq!(editor.get_text(), "alpha\nbeta\n");
12318        assert!(!editor.get_text().contains('\r'));
12319    }
12320
12321    #[test]
12322    fn pasted_enter_is_not_a_submit() {
12323        let now = std::time::Instant::now();
12324
12325        // Queued input behind the Enter => paste, regardless of history.
12326        assert!(enter_is_paste_burst(None, now, true));
12327
12328        // Enter immediately after pasted characters => paste (also covers a
12329        // paste whose final line ends with a newline, where nothing is queued).
12330        assert!(enter_is_paste_burst(
12331            Some(now - std::time::Duration::from_millis(1)),
12332            now,
12333            false
12334        ));
12335        assert!(enter_is_paste_burst(
12336            Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
12337            now,
12338            false
12339        ));
12340
12341        // A typed Enter: nothing queued and a real gap since the last key.
12342        assert!(!enter_is_paste_burst(None, now, false));
12343        assert!(!enter_is_paste_burst(
12344            Some(now - PASTE_BURST_GAP),
12345            now,
12346            false
12347        ));
12348        assert!(!enter_is_paste_burst(
12349            Some(now - std::time::Duration::from_millis(120)),
12350            now,
12351            false
12352        ));
12353    }
12354
12355    #[test]
12356    fn paste_probe_ignores_the_enter_keys_own_release() {
12357        use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
12358        let release =
12359            KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
12360
12361        // Windows queues press+release together over RDP: the release behind a
12362        // lone Enter must NOT count as queued paste content.
12363        let mut queued = vec![Event::Key(release)].into_iter();
12364        let (more, stashed) = probe_paste_input(None, || queued.next());
12365        assert!(!more, "a key release alone is not queued paste input");
12366        assert!(stashed.is_none());
12367
12368        // A real follow-up key (the next pasted line) IS queued input and is
12369        // stashed for the caller instead of being lost.
12370        let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
12371        let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
12372        let (more, stashed) = probe_paste_input(None, || queued.next());
12373        assert!(more, "a queued key press is paste continuation");
12374        assert!(matches!(stashed, Some(Event::Key(_))));
12375
12376        // Nothing queued at all.
12377        let (more, stashed) = probe_paste_input(None, || None);
12378        assert!(!more);
12379        assert!(stashed.is_none());
12380
12381        // An already-stashed event counts as queued input, without reading more.
12382        let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
12383            panic!("must not read when an event is already stashed")
12384        });
12385        assert!(more);
12386        assert!(matches!(stashed, Some(Event::Key(_))));
12387    }
12388
12389    // ---- ask_user TUI bridge ----
12390
12391    #[test]
12392    fn ask_user_tool_names_are_recognized() {
12393        assert!(is_ask_user_tool("ask_user"));
12394        assert!(is_ask_user_tool("ask_user_question"));
12395        assert!(!is_ask_user_tool("bash"));
12396    }
12397
12398    #[test]
12399    fn ask_user_args_display_never_leaks_transport_json() {
12400        let text = ask_user_args_display(&serde_json::json!({
12401            "question": "你的代理端口是多少?",
12402            "context": "本机",
12403            "options": ["7890", {"title": "7897", "description": "clash"}],
12404            "suggest": "1080"
12405        }));
12406        assert!(text.contains("你的代理端口是多少?"));
12407        assert!(text.contains("本机"));
12408        assert!(text.contains("Choices: 7890, 7897"));
12409        assert!(text.contains("Suggestions: 1080"));
12410        assert!(!text.trim_start().starts_with('{'));
12411
12412        let multi = ask_user_args_display(&serde_json::json!({
12413            "questions": [
12414                {"id": "a", "question": "Q1"},
12415                {"id": "b", "header": "H", "question": "Q2"}
12416            ]
12417        }));
12418        assert!(multi.contains("Q1"));
12419        assert!(multi.contains("H: Q2"));
12420
12421        assert_eq!(
12422            ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
12423            "Confirmation requested: Deploy?"
12424        );
12425        assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
12426    }
12427
12428    #[test]
12429    fn ask_user_result_and_progress_prefer_readable_content() {
12430        let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
12431        assert!(
12432            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
12433                .contains("端口?")
12434        );
12435        let empty = rpi_agent::AgentToolResult::default();
12436        assert!(
12437            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
12438                .contains("端口?")
12439        );
12440        let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
12441        assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
12442        assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
12443    }
12444
12445    #[test]
12446    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
12447        let flat = rpi_extensions::UiDialogRequest {
12448            request_id: "r1".into(),
12449            tool_call_id: Some("c1".into()),
12450            ui: serde_json::json!({
12451                "kind": "selector",
12452                "id": "proxy_port",
12453                "header": "Proxy",
12454                "question": "端口?",
12455                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
12456                "allowMultiple": true,
12457                "allowFreeform": false,
12458                "suggest": "1080"
12459            }),
12460            raw: serde_json::json!({}),
12461        };
12462        let prompt = parse_ask_user_prompt(&flat);
12463        assert_eq!(prompt.question_id, "proxy_port");
12464        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
12465        assert_eq!(prompt.options.len(), 2);
12466        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
12467        assert!(prompt.allow_multiple);
12468        assert!(!prompt.allow_freeform);
12469        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
12470
12471        let alias = rpi_extensions::UiDialogRequest {
12472            ui: serde_json::json!({
12473                "questions": [{"id": "q", "question": "Q?", "options": []}]
12474            }),
12475            ..flat.clone()
12476        };
12477        let prompt = parse_ask_user_prompt(&alias);
12478        assert_eq!(prompt.question_id, "q");
12479        assert!(prompt.allow_freeform, "no options defaults to freeform");
12480
12481        let confirm = rpi_extensions::UiDialogRequest {
12482            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
12483            ..flat.clone()
12484        };
12485        let prompt = parse_ask_user_prompt(&confirm);
12486        assert_eq!(prompt.kind, "confirm");
12487        assert_eq!(prompt.options.len(), 2);
12488        assert_eq!(prompt.options[0].0, "Yes");
12489    }
12490
12491    #[test]
12492    fn ask_user_bridge_answers_and_cancels_by_request_id() {
12493        let mailbox = rpi_extensions::UiDialogMailbox::new();
12494        mailbox.attach();
12495        let bridge = AskUserBridge::new(mailbox.clone());
12496
12497        // Two concurrent requests must not cross answers.
12498        mailbox
12499            .handle(serde_json::json!({
12500                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
12501                "ui": {"kind": "input", "question": "Port?"}
12502            }))
12503            .unwrap();
12504        mailbox
12505            .handle(serde_json::json!({
12506                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
12507                "ui": {"kind": "input", "question": "Level?"}
12508            }))
12509            .unwrap();
12510
12511        let first = bridge.take_pending().expect("r1 visible");
12512        assert_eq!(first.request_id, "r1");
12513        assert!(
12514            bridge.take_pending().is_none(),
12515            "only one request occupies the input slot"
12516        );
12517        bridge.respond("r1", serde_json::json!({"text": "7897"}));
12518        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
12519
12520        let second = bridge.take_pending().expect("r2 visible");
12521        assert_eq!(second.request_id, "r2");
12522        bridge.cancel("r2");
12523        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
12524
12525        // cancel_all drops anything still queued.
12526        mailbox
12527            .handle(serde_json::json!({
12528                "op": "open", "requestId": "r3",
12529                "ui": {"kind": "input", "question": "X?"}
12530            }))
12531            .unwrap();
12532        bridge.cancel_all();
12533        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
12534
12535        // A detached mailbox rejects new prompts (headless contract).
12536        bridge.shutdown();
12537        assert!(mailbox
12538            .handle(serde_json::json!({
12539                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
12540            }))
12541            .is_err());
12542    }
12543}