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, 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    /// Restore all queued steering/follow-up messages into the editor (the
3054    /// `app.message.dequeue` action — native pi's "edit all queued messages").
3055    Dequeue,
3056    /// A user-initiated `!command` / `!!command` shell run finished. Routed to
3057    /// the main loop so the `bashExecution` transcript record is persisted in
3058    /// order relative to agent runs (native pi's `recordBashResult` /
3059    /// `_pendingBashMessages`).
3060    UserBashFinished(UserBashReport),
3061}
3062
3063/// Terminal result of a user-initiated `!command` shell run.
3064///
3065/// Carries everything needed to build the persisted `bashExecution` message
3066/// plus the `!!` context-exclusion flag. See `start_user_bash`.
3067struct UserBashReport {
3068    command: String,
3069    output: String,
3070    exit_code: Option<i32>,
3071    cancelled: bool,
3072    truncated: bool,
3073    full_output_path: Option<String>,
3074    exclude_from_context: bool,
3075}
3076
3077/// Extract the concatenated text content from an assistant message (mirrors
3078/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
3079fn assistant_text(msg: &AssistantMessage) -> String {
3080    msg.content
3081        .iter()
3082        .filter_map(|c| match c {
3083            Content::Text(t) => Some(t.text.clone()),
3084            _ => None,
3085        })
3086        .collect()
3087}
3088
3089/// The user message's text (Text content or the text blocks of a Blocks
3090/// payload — images are skipped, consistent with the v1 text-only prompt path).
3091fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
3092    msg.content
3093        .iter()
3094        .filter_map(|c| match c {
3095            Content::Text(t) => Some(t.text.clone()),
3096            _ => None,
3097        })
3098        .collect::<Vec<_>>()
3099        .join("\n")
3100}
3101
3102fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
3103    match &msg.content {
3104        rpi_ai::types::UserContent::Text(s) => s.clone(),
3105        rpi_ai::types::UserContent::Blocks(blocks) => blocks
3106            .iter()
3107            .filter_map(|c| match c {
3108                Content::Text(t) => Some(t.text.clone()),
3109                _ => None,
3110            })
3111            .collect(),
3112    }
3113}
3114
3115/// The catalog allowed in the Ctrl+M cycle: the `/scoped-models` set from
3116/// settings.json when present, otherwise every model. The current model is
3117/// always included (fallback) so cycling can never strand the user off-scope.
3118fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
3119    let scoped = crate::settings::load_settings()
3120        .ok()
3121        .and_then(|s| s.scoped_models)
3122        .unwrap_or_default();
3123    if scoped.is_empty() {
3124        return catalog.to_vec();
3125    }
3126    let mut out: Vec<rpi_ai::Model> = catalog
3127        .iter()
3128        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
3129        .cloned()
3130        .collect();
3131    // Never strand the user: if the current model isn't in scope, keep it.
3132    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
3133        if let Some(cur) = catalog
3134            .iter()
3135            .find(|m| m.id.eq_ignore_ascii_case(current_id))
3136        {
3137            out.push(cur.clone());
3138        }
3139    }
3140    out
3141}
3142
3143/// Interactive `/settings` menu.
3144///
3145/// A [`SettingsList`] of editable settings (native pi's `SettingsSelectorComponent`):
3146/// Enter/Space cycles a row's value in place, and the theme/model/thinking/scope
3147/// rows open a sub-selector that replaces the active selector (the
3148/// `active_selector` slot is single). Value changes apply immediately where the
3149/// running TUI can honor them and are always persisted to `settings.json`, so
3150/// rows marked "applies on restart" take effect next launch.
3151fn open_settings_selector(
3152    state: &Arc<TuiState>,
3153    editor_container: &Arc<Container>,
3154    editor: &Arc<Editor>,
3155    tui: &Arc<TuiAltScreen>,
3156    lane: &Arc<dyn AgentLane>,
3157    catalog: &[rpi_ai::Model],
3158    lane_model_id: &str,
3159    chat: &Arc<Container>,
3160    cwd: &std::path::Path,
3161    package_resources: &Arc<crate::packages::PackageResources>,
3162) {
3163    let settings = crate::settings::load_settings().unwrap_or_default();
3164    let items = settings_menu_items(&settings, state, lane_model_id);
3165    let list = Arc::new(SettingsList::new(items));
3166
3167    // ---- submenu rows ----
3168    // These reuse the existing sub-selectors, which already persist their own
3169    // choices. The settings menu is replaced by the sub-selector (single
3170    // `active_selector` slot); cancelling the sub-selector returns to the editor.
3171    let state_sel = state.clone();
3172    let ec_sel = editor_container.clone();
3173    let editor_sel = editor.clone();
3174    let tui_sel = tui.clone();
3175    let lane_sel = lane.clone();
3176    let catalog_sel = catalog.to_vec();
3177    let lane_model_sel = lane_model_id.to_string();
3178    let chat_sel = chat.clone();
3179    let cwd_sel = cwd.to_path_buf();
3180    let package_resources_sel = package_resources.clone();
3181    list.on_select(Arc::new(move |item| match item.key.as_str() {
3182        "theme" => open_settings_theme_selector(
3183            &state_sel,
3184            &ec_sel,
3185            &editor_sel,
3186            &tui_sel,
3187            &chat_sel,
3188            &cwd_sel,
3189            &package_resources_sel,
3190        ),
3191        "model" => open_settings_model_selector(
3192            &state_sel,
3193            &ec_sel,
3194            &editor_sel,
3195            &tui_sel,
3196            &lane_sel,
3197            &catalog_sel,
3198            &lane_model_sel,
3199            &chat_sel,
3200        ),
3201        "thinking" => open_settings_thinking_selector(
3202            &state_sel,
3203            &ec_sel,
3204            &editor_sel,
3205            &tui_sel,
3206            &lane_sel,
3207            &catalog_sel,
3208            &lane_model_sel,
3209            &chat_sel,
3210        ),
3211        "scoped-models" => open_scoped_models_selector(
3212            &state_sel,
3213            &ec_sel,
3214            &editor_sel,
3215            &tui_sel,
3216            &catalog_sel,
3217            &chat_sel,
3218        ),
3219        _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3220    }));
3221
3222    // ---- value rows ----
3223    // Apply live where possible, then persist so the choice survives a restart.
3224    let state_change = state.clone();
3225    let chat_change = chat.clone();
3226    let tui_change = tui.clone();
3227    list.on_change(Arc::new(move |key, value| {
3228        let mut settings = crate::settings::load_settings().unwrap_or_default();
3229        let applied = apply_setting_change(&state_change, key, value, &mut settings);
3230        let saved = crate::settings::save_settings(&settings);
3231        if let Some(note) = applied {
3232            let suffix = if saved.is_ok() { "" } else { " (not saved)" };
3233            add_note_message(&chat_change, &format!("{note}{suffix}"));
3234        }
3235        tui_change.request_render(false);
3236    }));
3237
3238    let state_cancel = state.clone();
3239    let ec_cancel = editor_container.clone();
3240    let editor_cancel = editor.clone();
3241    let tui_cancel = tui.clone();
3242    list.on_cancel(Arc::new(move || {
3243        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3244    }));
3245
3246    open_selector_with_view(
3247        state,
3248        editor_container,
3249        editor,
3250        tui,
3251        SelectorView::Settings(list.clone()),
3252        list,
3253        SelectorKind::Settings,
3254    );
3255}
3256
3257/// Build the `/settings` rows from the loaded settings plus the live TUI state.
3258///
3259/// Descriptions carry "(applies on restart)" for values resolved once at
3260/// startup, so the menu never implies an effect the running session cannot
3261/// apply.
3262fn settings_menu_items(
3263    settings: &crate::settings::Settings,
3264    state: &Arc<TuiState>,
3265    lane_model_id: &str,
3266) -> Vec<SettingItem> {
3267    let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
3268    let current_model = settings
3269        .default_model
3270        .clone()
3271        .unwrap_or_else(|| lane_model_id.to_string());
3272    let current_thinking = settings
3273        .default_thinking_level
3274        .clone()
3275        .unwrap_or_else(|| "(default)".to_string());
3276    let scoped_desc = match &settings.scoped_models {
3277        Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
3278        _ => "all models".to_string(),
3279    };
3280    let hide_thinking = state.hide_thinking();
3281    let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
3282    let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
3283    let http_idle = settings
3284        .http_idle_timeout_ms()
3285        .map(|ms| {
3286            if ms == 0 {
3287                "disabled".to_string()
3288            } else {
3289                format!("{}s", ms / 1000)
3290            }
3291        })
3292        .unwrap_or_else(|| "5m".to_string());
3293
3294    vec![
3295        SettingItem::new("theme", "Theme", &current_theme)
3296            .with_description("Color theme for the interface")
3297            .with_submenu(),
3298        SettingItem::new("model", "Default model", &current_model)
3299            .with_description("Saved default model for new sessions")
3300            .with_submenu(),
3301        SettingItem::new("thinking", "Default thinking", &current_thinking)
3302            .with_description("Saved default thinking level")
3303            .with_submenu(),
3304        SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
3305            .with_description("Models enabled for Ctrl+M cycling")
3306            .with_submenu(),
3307        SettingItem::new(
3308            "hide-thinking",
3309            "Hide thinking",
3310            bool_setting_value(hide_thinking),
3311        )
3312        .with_description("Hide thinking blocks in assistant responses")
3313        .with_values(&["true", "false"]),
3314        SettingItem::new(
3315            "show-images",
3316            "Show images",
3317            bool_setting_value(show_images),
3318        )
3319        .with_description("Render images inline in the terminal")
3320        .with_values(&["true", "false"]),
3321        SettingItem::new(
3322            "cache-miss-notices",
3323            "Cache miss notices",
3324            bool_setting_value(show_cache_miss),
3325        )
3326        .with_description("Transcript notices for prompt-cache costs")
3327        .with_values(&["true", "false"]),
3328        SettingItem::new(
3329            "quiet-startup",
3330            "Quiet startup",
3331            bool_setting_value(settings.quiet_startup.unwrap_or(false)),
3332        )
3333        .with_description("Disable the verbose startup listing (applies on restart)")
3334        .with_values(&["true", "false"]),
3335        SettingItem::new(
3336            "terminal-progress",
3337            "Terminal progress",
3338            bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
3339        )
3340        .with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
3341        .with_values(&["true", "false"]),
3342        SettingItem::new(
3343            "fullscreen-copy-on-select",
3344            "Fullscreen copy on select",
3345            bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
3346        )
3347        .with_description("Copy selected text automatically in fullscreen mode")
3348        .with_values(&["true", "false"]),
3349        SettingItem::new(
3350            "double-escape-action",
3351            "Double-escape action",
3352            settings
3353                .double_escape_action
3354                .clone()
3355                .unwrap_or_else(|| "tree".to_string())
3356                .as_str(),
3357        )
3358        .with_description("Esc Esc with an empty editor (applies on restart)")
3359        .with_values(&["tree", "fork", "none"]),
3360        SettingItem::new(
3361            "editor-padding",
3362            "Editor padding",
3363            settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
3364        )
3365        .with_description("Horizontal editor padding, 0-3 (applies on restart)")
3366        .with_values(&["0", "1", "2", "3"]),
3367        SettingItem::new(
3368            "autocomplete-max-items",
3369            "Autocomplete max items",
3370            settings
3371                .autocomplete_max_visible
3372                .unwrap_or(5)
3373                .to_string()
3374                .as_str(),
3375        )
3376        .with_description("Max autocomplete rows, 3-20 (applies on restart)")
3377        .with_values(&["3", "5", "7", "10", "15", "20"]),
3378        SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
3379            .with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
3380            .with_values(&["30s", "1m", "5m", "disabled"]),
3381    ]
3382}
3383
3384/// `"true"` / `"false"` for a settings row value.
3385fn bool_setting_value(value: bool) -> &'static str {
3386    if value {
3387        "true"
3388    } else {
3389        "false"
3390    }
3391}
3392
3393/// Apply one `/settings` value change to the running TUI and to `settings`.
3394///
3395/// Returns a note to show in the transcript when the change is user-visible;
3396/// `None` for changes that only take effect on the next launch.
3397fn apply_setting_change(
3398    state: &Arc<TuiState>,
3399    key: &str,
3400    value: &str,
3401    settings: &mut crate::settings::Settings,
3402) -> Option<String> {
3403    let as_bool = || value == "true";
3404    match key {
3405        // Live: hide/show thinking blocks in the transcript immediately.
3406        "hide-thinking" => {
3407            state.set_hide_thinking(as_bool());
3408            settings.hide_thinking_block = Some(as_bool());
3409            Some(format!(
3410                "Thinking blocks {}.",
3411                if as_bool() { "hidden" } else { "shown" }
3412            ))
3413        }
3414        // Live: the image flag is consulted when images would be rendered.
3415        "show-images" => {
3416            if let Ok(mut guard) = state.show_images.lock() {
3417                *guard = as_bool();
3418            }
3419            settings.show_images = Some(as_bool());
3420            Some(format!(
3421                "Inline images {}.",
3422                if as_bool() { "enabled" } else { "disabled" }
3423            ))
3424        }
3425        // Live: read from `state` once per cache-miss notice.
3426        "cache-miss-notices" => {
3427            if let Ok(mut guard) = state.cache_miss_notices.lock() {
3428                *guard = as_bool();
3429            }
3430            settings.show_cache_miss_notices = Some(as_bool());
3431            Some(format!(
3432                "Cache miss notices {}.",
3433                if as_bool() { "enabled" } else { "disabled" }
3434            ))
3435        }
3436        // Live: read from settings on each selection mouse-up.
3437        "fullscreen-copy-on-select" => {
3438            settings.fullscreen_copy_on_select = Some(as_bool());
3439            Some(format!(
3440                "Copy on select {}.",
3441                if as_bool() { "enabled" } else { "disabled" }
3442            ))
3443        }
3444        // Persisted only: resolved once at startup.
3445        "quiet-startup" => {
3446            settings.quiet_startup = Some(as_bool());
3447            None
3448        }
3449        "terminal-progress" => {
3450            settings.show_terminal_progress = Some(as_bool());
3451            None
3452        }
3453        "double-escape-action" => {
3454            settings.double_escape_action = Some(value.to_string());
3455            None
3456        }
3457        "editor-padding" => {
3458            settings.editor_padding_x = value.parse().ok();
3459            None
3460        }
3461        "autocomplete-max-items" => {
3462            settings.autocomplete_max_visible = value.parse().ok();
3463            None
3464        }
3465        "http-idle-timeout" => {
3466            settings.http_idle_timeout = Some(match value {
3467                "disabled" => serde_json::Value::String("disabled".to_string()),
3468                "30s" => serde_json::json!(30_000),
3469                "1m" => serde_json::json!(60_000),
3470                _ => serde_json::json!(300_000),
3471            });
3472            None
3473        }
3474        _ => None,
3475    }
3476}
3477
3478/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
3479fn open_settings_theme_selector(
3480    state: &Arc<TuiState>,
3481    editor_container: &Arc<Container>,
3482    editor: &Arc<Editor>,
3483    tui: &Arc<TuiAltScreen>,
3484    chat: &Arc<Container>,
3485    cwd: &std::path::Path,
3486    package_resources: &Arc<crate::packages::PackageResources>,
3487) {
3488    let mut items = vec![
3489        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3490        SelectItem::new("light", "Light").with_description("Light background"),
3491        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3492    ];
3493    if state.themes_enabled {
3494        for path in package_resources.theme_files() {
3495            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3496                items.push(SelectItem::new(name, name).with_description("Package theme"));
3497            }
3498        }
3499    }
3500    let list = Arc::new(SelectList::new(items, 10));
3501
3502    let state_sel = state.clone();
3503    let ec_sel = editor_container.clone();
3504    let editor_sel = editor.clone();
3505    let tui_sel = tui.clone();
3506    let chat_sel = chat.clone();
3507    let cwd_sel = cwd.to_path_buf();
3508    let package_resources_sel = package_resources.clone();
3509    list.on_select(Arc::new(move |item| {
3510        let preset = match item.value.as_str() {
3511            "light" => Some(ThemePreset::Light),
3512            "monochrome" => Some(ThemePreset::Monochrome),
3513            "dark" => Some(ThemePreset::Dark),
3514            name => {
3515                if state_sel.themes_enabled {
3516                    if let Ok(custom) = crate::packages::load_theme_with_resources(
3517                        &cwd_sel,
3518                        name,
3519                        &package_resources_sel,
3520                    ) {
3521                        rpi_tui::global_theme_manager().set(custom.clone());
3522                        state_sel.theme_manager.set(custom);
3523                    }
3524                }
3525                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3526                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3527                tui_sel.render_now(true);
3528                return;
3529            }
3530        };
3531        let Some(preset) = preset else { return };
3532        apply_theme_preset(preset);
3533        state_sel.theme_manager.apply_preset(preset);
3534        let mut settings = crate::settings::load_settings().unwrap_or_default();
3535        settings.theme = Some(item.value.clone());
3536        let saved = crate::settings::save_settings(&settings);
3537        add_note_message(
3538            &chat_sel,
3539            &format!(
3540                "Theme set to {} (saved{})",
3541                item.label,
3542                if saved.is_ok() { "" } else { ", not saved" },
3543            ),
3544        );
3545        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3546        tui_sel.render_now(true);
3547    }));
3548    let state_cancel = state.clone();
3549    let ec_cancel = editor_container.clone();
3550    let editor_cancel = editor.clone();
3551    let tui_cancel = tui.clone();
3552    list.on_cancel(Arc::new(move || {
3553        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3554    }));
3555
3556    open_selector(
3557        state,
3558        editor_container,
3559        editor,
3560        tui,
3561        list,
3562        SelectorKind::Settings,
3563    );
3564}
3565
3566/// Choose the default model AND persist it (`/settings` → Default model):
3567/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
3568/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
3569fn open_settings_model_selector(
3570    state: &Arc<TuiState>,
3571    editor_container: &Arc<Container>,
3572    editor: &Arc<Editor>,
3573    tui: &Arc<TuiAltScreen>,
3574    lane: &Arc<dyn AgentLane>,
3575    catalog: &[rpi_ai::Model],
3576    lane_model_id: &str,
3577    chat: &Arc<Container>,
3578) {
3579    let items = model_selector_items(catalog, lane_model_id);
3580    if items.is_empty() {
3581        add_note_message(chat, "No models in the catalog.");
3582        tui.request_render(false);
3583        return;
3584    }
3585    let list = Arc::new(SelectList::new(items, 10));
3586
3587    let catalog_arc = catalog.to_vec();
3588    let state_sel = state.clone();
3589    let ec_sel = editor_container.clone();
3590    let editor_sel = editor.clone();
3591    let tui_sel = tui.clone();
3592    let chat_sel = chat.clone();
3593    let lane_sel = lane.clone();
3594    list.on_select(Arc::new(move |item| {
3595        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3596            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3597            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3598            return;
3599        };
3600        state_sel.set_current_model(&model);
3601        let lane = lane_sel.clone();
3602        tokio::spawn(async move {
3603            let _ = lane.set_model(model).await;
3604        });
3605        let mut settings = crate::settings::load_settings().unwrap_or_default();
3606        settings.default_model = Some(item.value.clone());
3607        let saved = crate::settings::save_settings(&settings);
3608        add_note_message(
3609            &chat_sel,
3610            &format!(
3611                "Default model set to {} (saved{}",
3612                short_model_name(&item.value),
3613                if saved.is_ok() { ")" } else { ", not saved)" },
3614            ),
3615        );
3616        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3617    }));
3618    let state_cancel = state.clone();
3619    let ec_cancel = editor_container.clone();
3620    let editor_cancel = editor.clone();
3621    let tui_cancel = tui.clone();
3622    list.on_cancel(Arc::new(move || {
3623        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3624    }));
3625
3626    open_searchable_selector(
3627        state,
3628        editor_container,
3629        editor,
3630        tui,
3631        Some("Default model"),
3632        Some("Type to filter by name, provider, or id"),
3633        list,
3634        SelectorKind::Settings,
3635    );
3636}
3637
3638/// Convert the authenticated runtime catalog into selector rows. Keep the
3639/// model id as the value so `/model <id>` and the selection callback share one
3640/// lookup path, while making the provider visible for OpenAI-compatible
3641/// gateways where the same model id may exist at multiple endpoints.
3642fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3643    let mut seen = std::collections::HashSet::new();
3644    catalog
3645        .iter()
3646        .filter(|m| {
3647            seen.insert((
3648                m.api.clone(),
3649                m.provider.to_ascii_lowercase(),
3650                m.id.to_ascii_lowercase(),
3651            ))
3652        })
3653        .map(|m| {
3654            let label = if m.name.is_empty() {
3655                short_model_name(&m.id)
3656            } else {
3657                m.name.clone()
3658            };
3659            let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3660                && m.provider.eq_ignore_ascii_case("anthropic")
3661            {
3662                m.id.clone()
3663            } else {
3664                format!("{}/{}", m.provider, m.id)
3665            };
3666            let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3667                " (current)"
3668            } else {
3669                ""
3670            };
3671            // Native pi ranks provider-prefixed queries first, so the bare id
3672            // is not the leading token (`getModelSelectorSearchText`).
3673            let name = if m.name.is_empty() {
3674                String::new()
3675            } else {
3676                format!(" {}", m.name)
3677            };
3678            let search_text = format!(
3679                "{} {}/{} {} {}{}",
3680                m.provider, m.provider, m.id, m.provider, m.id, name
3681            );
3682            SelectItem::new(&m.id, &label)
3683                .with_description(&format!("{identity}{marker}"))
3684                .with_search_text(&search_text)
3685        })
3686        .collect()
3687}
3688
3689/// Resolve a selector input by either bare model id or the qualified
3690/// `provider/model` identity shown for gateway models. This keeps manual
3691/// `/model ...` input consistent with the rows rendered by the selector.
3692fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3693    let (provider, id) = input
3694        .split_once('/')
3695        .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3696        .map_or((None, input), |(provider, id)| (Some(provider), id));
3697    catalog
3698        .iter()
3699        .find(|model| {
3700            model.id.eq_ignore_ascii_case(id)
3701                && provider.map_or(true, |provider| {
3702                    model.provider.eq_ignore_ascii_case(provider)
3703                        || (provider.eq_ignore_ascii_case("anthropic")
3704                            && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3705                })
3706        })
3707        .cloned()
3708}
3709
3710/// Choose the default thinking level AND persist it (`/settings` → Default
3711/// thinking): applies live via `lane.set_thinking_level` and saves
3712/// `defaultThinkingLevel` to settings.json.
3713fn open_settings_thinking_selector(
3714    state: &Arc<TuiState>,
3715    editor_container: &Arc<Container>,
3716    editor: &Arc<Editor>,
3717    tui: &Arc<TuiAltScreen>,
3718    lane: &Arc<dyn AgentLane>,
3719    catalog: &[rpi_ai::Model],
3720    lane_model_id: &str,
3721    chat: &Arc<Container>,
3722) {
3723    let model = catalog
3724        .iter()
3725        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3726    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3727        .map(|m| m.supported_thinking_levels())
3728        .unwrap_or_else(|| {
3729            use rpi_ai::types::ThinkingLevel::*;
3730            vec![Off, Minimal, Low, Medium, High]
3731        });
3732    let mut items: Vec<SelectItem> = Vec::new();
3733    for lvl in &levels {
3734        let name = thinking_level_name(*lvl);
3735        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3736    }
3737    if items.is_empty() {
3738        add_note_message(chat, "This model has no supported thinking levels.");
3739        tui.request_render(false);
3740        return;
3741    }
3742    let list = Arc::new(SelectList::new(items, 10));
3743
3744    let state_sel = state.clone();
3745    let ec_sel = editor_container.clone();
3746    let editor_sel = editor.clone();
3747    let tui_sel = tui.clone();
3748    let chat_sel = chat.clone();
3749    let lane_sel = lane.clone();
3750    list.on_select(Arc::new(move |item| {
3751        let Some(level) = thinking_level_from_name(&item.value) else {
3752            add_note_message(
3753                &chat_sel,
3754                &format!("Unknown thinking level: {}.", item.label),
3755            );
3756            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3757            return;
3758        };
3759        let lane = lane_sel.clone();
3760        let footer_sel = state_sel.footer.clone();
3761        tokio::spawn(async move {
3762            let _ = lane.set_thinking_level(level).await;
3763        });
3764        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3765        let mut settings = crate::settings::load_settings().unwrap_or_default();
3766        settings.default_thinking_level = Some(item.value.clone());
3767        let saved = crate::settings::save_settings(&settings);
3768        add_note_message(
3769            &chat_sel,
3770            &format!(
3771                "Default thinking set to {} (saved{}",
3772                item.label,
3773                if saved.is_ok() { ")" } else { ", not saved)" },
3774            ),
3775        );
3776        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3777    }));
3778    let state_cancel = state.clone();
3779    let ec_cancel = editor_container.clone();
3780    let editor_cancel = editor.clone();
3781    let tui_cancel = tui.clone();
3782    list.on_cancel(Arc::new(move || {
3783        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3784    }));
3785
3786    open_selector(
3787        state,
3788        editor_container,
3789        editor,
3790        tui,
3791        list,
3792        SelectorKind::Settings,
3793    );
3794}
3795
3796/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
3797/// item toggles it in the in-progress set (the selector stays open); Esc saves
3798/// the set to settings.json and closes. The active scoped set is echoed after
3799/// each toggle so the user sees the current selection.
3800fn open_scoped_models_selector(
3801    state: &Arc<TuiState>,
3802    editor_container: &Arc<Container>,
3803    editor: &Arc<Editor>,
3804    tui: &Arc<TuiAltScreen>,
3805    catalog: &[rpi_ai::Model],
3806    chat: &Arc<Container>,
3807) {
3808    if catalog.is_empty() {
3809        add_note_message(chat, "No models in the catalog.");
3810        tui.request_render(false);
3811        return;
3812    }
3813    // Seed the edit set from the saved scoped models.
3814    let seed: Vec<String> = crate::settings::load_settings()
3815        .ok()
3816        .and_then(|s| s.scoped_models)
3817        .unwrap_or_default();
3818    *state.scoped_edit.lock().unwrap() = Some(seed);
3819
3820    let mut items: Vec<SelectItem> = Vec::new();
3821    for m in catalog {
3822        items.push(SelectItem::new(&m.id, &m.id));
3823    }
3824    let list = Arc::new(SelectList::new(items, 10));
3825
3826    let state_sel = state.clone();
3827    let chat_sel = chat.clone();
3828    let tui_sel = tui.clone();
3829    list.on_select(Arc::new(move |item| {
3830        // Toggle the model in the in-progress set; the selector stays open.
3831        let mut set = state_sel.scoped_edit.lock().unwrap();
3832        let set = set.get_or_insert_with(Vec::new);
3833        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3834            set.remove(pos);
3835            add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3836        } else {
3837            set.push(item.value.clone());
3838            add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3839        }
3840        tui_sel.request_render(false);
3841    }));
3842    let state_cancel = state.clone();
3843    let ec_cancel = editor_container.clone();
3844    let editor_cancel = editor.clone();
3845    let tui_cancel = tui.clone();
3846    let chat_cancel = chat.clone();
3847    list.on_cancel(Arc::new(move || {
3848        // Save the edited set to settings.json and close.
3849        let set = state_cancel
3850            .scoped_edit
3851            .lock()
3852            .unwrap()
3853            .take()
3854            .unwrap_or_default();
3855        let mut settings = crate::settings::load_settings().unwrap_or_default();
3856        settings.scoped_models = if set.is_empty() {
3857            None
3858        } else {
3859            Some(set.clone())
3860        };
3861        match crate::settings::save_settings(&settings) {
3862            Ok(()) => {
3863                if set.is_empty() {
3864                    add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3865                } else {
3866                    add_note_message(
3867                        &chat_cancel,
3868                        &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3869                    );
3870                }
3871            }
3872            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3873        }
3874        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3875    }));
3876
3877    open_selector(
3878        state,
3879        editor_container,
3880        editor,
3881        tui,
3882        list,
3883        SelectorKind::ScopedModels,
3884    );
3885}
3886
3887/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
3888/// PATH, create a gist of the exported markdown; otherwise fall back to the
3889/// clipboard (best-effort) and note the local path.
3890async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3891    use std::process::Stdio;
3892
3893    // Reuse the export builder for the transcript text.
3894    let tree = harness.session().view("main");
3895    let entries = match tree
3896        .find_entries(&EntryQuery {
3897            entry_type: None,
3898            custom_type: None,
3899            // Exports append entries top-to-bottom, so use chronological order
3900            // instead of the session query default (newest-first).
3901            order: Some(EntryOrder::OldestFirst),
3902            limit: None,
3903            cursor: None,
3904        })
3905        .await
3906    {
3907        Ok(e) => e,
3908        Err(e) => {
3909            add_error_message(chat, &format!("Could not read session: {e}"));
3910            return;
3911        }
3912    };
3913    let mut md = String::from("# Session\n\n");
3914    for e in entries {
3915        let Entry::Message(me) = e else { continue };
3916        match &me.message {
3917            AgentMessage::User(u) => {
3918                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3919            }
3920            AgentMessage::Assistant(a) => {
3921                let text = assistant_text(a);
3922                if !text.is_empty() {
3923                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3924                }
3925            }
3926            _ => {}
3927        }
3928    }
3929
3930    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
3931    let gh = std::process::Command::new("gh")
3932        .arg("gist")
3933        .arg("create")
3934        .arg("--filename")
3935        .arg("session.md")
3936        .arg("-")
3937        .stdin(Stdio::piped())
3938        .stdout(Stdio::piped())
3939        .stderr(Stdio::null())
3940        .spawn();
3941    if let Ok(mut child) = gh {
3942        use std::io::Write;
3943        if let Some(mut stdin) = child.stdin.take() {
3944            let _ = stdin.write_all(md.as_bytes());
3945            let _ = stdin.flush();
3946        }
3947        let out = child.wait_with_output().ok();
3948        if let Some(out) = out {
3949            if out.status.success() {
3950                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3951                add_note_message(chat, &format!("Shared session: {url}"));
3952                return;
3953            }
3954        }
3955        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3956    } else {
3957        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3958    }
3959    // Clipboard fallback (or transcript echo when the clipboard feature is off).
3960    if copy_to_clipboard(&md) {
3961        add_note_message(chat, "Session transcript copied to the clipboard.");
3962    } else {
3963        add_note_message(
3964            chat,
3965            "Clipboard unavailable — use /export to write the transcript to a file.",
3966        );
3967    }
3968}
3969
3970// Session export is driven through the product-layer `AgentSession`
3971// (`crate::agent_session`), which owns format selection + default filenames —
3972// the TUI loop calls `AgentSession::export*` directly, so there is no separate
3973// helper here.
3974
3975/// Fork the current session into a new JSONL session and switch to it (TS
3976/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
3977/// session the user continues in). Uses the repo's `fork_typed`, then swaps
3978/// the harness backing and renders the (empty-ish) fork transcript.
3979/// Hot-switch the harness to another saved session: abort any in-flight run,
3980/// open the target session file, swap the durable backing, and re-render the
3981/// transcript from the new history (mirrors pi's `/session` resume-in-place).
3982/// Shared by the `/session` selector, `/import`, and `/fork`. The current
3983/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
3984async fn switch_to_session(
3985    harness: &AgentHarness,
3986    lane: &Arc<dyn AgentLane>,
3987    id: &str,
3988    cwd: &std::path::Path,
3989    chat: &Arc<Container>,
3990    state: &Arc<TuiState>,
3991) -> bool {
3992    if *state.status.lock().unwrap() == RunStatus::Working {
3993        state.set_status(RunStatus::Aborting);
3994        let _ = lane.abort().await;
3995    }
3996    let cwd_str = cwd.to_string_lossy().to_string();
3997    match crate::session::open_session_by_id(id, &cwd_str).await {
3998        Ok(new_session) => {
3999            let _ = harness.set_session(new_session).await;
4000            chat.clear();
4001            add_welcome_message(chat);
4002            render_session_history(
4003                harness,
4004                chat,
4005                state.markdown_transformer(),
4006                Some(state.extension_session.clone()),
4007            )
4008            .await;
4009            state.set_status(RunStatus::Idle);
4010            add_note_message(chat, &format!("Switched to session {id}."));
4011            true
4012        }
4013        Err(e) => {
4014            state.set_status(RunStatus::Idle);
4015            add_error_message(chat, &format!("Could not open session {id}: {e}"));
4016            false
4017        }
4018    }
4019}
4020
4021/// `/import <path>`: copy a JSONL session file into the default session dir,
4022/// then hot-switch to it (the file name becomes its id — matching the
4023/// selector/`open_session_by_id` containment rules).
4024async fn import_session(
4025    harness: &AgentHarness,
4026    lane: &Arc<dyn AgentLane>,
4027    path: &str,
4028    cwd: &std::path::Path,
4029    chat: &Arc<Container>,
4030    state: &Arc<TuiState>,
4031) {
4032    use std::path::Path as FsPath;
4033
4034    let src = FsPath::new(path);
4035    if !src.is_file() {
4036        add_error_message(chat, &format!("Import source not found: {path}"));
4037        return;
4038    }
4039    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
4040        add_error_message(chat, "Import source has no file name.");
4041        return;
4042    };
4043    if !fname.ends_with(".jsonl") {
4044        add_error_message(chat, "Import source must be a .jsonl session file.");
4045        return;
4046    }
4047    let dir = crate::session::default_session_dir(cwd);
4048    if let Err(e) = std::fs::create_dir_all(&dir) {
4049        add_error_message(chat, &format!("Could not create session dir: {e}"));
4050        return;
4051    }
4052    let dest = dir.join(fname);
4053    match std::fs::copy(src, &dest) {
4054        Ok(_) => {
4055            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
4056            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
4057                add_note_message(chat, &format!("Imported session from {path}"));
4058            }
4059        }
4060        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
4061    }
4062}
4063
4064async fn fork_session(
4065    harness: &AgentHarness,
4066    cwd: &std::path::Path,
4067    chat: &Arc<Container>,
4068    state: &Arc<TuiState>,
4069) {
4070    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
4071    use rpi_tools::FileSystem;
4072
4073    let cwd_str = cwd.to_string_lossy().to_string();
4074    let dir = crate::session::default_session_dir(cwd);
4075    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
4076    let fs: Arc<dyn FileSystem> = env.clone();
4077    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
4078        fs,
4079        sessions_root: dir.to_string_lossy().into_owned(),
4080        clock: Arc::new(rpi_harness::session::memory::SystemClock),
4081        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
4082    });
4083    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
4084    // it from the session list by the current session's id.
4085    let id = harness.session().storage().metadata().id.clone();
4086    let metas = match crate::session::list_session_metadata(&cwd_str).await {
4087        Ok(m) => m,
4088        Err(e) => {
4089            add_error_message(chat, &format!("Could not list sessions: {e}"));
4090            return;
4091        }
4092    };
4093    let Some(source) = metas.iter().find(|m| m.id == id) else {
4094        add_error_message(chat, &format!("Current session {id} not found on disk."));
4095        return;
4096    };
4097    let fork_storage = match repo
4098        .fork_typed(
4099            source,
4100            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
4101                id: None,
4102                parent_session_id: Some(source.id.clone()),
4103                cwd: cwd_str.clone(),
4104                metadata: None,
4105            },
4106            &rpi_harness::session::types::ForkOptions::default(),
4107        )
4108        .await
4109    {
4110        Ok(s) => s,
4111        Err(e) => {
4112            add_error_message(chat, &format!("Could not fork session: {e}"));
4113            return;
4114        }
4115    };
4116    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
4117    let _ = harness.set_session(new_session).await;
4118    chat.clear();
4119    add_welcome_message(chat);
4120    render_session_history(
4121        harness,
4122        chat,
4123        state.markdown_transformer(),
4124        Some(state.extension_session.clone()),
4125    )
4126    .await;
4127    state.set_status(RunStatus::Idle);
4128    add_note_message(chat, "Forked into a new session.");
4129}
4130
4131/// Render the restored session's prior transcript (user + assistant messages)
4132/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
4133/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
4134/// any session read failure just starts with an empty transcript.
4135///
4136/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
4137/// the identity path. Each restored assistant component installs it so replayed
4138/// history renders through the same `register_markdown_transformer` handlers
4139/// the live stream does.
4140async fn render_session_history(
4141    harness: &AgentHarness,
4142    chat: &Arc<Container>,
4143    transformer: Option<MarkdownTransformer>,
4144    extension_session: Option<crate::session::ExtensionSessionCell>,
4145) {
4146    let tree = harness.session().view("main");
4147    let entries = match tree
4148        .find_entries(&EntryQuery {
4149            entry_type: None,
4150            custom_type: None,
4151            // Session queries default to newest-first for selectors and
4152            // pagination. The transcript appends children top-to-bottom, so
4153            // restored history must explicitly be chronological.
4154            order: Some(EntryOrder::OldestFirst),
4155            limit: None,
4156            cursor: None,
4157        })
4158        .await
4159    {
4160        Ok(e) => e,
4161        Err(_) => return,
4162    };
4163    let mut rendered_any = false;
4164    for e in entries {
4165        match e {
4166            Entry::Message(me) => match &me.message {
4167                AgentMessage::User(u) => {
4168                    add_user_message(chat, &user_message_text(u));
4169                    rendered_any = true;
4170                }
4171                AgentMessage::Assistant(a) => {
4172                    let comp = Arc::new(AssistantMessageComponent::new(
4173                        AssistantMessageOptions::default(),
4174                    ));
4175                    if let Some(t) = &transformer {
4176                        comp.set_markdown_transformer(Some(t.clone()));
4177                    }
4178                    comp.update_blocks(&assistant_blocks(a));
4179                    chat.add_child(comp);
4180                    // Single trailing spacer: the next transcript entry (user or
4181                    // assistant) follows one blank line below.
4182                    chat.add_child(Arc::new(Spacer::new(1)));
4183                    if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
4184                        add_note_message(chat, &text);
4185                    }
4186                    if let Some(error) = assistant_error_text(a) {
4187                        add_error_message(chat, &error);
4188                    }
4189                    rendered_any = true;
4190                }
4191                AgentMessage::ToolResult(result) => {
4192                    // Tool results are persisted as separate message entries,
4193                    // not as part of the assistant text. Restore them as
4194                    // completed tool panels so resumed sessions show the
4195                    // command output as well as the user's prompts.
4196                    let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
4197                    comp.set_result(&tool_result_message_text(result), result.is_error);
4198                    chat.add_child(comp);
4199                    chat.add_child(Arc::new(Spacer::new(1)));
4200                    rendered_any = true;
4201                }
4202                AgentMessage::Custom(custom) => {
4203                    if let Some(session) = &extension_session {
4204                        if let Some(component) = extension_message_component(
4205                            session,
4206                            &custom.role,
4207                            &serde_json::json!({
4208                                "customType": custom.role,
4209                                "content": custom.content,
4210                                "details": custom.data,
4211                            }),
4212                            transformer.clone(),
4213                        ) {
4214                            chat.add_child(component);
4215                            chat.add_child(Arc::new(Spacer::new(1)));
4216                            rendered_any = true;
4217                            continue;
4218                        }
4219                    }
4220                    add_note_message(chat, &custom_message_fallback(&custom));
4221                    rendered_any = true;
4222                }
4223            },
4224            Entry::Compaction(compaction) => {
4225                add_note_message(
4226                    chat,
4227                    &format!(
4228                        "Compacted {} tokens: {}",
4229                        compaction.tokens_before, compaction.summary
4230                    ),
4231                );
4232                rendered_any = true;
4233            }
4234            Entry::BranchSummary(summary) => {
4235                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
4236                rendered_any = true;
4237            }
4238            Entry::Custom(custom) => {
4239                let rendered = extension_session.as_ref().and_then(|session| {
4240                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
4241                });
4242                if let Some(component) = rendered {
4243                    chat.add_child(component);
4244                    chat.add_child(Arc::new(Spacer::new(1)));
4245                    rendered_any = true;
4246                } else if let Some(text) =
4247                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
4248                {
4249                    add_note_message(chat, &text);
4250                    rendered_any = true;
4251                }
4252            }
4253            Entry::ModelChange(change) => {
4254                add_note_message(
4255                    chat,
4256                    &format!("Model changed to {}:{}", change.provider, change.model_id),
4257                );
4258                rendered_any = true;
4259            }
4260            Entry::ThinkingLevel(change) => {
4261                add_note_message(
4262                    chat,
4263                    &format!("Thinking level: {:?}", change.thinking_level),
4264                );
4265                rendered_any = true;
4266            }
4267            Entry::ActiveTools(change) => {
4268                add_note_message(
4269                    chat,
4270                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
4271                );
4272                rendered_any = true;
4273            }
4274        }
4275    }
4276    if rendered_any {
4277        // No trailing spacer here — each entry already adds its own trailing
4278        // Spacer(1), so an extra would double the bottom gap.
4279    }
4280}
4281
4282fn invoke_extension_renderer(
4283    session: &crate::session::ExtensionSessionCell,
4284    kind: rpi_extensions::RegisteredRendererKind,
4285    payload: &serde_json::Value,
4286) -> Option<serde_json::Value> {
4287    let snapshot = session.lock().ok()?.snapshot_arc()?;
4288    let input = serde_json::to_string(payload).ok()?;
4289    for renderer in snapshot.renderers_of(kind) {
4290        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4291            let mut out = rpi_plugin_sdk::StbString::empty();
4292            let rc = (renderer.render_fn)(
4293                rpi_plugin_sdk::StbStringRef::from_str(&input),
4294                &mut out as *mut rpi_plugin_sdk::StbString,
4295                renderer.user_data,
4296            );
4297            let text = if rc == 0 {
4298                Some(out.to_string_lossy())
4299            } else {
4300                None
4301            };
4302            out.free_with(Some(renderer.plugin_free_string));
4303            text
4304        }))
4305        .ok()
4306        .flatten();
4307        let Some(text) = outcome else { continue };
4308        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
4309            return Some(value);
4310        }
4311    }
4312    None
4313}
4314
4315fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
4316    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
4317        let text = lines
4318            .iter()
4319            .filter_map(|line| line.as_str())
4320            .collect::<Vec<_>>()
4321            .join("\n");
4322        return Some(Arc::new(Text::new(text, 0, 0)));
4323    }
4324    let text = value.get("text").and_then(|v| v.as_str())?;
4325    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4326        let component = Arc::new(AssistantMessageComponent::new(
4327            AssistantMessageOptions::default(),
4328        ));
4329        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4330        Some(component)
4331    } else {
4332        Some(Arc::new(Text::new(text, 0, 0)))
4333    }
4334}
4335
4336fn extension_message_component(
4337    session: &crate::session::ExtensionSessionCell,
4338    custom_type: &str,
4339    payload: &serde_json::Value,
4340    transformer: Option<MarkdownTransformer>,
4341) -> Option<Arc<dyn rpi_tui::Component>> {
4342    let value = invoke_extension_renderer(
4343        session,
4344        rpi_extensions::RegisteredRendererKind::Message,
4345        payload,
4346    )?;
4347    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4348        let text = value.get("text").and_then(|v| v.as_str())?;
4349        let component = Arc::new(AssistantMessageComponent::new(
4350            AssistantMessageOptions::default(),
4351        ));
4352        if let Some(transformer) = transformer {
4353            component.set_markdown_transformer(Some(transformer));
4354        }
4355        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4356        return Some(component);
4357    }
4358    extension_text_component(&value)
4359        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4360}
4361
4362/// Render usage from a completed assistant message through the registered
4363/// message renderers. Hosts without a token-usage renderer return `None`.
4364fn extension_usage_text(
4365    session: Option<&crate::session::ExtensionSessionCell>,
4366    usage: &rpi_ai::types::Usage,
4367) -> Option<String> {
4368    let session = session?;
4369    let payload = serde_json::json!({
4370        "customType": "token-usage",
4371        "usage": usage,
4372    });
4373    let value = invoke_extension_renderer(
4374        session,
4375        rpi_extensions::RegisteredRendererKind::Message,
4376        &payload,
4377    )?;
4378    value
4379        .get("text")
4380        .and_then(|value| value.as_str())
4381        .filter(|text| !text.trim().is_empty())
4382        .map(ToOwned::to_owned)
4383}
4384
4385fn extension_entry_component(
4386    session: &crate::session::ExtensionSessionCell,
4387    custom_type: &str,
4388    data: Option<serde_json::Value>,
4389) -> Option<Arc<dyn rpi_tui::Component>> {
4390    let payload = serde_json::json!({
4391        "customType": custom_type,
4392        "data": data,
4393    });
4394    let value = invoke_extension_renderer(
4395        session,
4396        rpi_extensions::RegisteredRendererKind::Entry,
4397        &payload,
4398    )?;
4399    extension_text_component(&value)
4400}
4401
4402/// Project an assistant message's content into the provider-free
4403/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
4404/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
4405/// are rendered by their own components in the transcript.
4406/// Whether startup intentionally opened a session that already has history.
4407fn launch_restores_history(args: &Args) -> bool {
4408    args.continue_session
4409        || args.resume
4410        || args.session.is_some()
4411        || args.session_id.is_some()
4412        || args.fork.is_some()
4413}
4414
4415fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4416    msg.content
4417        .iter()
4418        .filter_map(|c| match c {
4419            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4420            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4421            Content::Image(image) => base64::engine::general_purpose::STANDARD
4422                .decode(&image.data)
4423                .ok()
4424                .filter(|data| !data.is_empty())
4425                .map(AssistantBlock::Image),
4426            _ => None,
4427        })
4428        .collect()
4429}
4430
4431fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4432    let content = custom
4433        .content
4434        .iter()
4435        .filter_map(|item| match item {
4436            Content::Text(text) => Some(text.text.as_str()),
4437            _ => None,
4438        })
4439        .collect::<Vec<_>>()
4440        .join("\n");
4441    if content.is_empty() {
4442        format!("{}: {}", custom.role, custom.data)
4443    } else {
4444        format!("{}: {}", custom.role, content)
4445    }
4446}
4447
4448/// Path to the enclosing repository's `.git/HEAD`, if any.
4449fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
4450    let mut dir = Some(cwd);
4451    while let Some(current) = dir {
4452        let head = current.join(".git").join("HEAD");
4453        if head.exists() {
4454            return Some(head);
4455        }
4456        dir = current.parent();
4457    }
4458    None
4459}
4460
4461/// Current git branch for `cwd`, or `None` outside a repository / on error.
4462/// Reads `.git/HEAD` directly (no `git` subprocess) so the footer can call it
4463/// cheaply at startup.
4464fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
4465    let mut dir = Some(cwd);
4466    while let Some(current) = dir {
4467        let head = current.join(".git").join("HEAD");
4468        if let Ok(contents) = std::fs::read_to_string(&head) {
4469            let trimmed = contents.trim();
4470            if let Some(reference) = trimmed.strip_prefix("ref: ") {
4471                let branch = reference
4472                    .rsplit('/')
4473                    .next()
4474                    .unwrap_or(reference)
4475                    .trim()
4476                    .to_string();
4477                if !branch.is_empty() {
4478                    return Some(branch);
4479                }
4480            }
4481            if !trimmed.is_empty() {
4482                return Some(trimmed.chars().take(7).collect());
4483            }
4484        }
4485        dir = current.parent();
4486    }
4487    None
4488}
4489
4490/// The name displayed for a model id (last path segment / after the final
4491/// `:`), to keep the footer compact.
4492fn short_model_name(id: &str) -> String {
4493    id.rsplit([':', '/'])
4494        .next()
4495        .filter(|s| !s.is_empty())
4496        .unwrap_or(id)
4497        .to_string()
4498}
4499
4500// ===========================================================================
4501// Streaming run status
4502// ===========================================================================
4503
4504/// The live status of the agent run, fed to the footer + status slot.
4505#[derive(Clone, Copy, PartialEq, Eq)]
4506enum RunStatus {
4507    Idle,
4508    Working,
4509    Aborting,
4510}
4511
4512/// Which selector overlay (if any) is currently swapped into the editor slot.
4513#[derive(Clone, Copy, PartialEq, Eq)]
4514enum SelectorKind {
4515    /// `/model` — available models (live switch via `lane.set_model`).
4516    Model,
4517    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
4518    Thinking,
4519    /// `/tools` — toggle builtin tools on/off.
4520    Tools,
4521    /// `/images` — toggle inline image rendering.
4522    Images,
4523    /// `/session` — browse and switch saved JSONL sessions.
4524    Session,
4525    /// `/theme` — dark / light / monochrome presets applied live.
4526    Theme,
4527    /// `/scoped-models` — multi-toggle Ctrl+M cycle scope.
4528    ScopedModels,
4529    /// `/settings` — interactive settings menu (and its sub-selectors).
4530    Settings,
4531    /// `/tree` — navigate to an existing entry in the current session.
4532    Tree,
4533    /// Extension-provided selector; uses the same keyboard contract.
4534    Extension,
4535}
4536
4537/// The component receiving keys while a selector is open.
4538///
4539/// Plain selectors route straight to the [`SelectList`]; searchable ones route
4540/// through [`SearchableSelectList`] so typing filters instead of being ignored.
4541/// Both variants expose the underlying list for callback registration and
4542/// selection inspection.
4543#[derive(Clone)]
4544enum SelectorView {
4545    /// A bare `SelectList` (no search box).
4546    List(Arc<SelectList>),
4547    /// A `SelectList` wrapped with a fuzzy-filter input.
4548    Searchable(Arc<SearchableSelectList>),
4549    /// The `/settings` menu: value rows cycle in place, submenu rows open a
4550    /// nested selector.
4551    Settings(Arc<SettingsList>),
4552}
4553
4554impl SelectorView {
4555    /// Route a key to the appropriate child.
4556    fn handle_key(&self, key: KeyEvent) {
4557        match self {
4558            Self::List(list) => list.handle_key(key),
4559            Self::Searchable(searchable) => searchable.handle_key(key),
4560            Self::Settings(settings) => settings.handle_key(key),
4561        }
4562    }
4563}
4564
4565/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
4566/// and the render-tick task.
4567struct TuiState {
4568    /// The in-flight streaming assistant message (cleared on finalize).
4569    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4570    /// Tool-execution components keyed by `tool_call_id`.
4571    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4572    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
4573    /// generic tool map so bash output streams into a `BashExecutionComponent`
4574    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
4575    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4576    /// Whether package/custom themes may be selected in this session.
4577    themes_enabled: bool,
4578    /// Persisted display preference toggled by Ctrl+T.
4579    hide_thinking: std::sync::Mutex<bool>,
4580    /// Global tool-output expansion preference toggled by Ctrl+O.
4581    tool_outputs_expanded: std::sync::Mutex<bool>,
4582    /// Whether the native-style terminal progress indicator is enabled.
4583    show_terminal_progress: bool,
4584    /// Run status for the status indicator + interrupt routing.
4585    status: std::sync::Mutex<RunStatus>,
4586    /// Cancellation signal for the short phase that starts the persistent JS
4587    /// host and runs `before_agent_start`. The key thread can trigger this
4588    /// directly while the async message loop is awaiting the blocking worker.
4589    js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4590    /// Cancellation signal for a user-initiated `!command` / `!!command` shell
4591    /// run. `Some` while the command executes; Esc/Ctrl+C cancels it. `None`
4592    /// when no user bash is running.
4593    user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
4594    /// `bashExecution` messages produced by `!command` while an agent run was
4595    /// in flight. Flushed to the lane on `AgentEnd` so transcript order matches
4596    /// native pi's `_pendingBashMessages` (never spliced mid-turn).
4597    pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
4598    /// The footer, updated live by the drain task.
4599    footer: Arc<FooterComponent>,
4600    /// The status-container (status slot in the dock) — cleared/filled with a
4601    /// loader while a run is active.
4602    status_container: Arc<Container>,
4603    /// The chat transcript container.
4604    chat_container: Arc<Container>,
4605    /// The active loader shown while `Working`.
4606    loader: Arc<Loader>,
4607    /// The editor, so status changes can drive its top-border working
4608    /// indicator (the spinner + elapsed time shown inside the input box border).
4609    editor: Arc<Editor>,
4610    /// The last finalized assistant text (for `/copy`). Updated by the drain
4611    /// task on `MessageEnd` / `AgentEnd`.
4612    last_assistant_text: std::sync::Mutex<String>,
4613    /// The active selector overlay, swapped into the editor slot. `Some` while
4614    /// a selector is open; the key loop routes to it first and restores the
4615    /// editor on done/cancel.
4616    active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
4617    /// Extension-provided editor currently occupying the input slot.
4618    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4619    /// Single-line input currently occupying the input slot for an extension.
4620    active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4621    /// Callback used to resolve an extension dialog with a cancellation action.
4622    active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4623    /// The autocomplete manager (slash + @file providers) consulted on every
4624    /// editor keystroke.
4625    autocomplete: AutocompleteManager,
4626    /// The container rendered above the editor holding the live autocomplete
4627    /// suggestion list (cleared when there are no suggestions).
4628    autocomplete_container: Arc<Container>,
4629    /// Maximum number of autocomplete rows rendered above the editor.
4630    autocomplete_max_visible: usize,
4631    /// Images queued from clipboard paste and attached to the next prompt.
4632    pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4633    /// The owned theme manager — `/theme` applies presets here. The global
4634    /// `theme()` is read-only after OnceLock init, so per-instance state is the
4635    /// only way to apply a preset at runtime.
4636    theme_manager: Arc<ThemeManager>,
4637    /// The alt-screen handle, held so `set_status` can reflect run state in the
4638    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
4639    /// that never call `set_status` with a title.
4640    tui: Option<Arc<TuiAltScreen>>,
4641    /// The model id currently shown in the footer + used as the Ctrl+M
4642    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
4643    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
4644    current_model_id: std::sync::Mutex<String>,
4645    /// Whether inline image rendering is enabled (`/images` toggle). Stored
4646    /// even though image wiring is minimal this pass — the flag is consulted
4647    /// where images would be shown and echoed back by `/images`.
4648    show_images: std::sync::Mutex<bool>,
4649    /// Transcript notices for prompt-cache costs and provider recovery
4650    /// diagnostics (native pi `showCacheMissNotices`, default `false`).
4651    cache_miss_notices: std::sync::Mutex<bool>,
4652    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
4653    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
4654    history: std::sync::Mutex<Vec<String>>,
4655    /// Browse index while recalling history: -1 = not browsing, 0 = most
4656    /// recent, 1 = older, … Reset to -1 on every submit.
4657    history_index: std::sync::Mutex<isize>,
4658    /// The editor text captured when entering browse mode, restored when the
4659    /// user navigates back past the newest entry (TS `historyDraft`).
4660    history_draft: std::sync::Mutex<Option<String>>,
4661    /// Full cache-waste tracker (pi `detectCacheMiss`/`cache-stats.ts`):
4662    /// counts and prices prompt-cache misses across turns using the same
4663    /// noise floor, idle-gap, and model-change logic as the batch scan.
4664    cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
4665    /// The in-progress scoped-models selection while the `/scoped-models`
4666    /// selector is open (toggle per item, Esc saves). `None` when not editing.
4667    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4668    /// B5e: the live assistant-markdown transformer, built from the current
4669    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
4670    /// when no markdown transformers are registered (identity render path).
4671    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
4672    /// closure no-ops once its snapshot's `active` flag flips false) and
4673    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
4674    /// transform takes effect on the visible streaming message immediately.
4675    /// New assistant components pick up whatever closure is current at
4676    /// construction time via [`install_markdown_transformer`].
4677    markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4678    /// Live extension registry used by message/entry renderer dispatch.
4679    extension_session: crate::session::ExtensionSessionCell,
4680    /// Transcript search handler (Ctrl+Shift+F).
4681    search: Arc<AltScreenSearch>,
4682    /// Search bar component shown when search is active.
4683    search_bar: Arc<SearchBar>,
4684    /// Dock slot listing queued steering/follow-up messages while a run is
4685    /// active (native pi's `pendingMessagesContainer`). Empty when nothing is
4686    /// queued, so it renders zero rows and never affects the layout.
4687    pending_container: Arc<Container>,
4688    /// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`),
4689    /// shown in the pending-messages hint row.
4690    dequeue_hint: String,
4691    /// Last queue snapshot rendered into `pending_container`. Lets
4692    /// `set_pending_queue` skip redundant rebuilds + frame requests when the
4693    /// polled queue is unchanged.
4694    pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
4695    /// Fullscreen selection tracking for auto-copy.
4696    selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4697    selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4698}
4699
4700/// How many submitted messages are kept for ↑ recall (mirrors the TS
4701/// editor's 100-entry cap).
4702const HISTORY_LIMIT: usize = 100;
4703
4704/// Keep a few rows of overlap so page scrolling preserves visual context,
4705/// matching the upstream fullscreen viewport behavior.
4706const PAGE_SCROLL_OVERLAP: usize = 4;
4707
4708/// Native pi scrolls a small chunk for each wheel notch rather than moving the
4709/// transcript one physical row at a time. Three lines stays precise while
4710/// avoiding the sluggish feel of the previous implementation.
4711const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4712
4713/// Parse the compact key notation used by native Pi settings (for example
4714/// `ctrl+g`, `shift+tab`, or `escape`) into crossterm's representation.
4715fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4716    let mut modifiers = KeyModifiers::NONE;
4717    let mut key = None;
4718    for part in value.trim().to_ascii_lowercase().split('+') {
4719        match part {
4720            "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4721            "shift" => modifiers |= KeyModifiers::SHIFT,
4722            "alt" | "option" => modifiers |= KeyModifiers::ALT,
4723            "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4724            part if !part.is_empty() => key = Some(part.to_string()),
4725            _ => {}
4726        }
4727    }
4728    let key = key?;
4729    let code = match key.as_str() {
4730        "esc" | "escape" => KeyCode::Esc,
4731        "enter" | "return" => KeyCode::Enter,
4732        "tab" => {
4733            if modifiers.contains(KeyModifiers::SHIFT) {
4734                return Some(rpi_tui::KeyCombo::new(
4735                    KeyCode::BackTab,
4736                    modifiers & !KeyModifiers::SHIFT,
4737                ));
4738            }
4739            KeyCode::Tab
4740        }
4741        "backspace" | "back" => KeyCode::Backspace,
4742        "delete" | "del" => KeyCode::Delete,
4743        "up" | "arrowup" => KeyCode::Up,
4744        "down" | "arrowdown" => KeyCode::Down,
4745        "left" | "arrowleft" => KeyCode::Left,
4746        "right" | "arrowright" => KeyCode::Right,
4747        "home" => KeyCode::Home,
4748        "end" => KeyCode::End,
4749        "pageup" | "page-up" => KeyCode::PageUp,
4750        "pagedown" | "page-down" => KeyCode::PageDown,
4751        "space" => KeyCode::Char(' '),
4752        "f1" => KeyCode::F(1),
4753        "f2" => KeyCode::F(2),
4754        "f3" => KeyCode::F(3),
4755        "f4" => KeyCode::F(4),
4756        "f5" => KeyCode::F(5),
4757        "f6" => KeyCode::F(6),
4758        "f7" => KeyCode::F(7),
4759        "f8" => KeyCode::F(8),
4760        "f9" => KeyCode::F(9),
4761        "f10" => KeyCode::F(10),
4762        "f11" => KeyCode::F(11),
4763        "f12" => KeyCode::F(12),
4764        value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4765        _ => return None,
4766    };
4767    Some(rpi_tui::KeyCombo::new(code, modifiers))
4768}
4769
4770fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4771    let mut bindings = rpi_tui::Keybindings::new();
4772    let settings = crate::settings::load_settings().unwrap_or_default();
4773    let Some(overrides) = settings.keybindings else {
4774        rpi_tui::set_keybindings(bindings.clone());
4775        return Arc::new(bindings);
4776    };
4777    let known: &[(&str, rpi_tui::KeybindingId)] = &[
4778        ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4779        ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4780        ("app.exit", rpi_tui::keybindings::keys::EXIT),
4781        ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4782        (
4783            "app.model.cycleForward",
4784            rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4785        ),
4786        ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4787        (
4788            "app.thinking.toggle",
4789            rpi_tui::keybindings::keys::THINKING_TOGGLE,
4790        ),
4791        (
4792            "app.editor.external",
4793            rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4794        ),
4795        (
4796            "app.thinking.cycle",
4797            rpi_tui::keybindings::keys::THINKING_CYCLE,
4798        ),
4799        (
4800            "app.clipboard.pasteImage",
4801            rpi_tui::keybindings::keys::PASTE_IMAGE,
4802        ),
4803        ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4804    ];
4805    for (name, id) in known {
4806        let Some(value) = overrides.get(*name) else {
4807            continue;
4808        };
4809        let values: Vec<String> = match value {
4810            serde_json::Value::String(value) => vec![value.clone()],
4811            serde_json::Value::Array(values) => values
4812                .iter()
4813                .filter_map(|v| v.as_str().map(str::to_string))
4814                .collect(),
4815            serde_json::Value::Null => Vec::new(),
4816            _ => continue,
4817        };
4818        let combos: Vec<_> = values
4819            .iter()
4820            .filter_map(|value| parse_configured_key(value))
4821            .collect();
4822        if values.is_empty() || !combos.is_empty() {
4823            bindings.set(id, combos);
4824        }
4825    }
4826    rpi_tui::set_keybindings(bindings.clone());
4827    Arc::new(bindings)
4828}
4829
4830fn keybinding_matches(
4831    bindings: &rpi_tui::Keybindings,
4832    event: &crossterm::event::KeyEvent,
4833    id: rpi_tui::KeybindingId,
4834) -> bool {
4835    if bindings.matches(event, id) {
4836        return true;
4837    }
4838    // crossterm reports Shift+Tab as BackTab on some terminals and as Tab
4839    // plus Shift on others. Treat both forms as the same configured action.
4840    if event.code == KeyCode::BackTab {
4841        let normalized =
4842            crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4843        bindings.matches(&normalized, id)
4844    } else {
4845        false
4846    }
4847}
4848
4849/// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`), used in
4850/// the pending-messages hint row. Mirrors native pi's `getAppKeyDisplay`;
4851/// falls back to native pi's platform default when nothing is configured.
4852fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
4853    let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
4854    if keys.is_empty() {
4855        return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
4856    }
4857    keys.iter()
4858        .map(|combo| combo.display())
4859        .collect::<Vec<_>>()
4860        .join("/")
4861}
4862
4863fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4864    last.is_some_and(|previous| {
4865        now.duration_since(previous) <= std::time::Duration::from_millis(500)
4866    })
4867}
4868
4869/// How long to wait for queued console input before treating a bare Enter as a
4870/// real submit. Pasted input is already in the queue, so this only needs to
4871/// cover scheduler latency — small enough that a human Enter feels instant.
4872const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4873
4874/// Maximum gap between a pasted character and the next pasted key. A human
4875/// cannot deliver two keypresses this fast, so anything tighter is paste.
4876const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4877
4878/// Whether a bare Enter is pasted content (insert a newline) rather than a
4879/// submit.
4880///
4881/// `crossterm` 0.27 only implements bracketed paste on Unix; on Windows the
4882/// console event source never produces `Event::Paste`, so a pasted block
4883/// arrives as plain key events with a bare Enter per line. Two timing signals
4884/// separate a pasted Enter from a typed one:
4885///
4886/// - `more_queued`: real key input is already sitting in the console input
4887///   queue. Callers must feed this through [`probe_paste_input`], which drops
4888///   the key's own Release events — Windows queues press+release together over
4889///   RDP, and a bare poll cannot tell that Release apart from paste content.
4890/// - `last_text_key_at`: the Enter lands within [`PASTE_BURST_GAP`] of the
4891///   preceding pasted character, which also catches a paste that *ends* with a
4892///   newline (nothing queued behind it, but it was not typed by hand).
4893///
4894/// NOTE: the caller gates this on `cfg!(windows)`. On Unix, bracketed paste
4895/// delivers real pastes as `Event::Paste`, and the timing heuristic would
4896/// misclassify a remote/mobile client's coalesced text+Enter burst as paste
4897/// ("回车变成了换行").
4898fn enter_is_paste_burst(
4899    last_text_key_at: Option<std::time::Instant>,
4900    now: std::time::Instant,
4901    more_queued: bool,
4902) -> bool {
4903    if more_queued {
4904        return true;
4905    }
4906    last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4907}
4908
4909/// Decide whether more *real* input is queued behind the current key, for the
4910/// Windows paste-burst check.
4911///
4912/// Windows emits a `KeyEventKind::Release` for every press, usually queued
4913/// right behind the `Press`. A bare non-blocking poll therefore reports "more
4914/// input" for a lone Enter — which made a remote/RDP Enter look like a paste
4915/// burst. This drains and discards release events and returns the first
4916/// *meaningful* event it read (stashed so the caller's loop still processes it)
4917/// plus whether any real input was queued. `next_event` returns `None` when
4918/// nothing is available within the probe window.
4919fn probe_paste_input(
4920    pending: Option<Event>,
4921    mut next_event: impl FnMut() -> Option<Event>,
4922) -> (bool, Option<Event>) {
4923    let mut pending = pending;
4924    let mut more_queued = pending.is_some();
4925    while !more_queued {
4926        match next_event() {
4927            Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
4928            Some(ev) => {
4929                pending = Some(ev);
4930                more_queued = true;
4931            }
4932            None => break,
4933        }
4934    }
4935    (more_queued, pending)
4936}
4937
4938/// Milliseconds since the last text key, for the [`enter_is_paste_burst`] trace
4939/// line (`-` when there was no preceding text key).
4940fn gap_ms(last_text_key_at: Option<std::time::Instant>) -> String {
4941    match last_text_key_at {
4942        Some(previous) => std::time::Instant::now()
4943            .duration_since(previous)
4944            .as_millis()
4945            .to_string(),
4946        None => "-".to_string(),
4947    }
4948}
4949
4950fn transcript_page_size(viewport_height: usize) -> i32 {
4951    viewport_height
4952        .saturating_sub(PAGE_SCROLL_OVERLAP)
4953        .max(1)
4954        .min(i32::MAX as usize) as i32
4955}
4956
4957fn should_dispatch_key(kind: KeyEventKind) -> bool {
4958    kind != KeyEventKind::Release
4959}
4960
4961/// Last-resort quit, run on the key worker thread.
4962///
4963/// The async loop owns `run_prompt_streaming(..).await` for the whole run, so a
4964/// queued [`TuiMessage::Exit`] is not read while a run is in flight. When the
4965/// user asks to quit a second time — the first Ctrl+C only reached `Aborting` —
4966/// treat the run as wedged, restore the terminal, and exit.
4967///
4968/// `130` is the conventional `128 + SIGINT` status, so a wrapper script can tell
4969/// a forced quit from a clean one.
4970fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
4971    // Best effort: leave the alternate buffer and restore cooked mode so the
4972    // user's shell is usable afterwards. `stop` also joins the render
4973    // scheduler, so no in-flight frame can repaint over the restored screen.
4974    tui.stop(Default::default());
4975    eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
4976    std::process::exit(130);
4977}
4978
4979/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
4980fn format_tokens(n: i64) -> String {
4981    if n >= 1_000_000 {
4982        format!("{:.1}M", n as f64 / 1_000_000.0)
4983    } else if n >= 1_000 {
4984        format!("{:.1}K", n as f64 / 1_000.0)
4985    } else {
4986        n.to_string()
4987    }
4988}
4989
4990/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
4991/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
4992/// resets the browse state so a fresh prompt never resumes mid-history.
4993fn push_history(state: &Arc<TuiState>, text: &str) {
4994    let trimmed = text.trim().to_string();
4995    if trimmed.is_empty() {
4996        return;
4997    }
4998    let mut history = state.history.lock().unwrap();
4999    if history.first() == Some(&trimmed) {
5000        return;
5001    }
5002    history.insert(0, trimmed);
5003    history.truncate(HISTORY_LIMIT);
5004    *state.history_index.lock().unwrap() = -1;
5005    *state.history_draft.lock().unwrap() = None;
5006}
5007
5008/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
5009/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
5010/// current editor text as the draft; navigating back past the newest entry
5011/// restores that draft.
5012fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
5013    let history = state.history.lock().unwrap();
5014    if history.is_empty() {
5015        return;
5016    }
5017    let mut index = state.history_index.lock().unwrap();
5018    let new_index = *index - direction as isize;
5019    if new_index < -1 || new_index >= history.len() as isize {
5020        return;
5021    }
5022    if *index == -1 && new_index >= 0 {
5023        // Entering browse mode: stash the current input.
5024        *state.history_draft.lock().unwrap() = Some(editor.get_text());
5025    }
5026    *index = new_index;
5027    if new_index == -1 {
5028        // Exited browse mode: restore the draft (or clear if there was none).
5029        let draft = state.history_draft.lock().unwrap().take();
5030        match draft {
5031            Some(d) => {
5032                let len = d.len();
5033                editor.set_text(&d);
5034                editor.set_cursor(0, len);
5035            }
5036            None => editor.set_text(""),
5037        }
5038    } else {
5039        let text = history[new_index as usize].clone();
5040        let len = text.len();
5041        editor.set_text(&text);
5042        editor.set_cursor(0, len);
5043    }
5044}
5045
5046impl TuiState {
5047    fn begin_js_preparation(&self) -> CancellationToken {
5048        let cancellation = CancellationToken::new();
5049        if let Some(previous) = self
5050            .js_preparation_cancel
5051            .lock()
5052            .unwrap()
5053            .replace(cancellation.clone())
5054        {
5055            previous.cancel();
5056        }
5057        cancellation
5058    }
5059
5060    fn finish_js_preparation(&self) {
5061        self.js_preparation_cancel.lock().unwrap().take();
5062    }
5063
5064    fn cancel_js_preparation(&self) -> bool {
5065        let cancellation = self.js_preparation_cancel.lock().unwrap().take();
5066        if let Some(cancellation) = cancellation {
5067            cancellation.cancel();
5068            true
5069        } else {
5070            false
5071        }
5072    }
5073
5074    /// Whether a user-initiated `!command` shell run is currently active.
5075    fn user_bash_running(&self) -> bool {
5076        self.user_bash_cancel.lock().unwrap().is_some()
5077    }
5078
5079    /// Register a cancellation token for a new user bash run, cancelling any
5080    /// previous one (mirrors `begin_js_preparation`).
5081    fn begin_user_bash(&self) -> CancellationToken {
5082        let cancellation = CancellationToken::new();
5083        if let Some(previous) = self
5084            .user_bash_cancel
5085            .lock()
5086            .unwrap()
5087            .replace(cancellation.clone())
5088        {
5089            previous.cancel();
5090        }
5091        cancellation
5092    }
5093
5094    /// Clear the user-bash slot. Called when the command finishes so the
5095    /// running guard and the Esc router both see idle state.
5096    fn finish_user_bash(&self) {
5097        self.user_bash_cancel.lock().unwrap().take();
5098    }
5099
5100    /// Cancel a running user bash command. Returns `true` when one was active
5101    /// (mirrors `cancel_js_preparation`). The spawned task clears the slot
5102    /// itself once `execute_shell_with_capture` returns.
5103    fn cancel_user_bash(&self) -> bool {
5104        let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
5105        if let Some(cancellation) = cancellation {
5106            cancellation.cancel();
5107            true
5108        } else {
5109            false
5110        }
5111    }
5112
5113    fn set_status(&self, status: RunStatus) {
5114        *self.status.lock().unwrap() = status;
5115        self.apply_status(status);
5116    }
5117
5118    /// The dock container listing queued messages (native pi's
5119    /// `pendingMessagesContainer`).
5120    fn pending_container(&self) -> &Arc<Container> {
5121        &self.pending_container
5122    }
5123
5124    /// Render the current queue into the pending dock slot. Mirrors native
5125    /// pi's `updatePendingMessagesDisplay`: `Steering: <text>` /
5126    /// `Follow-up: <text>` rows (dim) plus a `↳ <key> to edit all queued
5127    /// messages` hint. A no-op when the snapshot is unchanged, so the poll
5128    /// caller can invoke it freely without forcing frames.
5129    fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
5130        {
5131            let mut current = self.pending_snapshot.lock().unwrap();
5132            if *current == messages {
5133                return;
5134            }
5135            *current = messages.clone();
5136        }
5137        let container = &self.pending_container;
5138        container.clear();
5139        if messages.is_empty() {
5140            return;
5141        }
5142        let colors = current_theme().colors;
5143        container.add_child(Arc::new(Spacer::new(1)));
5144        for text in &messages.steering {
5145            container.add_child(Arc::new(Text::new(
5146                colors.muted.fg(&format!("Steering: {text}")),
5147                1,
5148                0,
5149            )));
5150        }
5151        for text in &messages.follow_up {
5152            container.add_child(Arc::new(Text::new(
5153                colors.muted.fg(&format!("Follow-up: {text}")),
5154                1,
5155                0,
5156            )));
5157        }
5158        container.add_child(Arc::new(Text::new(
5159            colors.muted.fg(&format!(
5160                "↳ {} to edit all queued messages",
5161                self.dequeue_hint
5162            )),
5163            1,
5164            0,
5165        )));
5166    }
5167
5168    /// Atomically reserve the single interactive run slot. The editor callback
5169    /// runs on a different thread from the async prompt loop, so checking and
5170    /// setting in separate steps would allow rapid Enter presses to queue more
5171    /// than one operation.
5172    fn try_start_working(&self) -> bool {
5173        let mut status = self.status.lock().unwrap();
5174        if *status != RunStatus::Idle {
5175            return false;
5176        }
5177        *status = RunStatus::Working;
5178        drop(status);
5179        self.apply_status(RunStatus::Working);
5180        true
5181    }
5182
5183    fn apply_status(&self, status: RunStatus) {
5184        match status {
5185            RunStatus::Working => {
5186                // The status dock already shows the active loader. Keep the
5187                // footer focused on the model and shortcuts instead of
5188                // repeating a second `Working…` at the bottom of the TUI.
5189                self.footer.set_status("");
5190                // Reflect the in-flight turn in the terminal window/tab title
5191                // (OSC 2). No-op when `tui` is absent (unit tests).
5192                if let Some(tui) = &self.tui {
5193                    tui.set_title("🦀π rpi ⟳");
5194                }
5195                self.status_container.clear();
5196                // The working indicator renders inside the editor's top border.
5197                // `show_terminal_progress` disables it entirely.
5198                if self.show_terminal_progress {
5199                    self.editor.set_working(Some(WorkingState {
5200                        frame: 0,
5201                        started_at: std::time::Instant::now(),
5202                        message: "Working…".to_string(),
5203                    }));
5204                } else {
5205                    self.editor.set_working(None);
5206                }
5207            }
5208            RunStatus::Aborting => {
5209                self.footer.set_status("Aborting…");
5210                // Do not leave a frozen "Working" spinner on screen after the
5211                // render tick intentionally stops advancing in this state.
5212                self.loader.stop();
5213                self.status_container.clear();
5214                self.editor.set_working(None);
5215            }
5216            RunStatus::Idle => {
5217                self.footer.set_status("");
5218                if let Some(tui) = &self.tui {
5219                    tui.set_title("🦀π rpi");
5220                }
5221                self.loader.stop();
5222                self.status_container.clear();
5223                self.editor.set_working(None);
5224            }
5225        }
5226    }
5227
5228    /// The bash panel has its own `Running...` spinner. Keep the global
5229    /// `Working...` loader out of the status slot while any bash tool is active
5230    /// so the same operation is not presented as two simultaneous loaders.
5231    fn sync_working_loader_with_bash(&self) {
5232        if *self.status.lock().unwrap() != RunStatus::Working {
5233            return;
5234        }
5235
5236        // The working indicator now lives inside the editor's top border and
5237        // stays visible for the whole Working state, so no status-slot swap is
5238        // needed when bash panels (which render their own spinner in the
5239        // transcript) appear or disappear.
5240        self.status_container.clear();
5241    }
5242
5243    fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
5244        *self.status.lock().unwrap() = RunStatus::Working;
5245        self.footer.set_status("");
5246        if let Some(tui) = &self.tui {
5247            tui.set_title("🦀π rpi ↻");
5248        }
5249        self.loader.stop();
5250        self.status_container.clear();
5251        self.status_container
5252            .add_child(Arc::new(StatusIndicator::retry(
5253                attempt,
5254                max_retries,
5255                std::time::Duration::from_millis(delay_ms),
5256            )));
5257    }
5258
5259    /// Whether a selector overlay is currently open (routes keys to it first).
5260    fn selector_open(&self) -> bool {
5261        self.active_selector.lock().unwrap().is_some()
5262    }
5263
5264    fn extension_editor_open(&self) -> bool {
5265        self.active_extension_editor.lock().unwrap().is_some()
5266    }
5267
5268    fn extension_input_open(&self) -> bool {
5269        self.active_extension_input.lock().unwrap().is_some()
5270    }
5271
5272    fn extension_dialog_open(&self) -> bool {
5273        self.extension_editor_open() || self.extension_input_open()
5274    }
5275
5276    fn set_hide_thinking(&self, hide: bool) {
5277        *self.hide_thinking.lock().unwrap() = hide;
5278        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5279            comp.set_hide_thinking(hide);
5280        }
5281    }
5282
5283    fn hide_thinking(&self) -> bool {
5284        *self.hide_thinking.lock().unwrap()
5285    }
5286
5287    fn toggle_thinking(&self) -> bool {
5288        let next = !self.hide_thinking();
5289        self.set_hide_thinking(next);
5290        next
5291    }
5292
5293    fn toggle_tool_outputs(&self) -> bool {
5294        let next = !*self.tool_outputs_expanded.lock().unwrap();
5295        *self.tool_outputs_expanded.lock().unwrap() = next;
5296        for comp in self.tool_components.lock().unwrap().values() {
5297            comp.set_expanded(next);
5298        }
5299        for comp in self.bash_components.lock().unwrap().values() {
5300            comp.set_expanded(next);
5301        }
5302        next
5303    }
5304
5305    /// The model id currently tracked as active (footer + Ctrl+M anchor).
5306    fn current_model_id(&self) -> String {
5307        self.current_model_id.lock().unwrap().clone()
5308    }
5309
5310    /// Update the tracked model id + footer label after a switch (live or
5311    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
5312    fn set_current_model(&self, model: &rpi_ai::Model) {
5313        *self.current_model_id.lock().unwrap() = model.id.clone();
5314        self.footer.set_model(&short_model_name(&model.id));
5315        // A model switch changes the context window, so the `%/{window}` badge
5316        // must rescale against the same last-reported token count.
5317        self.footer.set_context_window(model.context_window as i64);
5318    }
5319
5320    /// B5e: read a clone of the current assistant-markdown transformer (if any).
5321    /// New assistant components call this at construction so they render with
5322    /// whatever plugin `register_markdown_transformer` handlers are live.
5323    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
5324        self.markdown_transformer.lock().unwrap().clone()
5325    }
5326
5327    /// B5e: swap the live transformer. Used at startup (install the first
5328    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
5329    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
5330    /// transform should take effect on the VISIBLE streaming message too, so
5331    /// this re-installs on the in-flight `current_assistant` component — its
5332    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
5333    /// `None` clears the transform (identity), e.g. a reload that unregisters
5334    /// every markdown transformer.
5335    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
5336        *self.markdown_transformer.lock().unwrap() = transformer.clone();
5337        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5338            comp.set_markdown_transformer(transformer);
5339        }
5340    }
5341
5342    fn queue_image(&self, image: rpi_ai::types::ImageContent) {
5343        self.pending_images.lock().unwrap().push(image);
5344    }
5345
5346    fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
5347        std::mem::take(&mut *self.pending_images.lock().unwrap())
5348    }
5349}
5350
5351// ===========================================================================
5352// interactive_tui — the entry point
5353// ===========================================================================
5354
5355#[derive(Debug, PartialEq)]
5356struct TuiStartupSettings {
5357    editor_padding_x: usize,
5358    autocomplete_max_visible: usize,
5359    hide_thinking: bool,
5360    quiet_startup: bool,
5361    show_terminal_progress: bool,
5362    /// Render images inline (`terminal.showImages` / flat `showImages`).
5363    show_images: bool,
5364    /// Transcript notices for prompt-cache costs (`showCacheMissNotices`).
5365    cache_miss_notices: bool,
5366}
5367
5368fn preferred_project_setting<T>(
5369    project_settings: &[crate::settings::Settings],
5370    field: impl Fn(&crate::settings::Settings) -> Option<T>,
5371) -> Option<T> {
5372    project_settings.iter().find_map(field)
5373}
5374
5375fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
5376    verbose || !quiet_startup
5377}
5378
5379fn git_only_update_resources(
5380    mut resources: crate::packages::PackageResources,
5381) -> crate::packages::PackageResources {
5382    resources
5383        .packages
5384        .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
5385    resources
5386}
5387
5388/// Resolve TUI-only settings with native Pi's project-over-global precedence.
5389/// `project_settings` must be ordered `.rpi` then `.pi`; an explicit `Some`
5390/// wins even when the value is `false` or zero.
5391fn resolve_tui_startup_settings(
5392    global: &crate::settings::Settings,
5393    project_settings: &[crate::settings::Settings],
5394    project_trusted: bool,
5395) -> TuiStartupSettings {
5396    let project_settings = if project_trusted {
5397        project_settings
5398    } else {
5399        &[]
5400    };
5401    let editor_padding_x =
5402        preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
5403            .or(global.editor_padding_x)
5404            .unwrap_or(1)
5405            .min(16);
5406    let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
5407        settings.autocomplete_max_visible
5408    })
5409    .or(global.autocomplete_max_visible)
5410    .unwrap_or(5)
5411    .clamp(1, 20);
5412    let hide_thinking =
5413        preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
5414            .or(global.hide_thinking_block)
5415            .unwrap_or(false);
5416    let quiet_startup =
5417        preferred_project_setting(project_settings, |settings| settings.quiet_startup)
5418            .or(global.quiet_startup)
5419            .unwrap_or(false);
5420    let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
5421        settings.show_terminal_progress()
5422    })
5423    .or_else(|| global.show_terminal_progress())
5424    .unwrap_or(true);
5425    let show_images =
5426        preferred_project_setting(project_settings, |settings| settings.show_images())
5427            .or_else(|| global.show_images())
5428            .unwrap_or(true);
5429    let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
5430        settings.show_cache_miss_notices
5431    })
5432    .or(global.show_cache_miss_notices)
5433    .unwrap_or(false);
5434
5435    TuiStartupSettings {
5436        editor_padding_x,
5437        autocomplete_max_visible,
5438        hide_thinking,
5439        quiet_startup,
5440        show_terminal_progress,
5441        show_images,
5442        cache_miss_notices,
5443    }
5444}
5445
5446/// TUI-based interactive mode.
5447///
5448/// `event_rx` carries the live `AgentEvent` stream (installed by
5449/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
5450/// fn), it falls back to a blocking, await-final-text path.
5451///
5452/// `model_catalog` is the read-only catalog the `/model` selector displays.
5453///
5454/// This implementation mirrors the TypeScript `InteractiveMode` class:
5455/// build the layout root once, drain `AgentEvent`s into UI mutations that
5456/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
5457/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
5458/// autocomplete are layered on via the editor-container swap pattern.
5459pub async fn interactive_tui(
5460    harness: &AgentHarness,
5461    event_rx: Option<broadcast::Receiver<AgentEvent>>,
5462    args: &Args,
5463    model_catalog: Vec<rpi_ai::Model>,
5464    initial: Option<String>,
5465    extra_messages: &[String],
5466    initial_images: Vec<rpi_ai::types::ImageContent>,
5467    theme: Option<&str>,
5468    no_themes: bool,
5469    reload_context: &crate::session::ReloadContext,
5470) -> i32 {
5471    let lane: Arc<dyn AgentLane> = harness.lane("main");
5472    // Reuse the startup snapshot captured before provider/session setup. An
5473    // extension may mutate the process cwd while loading; that must not change
5474    // which project settings or package update roots this TUI observes.
5475    let cwd = reload_context.cwd.clone();
5476    let saved_settings = crate::settings::load_settings().unwrap_or_default();
5477    let project_trusted = reload_context.project_trusted;
5478    let project_settings = if project_trusted {
5479        crate::settings::load_project_settings(&cwd)
5480    } else {
5481        Vec::new()
5482    };
5483    let tui_settings =
5484        resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5485    let editor_padding_x = tui_settings.editor_padding_x;
5486    let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5487    let mut hide_thinking = tui_settings.hide_thinking;
5488    let show_terminal_progress = tui_settings.show_terminal_progress;
5489    let show_images = tui_settings.show_images;
5490    let cache_miss_notices = tui_settings.cache_miss_notices;
5491    let quiet_startup = tui_settings.quiet_startup;
5492    let update_checks_enabled =
5493        !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
5494
5495    // Session display preferences (durable, latest-wins) override the
5496    // settings-file defaults. These are per-session facts stored as custom
5497    // entries (see `rpi_harness::session::values`), so resuming a session with
5498    // `-c`/`-r` restores the thinking/tool-output display the user last chose
5499    // there — independent of global settings. Absent keys leave the settings
5500    // value untouched.
5501    let session_values = rpi_harness::session::SessionValues::load(harness.session())
5502        .await
5503        .unwrap_or_default();
5504    if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5505        hide_thinking = persisted;
5506    }
5507
5508    // Resolve the active model once, up front. The full id feeds the TuiState
5509    // tracking field + the selectors/key loop (which run on a blocking thread
5510    // and can't await `lane.get_model()`); the short name feeds the footer.
5511    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5512    let model_name = short_model_name(&lane_model_id);
5513
5514    // Snapshot startup capabilities for the welcome screen. Both accessors
5515    // return defensive clones, so rendering this summary does not retain a
5516    // harness lock or trigger a second resource scan.
5517    let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5518    // AgentHarness uses an empty active-name list as the default "all tools"
5519    // state. Do not expose that implementation sentinel as `Tools (0) none`
5520    // in the welcome banner (it is especially visible on the first prompt).
5521    if active_tool_names.is_empty() {
5522        active_tool_names = harness
5523            .get_tools()
5524            .await
5525            .unwrap_or_default()
5526            .into_iter()
5527            .map(|tool| tool.tool.schema().name.clone())
5528            .collect();
5529    }
5530    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5531    let skill_names: Vec<String> = resources_snapshot
5532        .skills
5533        .as_deref()
5534        .unwrap_or(&[])
5535        .iter()
5536        .map(|skill| skill.name.clone())
5537        .collect();
5538
5539    // Resolve package resources for @file autocomplete + session discovery.
5540    let package_resources = reload_context.package_resources.clone();
5541
5542    // Channel between the key/callback threads and the main async loop.
5543    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
5544
5545    // Apply the saved theme before constructing transcript components. Some
5546    // components keep styled text, so doing this after the welcome banner left
5547    // the first screen in the dark palette until it was rebuilt.
5548    let theme_manager = Arc::new(ThemeManager::new());
5549    if let Some(preset) = match theme {
5550        Some("light") => Some(ThemePreset::Light),
5551        Some("monochrome") => Some(ThemePreset::Monochrome),
5552        Some("dark") => Some(ThemePreset::Dark),
5553        _ => None,
5554    } {
5555        apply_theme_preset(preset);
5556        theme_manager.apply_preset(preset);
5557    } else if !no_themes {
5558        if let Some(name) = theme {
5559            match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
5560                Ok(custom) => {
5561                    rpi_tui::global_theme_manager().set(custom.clone());
5562                    theme_manager.set(custom);
5563                }
5564                Err(error) => {
5565                    eprintln!("warning: could not load package theme `{name}`: {error}");
5566                }
5567            }
5568        }
5569    }
5570
5571    // ---- TUI + containers ----
5572    let terminal = Box::new(ProcessTerminal::new());
5573    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
5574    let js_dialog_bridge = Arc::new(JsDialogBridge::default());
5575    // Plugin `ask_user` prompts (runtime action 17). Attaching marks this
5576    // session as interactive so a detached/headless host fails loudly instead
5577    // of returning a fabricated answer. The mailbox is session-scoped (held in
5578    // `ReloadContext`) so in-flight prompts survive a `/reload`.
5579    let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
5580    ask_user_bridge.attach();
5581    tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
5582
5583    if let Some(js) = &reload_context.js_extension_session {
5584        if let Err(error) = js.install_ui_runtime(tui.clone()) {
5585            eprintln!("warning: could not enable JS custom UI bridge: {error}");
5586        } else if let Some(js_active) = js.active_tools() {
5587            // Apply the discovery-time JS subset while preserving Rust
5588            // built-ins already active in the harness lane. The real TUI
5589            // lifecycle reconciliation runs after the key worker starts below.
5590            let js_names = js.tool_names();
5591            let mut active = lane.get_active_tools().await.unwrap_or_default();
5592            active.retain(|name| {
5593                crate::session::tool_name_allowed(name, args)
5594                    && !js_names.iter().any(|js_name| js_name == name)
5595            });
5596            active.extend(js_active.into_iter().filter(|name| {
5597                js_names.iter().any(|js_name| js_name == name)
5598                    && crate::session::tool_name_allowed(name, args)
5599            }));
5600            active = crate::session::filter_active_tool_names(active, args);
5601            let _ = lane.set_active_tools(active).await;
5602            active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5603        }
5604    }
5605
5606    let chat_container = Arc::new(Container::new());
5607    if let Some(js) = &reload_context.js_extension_session {
5608        // Tool contexts do not have a command-result envelope. Route
5609        // `ctx.ui.notify()` through the live transcript so notifications from
5610        // tools such as ask_user_question are visible immediately.
5611        let chat_notify = chat_container.clone();
5612        let tui_notify = tui.clone();
5613        if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
5614            if action != "ui.notify" {
5615                return Err(format!("unsupported capability: {action}"));
5616            }
5617            let message = args
5618                .get("message")
5619                .and_then(serde_json::Value::as_str)
5620                .unwrap_or_default();
5621            if !message.is_empty() {
5622                if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
5623                    add_error_message(&chat_notify, message);
5624                } else {
5625                    add_note_message(&chat_notify, message);
5626                }
5627                tui_notify.request_render(false);
5628            }
5629            Ok(serde_json::json!(true))
5630        })) {
5631            if args.verbose {
5632                eprintln!("warning: could not enable JS notification bridge: {error}");
5633            }
5634        }
5635    }
5636    if should_show_startup_listing(args.verbose, quiet_startup) {
5637        add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5638    }
5639
5640    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
5641    // banner + the earendil announcement once, then write the sentinel. The TS
5642    // original is a multi-step dialog (theme picker + analytics opt-in); this
5643    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
5644    // analytics deferred — no telemetry wiring). See `extras.rs`.
5645    crate::extras::maybe_first_time_setup(&chat_container);
5646
5647    // A --continue/--resume/--session launch opens on an existing JSONL
5648    // session — render its prior user/assistant transcript so the user sees
5649    // where they left off (tool executions are skipped: their live display
5650    // belongs to the current run, and replaying old results would be noise).
5651    let initial_transformer = build_markdown_transformer(
5652        reload_context
5653            .extension_session
5654            .lock()
5655            .unwrap()
5656            .snapshot_arc(),
5657    );
5658    // A normal launch creates a fresh session and must not replay records from
5659    // another/project harness. Only explicit restore/fork modes render prior
5660    // conversation history. This fixes stale prompts appearing every startup.
5661    if launch_restores_history(args) {
5662        render_session_history(
5663            &harness,
5664            &chat_container,
5665            initial_transformer.clone(),
5666            Some(reload_context.extension_session.clone()),
5667        )
5668        .await;
5669    }
5670
5671    // `document_container` wraps the welcome header + chat so the scrollview
5672    // follows the whole transcript (mirrors TS `documentContainer`).
5673    let document_container = Arc::new(Container::new());
5674    document_container.add_child(chat_container.clone());
5675    // Add bottom padding to the output area for visual breathing room.
5676    document_container.add_child(Arc::new(Spacer::new(1)));
5677
5678    let scroll_view = Arc::new(ScrollView::new(
5679        document_container.clone(),
5680        ScrollViewOptions {
5681            follow: FollowMode::End,
5682            primary: true,
5683            overscroll: OverscrollMode::Chain,
5684            // Native pi keeps transcript chrome out of the way. Our Auto mode
5685            // has no hide timer yet and therefore became effectively permanent
5686            // after the first wheel event, unlike the upstream experience.
5687            scrollbar: ScrollbarMode::Hidden,
5688            ..Default::default()
5689        },
5690    ));
5691
5692    // ---- Editor ----
5693    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
5694    // editor renders full-width `─` top/bottom borders with padding-only lines
5695    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
5696    let keybindings = configured_keybindings();
5697    let editor = Arc::new(Editor::new(
5698        EditorOptions {
5699            padding_x: editor_padding_x,
5700            autocomplete_max_visible,
5701            ..Default::default()
5702        },
5703        EditorStyle::default(),
5704        keybindings.clone(),
5705    ));
5706
5707    // Bash-mode border: a `!`-prefixed draft recolors the editor border to the
5708    // bash accent (native pi's `updateEditorBorderColor` / `isBashMode`). The
5709    // editor fires `on_change` for keystrokes and for programmatic
5710    // `clear`/`set_text`, so the color also resets after a `!command` submits.
5711    {
5712        let editor_for_change = editor.clone();
5713        editor.on_change(Arc::new(move |text: &str| {
5714            let colors = current_theme().colors;
5715            let is_bash = text.trim_start().starts_with('!');
5716            editor_for_change.set_border_color(if is_bash {
5717                Some(colors.bash_mode)
5718            } else {
5719                None
5720            });
5721        }));
5722    }
5723
5724    // ---- Footer + status ----
5725    let footer = Arc::new(FooterComponent::new());
5726    footer.set_model(&model_name);
5727    footer.set_cwd(&cwd.to_string_lossy());
5728    footer.set_git_branch(git_branch_for(&cwd).as_deref());
5729    if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5730        footer.set_context_window(m.context_window as i64);
5731    }
5732    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");
5733
5734    // Live git-branch refresh (native fs-watch on `.git/HEAD`): a checkout or
5735    // commit updates the footer's branch without a manual refresh.
5736    let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5737        let footer_ref = footer.clone();
5738        let cwd_ref = cwd.clone();
5739        crate::fs_watch::watch_path(head, move || {
5740            footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5741        })
5742    });
5743
5744    let status_container = Arc::new(Container::new());
5745    let loader = Arc::new(Loader::with_text("Working…"));
5746
5747    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
5748    // Prompt templates discovered at session build (Part A2) are surfaced as
5749    // `/`-prefixed entries alongside the built-in slash commands: typing
5750    // `/<name>` in the editor expands the template (mirrors pi
5751    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
5752    // the template's frontmatter description (or a fallback) so the autocomplete
5753    // popover shows what each template does.
5754    //
5755    // We snapshot the full resources once (skills + prompt-templates): the
5756    // autocomplete builder consumes the templates, and the `/context` command
5757    // (fired from the blocking submit handler, which can't `.await`) reads the
5758    // snapshot to render the discovered-resources panel without touching the
5759    // harness async accessor.
5760    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5761        .prompt_templates
5762        .clone()
5763        .unwrap_or_default()
5764        .iter()
5765        .map(|t| SlashCommandEntry {
5766            name: format!("/{}", t.name),
5767            description: t
5768                .description
5769                .clone()
5770                .unwrap_or_else(|| "Expand prompt template".to_string()),
5771        })
5772        .collect();
5773    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5774        Arc::new(resources_snapshot);
5775    // Build the built-in command registry once — the single source of truth for
5776    // both dispatch and the built-in autocomplete entries. The discovered
5777    // prompt-template commands are merged into the autocomplete list separately
5778    // (they dispatch via template expansion, not the registry); built-ins come
5779    // first so they win on a fuzzy tie.
5780    let mut command_registry = build_builtin_registry();
5781    register_extension_commands(
5782        &mut command_registry,
5783        reload_context.extension_session.clone(),
5784    );
5785    register_js_extension_commands(
5786        &mut command_registry,
5787        reload_context.js_extension_session.clone(),
5788    );
5789    let registry = Arc::new(command_registry);
5790    let mut all_slash_commands = registry.visible_entries();
5791    all_slash_commands.extend(template_slash_commands);
5792    let autocomplete = AutocompleteManager::new();
5793    {
5794        let mut combined = CombinedAutocompleteProvider::new();
5795        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5796            all_slash_commands,
5797        )));
5798        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5799            cwd.clone(),
5800        )));
5801        autocomplete.set_provider(Arc::new(combined));
5802    }
5803    let autocomplete_container = Arc::new(Container::new());
5804
5805    let tool_outputs_expanded = session_values
5806        .get_as::<bool>("display.tool_outputs_expanded")
5807        .unwrap_or(false);
5808    let state = Arc::new(TuiState {
5809        current_assistant: std::sync::Mutex::new(None),
5810        tool_components: std::sync::Mutex::new(HashMap::new()),
5811        bash_components: std::sync::Mutex::new(HashMap::new()),
5812        themes_enabled: !no_themes,
5813        hide_thinking: std::sync::Mutex::new(hide_thinking),
5814        tool_outputs_expanded: std::sync::Mutex::new(tool_outputs_expanded),
5815        show_terminal_progress,
5816        status: std::sync::Mutex::new(RunStatus::Idle),
5817        js_preparation_cancel: std::sync::Mutex::new(None),
5818        user_bash_cancel: std::sync::Mutex::new(None),
5819        pending_bash_messages: std::sync::Mutex::new(Vec::new()),
5820        footer: footer.clone(),
5821        status_container: status_container.clone(),
5822        chat_container: chat_container.clone(),
5823        loader: loader.clone(),
5824        editor: editor.clone(),
5825        last_assistant_text: std::sync::Mutex::new(String::new()),
5826        active_selector: std::sync::Mutex::new(None),
5827        active_extension_editor: std::sync::Mutex::new(None),
5828        active_extension_input: std::sync::Mutex::new(None),
5829        active_extension_cancel: std::sync::Mutex::new(None),
5830        autocomplete,
5831        autocomplete_container: autocomplete_container.clone(),
5832        autocomplete_max_visible,
5833        pending_images: std::sync::Mutex::new(Vec::new()),
5834        theme_manager,
5835        tui: Some(tui.clone()),
5836        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5837        show_images: std::sync::Mutex::new(show_images),
5838        cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
5839        history: std::sync::Mutex::new(Vec::new()),
5840        history_index: std::sync::Mutex::new(-1),
5841        history_draft: std::sync::Mutex::new(None),
5842        cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5843        scoped_edit: std::sync::Mutex::new(None),
5844        markdown_transformer: std::sync::Mutex::new(initial_transformer),
5845        extension_session: reload_context.extension_session.clone(),
5846        search: Arc::new(AltScreenSearch::new()),
5847        search_bar: Arc::new(SearchBar::new()),
5848        pending_container: Arc::new(Container::new()),
5849        dequeue_hint: pending_dequeue_hint(&keybindings),
5850        pending_snapshot: std::sync::Mutex::new(
5851            rpi_harness::agent_harness::QueuedMessages::default(),
5852        ),
5853        selection_start: std::sync::Mutex::new(None),
5854        selection_end: std::sync::Mutex::new(None),
5855    });
5856
5857    // Capture the model catalog + cwd for the selector builders + the key loop
5858    // (the callbacks fire on blocking threads and need owned data).
5859    let model_catalog_arc = Arc::new(model_catalog.clone());
5860    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5861
5862    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
5863    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
5864    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
5865    //
5866    // The scrollview gets `basis(0)` so the constrained stack allocator starts
5867    // it at zero height and grows it to fill the space the dock does not need
5868    // — this keeps the dock (editor borders + footer) pinned to the bottom and
5869    // never shrinks it below the editor's 3 rows (top + content + bottom). The
5870    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
5871    // never clip the input panel below its minimum.
5872    let editor_container = Arc::new(Container::new());
5873    editor_container.add_child(editor.clone());
5874
5875    let dock = Arc::new(VStack::from_children(vec![
5876        StackChild::Entry(StackEntry::new(state.pending_container().clone())),
5877        StackChild::Entry(StackEntry::new(status_container.clone())),
5878        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5879        // Add top padding above the input box for visual breathing room.
5880        StackChild::Entry(StackEntry::new(Arc::new(Spacer::new(1)))),
5881        StackChild::Entry(
5882            StackEntry::new(editor_container.clone())
5883                .shrink(0)
5884                .min_size(3),
5885        ),
5886        StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5887        StackChild::Entry(StackEntry::new(footer.clone())),
5888    ]));
5889
5890    let root = VStack::from_children(vec![
5891        StackChild::Entry(
5892            StackEntry::new(scroll_view.clone())
5893                .basis(0)
5894                .grow(1)
5895                .shrink(1)
5896                .min_size(1),
5897        ),
5898        StackChild::Entry(StackEntry::new(dock).shrink(1)),
5899    ]);
5900
5901    tui.set_layout_root(Some(Arc::new(root)));
5902    tui.set_focus(Some(editor.clone()));
5903    editor.set_focused(true);
5904
5905    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
5906    //
5907    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
5908    // the old version made individually) + the registry, then routes `/`-text
5909    // through `dispatch_slash` and sends plain text directly. Each command's
5910    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
5911    // chat mutation) — the handler itself stays a thin router.
5912    //
5913    // One `CommandContext` is built and cloned for both the submit handler and
5914    // the key loop (Ctrl+L routes `/model` through the same registry); all
5915    // fields are `Arc`/cheap, so the clones are free.
5916    //
5917    // The product-layer `AgentSession` wraps a clone of the harness (the
5918    // harness handle is cheap and shares the session/bus), so `/usage`,
5919    // `/export`, and future product commands go through one surface.
5920    let scoped_models = args
5921        .model
5922        .as_deref()
5923        .map(crate::agent_session::ScopedModel::parse)
5924        .into_iter()
5925        .collect();
5926    let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5927        harness.clone(),
5928        model_catalog.clone(),
5929        scoped_models,
5930        cwd.clone(),
5931    ));
5932    let ctx = CommandContext {
5933        chat: chat_container.clone(),
5934        tui: tui.clone(),
5935        tx: tx.clone(),
5936        state: state.clone(),
5937        editor: editor.clone(),
5938        editor_container: editor_container.clone(),
5939        lane: lane.clone(),
5940        model_catalog: model_catalog_arc.clone(),
5941        lane_model_id: lane_model_id.clone(),
5942        cwd: cwd.clone(),
5943        package_resources: package_resources.clone(),
5944        resources: resources_arc.clone(),
5945        reload_context: Arc::new(reload_context.clone()),
5946        session: agent_session.clone(),
5947    };
5948
5949    if let Some(js) = &reload_context.js_extension_session {
5950        let bridge = js_dialog_bridge.clone();
5951        if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5952            bridge.handle_runtime_request(action, args)
5953        })) {
5954            eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5955        }
5956    }
5957
5958    let ctx_for_cb = ctx.clone();
5959    let registry_for_cb = registry.clone();
5960    editor.on_submit(Arc::new(move |text: &str| {
5961        let text = text.trim();
5962        if text.is_empty() {
5963            return;
5964        }
5965
5966        if text.starts_with('/') {
5967            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
5968            return;
5969        }
5970
5971        // User-initiated shell mode: `!command` runs in the background and is
5972        // recorded in the session context; `!!command` is excluded from it.
5973        // Handled before the agent prompt path so it never reaches the model.
5974        // Mirrors `interactive-mode.ts:3225`.
5975        if let Some((command, exclude_from_context)) = parse_user_bash(text) {
5976            if ctx_for_cb.state.user_bash_running() {
5977                add_error_message(
5978                    &ctx_for_cb.chat,
5979                    "A bash command is already running. Press Esc to cancel it first.",
5980                );
5981                ctx_for_cb.tui.request_render(false);
5982                return;
5983            }
5984            start_user_bash(&ctx_for_cb, command, exclude_from_context);
5985            return;
5986        }
5987
5988        let run_status = *ctx_for_cb.state.status.lock().unwrap();
5989        if run_status != RunStatus::Idle {
5990            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5991            let lane = ctx_for_cb.lane.clone();
5992            let chat = ctx_for_cb.chat.clone();
5993            let tui = ctx_for_cb.tui.clone();
5994            let state = ctx_for_cb.state.clone();
5995            tokio::spawn(async move {
5996                // Queue immediately while the agent loop is still running.
5997                // Routing this through the TUI's main channel delayed it until
5998                // `prompt_text()` returned, after the loop's drain points had
5999                // passed, so the queued message appeared to disappear.
6000                //
6001                // Native pi's `steer()` enqueues unconditionally, even after
6002                // the run ends, and the message stays in the queue for the
6003                // next run. This matches the TS design and avoids the race
6004                // between `activeRun` clearing and the status check.
6005                if let Err(error) = lane.steer(message).await {
6006                    add_error_message(&chat, &format!("Could not queue message: {error}"));
6007                    tui.render_now(false);
6008                    return;
6009                }
6010                // Surface the queued entry in the pending dock immediately;
6011                // the drain-time refresh only fires on the next agent event.
6012                refresh_pending_messages(&state, &lane).await;
6013                tui.request_render(false);
6014            });
6015            // A queued prompt is echoed only once the loop actually consumes
6016            // it: the run's `AgentEvent::MessageStart` renders the user bubble
6017            // (native pi renders `message_start` for user messages). Until
6018            // then it is surfaced by the pending-messages display, so the
6019            // transcript never shows a message that is still waiting.
6020            ctx_for_cb.tui.request_render(false);
6021            return;
6022        }
6023
6024        if !ctx_for_cb.state.try_start_working() {
6025            return;
6026        }
6027
6028        // Render the user bubble here, NOT from `AgentEvent::MessageStart`.
6029        //
6030        // The harness persists the prompt itself and then drives
6031        // `run_agent_loop` with an EMPTY prompts vec (so the prompt is not
6032        // double-counted in the provider context), which means the loop emits
6033        // no `message_start` for it — see
6034        // `directly_sent_prompt_emits_no_user_message_start` in
6035        // `crates/pi-harness/tests/harness_run_e2e.rs`. Queued steering /
6036        // follow-up prompts DO get a `message_start` (the loop drains those
6037        // itself), so those are deliberately rendered from the event instead.
6038        add_user_message(&ctx_for_cb.chat, text);
6039        // A new prompt starts a fresh interaction at the tail even when the
6040        // user had scrolled up to inspect older output.
6041        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
6042            scroll.scroll_to_end();
6043        }
6044        ctx_for_cb.tui.request_render(false);
6045        // Remember the message for ↑ recall (slash commands are not part of
6046        // the replayable message history).
6047        push_history(&ctx_for_cb.state, text);
6048        if ctx_for_cb
6049            .tx
6050            .send(TuiMessage::UserInput(text.to_string()))
6051            .is_err()
6052        {
6053            ctx_for_cb.state.set_status(RunStatus::Idle);
6054        }
6055    }));
6056
6057    // Turn on the native-pi frame throttle before the first frame: from here on
6058    // `request_render(false)` parks a frame and the scheduler thread paints at
6059    // most one per `MIN_RENDER_INTERVAL_MS` (16 ms). A provider that streams one
6060    // `MessageUpdate` per delta would otherwise force a full transcript
6061    // re-render + full-viewport terminal write per token, which is what made
6062    // long output saturate the terminal (and its GPU compositor). Immediate
6063    // renders (selectors, the first frame, `render_now(..)`) are unaffected.
6064    tui.start_render_scheduler();
6065    tui.start_readerless();
6066
6067    // Consume local self-update results even when network checks are disabled.
6068    // Keep package discovery separate so package managers and Git remotes
6069    // cannot delay an rpi update notice or a prior helper failure.
6070    let mut update_check_handles = Vec::new();
6071    let rpi_chat = chat_container.clone();
6072    let rpi_tui = tui.clone();
6073    update_check_handles.push(tokio::spawn(async move {
6074        let report = crate::updates::check_rpi_startup().await;
6075        if !report.is_empty() {
6076            add_update_notices(&rpi_chat, &report);
6077        }
6078        rpi_tui.request_render(false);
6079    }));
6080
6081    if update_checks_enabled {
6082        let update_args = args.clone();
6083        let update_cwd = cwd.clone();
6084        let update_project_trusted = project_trusted;
6085        let chat = chat_container.clone();
6086        let tui = tui.clone();
6087        update_check_handles.push(tokio::spawn(async move {
6088            let resources = crate::session::package_resources_for_update_check(
6089                &update_args,
6090                &update_cwd,
6091                update_project_trusted,
6092            );
6093            let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
6094            {
6095                Ok(npm_command) => {
6096                    crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
6097                        Some(&resources),
6098                        &npm_command,
6099                        update_project_trusted.then_some(update_cwd.as_path()),
6100                    )
6101                    .await
6102                }
6103                Err(error) => {
6104                    add_error_message(
6105                        &chat,
6106                        &format!("npm package update checks disabled: {error}"),
6107                    );
6108                    let git_resources = git_only_update_resources(resources);
6109                    crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
6110                }
6111            };
6112            if !report.is_empty() {
6113                add_update_notices(&chat, &report);
6114            }
6115            tui.request_render(false);
6116        }));
6117    }
6118
6119    // ---- Streaming drain task ----
6120    let drain_handle = if let Some(rx) = event_rx {
6121        let tui_drain = tui.clone();
6122        let state_drain = state.clone();
6123        let chat_drain = chat_container.clone();
6124        let lane_drain = lane.clone();
6125        Some(tokio::spawn(async move {
6126            drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
6127        }))
6128    } else {
6129        None
6130    };
6131
6132    // ---- B5d: plugin→TUI reload bridge ----
6133    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
6134    // (its cdylib would be unmapped while the call frame is still on the stack).
6135    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
6136    // (an `UnboundedSender<()>`); this task drains those signals and forwards
6137    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
6138    // `reload_extension_resources` routine asynchronously. The mailbox is the
6139    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
6140    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
6141    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
6142    reload_context.mailbox.install(reload_sig_tx);
6143    let reload_tx = tx.clone();
6144    let reload_bridge_handle = tokio::spawn(async move {
6145        while reload_sig_rx.recv().await.is_some() {
6146            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
6147                break; // main loop gone — stop forwarding
6148            }
6149        }
6150    });
6151
6152    // ---- Render-tick task (advances the loader spinner while Working) ----
6153    //
6154    // The `Loader` only advances its frame on render; without a periodic
6155    // `request_render` the spinner visibly freezes between events.
6156    let tui_tick = tui.clone();
6157    let state_tick = state.clone();
6158    let tick_handle = tokio::spawn(async move {
6159        // 80ms per frame — native pi's `DEFAULT_INTERVAL_MS`, i.e. a full
6160        // 10-frame cycle every 800ms. The tick only *requests a repaint*; the
6161        // frame is advanced by `Editor::render` (mirroring `Loader::render`),
6162        // so streaming output — which schedules a frame per token — spins it
6163        // faster, and this interval is the floor when the model is silent.
6164        //
6165        // Advancing here as well would double-step every tick and make the idle
6166        // spinner run at 40ms/frame, twice native pi's rate.
6167        let mut interval = tokio::time::interval(std::time::Duration::from_millis(
6168            rpi_tui::loader::SPINNER_FRAME_MS,
6169        ));
6170        interval.tick().await; // discard immediate
6171        loop {
6172            interval.tick().await;
6173            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
6174            if working {
6175                if state_tick.bash_components.lock().unwrap().is_empty() {
6176                    // Only the dock loader animates. Keep the already-rendered
6177                    // transcript instead of rebuilding a long history at 12.5
6178                    // frames per second.
6179                    tui_tick.request_render_reusing_scroll_content();
6180                } else {
6181                    // A running bash panel owns a loader inside the transcript.
6182                    tui_tick.request_render(false);
6183                }
6184            }
6185        }
6186    });
6187
6188    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
6189    let running = Arc::new(std::sync::Mutex::new(true));
6190    let running_key = running.clone();
6191    let tx_for_key = tx.clone();
6192    let tui_for_key = tui.clone();
6193    let editor_for_key = editor.clone();
6194    let scroll_for_key = scroll_view.clone();
6195    let lane_for_key = lane.clone();
6196    let state_for_key = state.clone();
6197    let model_catalog_for_key = model_catalog_arc.clone();
6198    let js_for_key = reload_context.js_extension_session.clone();
6199    let js_dialog_for_key = js_dialog_bridge.clone();
6200    let ask_user_for_key = ask_user_bridge.clone();
6201    // Ctrl+L routes through the same registry as `/model` (one path, not two),
6202    // so the key loop needs the same `CommandContext` + registry the submit
6203    // handler uses. All fields are `Arc`/cheap, so this clone is free.
6204    let ctx_for_key = ctx.clone();
6205    let registry_for_key = registry.clone();
6206    let keybindings_for_key = keybindings.clone();
6207    let double_escape_action = crate::settings::load_settings()
6208        .ok()
6209        .and_then(|settings| settings.double_escape_action)
6210        .unwrap_or_else(|| "tree".to_string())
6211        .to_ascii_lowercase();
6212
6213    let key_handle = tokio::task::spawn_blocking(move || {
6214        let mut last_escape_time = None;
6215        // Paste-burst tracking (see the bare-Enter guard below): the instant of
6216        // the most recent key event that could have been pasted text.
6217        let mut last_text_key_at: Option<std::time::Instant> = None;
6218        // One event peeked at by the paste-burst probe (which must discard a
6219        // key's Release but never lose a meaningful event it read). Restored
6220        // at the top of the next loop iteration.
6221        let mut pending_event: Option<Event> = None;
6222        if crate::key_trace::enabled() {
6223            crate::key_trace::note(&format!(
6224                "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
6225                std::process::id(),
6226                std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
6227                crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
6228            ));
6229        }
6230        loop {
6231            if !*running_key.lock().unwrap() {
6232                break;
6233            }
6234            if !state_for_key.selector_open()
6235                && !state_for_key.extension_dialog_open()
6236                && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
6237            {
6238                if let Some(request) = js_dialog_for_key.take_pending() {
6239                    open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
6240                } else if let Some(request) = ask_user_for_key.take_pending() {
6241                    open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
6242                }
6243            }
6244            cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
6245            let ev = if let Some(ev) = pending_event.take() {
6246                ev
6247            } else {
6248                // `event::read()` blocks indefinitely. Poll first so shutdown can
6249                // stop and join this worker even when no further key arrives.
6250                match crossterm::event::poll(std::time::Duration::from_millis(50)) {
6251                    Ok(true) => {}
6252                    Ok(false) => continue,
6253                    Err(_) => {
6254                        state_for_key.cancel_js_preparation();
6255                        let _ = tx_for_key.send(TuiMessage::Exit);
6256                        break;
6257                    }
6258                }
6259                let Ok(ev) = crossterm::event::read() else {
6260                    state_for_key.cancel_js_preparation();
6261                    let _ = tx_for_key.send(TuiMessage::Exit);
6262                    break;
6263                };
6264                ev
6265            };
6266            // `Event::Resize` is delivered as its own event (not a Key). With
6267            // `start_readerless` there is no competing terminal-reader thread to
6268            // handle it, so refresh the cached terminal size here and force a
6269            // full redraw so the constrained layout re-fits the new dimensions.
6270            if let Event::Resize(_cols, _rows) = ev {
6271                tui_for_key.refresh_size();
6272                if let Some(js) = &js_for_key {
6273                    if js.custom_active() {
6274                        let _ = js.send_custom_resize(_cols as usize, _rows as usize);
6275                        tui_for_key.request_render(false);
6276                    }
6277                }
6278                continue;
6279            }
6280            // Mouse wheel scrolls the transcript (pi supports wheel
6281            // scrolling). Previously every non-Key event was dropped, so a
6282            // wheel had zero effect — "滚动还是不行".
6283            if let Event::Mouse(m) = ev {
6284                use crossterm::event::MouseEventKind;
6285                match m.kind {
6286                    MouseEventKind::ScrollUp => {
6287                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
6288                        if scroll_for_key.scroll_by(delta) != delta {
6289                            tui_for_key.request_render_reusing_scroll_content();
6290                        }
6291                    }
6292                    MouseEventKind::ScrollDown => {
6293                        let delta = MOUSE_WHEEL_SCROLL_LINES;
6294                        if scroll_for_key.scroll_by(delta) != delta {
6295                            tui_for_key.request_render_reusing_scroll_content();
6296                        }
6297                    }
6298                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
6299                        // Start selection tracking. `selection_end` doubles as the
6300                        // "a real drag happened" marker: a plain tap never sets
6301                        // it, so it can never overwrite the clipboard.
6302                        *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
6303                        *state_for_key.selection_end.lock().unwrap() = None;
6304                    }
6305                    MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
6306                        // Motion with the button held = a genuine selection drag.
6307                        // (Terminals only report this with button-event mouse
6308                        // tracking, which `ProcessTerminal` enables.)
6309                        if state_for_key.selection_start.lock().unwrap().is_some() {
6310                            *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
6311                        }
6312                    }
6313                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
6314                        // End selection and auto-copy if enabled
6315                        if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
6316                            let end = (m.column, m.row);
6317
6318                            // Only auto-copy when the pointer actually MOVED with
6319                            // the button held (`Drag` set `selection_end`). A plain
6320                            // click/tap — even one whose Down/Up cells differ by a
6321                            // pixel over RDP — must not touch the clipboard: it used
6322                            // to fire `extract_selected_text`, which copies whatever
6323                            // rendered TUI line sat under the pointer, silently
6324                            // clobbering the user's real clipboard so the next paste
6325                            // returned that stale text.
6326                            let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
6327                            *state_for_key.selection_end.lock().unwrap() = Some(end);
6328
6329                            // Check if auto-copy is enabled
6330                            let auto_copy = crate::settings::load_settings()
6331                                .ok()
6332                                .and_then(|s| s.fullscreen_copy_on_select)
6333                                .unwrap_or(true);
6334
6335                            if auto_copy && is_drag {
6336                                // Try to extract selected text from chat container
6337                                if let Some(selected_text) =
6338                                    extract_selected_text(&state_for_key.chat_container, start, end)
6339                                {
6340                                    if !selected_text.trim().is_empty() {
6341                                        let _ = copy_to_clipboard(&selected_text);
6342                                    }
6343                                }
6344                            }
6345                        }
6346                        *state_for_key.selection_start.lock().unwrap() = None;
6347                        *state_for_key.selection_end.lock().unwrap() = None;
6348                    }
6349                    _ => {}
6350                }
6351                continue;
6352            }
6353            let Event::Key(key) = ev else {
6354                if let Event::Paste(text) = ev {
6355                    crate::key_trace::note(&format!(
6356                        "paste event ({} bytes, bracketed paste supported)",
6357                        text.len()
6358                    ));
6359                    let candidate = text.trim().trim_matches(['\"', '\'']);
6360                    let path = std::path::PathBuf::from(candidate);
6361                    if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
6362                        if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
6363                            add_image_preview(&state_for_key.chat_container, &image);
6364                            state_for_key.queue_image(image);
6365                            add_note_message(
6366                                &state_for_key.chat_container,
6367                                "Dropped image attached to the next prompt.",
6368                            );
6369                            tui_for_key.request_render(false);
6370                            continue;
6371                        }
6372                    }
6373                    editor_for_key.insert(&text);
6374                    refresh_autocomplete(&state_for_key, &editor_for_key);
6375                    tui_for_key.request_render_reusing_scroll_content();
6376                }
6377                continue;
6378            };
6379            // Drop releases but preserve Repeat so holding arrows, Backspace,
6380            // PageUp, etc. behaves naturally. Windows emits Press + Release
6381            // for a tap; terminals with keyboard enhancement may additionally
6382            // emit Repeat while a key is held.
6383            if !should_dispatch_key(key.kind) {
6384                continue;
6385            }
6386            // Diagnostic trace (off unless RPI_DEBUG_KEYS is set): records what
6387            // actually arrived before any routing decision, so a client that
6388            // sends LF for Enter is visible as `Char('j') mods=CONTROL`.
6389            crate::key_trace::key(&key, "");
6390
6391            // Prompt preparation runs on a blocking worker before the agent
6392            // lane owns the turn. Cancel it directly: an abort queued only to
6393            // the lane cannot wake a JS factory or lifecycle hook that never
6394            // resolves. This check precedes custom/dialog routing because
6395            // those components may themselves have been opened by the hook.
6396            let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
6397                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
6398                || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
6399            if prompt_abort && state_for_key.cancel_js_preparation() {
6400                state_for_key.set_status(RunStatus::Aborting);
6401                if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
6402                    close_extension_editor(
6403                        &state_for_key,
6404                        &ctx_for_key.editor_container,
6405                        &editor_for_key,
6406                        &tui_for_key,
6407                    );
6408                }
6409                js_dialog_for_key.cancel_open_requests();
6410                tui_for_key.set_render_suspended(false);
6411                tui_for_key.request_render(false);
6412                continue;
6413            }
6414
6415            if let Some(js) = &js_for_key {
6416                if js.custom_active() {
6417                    let visible = js.custom_accepts_input();
6418                    let data = key_event_to_input(key);
6419                    if !data.is_empty() {
6420                        // A visible custom owns the whole key stream, so its
6421                        // acknowledgement is unnecessary and would add a
6422                        // synchronous round-trip to every keystroke. Hidden
6423                        // overlays need the consume result to decide whether the
6424                        // outer editor should see the key.
6425                        if visible {
6426                            let _ = js.send_custom_input(&data);
6427                            continue;
6428                        }
6429                        let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
6430                        // A hidden component only keeps raw listeners alive (for
6431                        // example ask_user_question's reopen shortcut); an
6432                        // unconsumed key continues through the outer editor.
6433                        if consumed {
6434                            tui_for_key.request_render_reusing_scroll_content();
6435                            continue;
6436                        }
6437                    }
6438                }
6439            }
6440
6441            // Ctrl+C cancels an open selector before it reaches the global
6442            // abort/exit handler. Route through Esc so selector callbacks run.
6443            if key.modifiers == KeyModifiers::CONTROL
6444                && key.code == KeyCode::Char('c')
6445                && state_for_key.selector_open()
6446            {
6447                let selector = state_for_key
6448                    .active_selector
6449                    .lock()
6450                    .unwrap()
6451                    .clone()
6452                    .expect("selector_open guaranteed Some")
6453                    .0;
6454                selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6455                tui_for_key.request_render_reusing_scroll_content();
6456                continue;
6457            }
6458
6459            // Extension dialogs own the input slot while awaiting a result.
6460            // Esc and Ctrl+C both resolve the pending command with cancel;
6461            // all other keys go to the active native editor/input widget.
6462            if state_for_key.extension_dialog_open() {
6463                let cancel = key.code == KeyCode::Esc
6464                    || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
6465                if cancel {
6466                    if !run_extension_cancel(&state_for_key) {
6467                        close_extension_editor(
6468                            &state_for_key,
6469                            &ctx_for_key.editor_container,
6470                            &editor_for_key,
6471                            &tui_for_key,
6472                        );
6473                    }
6474                } else if let Some(extension_editor) = state_for_key
6475                    .active_extension_editor
6476                    .lock()
6477                    .unwrap()
6478                    .clone()
6479                {
6480                    extension_editor.handle_key(key);
6481                } else if let Some(extension_input) =
6482                    state_for_key.active_extension_input.lock().unwrap().clone()
6483                {
6484                    extension_input.handle_key(key);
6485                }
6486                tui_for_key.request_render_reusing_scroll_content();
6487                continue;
6488            }
6489
6490            // 0. Ctrl+C: copy the selection when the editor has one (pi
6491            //    `tui.input.copy`); otherwise abort an active run, or exit
6492            //    when idle. Open selectors and extension dialogs are handled
6493            //    above so their cancellation callbacks get first chance.
6494            if keybinding_matches(
6495                &keybindings_for_key,
6496                &key,
6497                rpi_tui::keybindings::keys::CLEAR,
6498            ) {
6499                if !state_for_key.selector_open() && editor_for_key.has_selection() {
6500                    editor_for_key.copy_selection();
6501                    continue;
6502                }
6503                let status = *state_for_key.status.lock().unwrap();
6504                match status {
6505                    RunStatus::Working => {
6506                        state_for_key.set_status(RunStatus::Aborting);
6507                        // Drop any outstanding ask_user prompt from this turn.
6508                        ask_user_for_key.cancel_all();
6509                        // Restore the editor if an extension dialog was occupying it.
6510                        if !run_extension_cancel(&state_for_key)
6511                            && state_for_key.extension_dialog_open()
6512                        {
6513                            close_extension_editor(
6514                                &state_for_key,
6515                                &ctx_for_key.editor_container,
6516                                &editor_for_key,
6517                                &tui_for_key,
6518                            );
6519                        }
6520                        let lane = lane_for_key.clone();
6521                        tokio::spawn(async move {
6522                            let _ = lane.abort().await;
6523                        });
6524                    }
6525                    // A held Ctrl+C can emit Repeat immediately after Press.
6526                    // Keep waiting for the in-flight cancellation instead of
6527                    // treating that repeat as a request to exit the process.
6528                    RunStatus::Aborting => {
6529                        // A second, *deliberate* Ctrl+C. The first press only
6530                        // got as far as `Aborting`, which means the abort has
6531                        // not landed yet — and the async loop is still inside
6532                        // `run_prompt_streaming(..).await`, so a queued
6533                        // `TuiMessage::Exit` would not be read until the run
6534                        // returns, which is exactly what is not happening.
6535                        //
6536                        // Exit here instead: the TUI must never be unquittable
6537                        // (a provider request or tool that never returns would
6538                        // otherwise trap the user forever). Auto-repeat is
6539                        // filtered out, so holding the key through a slow abort
6540                        // cannot kill the process by accident.
6541                        if key.kind != KeyEventKind::Repeat {
6542                            emergency_exit(&tui_for_key);
6543                        }
6544                    }
6545                    RunStatus::Idle => {
6546                        let _ = tx_for_key.send(TuiMessage::Exit);
6547                    }
6548                }
6549                continue;
6550            }
6551
6552            // 1. A selector overlay is open → route to it first. Only Esc
6553            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
6554            //    escape to the selector; on done/cancel the selector callbacks
6555            //    restore the editor and clear `active_selector`.
6556            if state_for_key.selector_open() {
6557                // Esc always cancels the selector (even with modifiers off).
6558                // Route through `SelectList::handle_key(Esc)` so the list's
6559                // `on_cancel` fires (the `/scoped-models` toggle selector saves
6560                // its edits there) — the old shortcut called `close_selector`
6561                // directly and skipped the callback.
6562                if key.code == KeyCode::Esc {
6563                    let (selector, _kind) = state_for_key
6564                        .active_selector
6565                        .lock()
6566                        .unwrap()
6567                        .clone()
6568                        .expect("selector_open guaranteed Some");
6569                    selector.handle_key(key);
6570                    continue;
6571                }
6572                let (selector, _kind) = state_for_key
6573                    .active_selector
6574                    .lock()
6575                    .unwrap()
6576                    .clone()
6577                    .expect("selector_open guaranteed Some");
6578                selector.handle_key(key);
6579                tui_for_key.request_render_reusing_scroll_content();
6580                continue;
6581            }
6582
6583            // 2a. Ctrl+D: pi's deleteCharForward inside the editor (mirrors
6584            //     `tui.editor.deleteCharForward`), and EOF-quit on an empty
6585            //     editor. With a run active, abort it first (same as Ctrl+C)
6586            //     so the key is never a no-op while a stuck command runs.
6587            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
6588                let status = *state_for_key.status.lock().unwrap();
6589                match status {
6590                    RunStatus::Working => {
6591                        state_for_key.set_status(RunStatus::Aborting);
6592                        ask_user_for_key.cancel_all();
6593                        if !run_extension_cancel(&state_for_key)
6594                            && state_for_key.extension_dialog_open()
6595                        {
6596                            close_extension_editor(
6597                                &state_for_key,
6598                                &ctx_for_key.editor_container,
6599                                &editor_for_key,
6600                                &tui_for_key,
6601                            );
6602                        }
6603                        let lane = lane_for_key.clone();
6604                        tokio::spawn(async move {
6605                            let _ = lane.abort().await;
6606                        });
6607                        continue;
6608                    }
6609                    RunStatus::Aborting => continue,
6610                    RunStatus::Idle => {}
6611                }
6612                if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
6613                    // Editor holds text — delete the char forward (pi parity).
6614                    editor_for_key.handle_key(key);
6615                    refresh_autocomplete(&state_for_key, &editor_for_key);
6616                    tui_for_key.request_render_reusing_scroll_content();
6617                    continue;
6618                }
6619                let _ = tx_for_key.send(TuiMessage::Exit);
6620                continue;
6621            }
6622
6623            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
6624            //     selector is open Esc already cancelled it above; when idle,
6625            //     Esc falls through to the editor (no-op-ish). Only fire while
6626            //     Working so an idle Esc doesn't abort a non-existent run.
6627            if keybinding_matches(
6628                &keybindings_for_key,
6629                &key,
6630                rpi_tui::keybindings::keys::INTERRUPT,
6631            ) {
6632                let status = *state_for_key.status.lock().unwrap();
6633                if status == RunStatus::Working {
6634                    state_for_key.set_status(RunStatus::Aborting);
6635                    ask_user_for_key.cancel_all();
6636                    if !run_extension_cancel(&state_for_key)
6637                        && state_for_key.extension_dialog_open()
6638                    {
6639                        close_extension_editor(
6640                            &state_for_key,
6641                            &ctx_for_key.editor_container,
6642                            &editor_for_key,
6643                            &tui_for_key,
6644                        );
6645                    }
6646                    let lane = lane_for_key.clone();
6647                    tokio::spawn(async move {
6648                        let _ = lane.abort().await;
6649                    });
6650                    continue;
6651                }
6652                // A running `!command` takes Esc next (native pi's `onEscape`
6653                // precedence: streaming → bash → bash-mode editor →
6654                // double-escape). The run slot stays occupied until the capture
6655                // resolves, so a second Esc is a no-op rather than a
6656                // double-escape trigger.
6657                if state_for_key.user_bash_running() {
6658                    state_for_key.cancel_user_bash();
6659                    tui_for_key.request_render(false);
6660                    continue;
6661                }
6662                if status == RunStatus::Idle
6663                    && editor_for_key.get_text().trim().is_empty()
6664                    && double_escape_action != "none"
6665                {
6666                    let now = std::time::Instant::now();
6667                    if double_escape_trigger(last_escape_time, now) {
6668                        last_escape_time = None;
6669                        match double_escape_action.as_str() {
6670                            "tree" => {
6671                                let _ = tx_for_key.send(TuiMessage::OpenTree);
6672                            }
6673                            "fork" => {
6674                                let _ = tx_for_key.send(TuiMessage::ForkSession);
6675                            }
6676                            _ => {}
6677                        }
6678                    } else {
6679                        last_escape_time = Some(now);
6680                    }
6681                }
6682                continue;
6683            }
6684
6685            // 2c. Ctrl+G: edit the current draft in the user's external
6686            // editor, matching native Pi's VISUAL/EDITOR fallback chain.
6687            if keybinding_matches(
6688                &keybindings_for_key,
6689                &key,
6690                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
6691            ) {
6692                launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
6693                continue;
6694            }
6695
6696            // 2d. Ctrl+O: toggle all tool output panels between compact and
6697            // expanded rendering (native Pi's global output toggle).
6698            if keybinding_matches(
6699                &keybindings_for_key,
6700                &key,
6701                rpi_tui::keybindings::keys::TOOLS_EXPAND,
6702            ) {
6703                let expanded = state_for_key.toggle_tool_outputs();
6704                // Persist per-session so a later `-c`/`-r` restores it.
6705                let session = ctx_for_key.session.clone();
6706                tokio::spawn(async move {
6707                    let _ = session
6708                        .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
6709                        .await;
6710                });
6711                tui_for_key.request_render(false);
6712                continue;
6713            }
6714
6715            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
6716            if keybinding_matches(
6717                &keybindings_for_key,
6718                &key,
6719                rpi_tui::keybindings::keys::THINKING_TOGGLE,
6720            ) {
6721                let hide = state_for_key.toggle_thinking();
6722                // Persist per-session so a later `-c`/`-r` restores it.
6723                let session = ctx_for_key.session.clone();
6724                tokio::spawn(async move {
6725                    let _ = session
6726                        .set_value("display.hide_thinking", serde_json::json!(hide))
6727                        .await;
6728                });
6729                tui_for_key.request_render(false);
6730                continue;
6731            }
6732
6733            // 2f. Ctrl+M: cycle to the next model in the catalog after the one
6734            //     currently tracked in `current_model_id`, apply it live via
6735            //     `lane.set_model` (takes effect on the next user message — the
6736            //     in-flight run's config is already snapshotted), and update the
6737            //     footer. `set_model` is async so it runs on a spawned task.
6738            if keybinding_matches(
6739                &keybindings_for_key,
6740                &key,
6741                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6742            ) {
6743                let current = state_for_key.current_model_id();
6744                // Cycle within the `/scoped-models` set (settings.json) when
6745                // configured; otherwise the full catalog.
6746                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
6747                if let Some(next) = cycle_next_model(&scope, &current) {
6748                    state_for_key.set_current_model(&next);
6749                    let lane = lane_for_key.clone();
6750                    tokio::spawn(async move {
6751                        let _ = lane.set_model(next).await;
6752                    });
6753                    tui_for_key.request_render_reusing_scroll_content();
6754                }
6755                continue;
6756            }
6757
6758            // 2f. Shift+Tab / BackTab: cycle the current model's supported
6759            // thinking levels, matching native Pi's thinking-level shortcut.
6760            if keybinding_matches(
6761                &keybindings_for_key,
6762                &key,
6763                rpi_tui::keybindings::keys::THINKING_CYCLE,
6764            ) {
6765                let lane = lane_for_key.clone();
6766                let catalog = model_catalog_for_key.clone();
6767                let state = state_for_key.clone();
6768                tokio::spawn(async move {
6769                    let Ok(current_model) = lane.get_model().await else {
6770                        return;
6771                    };
6772                    let levels = catalog
6773                        .iter()
6774                        .find(|model| {
6775                            model.provider == current_model.provider && model.id == current_model.id
6776                        })
6777                        .map(|model| model.supported_thinking_levels())
6778                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6779                    if levels.is_empty() {
6780                        return;
6781                    }
6782                    let current = lane
6783                        .get_thinking_level()
6784                        .await
6785                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6786                    let next = levels
6787                        .iter()
6788                        .position(|level| *level == current)
6789                        .map(|index| levels[(index + 1) % levels.len()])
6790                        .unwrap_or(levels[0]);
6791                    if lane.set_thinking_level(next).await.is_ok() {
6792                        state
6793                            .footer
6794                            .set_thinking_level(Some(thinking_level_name(next)));
6795                        state.tui.as_ref().map(|tui| tui.request_render(false));
6796                    }
6797                });
6798                continue;
6799            }
6800
6801            // Ctrl+V (or a configured paste-image key): if the system
6802            // clipboard holds an image, queue it for the next prompt;
6803            // otherwise paste the clipboard TEXT into the editor. Previously
6804            // a text-only clipboard fell through to the editor's Ctrl+V,
6805            // which yanks from the internal KILL RING — not the system
6806            // clipboard. On platforms without bracketed-paste `Event::Paste`
6807            // (Windows console, some remote clients) that meant Ctrl+V pasted
6808            // stale/other content instead of the user's real clipboard
6809            // ("不是系统的粘贴的内容").
6810            if keybinding_matches(
6811                &keybindings_for_key,
6812                &key,
6813                rpi_tui::keybindings::keys::PASTE_IMAGE,
6814            ) {
6815                match read_clipboard_image() {
6816                    Ok(Some(image)) => {
6817                        add_image_preview(&state_for_key.chat_container, &image);
6818                        state_for_key.queue_image(image);
6819                        add_note_message(
6820                            &state_for_key.chat_container,
6821                            "Clipboard image attached to the next prompt.",
6822                        );
6823                        tui_for_key.request_render(false);
6824                        continue;
6825                    }
6826                    Ok(None) | Err(_) => {}
6827                }
6828                // The arboard text fallback is Windows-only. On Unix a real
6829                // paste arrives as `Event::Paste` (bracketed paste is enabled
6830                // by `ProcessTerminal::enter_raw_mode`), so reading the
6831                // SERVER's clipboard here would paste stale TUI content on a
6832                // remote/mobile client — copy-on-select can have written the
6833                // welcome "Skills (…)" line into the server clipboard, and
6834                // Ctrl+V on the client returns that instead of the user's own
6835                // clipboard ("不是系统的粘贴的内容"). On Windows the console
6836                // never emits `Event::Paste`, so the arboard read is the only
6837                // paste source and must stay.
6838                if cfg!(windows) {
6839                    if let Some(text) = read_clipboard_text() {
6840                        if !text.is_empty() {
6841                            editor_for_key.insert(&text);
6842                            refresh_autocomplete(&state_for_key, &editor_for_key);
6843                            tui_for_key.request_render_reusing_scroll_content();
6844                            continue;
6845                        }
6846                    }
6847                }
6848            }
6849
6850            // 3. Ctrl+Shift+F: open transcript search
6851            if key
6852                .modifiers
6853                .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6854                && key.code == KeyCode::Char('F')
6855            {
6856                state_for_key.search.activate();
6857                state_for_key.search_bar.set_visible(true);
6858                state_for_key.search_bar.set_query("");
6859                state_for_key.search_bar.set_match_info(0, 0);
6860                tui_for_key.request_render(false);
6861                continue;
6862            }
6863
6864            // Handle search mode input
6865            if state_for_key.search.is_active() {
6866                let search_bar = state_for_key.search_bar.clone();
6867                match key.code {
6868                    KeyCode::Esc => {
6869                        state_for_key.search.deactivate();
6870                        search_bar.set_visible(false);
6871                        tui_for_key.request_render(false);
6872                        continue;
6873                    }
6874                    KeyCode::Enter => {
6875                        if key.modifiers.contains(KeyModifiers::SHIFT) {
6876                            state_for_key.search.previous_match();
6877                        } else {
6878                            state_for_key.search.next_match();
6879                        }
6880                        // Update search bar with current match info
6881                        let match_index = state_for_key.search.get_match_index();
6882                        let match_count = state_for_key.search.get_match_count();
6883                        search_bar.set_match_info(match_index, match_count);
6884                        tui_for_key.request_render(false);
6885                        continue;
6886                    }
6887                    KeyCode::Backspace => {
6888                        state_for_key.search.backspace();
6889                        // Re-search with updated query
6890                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6891                        state_for_key.search.find_matches(&lines);
6892                        // Update search bar
6893                        let query = state_for_key.search.get_query();
6894                        let match_count = state_for_key.search.get_match_count();
6895                        search_bar.set_query(&query);
6896                        search_bar.set_match_info(0, match_count);
6897                        tui_for_key.request_render(false);
6898                        continue;
6899                    }
6900                    KeyCode::Char(c) => {
6901                        state_for_key.search.append_char(c);
6902                        // Re-search with updated query
6903                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6904                        state_for_key.search.find_matches(&lines);
6905                        // Update search bar
6906                        let query = state_for_key.search.get_query();
6907                        let match_count = state_for_key.search.get_match_count();
6908                        search_bar.set_query(&query);
6909                        search_bar.set_match_info(0, match_count);
6910                        tui_for_key.request_render(false);
6911                        continue;
6912                    }
6913                    _ => continue,
6914                }
6915            }
6916
6917            // 3. Ctrl+L: open the model selector. Routed through the `/model`
6918            //    command so the hotkey and the slash command share one path
6919            //    (TS binds Ctrl+L to model-select).
6920            if keybinding_matches(
6921                &keybindings_for_key,
6922                &key,
6923                rpi_tui::keybindings::keys::MODEL_SELECT,
6924            ) {
6925                if let Some(cmd) = registry_for_key.find("/model") {
6926                    cmd.execute(&ctx_for_key, "");
6927                }
6928                continue;
6929            }
6930
6931            // 4. Tab: accept the top autocomplete suggestion (if any).
6932            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6933                if accept_top_suggestion(&state_for_key, &editor_for_key) {
6934                    tui_for_key.request_render_reusing_scroll_content();
6935                }
6936                continue;
6937            }
6938
6939            // 5. Global transcript scroll. PageUp/PageDown use the actual
6940            // viewport height with four rows of overlap (upstream behavior),
6941            // while Home/End jump to the transcript boundaries.
6942            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6943                let delta = -transcript_page_size(scroll_for_key.viewport_height());
6944                if scroll_for_key.scroll_by(delta) != delta {
6945                    tui_for_key.request_render_reusing_scroll_content();
6946                }
6947                continue;
6948            }
6949            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6950                let delta = transcript_page_size(scroll_for_key.viewport_height());
6951                if scroll_for_key.scroll_by(delta) != delta {
6952                    tui_for_key.request_render_reusing_scroll_content();
6953                }
6954                continue;
6955            }
6956            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
6957                scroll_for_key.scroll_to_start();
6958                tui_for_key.request_render_reusing_scroll_content();
6959                continue;
6960            }
6961            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
6962                scroll_for_key.scroll_to_end();
6963                tui_for_key.request_render_reusing_scroll_content();
6964                continue;
6965            }
6966
6967            // 5b. ↑/↓ browse submitted-message history when the editor is
6968            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
6969            //     without the surprise of replacing typed text. When the
6970            //     editor holds content, ↑/↓ fall through to cursor movement
6971            //     (typing "hello", pressing ↑ at the start, must never swap
6972            //     the draft for a history entry — reported as "text
6973            //     disappeared"). Once browsing, ↓ walks back and restores the
6974            //     draft.
6975            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
6976                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6977                if editor_for_key.get_text().is_empty() || browsing {
6978                    navigate_history(&state_for_key, &editor_for_key, -1);
6979                    tui_for_key.request_render_reusing_scroll_content();
6980                    continue;
6981                }
6982            }
6983            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
6984                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6985                if editor_for_key.get_text().is_empty() || browsing {
6986                    navigate_history(&state_for_key, &editor_for_key, 1);
6987                    tui_for_key.request_render_reusing_scroll_content();
6988                    continue;
6989                }
6990            }
6991
6992            // `app.message.dequeue` (Alt+Q on Windows, Alt+Up elsewhere):
6993            // restore every queued steering/follow-up message into the editor
6994            // so it can be edited before resending. Mirrors native pi's
6995            // `handleDequeue`; the pending-messages display shows the hint.
6996            if keybinding_matches(
6997                &keybindings_for_key,
6998                &key,
6999                rpi_tui::keybindings::keys::DEQUEUE,
7000            ) {
7001                // The main loop is the source of truth: it clears the lane's
7002                // queue and restores whatever was there (a no-op when empty).
7003                let _ = tx_for_key.send(TuiMessage::Dequeue);
7004                continue;
7005            }
7006
7007            // Alt+Enter queues a follow-up while a run is active. It is
7008            // handled here because Editor treats only a bare Enter as submit;
7009            // idle Alt+Enter keeps the normal prompt behavior.
7010            if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
7011                let prompt = editor_for_key.get_text().trim().to_string();
7012                if prompt.is_empty() {
7013                    continue;
7014                }
7015                editor_for_key.clear();
7016                let status = *state_for_key.status.lock().unwrap();
7017                if status == RunStatus::Idle {
7018                    if state_for_key.try_start_working() {
7019                        // Direct send: the harness emits no user `message_start`
7020                        // for it, so render the bubble here (see the submit
7021                        // handler in `interactive_tui`).
7022                        add_user_message(&state_for_key.chat_container, &prompt);
7023                        push_history(&state_for_key, &prompt);
7024                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
7025                    }
7026                } else {
7027                    // Follow-ups are echoed by the loop's
7028                    // `AgentEvent::MessageStart` once they are consumed; while
7029                    // they wait, the pending-messages display shows them.
7030                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
7031                    let lane = lane_for_key.clone();
7032                    let chat = state_for_key.chat_container.clone();
7033                    let tui = tui_for_key.clone();
7034                    let state = state_for_key.clone();
7035                    tokio::spawn(async move {
7036                        if let Err(error) = lane.follow_up(message).await {
7037                            add_error_message(&chat, &format!("Could not queue message: {error}"));
7038                            tui.request_render(false);
7039                            return;
7040                        }
7041                        refresh_pending_messages(&state, &lane).await;
7042                        tui.request_render(false);
7043                    });
7044                }
7045                tui_for_key.request_render(false);
7046                continue;
7047            }
7048
7049            // ---- Paste-burst coalescing (Windows only) ----------------------
7050            // `crossterm` 0.27 implements bracketed paste ONLY on Unix: its
7051            // Windows console event source (`ReadConsoleInputW`) never emits
7052            // `Event::Paste`, so `?2004h` buys nothing there and a pasted block
7053            // arrives as ordinary key events with a bare Enter per line. The
7054            // editor treats a bare Enter as "submit", so one paste became N
7055            // messages. See [`enter_is_paste_burst`] for the discriminator.
7056            //
7057            // On Unix the timing heuristic is both unnecessary AND harmful: a
7058            // remote/mobile client (SSH, soft keyboard) coalesces typed text +
7059            // Enter into one network burst, so the Enter lands within
7060            // [`PASTE_BURST_GAP`] of the last character and the heuristic
7061            // misclassifies it as pasted content — "回车变成了换行". Real pastes
7062            // on Unix arrive as `Event::Paste` (bracketed paste is enabled by
7063            // `ProcessTerminal::enter_raw_mode`), so gating to Windows restores
7064            // remote Enter without losing the Windows paste fix.
7065            if cfg!(windows)
7066                && key.modifiers.is_empty()
7067                && matches!(
7068                    key.code,
7069                    KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
7070                )
7071            {
7072                // Probe for a *real* queued key. Windows queues each key's
7073                // Release event directly behind its Press, and a bare
7074                // non-blocking poll cannot tell the two apart — treating that
7075                // Release as "more input" made every Enter on a remote/RDP
7076                // console look like a paste burst ("回车变成了换行"). See
7077                // [`probe_paste_input`].
7078                let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
7079                    if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
7080                        crossterm::event::read().ok()
7081                    } else {
7082                        None
7083                    }
7084                });
7085                pending_event = stashed;
7086                if enter_is_paste_burst(last_text_key_at, std::time::Instant::now(), more_queued) {
7087                    crate::key_trace::note(&format!(
7088                        "enter-decision -> newline (paste burst) gap_ms={} more_queued={more_queued}",
7089                        gap_ms(last_text_key_at),
7090                    ));
7091                    // Pasted newline: insert it and keep the remaining queued
7092                    // events flowing through this same path.
7093                    editor_for_key.insert("\n");
7094                    last_text_key_at = Some(std::time::Instant::now());
7095                    refresh_autocomplete(&state_for_key, &editor_for_key);
7096                    tui_for_key.request_render_reusing_scroll_content();
7097                    continue;
7098                }
7099                crate::key_trace::note(&format!(
7100                    "enter-decision -> submit gap_ms={} more_queued={more_queued}",
7101                    gap_ms(last_text_key_at),
7102                ));
7103                // A real submit ends the burst so a follow-up Enter is not
7104                // mistaken for paste continuation.
7105                last_text_key_at = None;
7106            } else if matches!(
7107                key.code,
7108                KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
7109            ) && !key
7110                .modifiers
7111                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
7112            {
7113                // Unmodified text keys extend the burst. Ctrl/Alt chords
7114                // (Ctrl+C, Alt+Enter, …) are commands, not paste content.
7115                last_text_key_at = Some(std::time::Instant::now());
7116            } else {
7117                // Arrows, Esc, Ctrl/Alt chords, PageUp/Down, … — not paste
7118                // content; break the burst.
7119                last_text_key_at = None;
7120            }
7121
7122            // 6. Otherwise forward to the editor + refresh autocomplete. The
7123            //    editor maps unmodified Enter and the encodings a remote client
7124            //    may send for it (`Char('\n')`/`Char('\r')`, and the raw LF
7125            //    crossterm reports as `Ctrl+J`) to submit, so a remote Enter
7126            //    sends instead of inserting a newline.
7127            editor_for_key.handle_key(key);
7128            refresh_autocomplete(&state_for_key, &editor_for_key);
7129            tui_for_key.request_render_reusing_scroll_content();
7130        }
7131    });
7132
7133    // ---- Session lifecycle (P1): SessionStart ----
7134    // The TUI + key worker are up and the session is ready to accept input.
7135    // Notify subscribed extensions now so they can initialize session-scoped
7136    // state. A veto here is **advisory** (the session is already up) — warn and
7137    // continue. No-op when no extension subscribes.
7138    if let Some(reason) = crate::session::dispatch_session_event_async(
7139        reload_context,
7140        rpi_plugin_sdk::EventTag::SessionStart,
7141    )
7142    .await
7143    {
7144        tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
7145    }
7146
7147    // ---- Initial prompts (run before reading from the channel) ----
7148    let mut prompts: Vec<String> = Vec::new();
7149    if let Some(init) = initial {
7150        prompts.push(init);
7151    }
7152    for m in extra_messages {
7153        prompts.push(m.clone());
7154    }
7155    // ---- Continue a run that recovery repaired, the way native pi does ----
7156    // This happens before any user input, and only when nothing was passed on the
7157    // command line: an explicit `-p` means the user has already said what they
7158    // want next. Driven here rather than inside the harness so the run's output
7159    // goes through the same streaming/render path as any other run, and so
7160    // startup is never blocked by a run the user cannot see.
7161    if prompts.is_empty() && lane.has_pending_resume().await {
7162        run_prompt_streaming(
7163            &lane,
7164            "",
7165            true,
7166            &tui,
7167            &state,
7168            drain_handle.is_some(),
7169            reload_context.js_extension_session.as_ref(),
7170            &js_dialog_bridge,
7171            args,
7172            Vec::new(),
7173        )
7174        .await;
7175    }
7176
7177    let mut images = initial_images;
7178    for prompt in prompts {
7179        if !*running.lock().unwrap() {
7180            break;
7181        }
7182        // The harness emits no user `message_start` for a directly-sent prompt
7183        // (it drives the loop with an empty prompts vec), so the initial
7184        // `-p` / `--prompt` bubbles are rendered here.
7185        add_user_message(&chat_container, &prompt);
7186        tui.request_render(false);
7187        run_prompt_streaming(
7188            &lane,
7189            &prompt,
7190            false,
7191            &tui,
7192            &state,
7193            drain_handle.is_some(),
7194            reload_context.js_extension_session.as_ref(),
7195            &js_dialog_bridge,
7196            args,
7197            std::mem::take(&mut images),
7198        )
7199        .await;
7200    }
7201
7202    // ---- Main loop: process submitted input + lifecycle messages ----
7203    loop {
7204        if !*running.lock().unwrap() {
7205            break;
7206        }
7207        match rx.recv().await {
7208            Some(TuiMessage::UserInput(prompt)) => {
7209                // Clear the editor so the next prompt starts fresh (the submit
7210                // handler runs on the blocking key thread and can't mutate the
7211                // editor state safely there; clearing here, on the async loop,
7212                // keeps it on one thread).
7213                editor.clear();
7214                let prompt_images = state.take_pending_images();
7215                if !prompt_images.is_empty() {
7216                    add_note_message(
7217                        &chat_container,
7218                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
7219                    );
7220                }
7221                run_prompt_streaming(
7222                    &lane,
7223                    &prompt,
7224                    false,
7225                    &tui,
7226                    &state,
7227                    drain_handle.is_some(),
7228                    reload_context.js_extension_session.as_ref(),
7229                    &js_dialog_bridge,
7230                    args,
7231                    prompt_images,
7232                )
7233                .await;
7234            }
7235            Some(TuiMessage::ExternalEditorResult(result)) => {
7236                match result {
7237                    Ok(text) => {
7238                        let cursor = text.chars().count();
7239                        editor.set_text(&text);
7240                        editor.set_cursor(0, cursor);
7241                        add_note_message(&chat_container, "Draft updated from external editor.");
7242                    }
7243                    Err(error) => add_error_message(&chat_container, &error),
7244                }
7245                tui.request_render(false);
7246            }
7247            Some(TuiMessage::Dequeue) => {
7248                // `app.message.dequeue`: pull every queued steering/follow-up
7249                // message out of the lane and restore it to the editor so the
7250                // user can edit before resending (native pi's
7251                // `restoreQueuedMessagesToEditor`). The pending display is
7252                // cleared to match.
7253                match lane.clear_queue().await {
7254                    Ok(queued) => {
7255                        let all: Vec<String> = queued
7256                            .steering
7257                            .iter()
7258                            .chain(queued.follow_up.iter())
7259                            .cloned()
7260                            .collect();
7261                        if !all.is_empty() {
7262                            let queued_text = all.join("\n\n");
7263                            let current = editor.get_text();
7264                            let combined = if current.trim().is_empty() {
7265                                queued_text
7266                            } else {
7267                                format!("{queued_text}\n\n{current}")
7268                            };
7269                            let cursor = combined.chars().count();
7270                            editor.set_text(&combined);
7271                            editor.set_cursor(0, cursor);
7272                        }
7273                        state.set_pending_queue(
7274                            rpi_harness::agent_harness::QueuedMessages::default(),
7275                        );
7276                        tui.request_render(false);
7277                    }
7278                    Err(error) => {
7279                        add_error_message(
7280                            &chat_container,
7281                            &format!("Could not restore queued messages: {error}"),
7282                        );
7283                        tui.request_render(false);
7284                    }
7285                }
7286            }
7287            Some(TuiMessage::UserBashFinished(report)) => {
7288                // Free the run slot first: the guard and the Esc router both
7289                // read it, so a finished command must never block the next one.
7290                state.finish_user_bash();
7291                let message = rpi_harness::messages::bash_execution_message(
7292                    report.command,
7293                    report.output,
7294                    report.exit_code,
7295                    report.cancelled,
7296                    report.truncated,
7297                    report.full_output_path,
7298                    Some(report.exclude_from_context),
7299                    now_ms(),
7300                );
7301                // While a run is in flight the message is deferred so it is not
7302                // spliced into the middle of a turn's tool sequence; it is
7303                // flushed at `AgentEnd` (native pi `_pendingBashMessages`).
7304                if *state.status.lock().unwrap() == RunStatus::Idle {
7305                    if let Err(error) = lane.append_message(message).await {
7306                        add_error_message(
7307                            &chat_container,
7308                            &format!("Could not record bash result: {error}"),
7309                        );
7310                        tui.request_render(false);
7311                    }
7312                } else {
7313                    state.pending_bash_messages.lock().unwrap().push(message);
7314                }
7315            }
7316            Some(TuiMessage::OpenTree) => {
7317                if *state.status.lock().unwrap() != RunStatus::Idle {
7318                    add_note_message(
7319                        &chat_container,
7320                        "Wait for the current run to finish before opening the tree.",
7321                    );
7322                    tui.request_render(false);
7323                } else {
7324                    open_tree_selector(
7325                        &harness,
7326                        &state,
7327                        &editor_container,
7328                        &editor,
7329                        &tui,
7330                        &chat_container,
7331                        &tx,
7332                    )
7333                    .await;
7334                }
7335            }
7336            Some(TuiMessage::NavigateTree(entry_id)) => {
7337                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
7338                    Ok(result) => match result.outcome {
7339                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
7340                            chat_container.clear();
7341                            add_welcome_message(&chat_container);
7342                            render_session_history(
7343                                &harness,
7344                                &chat_container,
7345                                state.markdown_transformer(),
7346                                Some(state.extension_session.clone()),
7347                            )
7348                            .await;
7349                            add_note_message(
7350                                &chat_container,
7351                                "Moved to the selected session entry.",
7352                            );
7353                        }
7354                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
7355                            add_error_message(&chat_container, &error.message);
7356                        }
7357                        _ => add_note_message(
7358                            &chat_container,
7359                            "The selected entry could not be opened.",
7360                        ),
7361                    },
7362                    Err(error) => add_error_message(
7363                        &chat_container,
7364                        &format!("Could not navigate session tree: {error}"),
7365                    ),
7366                }
7367                tui.request_render(false);
7368            }
7369            Some(TuiMessage::ClearChat) => {
7370                chat_container.clear();
7371                add_welcome_message(&chat_container);
7372                tui.request_render(false);
7373            }
7374            Some(TuiMessage::Compact) => {
7375                run_compact(&lane, &tui, &state).await;
7376            }
7377            Some(TuiMessage::Copy) => {
7378                copy_last_assistant(&state, &chat_container);
7379                tui.request_render(false);
7380            }
7381            Some(TuiMessage::Exit) => {
7382                *running.lock().unwrap() = false;
7383                break;
7384            }
7385            Some(TuiMessage::SwitchSession(id)) => {
7386                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
7387                tui.request_render(false);
7388            }
7389            Some(TuiMessage::ImportSession(path)) => {
7390                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
7391                tui.request_render(false);
7392            }
7393            Some(TuiMessage::ShareSession) => {
7394                share_session(&harness, &chat_container).await;
7395                tui.request_render(false);
7396            }
7397            Some(TuiMessage::SetSessionName(name)) => {
7398                let outcome = harness.session().set_name(Some(&name)).await;
7399                match outcome {
7400                    Ok(_) => add_note_message(
7401                        &chat_container,
7402                        &format!("Session renamed to \"{name}\"."),
7403                    ),
7404                    Err(e) => add_error_message(
7405                        &chat_container,
7406                        &format!("Could not rename session: {e}"),
7407                    ),
7408                }
7409                tui.request_render(false);
7410            }
7411            Some(TuiMessage::ExportSession) => {
7412                let file_name = agent_session
7413                    .default_export_file_name(crate::export::ExportFormat::Markdown)
7414                    .await;
7415                let path = cwd.join(&file_name);
7416                match agent_session.export_to_markdown(&path).await {
7417                    Ok(_) => add_note_message(
7418                        &chat_container,
7419                        &format!("Exported session to {}", path.display()),
7420                    ),
7421                    Err(e) => {
7422                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
7423                    }
7424                }
7425                tui.request_render(false);
7426            }
7427            Some(TuiMessage::ExportSessionWithFormat(format)) => {
7428                let file_name = agent_session.default_export_file_name(format.clone()).await;
7429                let path = cwd.join(&file_name);
7430                match agent_session.export(format, &path).await {
7431                    Ok(_) => add_note_message(
7432                        &chat_container,
7433                        &format!("Exported session to {}", path.display()),
7434                    ),
7435                    Err(e) => {
7436                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
7437                    }
7438                }
7439                tui.request_render(false);
7440            }
7441            Some(TuiMessage::ShowUsage) => {
7442                let stats = agent_session.usage_stats().await.ok();
7443                let total = agent_session.total_usage().await.ok();
7444                let snapshot = agent_session.snapshot().await.ok();
7445                match stats {
7446                    Some(stats) => {
7447                        let mut lines = vec![
7448                            "## Usage".to_string(),
7449                            String::new(),
7450                            format!("- messages: {}", stats.message_count),
7451                            format!("- total tokens: {}", stats.total_tokens),
7452                            format!("- cached tokens: {}", stats.cached_tokens),
7453                            format!("- uncached tokens: {}", stats.uncached_tokens),
7454                            format!("- cost: ${:.4}", stats.cost_total),
7455                        ];
7456                        match stats.cache_hit_ratio() {
7457                            Some(ratio) => {
7458                                lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
7459                            }
7460                            None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
7461                        }
7462                        if let Some(total) = total {
7463                            lines.push(String::new());
7464                            lines.push(format!(
7465                                "- last cumulative input/output: {} / {}",
7466                                total.input, total.output
7467                            ));
7468                        }
7469                        if let Some(snapshot) = snapshot {
7470                            lines.push(String::new());
7471                            lines.push(format!("- lane: `{}`", snapshot.lane));
7472                            lines.push(format!(
7473                                "- leaf: `{}`",
7474                                snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
7475                            ));
7476                            if let Some(active) = &snapshot.active {
7477                                lines.push(format!(
7478                                    "- active operation: {} (`{}`)",
7479                                    active.kind.as_str(),
7480                                    active.run_id
7481                                ));
7482                            }
7483                        }
7484                        add_note_message(&chat_container, &lines.join("\n"));
7485                    }
7486                    None => {
7487                        add_error_message(&chat_container, "Could not read session usage stats.")
7488                    }
7489                }
7490                tui.request_render(false);
7491            }
7492            Some(TuiMessage::ForkSession) => {
7493                fork_session(&harness, &cwd, &chat_container, &state).await;
7494                tui.request_render(false);
7495            }
7496            Some(TuiMessage::ReloadExtensions) => {
7497                // B5d: drive the shared reload routine on the async runtime,
7498                // then surface the outcome. `reload_context` was passed into
7499                // `interactive_tui` and is the same `Arc<ReloadContext>` the
7500                // `ReloadCommand` + the plugin mailbox both route through —
7501                // clone the `Arc` out so the borrow of `harness` (the main
7502                // loop's `&AgentHarness`) lives across the await.
7503                let reload_ctx = ctx.reload_context.clone();
7504                add_note_message(&chat_container, "Reloading extensions + resources…");
7505                tui.request_render(false);
7506                let outcome =
7507                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
7508                // B5e: the reload swapped a fresh `ExtensionSession` into the
7509                // context's cell. Rebuild the markdown transformer from that
7510                // fresh snapshot and install it on the in-flight streaming
7511                // component (so a reloaded plugin's transformer takes effect on
7512                // the visible message immediately) + future components (they
7513                // read `state.markdown_transformer()` at construction). The old
7514                // closure no-ops once its snapshot's `active` flag flips false
7515                // (reload already did that before the swap).
7516                let fresh_transformer = build_markdown_transformer(
7517                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
7518                );
7519                state.set_markdown_transformer_with_reinstall(fresh_transformer);
7520                if outcome.had_errors {
7521                    // Hard failure: the summary itself carries the error detail
7522                    // (e.g. "Settings reload failed: …"), so render it as an
7523                    // error without an extra stderr pointer.
7524                    add_error_message(&chat_container, &outcome.summary);
7525                } else if outcome.had_warnings {
7526                    // Reload succeeded but produced soft diagnostics (skill
7527                    // shadowing, name validation, …). The details are printed
7528                    // to stderr; keep this as an informational note so a
7529                    // successful reload isn't shown as a red ✗ error.
7530                    add_note_message(
7531                        &chat_container,
7532                        &format!(
7533                            "{} (with warnings — see stderr for details).",
7534                            outcome.summary
7535                        ),
7536                    );
7537                } else {
7538                    add_note_message(&chat_container, &outcome.summary);
7539                }
7540                tui.request_render(false);
7541            }
7542            None => break,
7543        }
7544    }
7545
7546    // ---- Shutdown ----
7547    // Wake any Node `ctx.ui.*` request that is still waiting on the dialog
7548    // bridge before joining the key worker and restoring the terminal.
7549    js_dialog_bridge.cancel_all();
7550    ask_user_bridge.shutdown();
7551    *running.lock().unwrap() = false;
7552    for handle in update_check_handles {
7553        handle.abort();
7554        let _ = handle.await;
7555    }
7556    // A hidden custom input listener can leave the key worker blocked on a
7557    // synchronous Node response. Stop the host first so transport shutdown
7558    // wakes that request before we join the worker.
7559    if let Some(js) = &reload_context.js_extension_session {
7560        js.shutdown();
7561    }
7562    // The input worker checks `running` at least every 50ms. Join it before
7563    // restoring cooked mode so no late event read races terminal cleanup.
7564    let _ = key_handle.await;
7565    tick_handle.abort();
7566    if let Some(handle) = drain_handle {
7567        handle.abort();
7568    }
7569    // Drop the reload bridge: clearing the mailbox closes the signal channel,
7570    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
7571    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
7572    reload_context.mailbox.clear();
7573    reload_bridge_handle.abort();
7574    tui.stop(Default::default());
7575
7576    // Native pi prints how to resume the session after an interactive exit so
7577    // a terminal that scrolled away can be recovered. Mirrors
7578    // `formatResumeCommand` (id + `--session-dir` only when non-default).
7579    let session_id = harness.session().storage().metadata().id;
7580    if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
7581        println!("\x1b[2mTo resume this session:\x1b[0m {command}");
7582    }
7583    println!("\nGoodbye!");
7584    let _ = args;
7585
7586    0
7587}
7588
7589// ===========================================================================
7590// Run a single prompt (streaming or blocking)
7591// ===========================================================================
7592
7593/// Prepare the session's persistent Node host immediately before a real prompt
7594/// enters the agent loop. The first call starts the lazy host; later calls run
7595/// `before_agent_start` again on that host so each prompt sees current state.
7596/// Keeping startup here leaves an idle TUI free of a Node child while still
7597/// giving the lifecycle hook the fully installed UI bridge.
7598async fn ensure_js_runtime_before_prompt(
7599    js: Option<&crate::js_extensions::JsExtensionSession>,
7600    lane: &Arc<dyn AgentLane>,
7601    state: &Arc<TuiState>,
7602    dialog_bridge: &JsDialogBridge,
7603    args: &Args,
7604) -> bool {
7605    let Some(js) = js else {
7606        return true;
7607    };
7608    let cancellation = state.begin_js_preparation();
7609    let worker_cancellation = cancellation.clone();
7610    let js_for_start = js.clone();
7611    let mut worker = tokio::task::spawn_blocking(move || {
7612        js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
7613    });
7614    let result = tokio::select! {
7615        result = &mut worker => result,
7616        _ = cancellation.cancelled() => {
7617            dialog_bridge.cancel_open_requests();
7618            let js_for_cancel = js.clone();
7619            let _ = tokio::task::spawn_blocking(move || {
7620                js_for_cancel.cancel_prompt_preparation();
7621            }).await;
7622            worker.await
7623        }
7624    };
7625    let was_cancelled = cancellation.is_cancelled();
7626    if was_cancelled {
7627        dialog_bridge.cancel_open_requests();
7628        let js_for_cancel = js.clone();
7629        let _ = tokio::task::spawn_blocking(move || {
7630            js_for_cancel.cancel_prompt_preparation();
7631        })
7632        .await;
7633        state.finish_js_preparation();
7634        dialog_bridge.reopen();
7635        return false;
7636    }
7637    state.finish_js_preparation();
7638    match result {
7639        Ok(Ok(())) => {
7640            // The lifecycle hook can change the JS-only active tool set once
7641            // it sees the real TUI context. Merge that subset with the Rust
7642            // built-ins while applying the command-line tool policy.
7643            if let Some(js_active) = js.active_tools() {
7644                let js_names = js.tool_names();
7645                let mut active = lane.get_active_tools().await.unwrap_or_default();
7646                active.retain(|name| {
7647                    crate::session::tool_name_allowed(name, args)
7648                        && !js_names.iter().any(|js_name| js_name == name)
7649                });
7650                active.extend(js_active.into_iter().filter(|name| {
7651                    js_names.iter().any(|js_name| js_name == name)
7652                        && crate::session::tool_name_allowed(name, args)
7653                }));
7654                active = crate::session::filter_active_tool_names(active, args);
7655                let _ = lane.set_active_tools(active).await;
7656            }
7657        }
7658        Ok(Err(error)) => {
7659            if args.verbose {
7660                eprintln!("warning: could not start JS extension runtime: {error}");
7661            }
7662        }
7663        Err(error) => {
7664            if args.verbose {
7665                eprintln!("warning: JS extension runtime worker failed: {error}");
7666            }
7667        }
7668    }
7669    true
7670}
7671
7672fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
7673    std::thread::spawn(move || {
7674        let file = match tempfile::Builder::new()
7675            .prefix("rpi-draft-")
7676            .suffix(".md")
7677            .tempfile()
7678        {
7679            Ok(file) => file,
7680            Err(error) => {
7681                let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7682                    "Could not create editor file: {error}"
7683                ))));
7684                return;
7685            }
7686        };
7687        if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
7688            let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7689                "Could not write editor file: {error}"
7690            ))));
7691            return;
7692        }
7693        let editor = std::env::var("RPI_EXTERNAL_EDITOR")
7694            .ok()
7695            .filter(|value| !value.trim().is_empty())
7696            .or_else(|| std::env::var("VISUAL").ok())
7697            .or_else(|| std::env::var("EDITOR").ok())
7698            .unwrap_or_else(|| {
7699                if cfg!(windows) {
7700                    "notepad".to_string()
7701                } else {
7702                    "nano".to_string()
7703                }
7704            });
7705        let status = std::process::Command::new(&editor)
7706            .arg(file.path())
7707            .status();
7708        let result = match status {
7709            Ok(status) if status.success() => std::fs::read_to_string(file.path())
7710                .map_err(|error| format!("Could not read editor file: {error}")),
7711            Ok(status) => Err(format!("External editor exited with {status}")),
7712            Err(error) => Err(format!(
7713                "Could not launch external editor `{editor}`: {error}"
7714            )),
7715        };
7716        let _ = tx.send(TuiMessage::ExternalEditorResult(result));
7717    });
7718}
7719
7720/// Apply the authoritative run result when it wins the race with the async
7721/// event drain, then detach the live component from further partial updates.
7722fn reconcile_streamed_assistant_completion(
7723    current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
7724    last_assistant_text: &Mutex<String>,
7725    final_message: Option<&AssistantMessage>,
7726) {
7727    let final_blocks = final_message.map(assistant_blocks);
7728    let final_text = final_message.map(assistant_text);
7729    let component = current_assistant.lock().unwrap().take();
7730
7731    if let Some(component) = component {
7732        if let Some(blocks) = final_blocks.as_deref() {
7733            component.update_blocks(blocks);
7734        }
7735        component.set_streaming(false);
7736    }
7737
7738    if let Some(text) = final_text.filter(|text| !text.is_empty()) {
7739        *last_assistant_text.lock().unwrap() = text;
7740    }
7741}
7742
7743/// Drive a single prompt through the lane. When `streaming` is true, the
7744/// `AgentEvent` drain task renders the response live and the completed outcome
7745/// reconciles its final snapshot. When false (no `event_rx`), this falls back
7746/// to the blocking await-final-text path.
7747async fn run_prompt_streaming(
7748    lane: &Arc<dyn AgentLane>,
7749    prompt: &str,
7750    resume: bool,
7751    tui: &Arc<TuiAltScreen>,
7752    state: &Arc<TuiState>,
7753    streaming: bool,
7754    js: Option<&crate::js_extensions::JsExtensionSession>,
7755    dialog_bridge: &JsDialogBridge,
7756    args: &Args,
7757    images: Vec<rpi_ai::types::ImageContent>,
7758) {
7759    // The persistent Node host is intentionally started at the first real
7760    // prompt. By this point the TUI key worker and all UI/runtime handlers are
7761    // live, so a `before_agent_start` hook may safely open a native dialog. A
7762    // session with no prompt never starts Node merely to render its welcome
7763    // screen; JS commands/tools still trigger the same lazy ensure path.
7764    // Preparation is part of the active turn. Mark it working before Node can
7765    // block so Ctrl+C, Ctrl+D, and Esc all retain their documented abort
7766    // semantics for initial argv prompts as well as editor submissions.
7767    state.set_status(RunStatus::Working);
7768    tui.request_render(false);
7769    if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
7770        state.set_status(RunStatus::Idle);
7771        tui.request_render(false);
7772        return;
7773    }
7774
7775    let outcome = if resume {
7776        // Continuing an interrupted run: no new user turn, just the provider call
7777        // the interrupted run never reached. Native pi behaves the same way.
7778        match lane.resume_pending().await {
7779            Ok(Some(result)) => Ok(result),
7780            // Nothing to resume after all: leave the status alone and let the
7781            // normal prompt path take over.
7782            Ok(None) => {
7783                state.set_status(RunStatus::Idle);
7784                tui.request_render(false);
7785                return;
7786            }
7787            Err(error) => Err(error),
7788        }
7789    } else {
7790        lane.prompt_text(prompt, images).await
7791    };
7792
7793    if streaming {
7794        // Broadcast delivery is asynchronous: the harness result can resolve
7795        // before the drain task processes MessageEnd. Reconcile from the
7796        // authoritative outcome before detaching the component so the final
7797        // streamed tail cannot be left at an earlier partial snapshot.
7798        let final_message = match &outcome {
7799            Ok(result) => match &result.outcome {
7800                HarnessRunOutcome::Completed { final_message, .. }
7801                | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7802                HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7803                HarnessRunOutcome::Suspended { .. } => None,
7804            },
7805            Err(_) => None,
7806        };
7807        reconcile_streamed_assistant_completion(
7808            &state.current_assistant,
7809            &state.last_assistant_text,
7810            final_message,
7811        );
7812    }
7813
7814    state.set_status(RunStatus::Idle);
7815    // Guarantee the pending-messages display reconciles at the end of every
7816    // run/prompt — covers aborts and the non-streaming path, where no
7817    // `AgentEnd` event necessarily reaches the drain task.
7818    refresh_pending_messages(state, lane).await;
7819
7820    // Authoritative context-badge fallback. The streaming drain normally
7821    // updates it from `MessageEnd`, but a `Lagged` broadcast or the blocking
7822    // path can skip that event; reconcile from the run outcome so the badge
7823    // always reflects the newest real response.
7824    if let Ok(result) = &outcome {
7825        let final_message: Option<&AssistantMessage> = match &result.outcome {
7826            HarnessRunOutcome::Completed { final_message, .. }
7827            | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7828            HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7829            HarnessRunOutcome::Suspended { .. } => None,
7830        };
7831        if let Some(a) = final_message {
7832            record_context_usage(&state.footer, a);
7833        }
7834    }
7835
7836    match outcome {
7837        Ok(result) => match &result.outcome {
7838            HarnessRunOutcome::Failed {
7839                error,
7840                final_message,
7841                ..
7842            } => {
7843                // Only add an error line if the stream did NOT already render
7844                // an assistant message for it (drain task leaves
7845                // current_assistant Some only on an abrupt end).
7846                // A final assistant error is emitted by the event drain only
7847                // in streaming mode. In regular mode there is no drain task,
7848                // so suppressing this branch merely hides 405/auth/network
7849                // diagnostics from the user.
7850                let already_rendered = streaming && final_message.is_some();
7851                if !already_rendered {
7852                    let msg = final_message
7853                        .as_ref()
7854                        .and_then(|m| m.error_message.clone())
7855                        .unwrap_or_else(|| format!("{error:?}"));
7856                    add_error_message(&state.chat_container, &msg);
7857                }
7858            }
7859            HarnessRunOutcome::Suspended { .. } => {
7860                add_error_message(
7861                    &state.chat_container,
7862                    "Run suspended (deferred) — resume is not supported in v1.",
7863                );
7864            }
7865            HarnessRunOutcome::Aborted { final_message, .. } => {
7866                // Aborted runs render their own partial/final message via the
7867                // stream; only add a note on the blocking fallback path.
7868                if !streaming {
7869                    add_error_message(&state.chat_container, "Request aborted.");
7870                    let _ = final_message; // (rendered by the stream in streaming mode)
7871                }
7872            }
7873            HarnessRunOutcome::Completed { final_message, .. } => {
7874                if !streaming {
7875                    let text = assistant_text(final_message);
7876                    if !text.is_empty() {
7877                        add_assistant_message_blocking(
7878                            &state.chat_container,
7879                            &text,
7880                            state.markdown_transformer(),
7881                        );
7882                        *state.last_assistant_text.lock().unwrap() = text;
7883                    }
7884                }
7885            }
7886        },
7887        Err(e) => {
7888            add_error_message(&state.chat_container, &e.to_string());
7889        }
7890    }
7891
7892    tui.request_render(false);
7893}
7894
7895/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
7896/// Reports the outcome as a transcript note; v1's compaction summarizes the
7897/// session in place, so no streaming display is wired (compaction emits no
7898/// `AgentEvent`s — only the harness bus `RunEnd`).
7899async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
7900    state.set_status(RunStatus::Working);
7901    tui.request_render(false);
7902    match lane.compact(None).await {
7903        Ok(_) => {
7904            add_note_message(&state.chat_container, "Conversation compacted.");
7905            // The last assistant usage describes the pre-compaction context;
7906            // reset the badge to `?` until the next response reports the new
7907            // (much smaller) prompt size — mirrors pi's `percent: null`.
7908            state.footer.set_context_tokens(None);
7909        }
7910        Err(e) => {
7911            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
7912        }
7913    }
7914    state.set_status(RunStatus::Idle);
7915    tui.request_render(false);
7916}
7917
7918/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
7919/// when no clipboard is available (or the `clipboard` feature is off), prints a
7920/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
7921fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
7922    let text = state.last_assistant_text.lock().unwrap().clone();
7923    if text.is_empty() {
7924        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
7925        return;
7926    }
7927    if copy_to_clipboard(&text) {
7928        add_note_message(chat, "Copied last reply to the clipboard.");
7929    } else {
7930        // Clipboard unavailable — print the text to the transcript so the user
7931        // can select/copy it manually (degrades gracefully in headless envs).
7932        let preview: String = text.chars().take(200).collect();
7933        add_note_message(
7934            chat,
7935            &format!(
7936                "Clipboard unavailable. Last reply: {preview}{}",
7937                if text.chars().count() > 200 {
7938                    "…"
7939                } else {
7940                    ""
7941                }
7942            ),
7943        );
7944    }
7945}
7946
7947/// Extract text from the chat container within the given screen coordinates.
7948/// Returns None if the selection is invalid or empty.
7949fn extract_selected_text(
7950    chat_container: &Arc<Container>,
7951    start: (u16, u16),
7952    end: (u16, u16),
7953) -> Option<String> {
7954    use rpi_tui::Component;
7955
7956    // Get the rendered lines from the chat container
7957    let lines = chat_container.render(80); // Use a reasonable width
7958    if lines.is_empty() {
7959        return None;
7960    }
7961
7962    // Normalize coordinates (ensure start is before end)
7963    let (start_row, end_row) = if start.1 <= end.1 {
7964        (start.1 as usize, end.1 as usize)
7965    } else {
7966        (end.1 as usize, start.1 as usize)
7967    };
7968
7969    // Clamp to valid range
7970    let start_row = start_row.min(lines.len().saturating_sub(1));
7971    let end_row = end_row.min(lines.len().saturating_sub(1));
7972
7973    if start_row > end_row {
7974        return None;
7975    }
7976
7977    // Extract the selected lines
7978    let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7979
7980    if selected_lines.is_empty() {
7981        return None;
7982    }
7983
7984    Some(selected_lines.join("\n"))
7985}
7986
7987/// Best-effort clipboard write. Enabled only with the `clipboard` feature
7988/// (`arboard`) on non-Android platforms; otherwise returns `false` so the
7989/// caller degrades to a hint.
7990#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7991fn copy_to_clipboard(text: &str) -> bool {
7992    match arboard::Clipboard::new() {
7993        Ok(mut cb) => cb.set_text(text).is_ok(),
7994        Err(_) => false,
7995    }
7996}
7997
7998#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7999fn copy_to_clipboard(_text: &str) -> bool {
8000    false
8001}
8002
8003/// Best-effort clipboard text read for Ctrl+V paste. The optional `clipboard`
8004/// feature keeps headless builds free of platform clipboard dependencies;
8005/// on Android the OS owns clipboard access so this degrades to `None`.
8006#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8007fn read_clipboard_text() -> Option<String> {
8008    let mut clipboard = arboard::Clipboard::new().ok()?;
8009    clipboard.get_text().ok()
8010}
8011
8012#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8013fn read_clipboard_text() -> Option<String> {
8014    None
8015}
8016
8017/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
8018/// `ImageContent` contract. The optional clipboard feature keeps headless
8019/// builds free of platform clipboard dependencies.
8020#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8021fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8022    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
8023    let image = match clipboard.get_image() {
8024        Ok(image) => image,
8025        Err(_) => return Ok(None),
8026    };
8027    let width =
8028        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
8029    let height =
8030        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
8031    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
8032        return Err("clipboard image dimensions are outside the supported range".into());
8033    }
8034    let mut bytes = Vec::new();
8035    {
8036        let mut encoder = png::Encoder::new(&mut bytes, width, height);
8037        encoder.set_color(png::ColorType::Rgba);
8038        encoder.set_depth(png::BitDepth::Eight);
8039        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
8040        writer
8041            .write_image_data(&image.bytes)
8042            .map_err(|e| e.to_string())?;
8043    }
8044    Ok(Some(rpi_ai::types::ImageContent {
8045        kind: rpi_ai::types::ImageContentType,
8046        data: base64::engine::general_purpose::STANDARD.encode(bytes),
8047        mime_type: "image/png".into(),
8048    }))
8049}
8050
8051fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
8052    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
8053        let mut options = ImageOptions::default();
8054        options.width = Some(48);
8055        options.alt_text = Some("Attached image".into());
8056        chat.add_child(Arc::new(Image::from_data(bytes, options)));
8057        chat.add_child(Arc::new(Spacer::new(1)));
8058    }
8059}
8060
8061#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8062fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8063    Ok(None)
8064}
8065
8066/// Blocking fallback (no `event_rx`): render the final assistant text as a
8067/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
8068/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
8069/// the identity path. The blocking path only fires when `event_rx` is absent,
8070/// so it shares the same transformer the streaming path installs on its
8071/// components.
8072fn add_assistant_message_blocking(
8073    container: &Arc<Container>,
8074    text: &str,
8075    transformer: Option<MarkdownTransformer>,
8076) {
8077    if text.is_empty() {
8078        return;
8079    }
8080    let msg = Arc::new(AssistantMessageComponent::new(
8081        AssistantMessageOptions::default(),
8082    ));
8083    if let Some(t) = &transformer {
8084        msg.set_markdown_transformer(Some(t.clone()));
8085    }
8086    msg.update_text(text);
8087    container.add_child(msg);
8088    container.add_child(Arc::new(Spacer::new(1)));
8089}
8090
8091// ===========================================================================
8092// AgentEvent drain task — the streaming core
8093// ===========================================================================
8094
8095/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
8096/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
8097/// lifetime of the TUI.
8098/// Re-read the lane's queue and update the pending-messages dock slot.
8099/// Cheap (two short mutex locks) and a no-op when the snapshot is unchanged,
8100/// so it is safe to call on every relevant event.
8101async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
8102    if let Ok(queued) = lane.queued_messages().await {
8103        state.set_pending_queue(queued);
8104    }
8105}
8106
8107async fn drain_agent_events(
8108    mut rx: broadcast::Receiver<AgentEvent>,
8109    tui: Arc<TuiAltScreen>,
8110    state: Arc<TuiState>,
8111    chat: Arc<Container>,
8112    lane: Arc<dyn AgentLane>,
8113) {
8114    loop {
8115        match rx.recv().await {
8116            Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
8117            Err(broadcast::error::RecvError::Lagged(_)) => {
8118                // We dropped some intermediate deltas; the next MessageUpdate/
8119                // MessageEnd carries a full partial snapshot so the UI re-syncs.
8120                continue;
8121            }
8122            Err(broadcast::error::RecvError::Closed) => break,
8123        }
8124    }
8125}
8126
8127/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
8128/// (`interactive-mode.ts:3068-3396`).
8129async fn handle_agent_event(
8130    event: AgentEvent,
8131    tui: &Arc<TuiAltScreen>,
8132    state: &Arc<TuiState>,
8133    chat: &Arc<Container>,
8134    lane: &Arc<dyn AgentLane>,
8135) {
8136    match event {
8137        AgentEvent::AgentStart => {
8138            state.set_status(RunStatus::Working);
8139            tui.request_render(false);
8140        }
8141
8142        AgentEvent::AgentEnd { .. } => {
8143            // Finalize any still-streaming assistant message.
8144            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8145                comp.set_streaming(false);
8146            }
8147            state.set_status(RunStatus::Idle);
8148            // Flush any `!command` results that completed mid-run: the run's
8149            // tool sequence is closed, so appending now keeps transcript order
8150            // intact (native pi's `flushPendingBashMessages`).
8151            let pending: Vec<AgentMessage> =
8152                std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
8153            for message in pending {
8154                if let Err(error) = lane.append_message(message).await {
8155                    add_error_message(chat, &format!("Could not record bash result: {error}"));
8156                }
8157            }
8158            // The run drained the queue; refresh so consumed entries drop out
8159            // of the pending-messages display.
8160            refresh_pending_messages(state, lane).await;
8161            tui.request_render(false);
8162        }
8163
8164        AgentEvent::RetryScheduled {
8165            attempt,
8166            max_retries,
8167            delay_ms,
8168            ..
8169        } => {
8170            state.show_retry(attempt, max_retries, delay_ms);
8171            tui.request_render(false);
8172        }
8173
8174        AgentEvent::TurnStart => {
8175            // A new turn: reset the streaming-assistant guard so the next
8176            // MessageStart creates a fresh component.
8177            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8178                comp.set_streaming(false);
8179            }
8180        }
8181
8182        AgentEvent::TurnEnd {
8183            message,
8184            tool_results,
8185        } => {
8186            // Finalize the assistant message for this turn.
8187            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8188                if let AgentMessage::Assistant(a) = &message {
8189                    comp.update_blocks(&assistant_blocks(a));
8190                }
8191                comp.set_streaming(false);
8192            }
8193            // Any tool results whose components were never ended by a
8194            // ToolExecutionEnd get a static rendering here (best-effort). The
8195            // normal path removes the component via ToolExecutionEnd; this is
8196            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
8197            let tools = state.tool_components.lock().unwrap();
8198            for tr in &tool_results {
8199                if tools.contains_key(&tr.tool_call_id) {
8200                    // Will be removed below via ToolExecutionEnd in the normal
8201                    // path; leave as-is if still present.
8202                    let _ = tr;
8203                }
8204            }
8205            drop(tools);
8206            tui.request_render(false);
8207        }
8208
8209        AgentEvent::MessageStart { message } => match message {
8210            AgentMessage::Assistant(a) => {
8211                let comp = Arc::new(AssistantMessageComponent::new(
8212                    AssistantMessageOptions::default(),
8213                ));
8214                // B5e: install the live markdown transformer so the plugin's
8215                // `register_markdown_transformer` handlers apply from the very
8216                // first streamed delta. `set_streaming` before the transform
8217                // install is fine (transform fires on `update_blocks`, below).
8218                if let Some(t) = state.markdown_transformer() {
8219                    comp.set_markdown_transformer(Some(t));
8220                }
8221                comp.set_hide_thinking(state.hide_thinking());
8222                comp.set_streaming(true);
8223                // Render text AND thinking blocks in order (the old path fed
8224                // only the concatenated text, so thinking blocks never showed).
8225                comp.update_blocks(&assistant_blocks(&a));
8226                chat.add_child(comp.clone());
8227                // Spacer(1) separates this assistant turn from the next entry;
8228                // the component itself adds no leading spacer.
8229                chat.add_child(Arc::new(Spacer::new(1)));
8230                *state.current_assistant.lock().unwrap() = Some(comp);
8231                tui.request_render(false);
8232            }
8233            AgentMessage::Custom(custom) => {
8234                let payload = serde_json::json!({
8235                    "customType": custom.role,
8236                    "content": custom.content,
8237                    "details": custom.data,
8238                    "expanded": false,
8239                    "outputPad": 1,
8240                });
8241                if let Some(component) = extension_message_component(
8242                    &state.extension_session,
8243                    &custom.role,
8244                    &payload,
8245                    state.markdown_transformer(),
8246                ) {
8247                    chat.add_child(component);
8248                    chat.add_child(Arc::new(Spacer::new(1)));
8249                    tui.request_render(false);
8250                } else {
8251                    add_note_message(chat, &custom_message_fallback(&custom));
8252                    tui.request_render(false);
8253                }
8254            }
8255            // Queued (steering / follow-up) user messages are rendered here,
8256            // not at queue time, so a message that is still waiting never looks
8257            // delivered. The loop drains those itself and emits
8258            // `message_start` for them (`agent_loop.rs` pending-message drain).
8259            //
8260            // A DIRECTLY-sent prompt does NOT reach this arm: the harness
8261            // persists it up front and runs the loop with an empty prompts vec,
8262            // so no `message_start` is emitted for it — the submit handler
8263            // renders that bubble instead.
8264            AgentMessage::User(user) => {
8265                add_user_message(chat, &user_message_text(&user));
8266                // A consumed entry must drop out of the pending display.
8267                refresh_pending_messages(state, lane).await;
8268                tui.request_render(false);
8269            }
8270            // ToolResult / other starts are echoed via the tool-execution
8271            // components; ignore the dupes.
8272            _ => {}
8273        },
8274
8275        AgentEvent::MessageUpdate {
8276            message,
8277            assistant_message_event,
8278        } => {
8279            {
8280                // `message` is the shared partial snapshot
8281                // (`Arc<AssistantMessage>`), so read through it instead of
8282                // cloning the whole growing message once per delta. The bare
8283                // block preserves the original nesting.
8284                let a: &rpi_ai::types::AssistantMessage = &message;
8285                let text = assistant_text(a);
8286                let mut saw_bash_tool_call = false;
8287                // Scan content for finalized tool calls → proactively create
8288                // tool components (TS shows the tool as soon as the assistant
8289                // emits the ToolCall; ToolExecutionStart coalesces if it
8290                // already exists).
8291                for c in &a.content {
8292                    if let Content::ToolCall(tc) = c {
8293                        // Streaming providers may expose a placeholder tool
8294                        // call before its name has arrived. It is not a real
8295                        // tool panel and must not leave an empty first row.
8296                        if tc.name.trim().is_empty() {
8297                            continue;
8298                        }
8299                        if tc.name == "bash" {
8300                            // Bash has a dedicated component. Create it here as
8301                            // well as on ToolExecutionStart because the tool
8302                            // call can become visible in a MessageUpdate first.
8303                            // Keeping it in the bash map lets Start coalesce
8304                            // with this panel instead of appending a second one.
8305                            let command = tc
8306                                .arguments
8307                                .get("command")
8308                                .and_then(|v| v.as_str())
8309                                .unwrap_or("");
8310                            // Streaming tool-call arguments may still be `{}`
8311                            // here. Do not create a running bash panel until
8312                            // the lifecycle start event provides the command;
8313                            // otherwise the spinner renders first and the
8314                            // actual `$ command` header appears one frame
8315                            // later.
8316                            if command.trim().is_empty() {
8317                                continue;
8318                            }
8319                            saw_bash_tool_call = true;
8320                            let mut bash = state.bash_components.lock().unwrap();
8321                            if let Some(existing) = bash.get(&tc.id) {
8322                                // Tool-call arguments arrive incrementally.
8323                                // Refresh the running panel from every full
8324                                // assistant snapshot instead of leaving its
8325                                // header on the first partial command.
8326                                existing.set_command(command);
8327                            } else {
8328                                let comp = Arc::new(BashExecutionComponent::new(command));
8329                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8330                                chat.add_child(comp.clone());
8331                                bash.insert(tc.id.clone(), comp);
8332                            }
8333                        } else {
8334                            let mut tools = state.tool_components.lock().unwrap();
8335                            let display_args = if is_ask_user_tool(&tc.name) {
8336                                ask_user_args_display(&tc.arguments)
8337                            } else {
8338                                tc.arguments.to_string()
8339                            };
8340                            if let Some(existing) = tools.get(&tc.id) {
8341                                // Regular tool arguments stream incrementally
8342                                // too, so refresh their live header as soon as
8343                                // a more complete snapshot arrives.
8344                                if !display_args.trim().is_empty() && display_args.trim() != "{}" {
8345                                    existing.set_args(&display_args);
8346                                }
8347                            } else {
8348                                let comp =
8349                                    Arc::new(ToolExecutionComponent::new(&tc.name, &display_args));
8350                                if is_ask_user_tool(&tc.name) {
8351                                    comp.set_display_title("ASK USER");
8352                                }
8353                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8354                                comp.set_running();
8355                                chat.add_child(comp.clone());
8356                                tools.insert(tc.id.clone(), comp);
8357                            }
8358                        }
8359                    }
8360                }
8361                // MessageUpdate can expose the finalized bash call before
8362                // ToolExecutionStart arrives. Hide the global `Working…`
8363                // loader immediately when creating that bash panel; otherwise
8364                // it briefly appears alongside the panel's `Running…` spinner.
8365                if saw_bash_tool_call {
8366                    state.sync_working_loader_with_bash();
8367                }
8368                let _ = assistant_message_event; // snapshot already applied via `a`
8369                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
8370                    // Stream the full block list (text + thinking) each update
8371                    // so thinking blocks render live as they arrive.
8372                    comp.update_blocks(&assistant_blocks(a));
8373                }
8374                *state.last_assistant_text.lock().unwrap() = text;
8375                tui.request_render(false);
8376            }
8377        }
8378
8379        AgentEvent::MessageEnd { message } => {
8380            if let AgentMessage::Assistant(a) = &message {
8381                let text = assistant_text(a);
8382                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8383                    comp.update_blocks(&assistant_blocks(a));
8384                    comp.set_streaming(false);
8385                }
8386                // Cache the finalized text for `/copy`.
8387                if !text.is_empty() {
8388                    *state.last_assistant_text.lock().unwrap() = text;
8389                }
8390                // Cache-miss notice (`maybeShowCacheMissNotice`): the previous
8391                // turn's input established a cacheable prefix; a prompt that
8392                // re-reads less than it should means the prefix was re-billed.
8393                // Uses the shared cache-stats logic (noise floor, idle gap,
8394                // model change) and prices the miss when known.
8395                let usage = &a.usage;
8396                // Footer usage totals + cache hit rate (pi footer.ts).
8397                state.footer.add_usage(
8398                    usage.input,
8399                    usage.output,
8400                    usage.cache_read,
8401                    usage.cache_write,
8402                    usage.cost.total,
8403                );
8404                let denom = usage.input + usage.cache_read + usage.cache_write;
8405                if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
8406                    state
8407                        .footer
8408                        .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
8409                }
8410                // Context-window badge (`?/512k` → `63.2%/512k`): the provider
8411                // reports the prompt token count for this request, which is the
8412                // context size the footer displays.
8413                record_context_usage(&state.footer, a);
8414                let miss = state
8415                    .cache_tracker
8416                    .lock()
8417                    .unwrap()
8418                    .observe(a, &rpi_harness::cache_stats::NoPrices);
8419                // Native pi gates these behind `showCacheMissNotices`
8420                // (default `false`); the tracker still runs so the footer's
8421                // cache-hit rate stays accurate either way.
8422                let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
8423                if let Some(miss) = miss.filter(|_| cache_miss_notices) {
8424                    let cost = if miss.missed_cost > 0.0 {
8425                        format!(" (~${:.4})", miss.missed_cost)
8426                    } else {
8427                        String::new()
8428                    };
8429                    add_note_message(
8430                        &state.chat_container,
8431                        &format!(
8432                            "Cache miss: {} tokens re-billed{}",
8433                            format_tokens(miss.missed_tokens),
8434                            cost
8435                        ),
8436                    );
8437                }
8438                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
8439                    add_note_message(chat, &text);
8440                }
8441                // Error assistant messages carry the provider diagnostic in
8442                // `error_message`, not in text content. The assistant
8443                // component is empty for these messages, so surface the
8444                // diagnostic as a visible error row in the transcript.
8445                if let Some(error) = assistant_error_text(a) {
8446                    add_error_message(chat, &error);
8447                }
8448            }
8449            tui.request_render(false);
8450        }
8451
8452        AgentEvent::ToolExecutionStart {
8453            tool_call_id,
8454            tool_name,
8455            args,
8456        } => {
8457            // Ignore placeholder lifecycle events emitted before the
8458            // provider has supplied a tool name.
8459            if tool_name.trim().is_empty() {
8460                return;
8461            }
8462            if tool_name == "bash" {
8463                // Bash streams into a dedicated BashExecutionComponent (command
8464                // header + live preview + exit/truncation status) rather than a
8465                // generic ToolExecutionComponent. The command comes from the
8466                // `command` field of the bash tool args.
8467                let command = args
8468                    .get("command")
8469                    .and_then(|v| v.as_str())
8470                    .unwrap_or("")
8471                    .to_string();
8472                // Bash arguments can still be `{}` when the lifecycle event
8473                // races the streamed tool-call argument finalization. Defer
8474                // the panel until a later start event carries the command.
8475                if command.trim().is_empty() {
8476                    return;
8477                }
8478                let mut bash_map = state.bash_components.lock().unwrap();
8479                if let Some(existing) = bash_map.get(&tool_call_id) {
8480                    // A ToolExecutionUpdate already created the panel (fast
8481                    // command — Update can arrive before Start); backfill the
8482                    // command header instead of adding a SECOND panel, which
8483                    // used to stack an empty "$ " box above the real one.
8484                    existing.set_command(&command);
8485                } else {
8486                    let comp = Arc::new(BashExecutionComponent::new(command));
8487                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8488                    chat.add_child(comp.clone());
8489                    bash_map.insert(tool_call_id.clone(), comp);
8490                }
8491            } else {
8492                let _comp = {
8493                    let mut tools = state.tool_components.lock().unwrap();
8494                    if let Some(existing) = tools.get(&tool_call_id) {
8495                        if is_ask_user_tool(&tool_name) {
8496                            existing.set_display_title("ASK USER");
8497                            existing.set_args(&ask_user_args_display(&args));
8498                        } else {
8499                            existing.set_args(&args.to_string());
8500                        }
8501                        existing.clone()
8502                    } else {
8503                        let display_args = if is_ask_user_tool(&tool_name) {
8504                            ask_user_args_display(&args)
8505                        } else {
8506                            args.to_string()
8507                        };
8508                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8509                        if is_ask_user_tool(&tool_name) {
8510                            comp.set_display_title("ASK USER");
8511                        }
8512                        comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8513                        // A `read` of a SKILL.md renders as native Pi's
8514                        // `[skill] <name>` invocation box (custom-message
8515                        // background, collapsed to one line, Ctrl+O expands the
8516                        // skill markdown) instead of a generic READ tool panel.
8517                        if let Some(skill) = skill_tool_name(&tool_name, &args) {
8518                            comp.set_skill_name(skill);
8519                        }
8520                        comp.set_running();
8521                        chat.add_child(comp.clone());
8522                        tools.insert(tool_call_id.clone(), comp.clone());
8523                        comp
8524                    }
8525                };
8526            }
8527            state.sync_working_loader_with_bash();
8528            tui.request_render(false);
8529        }
8530
8531        AgentEvent::ToolExecutionUpdate {
8532            tool_call_id,
8533            tool_name,
8534            args,
8535            partial_result,
8536        } => {
8537            if tool_name.trim().is_empty() {
8538                return;
8539            }
8540            let partial_text = if is_ask_user_tool(&tool_name) {
8541                ask_user_progress_text(&args, &partial_result)
8542            } else {
8543                tool_result_text(&partial_result)
8544            };
8545            let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
8546            if tool_name == "bash" {
8547                // Append the streamed chunk to the bash component's preview.
8548                // RAW text (no single-line collapsing) — the old
8549                // `summarize_tool_result` folded every newline into a `⏎`
8550                // glyph, cramming e.g. `ls -la`'s listing onto one line.
8551                let chunk = partial_text;
8552                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
8553                    // Lifecycle updates can contain a more complete args object
8554                    // than the snapshot that created this running panel.
8555                    if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
8556                        bash.set_command(command);
8557                    }
8558                    if has_partial_payload {
8559                        bash.append_output(&chunk);
8560                    }
8561                } else if has_partial_payload {
8562                    // ToolExecutionStart is emitted before a tool can run.
8563                    // Ignore an out-of-order partial until that event gives us
8564                    // the real command, rather than showing a spinner above an
8565                    // empty `$ ` header. Normal updates are handled by the
8566                    // component created in ToolExecutionStart.
8567                }
8568            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
8569                if let Some(skill) = skill_tool_name(&tool_name, &args) {
8570                    comp.set_skill_name(skill);
8571                }
8572                if is_ask_user_tool(&tool_name) {
8573                    comp.set_display_title("ASK USER");
8574                    comp.set_args(&ask_user_args_display(&args));
8575                } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
8576                    comp.set_args(&args.to_string());
8577                }
8578                // Raw multi-line text — read/ls-style tools must show their
8579                // full content, not the single-line ⏎-folded summary.
8580                if has_partial_payload {
8581                    comp.set_result(&partial_text, false);
8582                    apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
8583                }
8584            } else if has_partial_payload {
8585                // No component yet — create a running one so the partial shows.
8586                // Empty callbacks are common before ToolExecutionStart; wait
8587                // for Start so the first panel has the real arguments instead
8588                // of an empty `TOOLS` box.
8589                let display_args = if is_ask_user_tool(&tool_name) {
8590                    ask_user_args_display(&args)
8591                } else {
8592                    args.to_string()
8593                };
8594                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8595                if is_ask_user_tool(&tool_name) {
8596                    comp.set_display_title("ASK USER");
8597                }
8598                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8599                if let Some(skill) = skill_tool_name(&tool_name, &args) {
8600                    comp.set_skill_name(skill);
8601                }
8602                comp.set_running();
8603                comp.set_result(&partial_text, false);
8604                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
8605                chat.add_child(comp.clone());
8606                state
8607                    .tool_components
8608                    .lock()
8609                    .unwrap()
8610                    .insert(tool_call_id.clone(), comp.clone());
8611            }
8612            state.sync_working_loader_with_bash();
8613            tui.request_render(false);
8614        }
8615
8616        AgentEvent::ToolExecutionEnd {
8617            tool_call_id,
8618            tool_name,
8619            result,
8620            is_error,
8621        } => {
8622            if tool_name.trim().is_empty() {
8623                return;
8624            }
8625            if tool_name == "bash" {
8626                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
8627                if let Some(bash) = bash {
8628                    finalize_bash(&bash, &result, is_error);
8629                } else {
8630                    // Bash ended without a Start/Update — render a finalized
8631                    // component directly from the result text.
8632                    let command = result
8633                        .details
8634                        .get("command")
8635                        .and_then(|v| v.as_str())
8636                        .unwrap_or("")
8637                        .to_string();
8638                    if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
8639                        state.sync_working_loader_with_bash();
8640                        // Edge case: empty bash result. Still render immediately
8641                        // to clear the panel state for the user.
8642                        tui.render_now(false);
8643                        return;
8644                    }
8645                    let comp = Arc::new(BashExecutionComponent::new(command));
8646                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8647                    comp.append_output(&tool_result_text(&result));
8648                    finalize_bash(&comp, &result, is_error);
8649                    chat.add_child(comp);
8650                }
8651            } else {
8652                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
8653                if let Some(comp) = comp {
8654                    let result_text = if is_ask_user_tool(&tool_name) {
8655                        ask_user_result_text(&result)
8656                    } else {
8657                        tool_result_text(&result)
8658                    };
8659                    comp.set_result(&result_text, is_error);
8660                    if !is_ask_user_tool(&tool_name) {
8661                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8662                    }
8663                } else {
8664                    // Tool ended without a Start/Update (e.g. a very fast tool):
8665                    // render a finalized component directly.
8666                    let comp = Arc::new(ToolExecutionComponent::new(
8667                        &tool_name,
8668                        &if is_ask_user_tool(&tool_name) {
8669                            ask_user_args_display(&serde_json::Value::Null)
8670                        } else {
8671                            "".to_string()
8672                        },
8673                    ));
8674                    if is_ask_user_tool(&tool_name) {
8675                        comp.set_display_title("ASK USER");
8676                    }
8677                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8678                    let result_text = if is_ask_user_tool(&tool_name) {
8679                        ask_user_result_text(&result)
8680                    } else {
8681                        tool_result_text(&result)
8682                    };
8683                    comp.set_result(&result_text, is_error);
8684                    if !is_ask_user_tool(&tool_name) {
8685                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8686                    }
8687                    chat.add_child(comp.clone());
8688                }
8689            }
8690            state.sync_working_loader_with_bash();
8691            // Bash tool completion is user-visible: immediate render ensures the
8692            // result is displayed even when the scheduler thread is busy or the
8693            // throttle window has not elapsed. This single immediate frame does
8694            // not regress the streaming-output throttle that keeps CPU/GPU use
8695            // low (bash completions are discrete events, not per-token bursts).
8696            tui.render_now(false);
8697        }
8698    }
8699}
8700
8701/// Update the footer's context-window badge from one assistant response.
8702///
8703/// `Usage::context_tokens()` is the prompt size the provider reported for that
8704/// request — the value pi's footer renders as `{percent}%/{window}`. Aborted
8705/// and errored turns are skipped (their usage is not a real context
8706/// measurement, mirroring pi's `lastAssistantUsageInfo`) so the last valid
8707/// count stays on screen instead of flickering to `?`.
8708fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
8709    if message.stop_reason == rpi_ai::StopReason::Aborted
8710        || message.stop_reason == rpi_ai::StopReason::Error
8711    {
8712        return;
8713    }
8714    let tokens = message.usage.context_tokens();
8715    if tokens > 0 {
8716        footer.set_context_tokens(Some(tokens));
8717    }
8718}
8719
8720/// Return the diagnostic carried by a failed or aborted provider request.
8721/// Providers may omit `error_message`; keep a stable fallback so a terminal
8722/// request failure can never render as an empty transcript turn.
8723fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
8724    let fallback = match message.stop_reason {
8725        rpi_ai::StopReason::Error => "Provider request failed.",
8726        rpi_ai::StopReason::Aborted => "Request aborted.",
8727        _ => return None,
8728    };
8729    Some(
8730        message
8731            .error_message
8732            .as_deref()
8733            .filter(|text| !text.trim().is_empty())
8734            .unwrap_or(fallback)
8735            .to_string(),
8736    )
8737}
8738
8739/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
8740/// tool result and mark the component complete. Mirrors the TS bash finalize
8741/// path; only the fields `BashExecutionComponent` needs are read.
8742fn finalize_bash(
8743    comp: &Arc<BashExecutionComponent>,
8744    result: &rpi_agent::AgentToolResult,
8745    is_error: bool,
8746) {
8747    // The exit code isn't in details directly (TS carries it elsewhere); use
8748    // `is_error` as the error signal and 0/1 as a best-effort exit code.
8749    let exit_code = if is_error { Some(1) } else { Some(0) };
8750    let truncated = result
8751        .details
8752        .get("truncation")
8753        .and_then(|t| t.get("truncated"))
8754        .and_then(|v| v.as_bool())
8755        .unwrap_or(false);
8756    let full_output_path = result
8757        .details
8758        .get("full_output_path")
8759        .and_then(|v| v.as_str())
8760        .map(|s| s.to_string());
8761    let truncation = BashTruncation {
8762        truncated,
8763        full_output_path,
8764    };
8765    // Some tool backends attach the authoritative command to result details.
8766    // Backfill it before completing so this is a fallback, never the first
8767    // opportunity for the UI to show the whole command.
8768    if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
8769        comp.set_command(command);
8770    }
8771    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
8772    comp.set_complete(exit_code, cancelled, truncation);
8773}
8774
8775/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
8776/// display-diff string, render it with colors and attach to the component so
8777/// the changes show in the transcript. `write` has no diff (details: Null) and
8778/// stays a plain summary.
8779fn apply_edit_diff(
8780    comp: &Arc<ToolExecutionComponent>,
8781    tool_name: &str,
8782    details: &serde_json::Value,
8783    tui: &Arc<TuiAltScreen>,
8784) {
8785    if tool_name != "edit" {
8786        return;
8787    }
8788    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
8789        return;
8790    };
8791    if diff_text.is_empty() {
8792        return;
8793    }
8794    let width = tui.width();
8795    let lines = render_diff(diff_text, width);
8796    comp.set_diff(lines);
8797}
8798
8799/// The skill name when `tool_name` is a `read` of a `SKILL.md` file, else
8800/// `None`. The name is the `SKILL.md` parent directory's basename (matching
8801/// native Pi's skill-file convention). Ordinary markdown/document reads
8802/// return `None` and remain regular `READ` tool panels.
8803fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
8804    if tool_name != "read" {
8805        return None;
8806    }
8807    let path = args.get("path").and_then(|value| value.as_str())?;
8808    let normalized = path.replace('\\', "/");
8809    let file_name = normalized.rsplit('/').next()?;
8810    if !file_name.eq_ignore_ascii_case("SKILL.md") {
8811        return None;
8812    }
8813    normalized
8814        .trim_end_matches('/')
8815        .rsplit('/')
8816        .nth(1)
8817        .filter(|name| !name.is_empty())
8818        .map(str::to_string)
8819}
8820
8821/// The raw multi-line text of a tool result (no single-line collapsing). The
8822/// bash panel needs the original line structure — the old path fed it through
8823/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
8824/// crammed e.g. `ls -la`'s whole listing onto one line.
8825fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
8826    use rpi_agent::TextContentOrImage;
8827    let mut parts: Vec<String> = Vec::new();
8828    for c in &result.content {
8829        if let TextContentOrImage::Text(t) = c {
8830            parts.push(t.text.clone());
8831        }
8832    }
8833    parts.join("\n")
8834}
8835
8836/// Empty progress callbacks are valid (notably before a tool's start event),
8837/// but they do not contain anything useful to render. Defer those callbacks so
8838/// the first tool panel is created from `ToolExecutionStart` with real args.
8839fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
8840    !text.trim().is_empty() || !result.details.is_null()
8841}
8842
8843/// Tool names rendered through the dedicated `ASK USER` panel instead of a
8844/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
8845fn is_ask_user_tool(tool_name: &str) -> bool {
8846    matches!(tool_name, "ask_user" | "ask_user_question")
8847}
8848
8849/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
8850/// transport JSON must never leak into the transcript.
8851fn ask_user_args_display(args: &serde_json::Value) -> String {
8852    if args.is_null() {
8853        return String::new();
8854    }
8855    if let Some(summary) = args
8856        .get("summary")
8857        .and_then(serde_json::Value::as_str)
8858        .map(str::trim)
8859        .filter(|text| !text.is_empty())
8860    {
8861        return format!("Confirmation requested: {summary}");
8862    }
8863
8864    let mut lines: Vec<String> = Vec::new();
8865    let questions: Vec<&serde_json::Value> = args
8866        .get("questions")
8867        .and_then(serde_json::Value::as_array)
8868        .filter(|items| !items.is_empty())
8869        .map(|items| items.iter().collect())
8870        .unwrap_or_else(|| vec![args]);
8871
8872    for question in questions {
8873        let prompt = question
8874            .get("question")
8875            .and_then(serde_json::Value::as_str)
8876            .or_else(|| args.get("question").and_then(serde_json::Value::as_str))
8877            .map(str::trim)
8878            .unwrap_or_default();
8879        if prompt.is_empty() {
8880            continue;
8881        }
8882        if let Some(header) = question
8883            .get("header")
8884            .and_then(serde_json::Value::as_str)
8885            .map(str::trim)
8886            .filter(|text| !text.is_empty())
8887        {
8888            lines.push(format!("{header}: {prompt}"));
8889        } else {
8890            lines.push(prompt.to_string());
8891        }
8892        let context = question
8893            .get("context")
8894            .and_then(serde_json::Value::as_str)
8895            .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
8896            .map(str::trim)
8897            .filter(|text| !text.is_empty());
8898        if let Some(context) = context {
8899            lines.push(format!("  {context}"));
8900        }
8901        let options = question
8902            .get("options")
8903            .and_then(serde_json::Value::as_array)
8904            .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
8905        if let Some(options) = options {
8906            let labels: Vec<String> = options
8907                .iter()
8908                .filter_map(|option| {
8909                    option
8910                        .get("title")
8911                        .and_then(serde_json::Value::as_str)
8912                        .or_else(|| option.as_str())
8913                        .map(str::trim)
8914                        .filter(|label| !label.is_empty())
8915                        .map(str::to_string)
8916                })
8917                .collect();
8918            if !labels.is_empty() {
8919                lines.push(format!("Choices: {}", labels.join(", ")));
8920            }
8921        }
8922        if let Some(suggest) = question
8923            .get("suggest")
8924            .and_then(serde_json::Value::as_str)
8925            .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
8926            .map(str::trim)
8927            .filter(|text| !text.is_empty())
8928        {
8929            lines.push(format!("Suggestions: {suggest}"));
8930        }
8931    }
8932
8933    lines.join("\n")
8934}
8935
8936/// Progress text while an ask_user tool is pending. Prefer the plugin's own
8937/// readable `content`, otherwise show the question so the panel is not blank.
8938fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
8939    let text = tool_result_text(result);
8940    if !text.trim().is_empty() {
8941        return text;
8942    }
8943    let args_text = ask_user_args_display(args);
8944    if args_text.trim().is_empty() {
8945        "Waiting for your answer…".to_string()
8946    } else {
8947        args_text
8948    }
8949}
8950
8951/// Final result text for an ask_user tool. The plugin returns a human-readable
8952/// answer summary in `content`; never surface the transport JSON.
8953fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
8954    let text = tool_result_text(result);
8955    if text.trim().is_empty() {
8956        "Answer recorded.".to_string()
8957    } else {
8958        text
8959    }
8960}
8961
8962// ===========================================================================
8963// Selectors — editor-container swap (TS showSelector pattern)
8964// ===========================================================================
8965
8966/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
8967/// hiding the editor while the selector is open. Records the selector in
8968/// `state.active_selector` so the key loop routes to it.
8969fn open_selector(
8970    state: &Arc<TuiState>,
8971    editor_container: &Arc<Container>,
8972    editor: &Arc<Editor>,
8973    tui: &Arc<TuiAltScreen>,
8974    list: Arc<SelectList>,
8975    kind: SelectorKind,
8976) {
8977    open_selector_with_view(
8978        state,
8979        editor_container,
8980        editor,
8981        tui,
8982        SelectorView::List(list.clone()),
8983        list,
8984        kind,
8985    );
8986}
8987
8988/// Open a searchable selector: the list plus a fuzzy-filter search box.
8989///
8990/// Returns the wrapper so callers can prefill the query (native pi's
8991/// `initialSearchInput`). Mirrors `SelectSubmenu` with `searchable: true`.
8992fn open_searchable_selector(
8993    state: &Arc<TuiState>,
8994    editor_container: &Arc<Container>,
8995    editor: &Arc<Editor>,
8996    tui: &Arc<TuiAltScreen>,
8997    title: Option<&str>,
8998    description: Option<&str>,
8999    list: Arc<SelectList>,
9000    kind: SelectorKind,
9001) -> Arc<SearchableSelectList> {
9002    let searchable = Arc::new(SearchableSelectList::new(title, description, list));
9003    open_selector_with_view(
9004        state,
9005        editor_container,
9006        editor,
9007        tui,
9008        SelectorView::Searchable(searchable.clone()),
9009        searchable.clone(),
9010        kind,
9011    );
9012    // `TuiAltScreen::set_focus` only records the focused component; components
9013    // manage their own focus flag. Without this the search caret never renders
9014    // (and IME candidate positioning stays on the editor's old caret).
9015    searchable.set_focused(true);
9016    searchable
9017}
9018
9019/// Open a selector with an optional framed view. Native extension selectors
9020/// wrap the list with a title and hint while built-in selectors keep the list
9021/// as the complete view.
9022///
9023/// `view_router` receives keys (a bare list, or a searchable wrapper) while
9024/// `view` is what renders — the two are the same object for every built-in
9025/// selector, but extension selectors frame a plain list.
9026fn open_selector_with_view<V: Component + 'static>(
9027    state: &Arc<TuiState>,
9028    editor_container: &Arc<Container>,
9029    editor: &Arc<Editor>,
9030    tui: &Arc<TuiAltScreen>,
9031    view_router: SelectorView,
9032    view: Arc<V>,
9033    kind: SelectorKind,
9034) {
9035    // Dispatch ui_prompt_start event
9036    dispatch_ui_prompt_event(state);
9037
9038    // Unfocus the editor so its cursor marker doesn't render behind the list.
9039    editor.set_focused(false);
9040    // A selector replaces the editor slot. Drop stale slash/@file
9041    // suggestions so they cannot reappear after the selector closes.
9042    state.autocomplete_container.clear();
9043    // Swap: clear the container and add the selector view.
9044    editor_container.clear();
9045    editor_container.add_child(view.clone());
9046    *state.active_selector.lock().unwrap() = Some((view_router, kind));
9047    let focused: Arc<dyn Component> = view;
9048    tui.set_focus(Some(focused));
9049    tui.request_render(false);
9050}
9051
9052/// Restore the editor into the `editor_container` and clear the active
9053/// selector. Called by selector `on_cancel` and the Esc handler.
9054fn close_selector(
9055    state: &Arc<TuiState>,
9056    editor_container: &Arc<Container>,
9057    editor: &Arc<Editor>,
9058    tui: &Arc<TuiAltScreen>,
9059) {
9060    // Dispatch ui_prompt_end event
9061    dispatch_ui_prompt_event_end(state);
9062
9063    editor_container.clear();
9064    editor_container.add_child(editor.clone());
9065    state.autocomplete_container.clear();
9066    editor.set_focused(true);
9067    *state.active_selector.lock().unwrap() = None;
9068    *state.active_extension_cancel.lock().unwrap() = None;
9069    tui.set_focus(Some(editor.clone()));
9070    tui.request_render(false);
9071}
9072
9073/// Build + open the `/model` selector. Items are the resolved catalog (display
9074/// label = model name; description = id), with the current model marked.
9075/// Selecting applies the model **live** via `lane.set_model` (takes effect on
9076/// the next user message — the in-flight run's config is already snapshotted),
9077/// updates the footer, and notes the next-prompt effect.
9078fn open_model_selector(
9079    state: &Arc<TuiState>,
9080    editor_container: &Arc<Container>,
9081    editor: &Arc<Editor>,
9082    tui: &Arc<TuiAltScreen>,
9083    catalog: &[rpi_ai::Model],
9084    lane: &Arc<dyn AgentLane>,
9085    lane_model_id: &str,
9086    chat: &Arc<Container>,
9087    initial_search: Option<&str>,
9088) -> Option<Arc<SearchableSelectList>> {
9089    let items = model_selector_items(catalog, lane_model_id);
9090    if items.is_empty() {
9091        add_note_message(
9092            chat,
9093            "No models in the catalog. Use --model at startup to select one.",
9094        );
9095        tui.request_render(false);
9096        return None;
9097    }
9098    let list = Arc::new(SelectList::new(items, 10));
9099
9100    // Capture the catalog + lane so the on_select closure can resolve the
9101    // chosen Model and apply it. `on_select` fires on the blocking key thread,
9102    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
9103    let catalog_arc = catalog.to_vec();
9104    let state_sel = state.clone();
9105    let ec_sel = editor_container.clone();
9106    let editor_sel = editor.clone();
9107    let tui_sel = tui.clone();
9108    let chat_sel = chat.clone();
9109    let lane_sel = lane.clone();
9110    list.on_select(Arc::new(move |item| {
9111        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
9112            add_note_message(
9113                &chat_sel,
9114                &format!("Model {} not found in catalog.", item.label),
9115            );
9116            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9117            return;
9118        };
9119        state_sel.set_current_model(&model);
9120        let lane = lane_sel.clone();
9121        tokio::spawn(async move {
9122            let _ = lane.set_model(model).await;
9123        });
9124        add_note_message(
9125            &chat_sel,
9126            &format!(
9127                "Model set to {} — applies to the next message.",
9128                short_model_name(&item.value)
9129            ),
9130        );
9131        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9132    }));
9133    let state_cancel = state.clone();
9134    let ec_cancel = editor_container.clone();
9135    let editor_cancel = editor.clone();
9136    let tui_cancel = tui.clone();
9137    list.on_cancel(Arc::new(move || {
9138        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9139    }));
9140
9141    let searchable = open_searchable_selector(
9142        state,
9143        editor_container,
9144        editor,
9145        tui,
9146        Some("Select model"),
9147        Some("Type to filter by name, provider, or id"),
9148        list,
9149        SelectorKind::Model,
9150    );
9151    if let Some(term) = initial_search {
9152        searchable.set_search_text(term);
9153    }
9154    Some(searchable)
9155}
9156
9157/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
9158/// Returns `None` only when the catalog is empty or the current id isn't
9159/// found (in which case the first entry is returned — a no-op if it IS the
9160/// current). Used by the Ctrl+M model-cycle hotkey.
9161fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9162    if catalog.is_empty() {
9163        return None;
9164    }
9165    let idx = catalog
9166        .iter()
9167        .position(|m| m.id.eq_ignore_ascii_case(current_id));
9168    match idx {
9169        Some(i) => {
9170            let next = (i + 1) % catalog.len();
9171            Some(catalog[next].clone())
9172        }
9173        None => Some(catalog[0].clone()),
9174    }
9175}
9176
9177/// Build + open the `/session` selector. Lists JSONL session files under the
9178/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
9179/// fallback). Selecting reports "restore not
9180/// implemented in v1" (existing constraint) but shows the list for
9181/// discoverability.
9182fn open_session_selector(
9183    state: &Arc<TuiState>,
9184    editor_container: &Arc<Container>,
9185    editor: &Arc<Editor>,
9186    tui: &Arc<TuiAltScreen>,
9187    cwd: &std::path::Path,
9188    tx: &mpsc::UnboundedSender<TuiMessage>,
9189) {
9190    let dir = crate::session::default_session_dir(cwd);
9191    let mut items: Vec<SelectItem> = Vec::new();
9192    if let Ok(entries) = std::fs::read_dir(&dir) {
9193        for entry in entries.flatten() {
9194            let path = entry.path();
9195            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
9196                continue;
9197            }
9198            let stem = path
9199                .file_stem()
9200                .and_then(|s| s.to_str())
9201                .unwrap_or("(unnamed)")
9202                .to_string();
9203            let display = path
9204                .file_name()
9205                .and_then(|s| s.to_str())
9206                .unwrap_or(&stem)
9207                .to_string();
9208            items.push(SelectItem::new(&stem, &display));
9209        }
9210    }
9211    if items.is_empty() {
9212        add_note_message(
9213            &state.chat_container,
9214            "No saved sessions found. Sessions are created automatically in interactive mode.",
9215        );
9216        tui.request_render(false);
9217        return;
9218    }
9219    let list = Arc::new(SelectList::new(items, 10));
9220
9221    let state_sel = state.clone();
9222    let ec_sel = editor_container.clone();
9223    let editor_sel = editor.clone();
9224    let tui_sel = tui.clone();
9225    let tx_sel = tx.clone();
9226    list.on_select(Arc::new(move |item| {
9227        // Close the selector first, then ask the async loop to hot-switch:
9228        // opening the session file + swapping the harness backing is async
9229        // (repo list/open) and must not run on the blocking key thread.
9230        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9231        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
9232    }));
9233    let state_cancel = state.clone();
9234    let ec_cancel = editor_container.clone();
9235    let editor_cancel = editor.clone();
9236    let tui_cancel = tui.clone();
9237    list.on_cancel(Arc::new(move || {
9238        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9239    }));
9240
9241    open_searchable_selector(
9242        state,
9243        editor_container,
9244        editor,
9245        tui,
9246        Some("Resume session"),
9247        Some("Type to filter by session id"),
9248        list,
9249        SelectorKind::Session,
9250    );
9251}
9252
9253fn custom_entry_display_text(
9254    custom_type: &str,
9255    data: Option<&serde_json::Value>,
9256) -> Option<String> {
9257    let data = data?;
9258    let text = data
9259        .get("summary")
9260        .or_else(|| data.get("text"))
9261        .or_else(|| data.get("output"))
9262        .and_then(|value| value.as_str())
9263        .filter(|value| !value.trim().is_empty())?;
9264    let label = match custom_type {
9265        "compactionSummary" => "Compaction summary",
9266        "branchSummary" => "Branch summary",
9267        "bashExecution" => "Command output",
9268        other => other,
9269    };
9270    Some(format!("{label}: {text}"))
9271}
9272
9273/// Open a selector for the current session's persisted entry tree. Selecting a
9274/// message moves the main lane leaf to that entry, then the caller reloads the
9275/// visible branch from durable storage.
9276async fn open_tree_selector(
9277    harness: &AgentHarness,
9278    state: &Arc<TuiState>,
9279    editor_container: &Arc<Container>,
9280    editor: &Arc<Editor>,
9281    tui: &Arc<TuiAltScreen>,
9282    chat: &Arc<Container>,
9283    tx: &mpsc::UnboundedSender<TuiMessage>,
9284) {
9285    let entries = match harness
9286        .session()
9287        .view("main")
9288        .find_entries(&EntryQuery {
9289            order: Some(EntryOrder::OldestFirst),
9290            ..Default::default()
9291        })
9292        .await
9293    {
9294        Ok(entries) => entries,
9295        Err(error) => {
9296            add_error_message(chat, &format!("Could not read session tree: {error}"));
9297            tui.request_render(false);
9298            return;
9299        }
9300    };
9301    let current = harness.session().get_leaf_id().await.ok().flatten();
9302    let items: Vec<SelectItem> = entries
9303        .iter()
9304        .map(|entry| {
9305            let label = if current.as_deref() == Some(entry.id()) {
9306                format!("{} #{} (current)", entry.entry_type(), entry.seq())
9307            } else {
9308                format!("{} #{}", entry.entry_type(), entry.seq())
9309            };
9310            let short_id = &entry.id()[..entry.id().len().min(12)];
9311            // Search on the entry type, sequence, and id so `/tree` stays
9312            // usable in long sessions.
9313            let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
9314            SelectItem::new(entry.id(), &label)
9315                .with_description(short_id)
9316                .with_search_text(&search_text)
9317        })
9318        .collect();
9319    if items.is_empty() {
9320        add_note_message(chat, "The current session has no entries to navigate.");
9321        tui.request_render(false);
9322        return;
9323    }
9324    let list = Arc::new(SelectList::new(items, 12));
9325    let state_sel = state.clone();
9326    let ec_sel = editor_container.clone();
9327    let editor_sel = editor.clone();
9328    let tui_sel = tui.clone();
9329    let tx_sel = tx.clone();
9330    list.on_select(Arc::new(move |item| {
9331        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
9332        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9333    }));
9334    let state_cancel = state.clone();
9335    let ec_cancel = editor_container.clone();
9336    let editor_cancel = editor.clone();
9337    let tui_cancel = tui.clone();
9338    list.on_cancel(Arc::new(move || {
9339        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9340    }));
9341    open_searchable_selector(
9342        state,
9343        editor_container,
9344        editor,
9345        tui,
9346        Some("Session tree"),
9347        Some("Type to filter by entry type, sequence, or id"),
9348        list,
9349        SelectorKind::Tree,
9350    );
9351}
9352
9353/// Build + open the `/theme` selector. Built-in presets and enabled package
9354/// themes are shown; selecting applies the theme live and re-renders.
9355fn open_theme_selector(
9356    state: &Arc<TuiState>,
9357    editor_container: &Arc<Container>,
9358    editor: &Arc<Editor>,
9359    tui: &Arc<TuiAltScreen>,
9360    cwd: &std::path::Path,
9361    package_resources: &Arc<crate::packages::PackageResources>,
9362) {
9363    let mut items = vec![
9364        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
9365        SelectItem::new("light", "Light").with_description("Light background"),
9366        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
9367    ];
9368    if state.themes_enabled {
9369        for path in package_resources.theme_files() {
9370            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
9371                items.push(SelectItem::new(name, name).with_description("Package theme"));
9372            }
9373        }
9374    }
9375    let list = Arc::new(SelectList::new(items, 10));
9376
9377    let state_sel = state.clone();
9378    let ec_sel = editor_container.clone();
9379    let editor_sel = editor.clone();
9380    let tui_sel = tui.clone();
9381    let chat_sel = state.chat_container.clone();
9382    let cwd_sel = cwd.to_path_buf();
9383    let package_resources_sel = package_resources.clone();
9384    list.on_select(Arc::new(move |item| {
9385        let preset = match item.value.as_str() {
9386            "light" => Some(ThemePreset::Light),
9387            "monochrome" => Some(ThemePreset::Monochrome),
9388            "dark" => Some(ThemePreset::Dark),
9389            name => {
9390                if state_sel.themes_enabled {
9391                    if let Ok(custom) = crate::packages::load_theme_with_resources(
9392                        &cwd_sel,
9393                        name,
9394                        &package_resources_sel,
9395                    ) {
9396                        rpi_tui::global_theme_manager().set(custom.clone());
9397                        state_sel.theme_manager.set(custom);
9398                    }
9399                }
9400                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9401                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9402                tui_sel.render_now(true);
9403                return;
9404            }
9405        };
9406        let Some(preset) = preset else { return };
9407        apply_theme_preset(preset);
9408        state_sel.theme_manager.apply_preset(preset);
9409        // A quick accent note so the user sees the change registered even if
9410        // the terminal's own colors mask the preset difference.
9411        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9412        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9413        tui_sel.render_now(true);
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
9423    open_selector(
9424        state,
9425        editor_container,
9426        editor,
9427        tui,
9428        list,
9429        SelectorKind::Theme,
9430    );
9431}
9432
9433// ===========================================================================
9434// Feasible selectors — /thinking, /tools, /images
9435// ===========================================================================
9436
9437/// One-line descriptions for each thinking level, ported from
9438/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
9439fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9440    use rpi_ai::types::ThinkingLevel::*;
9441    match level {
9442        Off => "Off — No reasoning",
9443        Minimal => "Minimal — Brief reasoning (~1k tokens)",
9444        Low => "Low — Light reasoning (~1k tokens)",
9445        Medium => "Medium — Moderate reasoning (~80% of max)",
9446        High => "High — Extensive reasoning (~95% of max)",
9447        Xhigh => "Xhigh — Near-maximal reasoning",
9448        Max => "Max — Maximum reasoning",
9449    }
9450}
9451
9452/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
9453/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
9454fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9455    use rpi_ai::types::ThinkingLevel::*;
9456    match level {
9457        Off => "off",
9458        Minimal => "minimal",
9459        Low => "low",
9460        Medium => "medium",
9461        High => "high",
9462        Xhigh => "xhigh",
9463        Max => "max",
9464    }
9465}
9466
9467/// Parse a thinking-level name back to the enum (case-insensitive). Returns
9468/// `None` for an unknown name; used by the `/thinking` selector callback.
9469fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
9470    use rpi_ai::types::ThinkingLevel::*;
9471    match name.to_ascii_lowercase().as_str() {
9472        "off" => Some(Off),
9473        "minimal" => Some(Minimal),
9474        "low" => Some(Low),
9475        "medium" => Some(Medium),
9476        "high" => Some(High),
9477        "xhigh" => Some(Xhigh),
9478        "max" => Some(Max),
9479        _ => None,
9480    }
9481}
9482
9483/// Build + open the `/thinking` selector. Items are the levels the current
9484/// model supports (`Model::supported_thinking_levels`), each with a
9485/// description; the current level (read beforehand via `lane.get_thinking_level`)
9486/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
9487///
9488/// `on_select` fires on the blocking key thread, so it can't await
9489/// `lane.get_thinking_level()` to know the current level — the opener resolves
9490/// it first (best-effort) and preselects; the toggle on_select just applies
9491/// whatever was picked.
9492fn open_thinking_selector(
9493    state: &Arc<TuiState>,
9494    editor_container: &Arc<Container>,
9495    editor: &Arc<Editor>,
9496    tui: &Arc<TuiAltScreen>,
9497    lane: &Arc<dyn AgentLane>,
9498    catalog: &[rpi_ai::Model],
9499    lane_model_id: &str,
9500    chat: &Arc<Container>,
9501) {
9502    // Find the current model in the catalog to read its supported levels. If
9503    // absent, fall back to all levels so the selector still opens.
9504    let model = catalog
9505        .iter()
9506        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
9507    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
9508        .map(|m| m.supported_thinking_levels())
9509        .unwrap_or_else(|| {
9510            use rpi_ai::types::ThinkingLevel::*;
9511            vec![Off, Minimal, Low, Medium, High]
9512        });
9513    let mut items: Vec<SelectItem> = Vec::new();
9514    for lvl in &levels {
9515        let name = thinking_level_name(*lvl);
9516        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
9517    }
9518    if items.is_empty() {
9519        add_note_message(chat, "This model has no supported thinking levels.");
9520        tui.request_render(false);
9521        return;
9522    }
9523    let list = Arc::new(SelectList::new(items, 10));
9524
9525    let state_sel = state.clone();
9526    let ec_sel = editor_container.clone();
9527    let editor_sel = editor.clone();
9528    let tui_sel = tui.clone();
9529    let chat_sel = chat.clone();
9530    let lane_sel = lane.clone();
9531    list.on_select(Arc::new(move |item| {
9532        let Some(level) = thinking_level_from_name(&item.value) else {
9533            add_note_message(
9534                &chat_sel,
9535                &format!("Unknown thinking level: {}.", item.label),
9536            );
9537            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9538            return;
9539        };
9540        let lane = lane_sel.clone();
9541        let footer_sel = state_sel.footer.clone();
9542        tokio::spawn(async move {
9543            let _ = lane.set_thinking_level(level).await;
9544        });
9545        // Reflect the chosen level in the footer's model suffix (pi parity:
9546        // `model • thinking off` / `model • medium`). The shown text for the
9547        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
9548        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
9549        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
9550        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9551    }));
9552    let state_cancel = state.clone();
9553    let ec_cancel = editor_container.clone();
9554    let editor_cancel = editor.clone();
9555    let tui_cancel = tui.clone();
9556    list.on_cancel(Arc::new(move || {
9557        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9558    }));
9559
9560    open_selector(
9561        state,
9562        editor_container,
9563        editor,
9564        tui,
9565        list,
9566        SelectorKind::Thinking,
9567    );
9568}
9569
9570/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
9571/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
9572/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
9573/// — the blocking key thread can't await) and selecting a tool **toggles** it
9574/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
9575fn open_tools_selector(
9576    state: &Arc<TuiState>,
9577    editor_container: &Arc<Container>,
9578    editor: &Arc<Editor>,
9579    tui: &Arc<TuiAltScreen>,
9580    lane: &Arc<dyn AgentLane>,
9581    chat: &Arc<Container>,
9582) {
9583    // Best-effort read of the current active set. The opener runs on the async
9584    // runtime (it's called from the main loop's channel dispatch or the submit
9585    // closure that lives on the blocking thread — but `handle.block_on` is safe
9586    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
9587    let mut active = match tokio::runtime::Handle::try_current() {
9588        Ok(h) => h
9589            .block_on(async { lane.get_active_tools().await })
9590            .unwrap_or_default(),
9591        Err(_) => Vec::new(),
9592    };
9593    // An empty active set is the harness sentinel for "all registered tools"
9594    // (the selector only exposes built-ins). Expand it before rendering and
9595    // toggling so the first `/tools` visit does not show every tool as off or
9596    // accidentally reduce the active set to the one item selected.
9597    if active.is_empty() {
9598        active = crate::session::BUILTIN_TOOL_NAMES
9599            .iter()
9600            .map(|name| (*name).to_string())
9601            .collect();
9602    }
9603    let mut items: Vec<SelectItem> = Vec::new();
9604    for name in crate::session::BUILTIN_TOOL_NAMES {
9605        let on = active.iter().any(|a| a == name);
9606        let label = if on {
9607            format!("{name} (on)")
9608        } else {
9609            (*name).to_string()
9610        };
9611        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
9612    }
9613    let list = Arc::new(SelectList::new(items, 10));
9614
9615    // Capture the active set so on_select can toggle without re-reading.
9616    let active_captured = active.clone();
9617    let state_sel = state.clone();
9618    let ec_sel = editor_container.clone();
9619    let editor_sel = editor.clone();
9620    let tui_sel = tui.clone();
9621    let chat_sel = chat.clone();
9622    let lane_sel = lane.clone();
9623    list.on_select(Arc::new(move |item| {
9624        let mut next = active_captured.clone();
9625        if let Some(pos) = next.iter().position(|a| a == &item.value) {
9626            next.remove(pos);
9627        } else {
9628            next.push(item.value.clone());
9629        }
9630        let on = next.iter().any(|a| a == &item.value);
9631        let lane = lane_sel.clone();
9632        let next_clone = next.clone();
9633        tokio::spawn(async move {
9634            let _ = lane.set_active_tools(next_clone).await;
9635        });
9636        let list_str = if next.is_empty() {
9637            "(none)".to_string()
9638        } else {
9639            next.join(", ")
9640        };
9641        add_note_message(
9642            &chat_sel,
9643            &format!(
9644                "{} {} — active tools: {}",
9645                item.value,
9646                if on { "enabled" } else { "disabled" },
9647                list_str
9648            ),
9649        );
9650        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9651    }));
9652    let state_cancel = state.clone();
9653    let ec_cancel = editor_container.clone();
9654    let editor_cancel = editor.clone();
9655    let tui_cancel = tui.clone();
9656    list.on_cancel(Arc::new(move || {
9657        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9658    }));
9659
9660    open_selector(
9661        state,
9662        editor_container,
9663        editor,
9664        tui,
9665        list,
9666        SelectorKind::Tools,
9667    );
9668}
9669
9670/// Build + open the `/images` selector (Yes/No). Stores the choice in
9671/// `state.show_images` and notes it. Image wiring is minimal this pass — the
9672/// flag is consulted where images would be shown and echoed back here.
9673fn open_images_selector(
9674    state: &Arc<TuiState>,
9675    editor_container: &Arc<Container>,
9676    editor: &Arc<Editor>,
9677    tui: &Arc<TuiAltScreen>,
9678    chat: &Arc<Container>,
9679) {
9680    let current = *state.show_images.lock().unwrap();
9681    let items = vec![
9682        SelectItem::new("yes", "Yes").with_description(if current {
9683            "Inline images (current)"
9684        } else {
9685            "Inline images"
9686        }),
9687        SelectItem::new("no", "No").with_description(if current {
9688            "Placeholder only"
9689        } else {
9690            "Placeholder only (current)"
9691        }),
9692    ];
9693    let list = Arc::new(SelectList::new(items, 5));
9694
9695    let state_sel = state.clone();
9696    let ec_sel = editor_container.clone();
9697    let editor_sel = editor.clone();
9698    let tui_sel = tui.clone();
9699    let chat_sel = chat.clone();
9700    list.on_select(Arc::new(move |item| {
9701        let on = item.value == "yes";
9702        *state_sel.show_images.lock().unwrap() = on;
9703        add_note_message(
9704            &chat_sel,
9705            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
9706        );
9707        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9708    }));
9709    let state_cancel = state.clone();
9710    let ec_cancel = editor_container.clone();
9711    let editor_cancel = editor.clone();
9712    let tui_cancel = tui.clone();
9713    list.on_cancel(Arc::new(move || {
9714        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9715    }));
9716
9717    open_selector(
9718        state,
9719        editor_container,
9720        editor,
9721        tui,
9722        list,
9723        SelectorKind::Images,
9724    );
9725}
9726
9727// ===========================================================================
9728// Autocomplete
9729// ===========================================================================
9730
9731/// Refresh the autocomplete suggestion list from the current editor text +
9732/// cursor. Renders the suggestions into `autocomplete_container` (above the
9733/// editor) or clears it when there are none.
9734fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
9735    let text = editor.get_text();
9736    let cursor = editor_cursor_offset(editor, &text);
9737    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
9738    render_autocomplete(state, suggestions);
9739}
9740
9741/// Convert the editor's logical `(row, byte-column)` caret into the absolute
9742/// byte offset expected by autocomplete providers.
9743fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
9744    let (row, col) = editor.cursor_position();
9745    let mut offset = 0;
9746    for (index, line) in text.split('\n').enumerate() {
9747        if index == row {
9748            return (offset + col.min(line.len())).min(text.len());
9749        }
9750        offset = offset.saturating_add(line.len() + 1);
9751    }
9752    text.len()
9753}
9754
9755/// Restore an editor caret from an absolute byte offset after autocomplete
9756/// replaces a span in a multi-line draft.
9757fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
9758    let offset = offset.min(text.len());
9759    let mut consumed = 0;
9760    for (row, line) in text.split('\n').enumerate() {
9761        let end = consumed + line.len();
9762        if offset <= end {
9763            editor.set_cursor(row, offset - consumed);
9764            return;
9765        }
9766        consumed = end + 1;
9767    }
9768    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
9769    editor.set_cursor(
9770        last_row,
9771        text.rsplit('\n').next().map(str::len).unwrap_or(0),
9772    );
9773}
9774
9775/// Render (or clear) the autocomplete suggestion list into the container.
9776fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
9777    state.autocomplete_container.clear();
9778    let Some(sugg) = suggestions else {
9779        return;
9780    };
9781    if sugg.items.is_empty() {
9782        return;
9783    }
9784    // Build a compact list: top item marked with `→`, rest with `  `.
9785    // Cap the list so the dock doesn't swallow the transcript.
9786    let accent = state.theme_manager.get().colors.accent;
9787    let muted = state.theme_manager.get().colors.muted;
9788    for (i, item) in sugg
9789        .items
9790        .iter()
9791        .take(state.autocomplete_max_visible)
9792        .enumerate()
9793    {
9794        let prefix = if i == 0 { "→ " } else { "  " };
9795        let label = item.display_text();
9796        let line = if i == 0 {
9797            format!(
9798                "{prefix}{} {}",
9799                accent.fg(label),
9800                muted.fg(item.description.as_deref().unwrap_or(""))
9801            )
9802        } else {
9803            format!(
9804                "{prefix}{} {}",
9805                muted.fg(label),
9806                muted.fg(item.description.as_deref().unwrap_or(""))
9807            )
9808        };
9809        state
9810            .autocomplete_container
9811            .add_child(Arc::new(Text::new(line, 1, 0)));
9812    }
9813}
9814
9815/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
9816/// suggestion text, reposition the caret, and clear the suggestion list.
9817/// Returns `true` if a suggestion was accepted.
9818fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
9819    let text = editor.get_text();
9820    let cursor = editor_cursor_offset(editor, &text);
9821    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
9822        return false;
9823    };
9824    let Some(top) = sugg.items.first() else {
9825        return false;
9826    };
9827    // Replace the [start, end) span with the suggestion text. `start`/`end`
9828    // are byte offsets emitted by the providers on char boundaries, so the
9829    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
9830    let start = sugg.start.min(text.len());
9831    let end = sugg.end.min(text.len());
9832    let mut replaced = String::with_capacity(text.len() + top.text.len());
9833    replaced.push_str(&text[..start]);
9834    replaced.push_str(&top.text);
9835    // Keep the text AFTER the replaced span (mid-line completion: replacing
9836    // `[start, end)` must not drop the rest of the line).
9837    replaced.push_str(&text[end..]);
9838    if top.insert_space && !replaced.ends_with('/') {
9839        replaced.push(' ');
9840    }
9841    // New caret position: after the inserted text (byte offset; the editor
9842    // snaps `set_cursor` to a char boundary as a safety net).
9843    let new_cursor = replaced.len().min(
9844        start
9845            + top.text.len()
9846            + if top.insert_space && !top.text.ends_with('/') {
9847                1
9848            } else {
9849                0
9850            },
9851    );
9852    editor.set_text(&replaced);
9853    set_editor_cursor_offset(editor, &replaced, new_cursor);
9854    state.autocomplete_container.clear();
9855    true
9856}
9857
9858// ===========================================================================
9859// Transcript message helpers
9860// ===========================================================================
9861
9862/// Add the welcome header to the chat container.
9863fn add_welcome_message(container: &Arc<Container>) {
9864    add_welcome_message_with_capabilities(container, &[], &[]);
9865}
9866
9867/// Add the startup welcome header and a compact snapshot of active tools and
9868/// discovered skills. The snapshot reflects the harness configuration used by
9869/// the first turn, including tools contributed by extensions.
9870fn add_welcome_message_with_capabilities(
9871    container: &Arc<Container>,
9872    active_tools: &[String],
9873    skills: &[String],
9874) {
9875    let c = current_theme().colors;
9876    // ASCII art logo combining Rust crab and Pi
9877    let logo = format!(
9878        "{}\n{}\n{}\n{}",
9879        c.accent.fg("    🦀"),
9880        c.accent.fg("  ╭─π─╮"),
9881        c.accent.fg("  │") + &c.accent.fg(&tui_bold("rpi")) + &c.accent.fg("│"),
9882        c.accent.fg("  ╰───╯")
9883    );
9884    container.add_child(Arc::new(Text::new(logo, 1, 0)));
9885    container.add_child(Arc::new(Spacer::new(1)));
9886    // Accent logotype + a dim tagline, separated from the rest by a thin
9887    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
9888    // to the body text, so the header didn't read as a header.
9889    let title = format!(
9890        "{} {}",
9891        c.accent.fg(&tui_bold("rpi")),
9892        c.muted.fg("interactive TUI")
9893    );
9894    container.add_child(Arc::new(Text::new(title, 1, 0)));
9895    container.add_child(Arc::new(Spacer::new(1)));
9896    container.add_child(Arc::new(Text::new(
9897        c.dim.fg("Type your message and press Enter to send."),
9898        1,
9899        0,
9900    )));
9901    let hint = c
9902        .dim
9903        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
9904    container.add_child(Arc::new(Text::new(hint, 1, 0)));
9905    container.add_child(Arc::new(Spacer::new(1)));
9906    container.add_child(Arc::new(Text::new(
9907        welcome_capability_line("Tools", active_tools),
9908        1,
9909        0,
9910    )));
9911    container.add_child(Arc::new(Text::new(
9912        welcome_capability_line("Skills", skills),
9913        1,
9914        0,
9915    )));
9916    container.add_child(Arc::new(DynamicBorder::new()));
9917}
9918
9919/// Append update notices inside the live transcript. Fullscreen mode clears
9920/// pre-TUI stdout/stderr, so update state must be represented by components.
9921fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
9922    let colors = current_theme().colors;
9923    let group = Arc::new(Container::new());
9924
9925    for warning in &report.warnings {
9926        let body = format!(
9927            "{}\n{} {}{}",
9928            colors.error.fg(&warning.message),
9929            colors.muted.fg("Run"),
9930            colors.accent.fg(&warning.command),
9931            colors.muted.fg(" to retry.")
9932        );
9933        add_update_panel(&group, "Update Failed", &body, colors.error);
9934    }
9935
9936    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
9937        let body = format!(
9938            "{} {}{}",
9939            colors
9940                .muted
9941                .fg(&format!("New version {} is available. Run", notice.latest)),
9942            colors.accent.fg(&notice.command),
9943            colors.muted.fg(".")
9944        );
9945        add_update_panel(&group, "Update Available", &body, colors.warning);
9946    }
9947
9948    let package_notices = report
9949        .notices
9950        .iter()
9951        .filter(|notice| notice.name != "rpi")
9952        .collect::<Vec<_>>();
9953    if !package_notices.is_empty() {
9954        let command = package_notices[0].command.as_str();
9955        let mut lines = vec![format!(
9956            "{} {}{}",
9957            colors.muted.fg("Package updates are available. Run"),
9958            colors.accent.fg(command),
9959            colors.muted.fg(".")
9960        )];
9961        lines.push(colors.muted.fg("Packages:"));
9962        lines.extend(
9963            package_notices
9964                .into_iter()
9965                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
9966        );
9967        add_update_panel(
9968            &group,
9969            "Package Updates Available",
9970            &lines.join("\n"),
9971            colors.warning,
9972        );
9973    }
9974
9975    // Other transcript producers append concurrently. Add the fully built
9976    // group in one operation so card borders and content cannot interleave
9977    // with user, assistant, tool, or extension messages.
9978    if group.child_count() > 0 {
9979        container.add_child(group);
9980    }
9981}
9982
9983fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
9984    container.add_child(Arc::new(Spacer::new(1)));
9985    container.add_child(Arc::new(DynamicBorder::with_color(color)));
9986    container.add_child(Arc::new(Text::new(
9987        format!("{}\n{body}", color.fg(&tui_bold(title))),
9988        1,
9989        0,
9990    )));
9991    container.add_child(Arc::new(DynamicBorder::with_color(color)));
9992}
9993
9994fn welcome_capability_line(label: &str, names: &[String]) -> String {
9995    let c = current_theme().colors;
9996    let value = if names.is_empty() {
9997        "none".to_string()
9998    } else {
9999        names.join(" · ")
10000    };
10001    format!(
10002        "{} {}",
10003        c.accent.fg(&format!("{label} ({})", names.len())),
10004        c.muted.fg(&value)
10005    )
10006}
10007
10008/// Add the `/help` command listing to the chat container.
10009fn add_help_message(container: &Arc<Container>) {
10010    let c = current_theme().colors;
10011    // Section header + a thin themed rule, then a two-column command table:
10012    // `cmd` in accent, `— desc` in muted. The old single-space layout made
10013    // the description column wander depending on command length.
10014    container.add_child(Arc::new(Text::new(
10015        c.md_heading.fg(&tui_bold("📚 Available Commands")),
10016        1,
10017        0,
10018    )));
10019    container.add_child(Arc::new(Spacer::new(1)));
10020
10021    let cmds: &[(&str, &str)] = &[
10022        ("/help, /?", "Show this help message"),
10023        ("/clear, /new", "Clear the conversation"),
10024        ("/exit, /quit, /q", "Exit the application"),
10025        ("/version, /v", "Show version information"),
10026        ("/changelog", "Show recent release changes"),
10027        ("/model, /m", "Choose a model (live switch)"),
10028        ("/thinking, /think", "Set reasoning depth (selector)"),
10029        ("/tools", "Toggle built-in tools on/off"),
10030        ("/images", "Toggle inline image rendering"),
10031        ("/session", "List saved sessions"),
10032        ("/theme", "Choose a theme (selector)"),
10033        ("/compact", "Compact the conversation"),
10034        ("/copy", "Copy last reply to clipboard"),
10035        ("/hotkeys", "Show keyboard shortcuts"),
10036        ("/armin", "🐾 Easter egg"),
10037        ("/earendil", "Earendil announcement"),
10038    ];
10039    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10040    for (cmd, desc) in cmds {
10041        let row = format!(
10042            "  {:<cmd_w$}  {}  {}",
10043            c.accent.fg(cmd),
10044            c.dim.fg("—"),
10045            c.muted.fg(desc)
10046        );
10047        container.add_child(Arc::new(Text::new(row, 1, 0)));
10048    }
10049    container.add_child(Arc::new(Spacer::new(1)));
10050}
10051
10052/// Add the `/version` block to the chat container.
10053fn add_version_message(container: &Arc<Container>) {
10054    let c = current_theme().colors;
10055    container.add_child(Arc::new(Text::new(
10056        c.md_heading.fg(&tui_bold("📦 Version Information")),
10057        1,
10058        0,
10059    )));
10060    container.add_child(Arc::new(Spacer::new(1)));
10061    // Use the crate version (kept in sync via `version.workspace = true`)
10062    // instead of the stale hardcoded "v0.1.2".
10063    container.add_child(Arc::new(Text::new(
10064        format!(
10065            "  {} {}",
10066            c.muted.fg("rpi-cli"),
10067            c.text.fg(&format!("v{}", crate::VERSION))
10068        ),
10069        1,
10070        0,
10071    )));
10072    container.add_child(Arc::new(Text::new(
10073        format!(
10074            "  {}",
10075            c.dim.fg("Rust implementation of pi coding agent TUI")
10076        ),
10077        1,
10078        0,
10079    )));
10080    container.add_child(Arc::new(Spacer::new(1)));
10081}
10082
10083/// Add a compact `/changelog` block to the chat container. Keep this local to
10084/// the binary so the command remains useful in installed builds without a
10085/// source checkout or a network request.
10086fn add_changelog_message(container: &Arc<Container>) {
10087    let c = current_theme().colors;
10088    container.add_child(Arc::new(Text::new(
10089        c.md_heading.fg(&tui_bold("Recent Changes")),
10090        1,
10091        0,
10092    )));
10093    container.add_child(Arc::new(Spacer::new(1)));
10094    let entries = [
10095        (
10096            "Native parity phase 1",
10097            "models, images, trust, export, and JSON events",
10098        ),
10099        (
10100            "TUI controls",
10101            "external editor, thinking levels, and tool output toggles",
10102        ),
10103        (
10104            "Provider auth",
10105            "OpenAI-compatible API key aliases and gateway headers",
10106        ),
10107    ];
10108    for (release, summary) in entries {
10109        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
10110        container.add_child(Arc::new(Text::new(row, 1, 0)));
10111    }
10112    container.add_child(Arc::new(Text::new(
10113        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
10114        1,
10115        0,
10116    )));
10117    container.add_child(Arc::new(Spacer::new(1)));
10118}
10119
10120/// Add the `/hotkeys` block to the chat container.
10121fn add_hotkeys_message(container: &Arc<Container>) {
10122    let c = current_theme().colors;
10123    container.add_child(Arc::new(Text::new(
10124        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
10125        1,
10126        0,
10127    )));
10128    container.add_child(Arc::new(Spacer::new(1)));
10129    let keys: &[(&str, &str)] = &[
10130        ("Enter", "Send message"),
10131        ("Shift+Enter", "New line"),
10132        ("Tab", "Accept autocomplete suggestion"),
10133        ("Ctrl+A / Ctrl+E", "Line start / end"),
10134        (
10135            "Ctrl+K / Ctrl+U",
10136            "Kill to end / start of line (Ctrl+Y yanks)",
10137        ),
10138        ("Ctrl+- / Ctrl+R", "Undo / redo"),
10139        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
10140        ("Alt+Backspace", "Kill previous word"),
10141        ("Ctrl+C", "Abort a run, or exit when idle"),
10142        ("Esc", "Abort a running prompt"),
10143        ("Ctrl+L", "Open model selector"),
10144        ("Ctrl+M", "Cycle to the next model (live)"),
10145        ("Ctrl+O", "Expand/collapse all tool output"),
10146        ("Ctrl+T", "Show/hide reasoning blocks"),
10147        ("PageUp/Down", "Scroll transcript by one page"),
10148        ("Home / End", "Jump to transcript start / latest output"),
10149    ];
10150    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10151    for (key, desc) in keys {
10152        let row = format!(
10153            "  {:<key_w$}  {}  {}",
10154            c.accent.fg(key),
10155            c.dim.fg("—"),
10156            c.muted.fg(desc)
10157        );
10158        container.add_child(Arc::new(Text::new(row, 1, 0)));
10159    }
10160    container.add_child(Arc::new(Spacer::new(1)));
10161}
10162
10163/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
10164/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
10165/// plain `> text` echo. A trailing Spacer(1) separates it from the next
10166// transcript entry (every entry contributes one trailing spacer so
10167// consecutive turns are separated by exactly one blank line).
10168fn add_user_message(container: &Arc<Container>, text: &str) {
10169    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
10170    container.add_child(Arc::new(Spacer::new(1)));
10171}
10172
10173/// Add an error message to the chat container.
10174/// Collect all rendered lines from the chat container for search.
10175fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
10176    let width = 80; // Default width for search; actual width varies by terminal
10177    chat_container.render(width)
10178}
10179
10180/// Build the shell command that resumes `session_id` in this project, or
10181/// `None` when there is nothing to resume. Mirrors native pi's
10182/// `formatResumeCommand`: `rpi --session <id>`, prefixing
10183/// `--session-dir <dir>` only when a non-default directory was requested.
10184fn format_resume_command(
10185    session_id: &str,
10186    cwd: &std::path::Path,
10187    session_dir: Option<&std::path::Path>,
10188) -> Option<String> {
10189    if session_id.is_empty() {
10190        return None;
10191    }
10192    let mut args = vec![crate::APP_NAME.to_string()];
10193    if let Some(dir) = session_dir {
10194        let default = crate::session::default_session_dir(cwd);
10195        if dir != default.as_path() {
10196            args.push("--session-dir".to_string());
10197            args.push(quote_shell_arg(&dir.to_string_lossy()));
10198        }
10199    }
10200    args.push("--session".to_string());
10201    args.push(session_id.to_string());
10202    Some(args.join(" "))
10203}
10204
10205/// Quote a shell argument when it contains whitespace or shell metacharacters
10206/// (mirrors native pi's `quoteIfNeeded`).
10207fn quote_shell_arg(value: &str) -> String {
10208    let needs_quoting = value.is_empty()
10209        || value
10210            .chars()
10211            .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
10212    if needs_quoting {
10213        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
10214    } else {
10215        value.to_string()
10216    }
10217}
10218
10219fn add_error_message(container: &Arc<Container>, text: &str) {
10220    let c = current_theme().colors;
10221    let text = sanitize_error_message(text);
10222    container.add_child(Arc::new(Text::new(
10223        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
10224        1,
10225        0,
10226    )));
10227    container.add_child(Arc::new(Spacer::new(1)));
10228}
10229
10230/// Keep provider diagnostics printable in the transcript. HTTP error bodies
10231/// may contain carriage returns, terminal escapes, or an unexpectedly large
10232/// JSON payload; letting those bytes reach the renderer can corrupt the input
10233/// row or make the whole frame exceed terminal limits.
10234fn sanitize_error_message(text: &str) -> String {
10235    const MAX_ERROR_CHARS: usize = 16 * 1024;
10236    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
10237    let mut count = 0;
10238    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
10239    let mut escape_mode = 0u8;
10240    for ch in text.chars() {
10241        if escape_mode != 0 {
10242            match escape_mode {
10243                1 if ch == '[' => escape_mode = 2,
10244                1 if ch == ']' => escape_mode = 3,
10245                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
10246                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
10247                3 if ch == '\x07' => escape_mode = 0,
10248                _ => {}
10249            }
10250            continue;
10251        }
10252        if ch == '\x1b' {
10253            escape_mode = 1;
10254            continue;
10255        }
10256        if count >= MAX_ERROR_CHARS {
10257            result.push_str("…");
10258            break;
10259        }
10260        match ch {
10261            '\n' | '\t' => {
10262                result.push(ch);
10263                count += 1;
10264            }
10265            '\r' => {}
10266            c if c.is_control() => {}
10267            c => {
10268                result.push(c);
10269                count += 1;
10270            }
10271        }
10272    }
10273    if result.trim().is_empty() {
10274        "Provider request failed.".to_string()
10275    } else {
10276        result
10277    }
10278}
10279
10280/// Add a neutral note (e.g. unsupported-command message) to the chat container.
10281fn add_note_message(container: &Arc<Container>, text: &str) {
10282    let c = current_theme().colors;
10283    container.add_child(Arc::new(Text::new(
10284        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
10285        1,
10286        0,
10287    )));
10288    container.add_child(Arc::new(Spacer::new(1)));
10289}
10290
10291/// Render the `/context` panel: a transcript message listing the discovered
10292/// context files, skills, and prompt templates loaded for this session
10293/// (Part A resource discovery). Reads the harness resources snapshot captured
10294/// at TUI startup (the blocking submit handler can't `await get_resources()`.
10295///
10296/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
10297/// via `/reload`); here it's a transcript note rather than an overlay since the
10298/// resource set is session-static between `/reload`s (deferred).
10299fn show_context_panel(
10300    chat: &Arc<Container>,
10301    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
10302) {
10303    let skills = resources.skills.as_deref().unwrap_or(&[]);
10304    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
10305    let mut lines: Vec<String> = Vec::new();
10306    lines.push("📂 Discovered resources for this session:".into());
10307
10308    if skills.is_empty() {
10309        lines.push(
10310            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
10311        );
10312    } else {
10313        lines.push(format!("  Skills ({}):", skills.len()));
10314        for s in skills {
10315            let marker = if s.disable_model_invocation == Some(true) {
10316                " [hidden]"
10317            } else {
10318                ""
10319            };
10320            let desc: String = s.description.chars().take(72).collect();
10321            lines.push(format!("    • {}{marker} — {desc}", s.name));
10322        }
10323    }
10324
10325    if templates.is_empty() {
10326        lines.push(
10327            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
10328        );
10329    } else {
10330        lines.push(format!("  Prompt templates ({}):", templates.len()));
10331        for t in templates {
10332            let desc = t
10333                .description
10334                .as_deref()
10335                .unwrap_or("(no description)")
10336                .chars()
10337                .take(72)
10338                .collect::<String>();
10339            lines.push(format!("    • /{} — {desc}", t.name));
10340        }
10341    }
10342    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
10343    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
10344    lines.push(
10345        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
10346            .into(),
10347    );
10348    let body = lines.join("\n");
10349    container_note_block(chat, &body);
10350}
10351
10352/// Append a multi-line neutral note (header line + body) to the chat container.
10353fn container_note_block(container: &Arc<Container>, body: &str) {
10354    for line in body.lines() {
10355        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
10356    }
10357    container.add_child(Arc::new(Spacer::new(1)));
10358}
10359
10360// ===========================================================================
10361// TUI support + entry detection
10362// ===========================================================================
10363
10364/// Check if the terminal supports TUI mode.
10365pub fn is_tui_supported() -> bool {
10366    std::io::stdout().is_terminal()
10367}
10368
10369// Keep the `Color` import used (theme accent rendering in autocomplete).
10370#[allow(unused_imports)]
10371use rpi_tui::Color as _Color;
10372
10373#[cfg(test)]
10374mod tests {
10375    use super::*;
10376    use rpi_tui::Component;
10377
10378    /// Minimal `TuiState` for unit tests that only touch state flags. Mirrors
10379    /// the explicit literals other tests build, but keeps one copy in sync.
10380    fn test_tui_state() -> Arc<TuiState> {
10381        Arc::new(TuiState {
10382            current_assistant: std::sync::Mutex::new(None),
10383            tool_components: std::sync::Mutex::new(HashMap::new()),
10384            bash_components: std::sync::Mutex::new(HashMap::new()),
10385            themes_enabled: true,
10386            hide_thinking: std::sync::Mutex::new(false),
10387            tool_outputs_expanded: std::sync::Mutex::new(false),
10388            show_terminal_progress: true,
10389            status: std::sync::Mutex::new(RunStatus::Idle),
10390            js_preparation_cancel: std::sync::Mutex::new(None),
10391            user_bash_cancel: std::sync::Mutex::new(None),
10392            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10393            footer: Arc::new(FooterComponent::new()),
10394            status_container: Arc::new(Container::new()),
10395            chat_container: Arc::new(Container::new()),
10396            loader: Arc::new(Loader::new()),
10397            editor: Arc::new(Editor::simple()),
10398            last_assistant_text: std::sync::Mutex::new(String::new()),
10399            active_selector: std::sync::Mutex::new(None),
10400            active_extension_editor: std::sync::Mutex::new(None),
10401            active_extension_input: std::sync::Mutex::new(None),
10402            active_extension_cancel: std::sync::Mutex::new(None),
10403            autocomplete: AutocompleteManager::new(),
10404            autocomplete_container: Arc::new(Container::new()),
10405            autocomplete_max_visible: 5,
10406            pending_images: std::sync::Mutex::new(Vec::new()),
10407            theme_manager: Arc::new(ThemeManager::new()),
10408            tui: None,
10409            current_model_id: std::sync::Mutex::new(String::new()),
10410            show_images: std::sync::Mutex::new(true),
10411            cache_miss_notices: std::sync::Mutex::new(false),
10412            history: std::sync::Mutex::new(Vec::new()),
10413            history_index: std::sync::Mutex::new(-1),
10414            history_draft: std::sync::Mutex::new(None),
10415            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10416            scoped_edit: std::sync::Mutex::new(None),
10417            markdown_transformer: std::sync::Mutex::new(None),
10418            extension_session: Arc::new(std::sync::Mutex::new(
10419                rpi_extensions::ExtensionSession::none(),
10420            )),
10421            search: Arc::new(AltScreenSearch::new()),
10422            search_bar: Arc::new(SearchBar::new()),
10423            pending_container: Arc::new(Container::new()),
10424            dequeue_hint: String::new(),
10425            pending_snapshot: std::sync::Mutex::new(
10426                rpi_harness::agent_harness::QueuedMessages::default(),
10427            ),
10428            selection_start: std::sync::Mutex::new(None),
10429            selection_end: std::sync::Mutex::new(None),
10430        })
10431    }
10432
10433    #[test]
10434    fn settings_menu_covers_the_wired_settings_surface() {
10435        let state = test_tui_state();
10436        let settings = crate::settings::Settings::default();
10437        let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
10438        let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
10439        assert_eq!(
10440            keys,
10441            [
10442                "theme",
10443                "model",
10444                "thinking",
10445                "scoped-models",
10446                "hide-thinking",
10447                "show-images",
10448                "cache-miss-notices",
10449                "quiet-startup",
10450                "terminal-progress",
10451                "fullscreen-copy-on-select",
10452                "double-escape-action",
10453                "editor-padding",
10454                "autocomplete-max-items",
10455                "http-idle-timeout",
10456            ]
10457        );
10458        // Submenu rows must not cycle their displayed value.
10459        for item in items.iter().filter(|item| item.has_submenu) {
10460            assert!(
10461                item.values.is_empty(),
10462                "{} should be submenu-only",
10463                item.key
10464            );
10465        }
10466        // Value rows advertise a cycle that contains the current value.
10467        for item in items.iter().filter(|item| !item.has_submenu) {
10468            assert!(!item.values.is_empty(), "{} has no values", item.key);
10469            assert!(
10470                item.values.iter().any(|value| value == &item.value),
10471                "{} current value {} not in {:?}",
10472                item.key,
10473                item.value,
10474                item.values
10475            );
10476        }
10477        // The default model row falls back to the running lane's model.
10478        assert_eq!(items[1].value, "claude-sonnet-5");
10479        // Native defaults: cache-miss notices off, terminal progress on.
10480        assert_eq!(items[6].value, "false");
10481        assert_eq!(items[8].value, "true");
10482    }
10483
10484    #[test]
10485    fn apply_setting_change_updates_settings_and_live_state() {
10486        let state = test_tui_state();
10487        let mut settings = crate::settings::Settings::default();
10488
10489        // Live: hide-thinking flips both the runtime flag and the persisted one.
10490        let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
10491        assert!(state.hide_thinking());
10492        assert_eq!(settings.hide_thinking_block, Some(true));
10493        assert!(note.expect("user-visible note").contains("hidden"));
10494
10495        // Live: show-images writes the runtime mutex.
10496        apply_setting_change(&state, "show-images", "false", &mut settings);
10497        assert!(!*state.show_images.lock().unwrap());
10498        assert_eq!(settings.show_images, Some(false));
10499
10500        // Live: cache-miss notices.
10501        apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
10502        assert!(*state.cache_miss_notices.lock().unwrap());
10503        assert_eq!(settings.show_cache_miss_notices, Some(true));
10504
10505        // Persist-only rows change settings without a transcript note.
10506        assert_eq!(
10507            apply_setting_change(&state, "editor-padding", "3", &mut settings),
10508            None
10509        );
10510        assert_eq!(settings.editor_padding_x, Some(3));
10511        apply_setting_change(&state, "double-escape-action", "none", &mut settings);
10512        assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
10513        apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
10514        assert_eq!(settings.autocomplete_max_visible, Some(20));
10515
10516        // HTTP idle timeout maps labels onto the numeric/`disabled` forms.
10517        apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
10518        assert_eq!(settings.http_idle_timeout_ms(), Some(0));
10519        apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
10520        assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
10521
10522        // Unknown keys are ignored rather than panicking.
10523        assert_eq!(
10524            apply_setting_change(&state, "nope", "x", &mut settings),
10525            None
10526        );
10527    }
10528
10529    #[test]
10530    fn add_user_message_renders_a_visible_user_bubble() {
10531        // The submit handler renders directly-sent prompts through this helper
10532        // (the harness emits no user `message_start` for them), so a silent
10533        // failure here would make every prompt vanish from the transcript.
10534        let chat = Arc::new(Container::new());
10535        add_user_message(&chat, "hello from the user");
10536        let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
10537        let plain = strip_ansi(&rendered.join("\n"));
10538        assert!(
10539            plain.contains("hello from the user"),
10540            "user bubble must show the prompt text; got: {plain:?}"
10541        );
10542    }
10543
10544    #[test]
10545    fn verbose_overrides_quiet_startup_listing() {
10546        assert!(should_show_startup_listing(false, false));
10547        assert!(should_show_startup_listing(true, false));
10548        assert!(should_show_startup_listing(true, true));
10549        assert!(!should_show_startup_listing(false, true));
10550    }
10551
10552    #[test]
10553    fn resume_command_omits_the_default_session_dir() {
10554        let cwd = std::path::Path::new("/proj/demo");
10555        assert_eq!(format_resume_command("", cwd, None), None);
10556        assert_eq!(
10557            format_resume_command("abc-123", cwd, None).as_deref(),
10558            Some("rpi --session abc-123")
10559        );
10560        // The default dir carries no flag (mirrors native pi).
10561        let default = crate::session::default_session_dir(cwd);
10562        assert_eq!(
10563            format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
10564            Some("rpi --session abc-123")
10565        );
10566        // A custom dir is echoed, quoting when needed.
10567        let custom = std::path::Path::new("/tmp/my sessions");
10568        assert_eq!(
10569            format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
10570            Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
10571        );
10572    }
10573
10574    #[test]
10575    fn quote_shell_arg_quotes_only_when_needed() {
10576        assert_eq!(quote_shell_arg("plain"), "plain");
10577        assert_eq!(quote_shell_arg("with space"), "\"with space\"");
10578        assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
10579    }
10580
10581    #[test]
10582    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
10583        let tmp = tempfile::tempdir().unwrap();
10584        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
10585        let local_root = tmp.path().join("local-package");
10586        std::fs::create_dir_all(git_root.join(".git")).unwrap();
10587        std::fs::create_dir_all(&local_root).unwrap();
10588        std::fs::write(
10589            git_root.join("package.json"),
10590            r#"{"name":"git-demo","version":"1.0.0"}"#,
10591        )
10592        .unwrap();
10593        std::fs::write(
10594            local_root.join("package.json"),
10595            r#"{"name":"local-demo","version":"1.0.0"}"#,
10596        )
10597        .unwrap();
10598        let resources = crate::packages::discover(
10599            tmp.path(),
10600            &[
10601                "git:github.com/example/repo".to_string(),
10602                local_root.to_string_lossy().into_owned(),
10603            ],
10604        );
10605        assert_eq!(resources.packages.len(), 2);
10606
10607        let filtered = git_only_update_resources(resources);
10608
10609        assert_eq!(filtered.packages.len(), 1);
10610        assert!(matches!(
10611            filtered.packages[0].source,
10612            crate::packages::PackageSource::Git
10613        ));
10614    }
10615
10616    #[test]
10617    fn trust_command_uses_the_session_cwd_after_process_chdir() {
10618        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
10619        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
10620        const TEST_NAME: &str =
10621            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
10622
10623        if std::env::var_os(CHILD_ENV).is_some() {
10624            let session_cwd = std::path::PathBuf::from(
10625                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
10626            );
10627            let changed_cwd = std::env::current_dir().unwrap();
10628
10629            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
10630
10631            assert_eq!(
10632                crate::config::project_trust_decision(&session_cwd).unwrap(),
10633                Some(true)
10634            );
10635            assert_eq!(
10636                crate::config::project_trust_decision(&changed_cwd).unwrap(),
10637                None
10638            );
10639            return;
10640        }
10641
10642        let tmp = tempfile::tempdir().unwrap();
10643        let session_cwd = tmp.path().join("session-project");
10644        let changed_cwd = tmp.path().join("extension-cwd");
10645        let agent_dir = tmp.path().join("agent");
10646        std::fs::create_dir_all(&session_cwd).unwrap();
10647        std::fs::create_dir_all(&changed_cwd).unwrap();
10648        std::fs::create_dir_all(&agent_dir).unwrap();
10649
10650        let status = std::process::Command::new(std::env::current_exe().unwrap())
10651            .arg("--exact")
10652            .arg(TEST_NAME)
10653            .arg("--nocapture")
10654            .env(CHILD_ENV, "1")
10655            .env(SESSION_CWD_ENV, &session_cwd)
10656            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
10657            .current_dir(&changed_cwd)
10658            .status()
10659            .unwrap();
10660
10661        assert!(status.success(), "child test process failed: {status}");
10662    }
10663
10664    #[test]
10665    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
10666        let global = crate::settings::Settings {
10667            editor_padding_x: Some(3),
10668            autocomplete_max_visible: Some(6),
10669            hide_thinking_block: Some(false),
10670            quiet_startup: Some(true),
10671            show_terminal_progress: Some(true),
10672            ..Default::default()
10673        };
10674        let rpi_project = crate::settings::Settings {
10675            editor_padding_x: Some(0),
10676            hide_thinking_block: Some(true),
10677            quiet_startup: Some(false),
10678            show_terminal_progress: Some(false),
10679            ..Default::default()
10680        };
10681        let pi_project = crate::settings::Settings {
10682            editor_padding_x: Some(9),
10683            autocomplete_max_visible: Some(12),
10684            quiet_startup: Some(true),
10685            ..Default::default()
10686        };
10687
10688        assert_eq!(
10689            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
10690            TuiStartupSettings {
10691                editor_padding_x: 0,
10692                autocomplete_max_visible: 12,
10693                hide_thinking: true,
10694                quiet_startup: false,
10695                show_terminal_progress: false,
10696                show_images: true,
10697                cache_miss_notices: false,
10698            }
10699        );
10700    }
10701
10702    #[test]
10703    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
10704        let global = crate::settings::Settings {
10705            quiet_startup: Some(false),
10706            ..Default::default()
10707        };
10708        let rpi_project = crate::settings::Settings::default();
10709        let pi_project = crate::settings::Settings {
10710            quiet_startup: Some(true),
10711            ..Default::default()
10712        };
10713
10714        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
10715
10716        assert!(resolved.quiet_startup);
10717    }
10718
10719    #[test]
10720    fn tui_startup_settings_use_global_values_without_project_fields() {
10721        let global = crate::settings::Settings {
10722            editor_padding_x: Some(7),
10723            autocomplete_max_visible: Some(8),
10724            hide_thinking_block: Some(true),
10725            quiet_startup: Some(true),
10726            show_terminal_progress: Some(false),
10727            show_images: Some(false),
10728            show_cache_miss_notices: Some(true),
10729            ..Default::default()
10730        };
10731
10732        assert_eq!(
10733            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
10734            TuiStartupSettings {
10735                editor_padding_x: 7,
10736                autocomplete_max_visible: 8,
10737                hide_thinking: true,
10738                quiet_startup: true,
10739                show_terminal_progress: false,
10740                show_images: false,
10741                cache_miss_notices: true,
10742            }
10743        );
10744    }
10745
10746    #[test]
10747    fn tui_startup_settings_read_native_nested_terminal_block() {
10748        // Native pi nests these under `terminal`; rpi's flat keys stay
10749        // compatible and the nested block is the fallback.
10750        let global = crate::settings::Settings {
10751            terminal: Some(crate::settings::TerminalSettings {
10752                show_images: Some(false),
10753                show_terminal_progress: Some(false),
10754                clear_on_shrink: None,
10755            }),
10756            ..Default::default()
10757        };
10758        let resolved =
10759            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10760        assert!(!resolved.show_images);
10761        assert!(!resolved.show_terminal_progress);
10762    }
10763
10764    #[test]
10765    fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
10766        let global = crate::settings::Settings {
10767            show_images: Some(true),
10768            terminal: Some(crate::settings::TerminalSettings {
10769                show_images: Some(false),
10770                show_terminal_progress: None,
10771                clear_on_shrink: None,
10772            }),
10773            ..Default::default()
10774        };
10775        let resolved =
10776            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10777        assert!(resolved.show_images);
10778    }
10779
10780    #[test]
10781    fn tui_startup_settings_ignore_untrusted_project_values() {
10782        let global = crate::settings::Settings {
10783            quiet_startup: Some(false),
10784            show_terminal_progress: Some(true),
10785            ..Default::default()
10786        };
10787        let project = crate::settings::Settings {
10788            quiet_startup: Some(true),
10789            show_terminal_progress: Some(false),
10790            ..Default::default()
10791        };
10792
10793        let resolved = resolve_tui_startup_settings(&global, &[project], false);
10794
10795        assert!(!resolved.quiet_startup);
10796        assert!(resolved.show_terminal_progress);
10797    }
10798
10799    #[test]
10800    fn transcript_page_uses_viewport_with_overlap() {
10801        assert_eq!(transcript_page_size(24), 20);
10802        assert_eq!(transcript_page_size(4), 1);
10803        assert_eq!(transcript_page_size(0), 1);
10804    }
10805
10806    #[test]
10807    fn parse_user_bash_distinguishes_single_and_double_bang() {
10808        // `!cmd` runs and stays in context.
10809        assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
10810        // `!!cmd` runs but is excluded from context.
10811        assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
10812        // Surrounding whitespace on the command is trimmed, the `!` prefix is not.
10813        assert_eq!(parse_user_bash("!  echo hi  "), Some(("echo hi", false)));
10814        assert_eq!(parse_user_bash("!!  echo hi"), Some(("echo hi", true)));
10815    }
10816
10817    #[test]
10818    fn parse_user_bash_ignores_bare_bang_and_plain_text() {
10819        // A bare `!` / `!!` has no command: fall through to the prompt path
10820        // instead of executing an empty shell line.
10821        assert_eq!(parse_user_bash("!"), None);
10822        assert_eq!(parse_user_bash("!!"), None);
10823        assert_eq!(parse_user_bash("!   "), None);
10824        assert_eq!(parse_user_bash("hello"), None);
10825        assert_eq!(parse_user_bash(""), None);
10826        // `/` keeps routing to the slash-command registry.
10827        assert_eq!(parse_user_bash("/model"), None);
10828    }
10829
10830    #[test]
10831    fn user_bash_run_slot_guards_and_cancels() {
10832        let state = test_tui_state();
10833        assert!(!state.user_bash_running());
10834        let cancel = state.begin_user_bash();
10835        assert!(state.user_bash_running());
10836        // Esc cancels without freeing the slot; the spawned task clears it when
10837        // the capture resolves (native pi's `isBashRunning`).
10838        assert!(state.cancel_user_bash());
10839        assert!(state.user_bash_running());
10840        assert!(cancel.is_cancelled());
10841        state.finish_user_bash();
10842        assert!(!state.user_bash_running());
10843        // Nothing to cancel once idle.
10844        assert!(!state.cancel_user_bash());
10845    }
10846
10847    #[test]
10848    fn key_repeat_is_dispatched_but_release_is_not() {
10849        assert!(should_dispatch_key(KeyEventKind::Press));
10850        assert!(should_dispatch_key(KeyEventKind::Repeat));
10851        assert!(!should_dispatch_key(KeyEventKind::Release));
10852    }
10853
10854    #[test]
10855    fn key_event_encoding_matches_pi_keybinding_protocol() {
10856        let key = |code, modifiers| KeyEvent::new(code, modifiers);
10857        assert_eq!(
10858            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
10859            "\r"
10860        );
10861        assert_eq!(
10862            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
10863            "\x1b[13;2u"
10864        );
10865        assert_eq!(
10866            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
10867            "\x1b[9;2u"
10868        );
10869        assert_eq!(
10870            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
10871            "\x1b[Z"
10872        );
10873        assert_eq!(
10874            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
10875            "\x1b[Z"
10876        );
10877        assert_eq!(
10878            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
10879            "\x03"
10880        );
10881        assert_eq!(
10882            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
10883            "\x0f"
10884        );
10885        assert_eq!(
10886            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
10887            "!"
10888        );
10889        assert_eq!(
10890            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
10891            "1"
10892        );
10893    }
10894
10895    #[test]
10896    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
10897        let bridge = JsDialogBridge::default();
10898        let (sender, receiver) = std_mpsc::channel();
10899        bridge.pending.lock().unwrap().push_back(JsDialogPending {
10900            request: JsDialogRequest {
10901                id: "dialog-1".into(),
10902                method: "input".into(),
10903                title: String::new(),
10904                message: String::new(),
10905                options: Vec::new(),
10906                placeholder: None,
10907                prefill: None,
10908            },
10909            result: sender,
10910        });
10911
10912        // Model the cancellation arriving after the queue entry has been
10913        // removed but before the TUI has installed the native widget.
10914        bridge.cancel("dialog-1");
10915        assert!(bridge.take_pending().is_none());
10916        assert_eq!(
10917            receiver.recv().unwrap(),
10918            serde_json::json!({ "cancelled": true })
10919        );
10920        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
10921    }
10922
10923    #[test]
10924    fn test_layout_renders_welcome_message() {
10925        let chat = Arc::new(Container::new());
10926        add_welcome_message(&chat);
10927
10928        let scroll = Arc::new(ScrollView::new(
10929            chat.clone(),
10930            ScrollViewOptions {
10931                follow: FollowMode::End,
10932                primary: true,
10933                ..Default::default()
10934            },
10935        ));
10936
10937        let editor = Arc::new(Editor::new(
10938            EditorOptions {
10939                padding_x: 1,
10940                ..Default::default()
10941            },
10942            EditorStyle::default(),
10943            Arc::new(rpi_tui::Keybindings::new()),
10944        ));
10945        let dock = Arc::new(Container::new());
10946        dock.add_child(editor);
10947
10948        let footer = Arc::new(FooterComponent::new());
10949
10950        let root = VStack::from_children(vec![
10951            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
10952            StackChild::Entry(StackEntry::new(dock)),
10953            StackChild::Entry(StackEntry::new(footer)),
10954        ]);
10955
10956        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
10957
10958        let all: String = frame.lines.join("\n");
10959        assert!(
10960            all.contains("rpi"),
10961            "Welcome message not found. Rendered: {}",
10962            all
10963        );
10964        assert!(
10965            all.contains("Type your message"),
10966            "Help text not found. Rendered: {}",
10967            all
10968        );
10969    }
10970
10971    #[test]
10972    fn test_chat_container_has_welcome_content() {
10973        let chat = Arc::new(Container::new());
10974        add_welcome_message_with_capabilities(
10975            &chat,
10976            &["read".into(), "bash".into(), "web_fetch".into()],
10977            &["rust-review".into(), "release".into()],
10978        );
10979
10980        let lines = chat.render(80);
10981        let all: String = lines.join("\n");
10982        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
10983        // joined by an ANSI reset — strip ANSI before checking the substring.
10984        let plain = strip_ansi(&all);
10985        assert!(
10986            plain.contains("rpi"),
10987            "Welcome message not in chat container: {:?}",
10988            lines
10989        );
10990        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
10991        assert!(
10992            plain.contains("read · bash · web_fetch"),
10993            "Tool names missing: {plain}"
10994        );
10995        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
10996        assert!(
10997            plain.contains("rust-review · release"),
10998            "Skill names missing: {plain}"
10999        );
11000    }
11001
11002    #[test]
11003    fn update_notices_render_inside_the_transcript() {
11004        let chat = Arc::new(Container::new());
11005        let report = crate::updates::UpdateReport {
11006            notices: vec![
11007                crate::updates::UpdateNotice {
11008                    name: "rpi".into(),
11009                    current: "0.1.10".into(),
11010                    latest: "0.1.11".into(),
11011                    command: "rpi update".into(),
11012                },
11013                crate::updates::UpdateNotice {
11014                    name: "rpi-search".into(),
11015                    current: "0.1.0".into(),
11016                    latest: "0.1.1".into(),
11017                    command: "rpi pi-package update".into(),
11018                },
11019            ],
11020            warnings: vec![crate::updates::UpdateWarning {
11021                message: "The previously scheduled rpi update failed: access denied".into(),
11022                command: "rpi update".into(),
11023            }],
11024        };
11025
11026        add_update_notices(&chat, &report);
11027
11028        assert_eq!(chat.child_count(), 1);
11029        let plain = strip_ansi(&chat.render(80).join("\n"));
11030        assert!(plain.contains("Update Failed"), "{plain}");
11031        assert!(
11032            plain.contains("rpi update failed: access denied"),
11033            "{plain}"
11034        );
11035        assert!(plain.contains("Update Available"), "{plain}");
11036        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
11037        assert!(plain.contains("rpi update"), "{plain}");
11038        assert!(plain.contains("Package Updates Available"), "{plain}");
11039        assert!(plain.contains("rpi pi-package update"), "{plain}");
11040        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
11041    }
11042
11043    #[test]
11044    fn empty_update_report_does_not_add_transcript_content() {
11045        let chat = Arc::new(Container::new());
11046
11047        add_update_notices(&chat, &crate::updates::UpdateReport::default());
11048
11049        assert_eq!(chat.child_count(), 0);
11050    }
11051
11052    #[test]
11053    fn skill_reads_are_detected_by_path() {
11054        let name = skill_tool_name(
11055            "read",
11056            &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
11057        );
11058        assert_eq!(name.as_deref(), Some("release"));
11059
11060        let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
11061        assert!(name.is_none());
11062
11063        // Only `read` (not other tools) triggers the skill box.
11064        assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
11065    }
11066
11067    #[test]
11068    fn welcome_capabilities_show_empty_state() {
11069        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
11070        assert_eq!(plain, "Skills (0) none");
11071    }
11072
11073    #[test]
11074    fn empty_tool_progress_is_deferred_until_start() {
11075        let empty = rpi_agent::AgentToolResult::default();
11076        assert!(!tool_update_has_payload("", &empty));
11077
11078        let text = rpi_agent::AgentToolResult::text("partial output");
11079        assert!(tool_update_has_payload("partial output", &text));
11080
11081        let details = rpi_agent::AgentToolResult {
11082            details: serde_json::json!({"path": "src/lib.rs"}),
11083            ..Default::default()
11084        };
11085        assert!(tool_update_has_payload("", &details));
11086    }
11087
11088    #[test]
11089    fn completed_stream_reconciles_the_final_tail_before_detaching() {
11090        let component = Arc::new(AssistantMessageComponent::new(
11091            AssistantMessageOptions::default(),
11092        ));
11093        component.set_streaming(true);
11094        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
11095
11096        let current = Mutex::new(Some(component.clone()));
11097        let cached = Mutex::new("partial response".to_string());
11098        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
11099        final_message.content = vec![Content::text(
11100            "partial response with the previously missing final tail",
11101        )];
11102        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
11103
11104        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
11105
11106        assert!(current.lock().unwrap().is_none());
11107        assert_eq!(
11108            cached.lock().unwrap().as_str(),
11109            "partial response with the previously missing final tail"
11110        );
11111        let rendered = strip_ansi(&component.render(100).join("\n"));
11112        assert!(
11113            rendered.contains("previously missing final tail"),
11114            "{rendered}"
11115        );
11116    }
11117
11118    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
11119    /// replaces the editor text, the NEXT rendered frame must show the
11120    /// completed text (" /model " with the caret after it), not the old
11121    /// prefix. Mirrors the real dock layout (autocomplete_container above the
11122    /// bordered editor) and drives the same accept path the Tab handler uses.
11123    #[test]
11124    fn tab_accept_suggestion_reflects_in_next_render() {
11125        use rpi_tui::render_layout_frame;
11126
11127        let editor = Arc::new(Editor::new(
11128            EditorOptions {
11129                padding_x: 1,
11130                ..Default::default()
11131            },
11132            EditorStyle::default(),
11133            Arc::new(rpi_tui::Keybindings::new()),
11134        ));
11135        editor.set_focused(true);
11136        let editor_container = Arc::new(Container::new());
11137        editor_container.add_child(editor.clone());
11138        let autocomplete_container = Arc::new(Container::new());
11139        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
11140        let dock = Arc::new(VStack::from_children(vec![
11141            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
11142            StackChild::Entry(
11143                StackEntry::new(editor_container.clone())
11144                    .shrink(0)
11145                    .min_size(3),
11146            ),
11147            StackChild::Entry(StackEntry::new(footer)),
11148        ]));
11149
11150        // Simulate the user typing "/mo" (the popup shows suggestions).
11151        let manager = AutocompleteManager::new();
11152        let mut combined = CombinedAutocompleteProvider::new();
11153        combined.add_provider(Arc::new(
11154            SlashCommandAutocompleteProvider::with_default_commands(),
11155        ));
11156        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
11157        manager.set_provider(Arc::new(combined));
11158        // Simulate typing "/mo" via the real insert path (advances the caret
11159        // by char length, like `handle_key` does).
11160        editor.insert("/mo");
11161        assert_eq!(editor.cursor_position(), (0, 3));
11162
11163        let frame_before = render_layout_frame(dock.clone(), 80, 10);
11164        assert!(
11165            frame_before.lines.iter().any(|l| l.contains("/mo")),
11166            "precondition: editor shows the typed prefix. Frame rows:\n{}",
11167            frame_before
11168                .lines
11169                .iter()
11170                .map(|l| format!("  [{l}]"))
11171                .collect::<Vec<_>>()
11172                .join("\n")
11173        );
11174
11175        // Tab: accept the top suggestion (the same code path as the key loop).
11176        let text = editor.get_text();
11177        let cursor = editor_cursor_offset(&editor, &text);
11178        let sugg = manager
11179            .get_suggestions(&text, cursor)
11180            .expect("slash suggestions for /mo");
11181        let top = sugg.items.first().expect("at least one suggestion");
11182        let start = sugg.start.min(text.len());
11183        let end = sugg.end.min(text.len());
11184        let mut replaced = String::new();
11185        replaced.push_str(&text[..start]);
11186        replaced.push_str(&top.text);
11187        replaced.push_str(&text[end..]);
11188        if top.insert_space && !replaced.ends_with('/') {
11189            replaced.push(' ');
11190        }
11191        let new_cursor = start + top.text.len();
11192        editor.set_text(&replaced);
11193        set_editor_cursor_offset(&editor, &replaced, new_cursor);
11194        autocomplete_container.clear();
11195        assert_eq!(editor.get_text(), "/model");
11196
11197        // The next render MUST display the completed text.
11198        let frame_after = render_layout_frame(dock, 80, 10);
11199        let all: String = frame_after.lines.join("\n");
11200        assert!(
11201            all.contains("/model"),
11202            "completed text missing from next render. Got:\n{all}"
11203        );
11204        // The caret must sit AFTER the completed command (the snap_boundary
11205        // regression put it one char early: "/mode|l" with the final char
11206        // dangling past the caret).
11207        let editor_line = frame_after
11208            .lines
11209            .iter()
11210            .find(|l| l.contains("/model"))
11211            .expect("editor row with completed text");
11212        assert!(
11213            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
11214            "caret must follow the full completed text. Got: {editor_line:?}"
11215        );
11216    }
11217
11218    #[test]
11219    fn multiline_autocomplete_preserves_row_and_column() {
11220        let editor = Arc::new(Editor::simple());
11221        editor.set_text("first\n/mo");
11222        editor.set_cursor(1, 3);
11223
11224        let text = editor.get_text();
11225        assert_eq!(editor_cursor_offset(&editor, &text), 9);
11226
11227        let replaced = "first\n/model";
11228        editor.set_text(replaced);
11229        set_editor_cursor_offset(&editor, replaced, 12);
11230        assert_eq!(editor.cursor_position(), (1, 6));
11231    }
11232
11233    #[test]
11234    fn test_slash_command_dispatch() {
11235        // The registry is the single source of truth for dispatch: `find(token)`
11236        // returns the command (by name or alias) whose `name()` is the canonical
11237        // form, or `None` for an unknown token. This replaces the old enum-based
11238        // `handle_slash_command` assertions with equivalent registry lookups.
11239        let registry = build_builtin_registry();
11240
11241        // Helper: a token resolves to the command with this canonical name.
11242        let resolves_to = |token: &str, canonical: &str| {
11243            let found = registry.find(token).expect("{token} should resolve");
11244            assert_eq!(
11245                found.name(),
11246                canonical,
11247                "{token} resolved to {} (expected {canonical})",
11248                found.name()
11249            );
11250        };
11251
11252        resolves_to("/help", "/help");
11253        resolves_to("/?", "/help"); // alias → canonical
11254        resolves_to("/clear", "/clear");
11255        resolves_to("/new", "/clear"); // alias
11256        resolves_to("/q", "/exit"); // alias
11257        resolves_to("/quit", "/exit"); // alias
11258        resolves_to("/version", "/version");
11259        resolves_to("/v", "/version"); // alias
11260        resolves_to("/changelog", "/changelog");
11261        resolves_to("/hotkeys", "/hotkeys");
11262        resolves_to("/model", "/model");
11263        resolves_to("/m", "/model"); // alias
11264        resolves_to("/theme", "/theme");
11265        resolves_to("/session", "/session");
11266        resolves_to("/resume", "/session"); // alias
11267        resolves_to("/compact", "/compact");
11268        resolves_to("/copy", "/copy");
11269        resolves_to("/thinking", "/thinking");
11270        resolves_to("/think", "/thinking"); // alias
11271        resolves_to("/tools", "/tools");
11272        resolves_to("/images", "/images");
11273        resolves_to("/armin", "/armin");
11274        resolves_to("/earendil", "/earendil");
11275        resolves_to("/context", "/context");
11276        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
11277        resolves_to("/settings", "/settings");
11278        resolves_to("/name", "/name");
11279        resolves_to("/export", "/export");
11280
11281        // Unknown token → not found.
11282        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
11283    }
11284
11285    #[test]
11286
11287    fn test_registry_visible_entries_cover_dispatch() {
11288        // The autocomplete list is derived from the registry, so every visible
11289        // command the dispatcher recognizes must appear in it — by construction,
11290        // but this guards against a future command being registered with
11291        // `visible()` / a non-empty description that the builder drops.
11292        let registry = build_builtin_registry();
11293        let names: Vec<String> = registry
11294            .visible_entries()
11295            .iter()
11296            .map(|c| c.name.clone())
11297            .collect();
11298        for recognized in [
11299            "/help",
11300            "/clear",
11301            "/new",
11302            "/exit",
11303            "/quit",
11304            "/version",
11305            "/changelog",
11306            "/model",
11307            "/session",
11308            "/theme",
11309            "/compact",
11310            "/copy",
11311            "/hotkeys",
11312            "/tools",
11313            "/images",
11314            "/thinking",
11315            "/usage",
11316            "/armin",
11317            "/earendil",
11318        ] {
11319            assert!(
11320                names.contains(&recognized.to_string()),
11321                "{recognized} missing from autocomplete list"
11322            );
11323        }
11324        // Hidden commands stay off the list.
11325        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
11326            assert!(
11327                !names.contains(&hidden.to_string()),
11328                "{hidden} should be hidden from autocomplete"
11329            );
11330        }
11331    }
11332
11333    #[test]
11334    fn test_agent_event_mapping_creates_assistant_and_tool() {
11335        // Synthetic AgentEvent sequence → UI mutations, exercised against the
11336        // real drain handler with a no-op TUI stand-in.
11337        use rpi_ai::types::{
11338            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
11339            ToolCall, ToolCallType, Usage,
11340        };
11341
11342        let state = Arc::new(TuiState {
11343            current_assistant: std::sync::Mutex::new(None),
11344            tool_components: std::sync::Mutex::new(HashMap::new()),
11345            bash_components: std::sync::Mutex::new(HashMap::new()),
11346            themes_enabled: true,
11347            hide_thinking: std::sync::Mutex::new(false),
11348            tool_outputs_expanded: std::sync::Mutex::new(false),
11349            show_terminal_progress: true,
11350            status: std::sync::Mutex::new(RunStatus::Idle),
11351            js_preparation_cancel: std::sync::Mutex::new(None),
11352            user_bash_cancel: std::sync::Mutex::new(None),
11353            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11354            footer: Arc::new(FooterComponent::new()),
11355            status_container: Arc::new(Container::new()),
11356            chat_container: Arc::new(Container::new()),
11357            loader: Arc::new(Loader::new()),
11358            editor: Arc::new(Editor::simple()),
11359            last_assistant_text: std::sync::Mutex::new(String::new()),
11360            active_selector: std::sync::Mutex::new(None),
11361            active_extension_editor: std::sync::Mutex::new(None),
11362            active_extension_input: std::sync::Mutex::new(None),
11363            active_extension_cancel: std::sync::Mutex::new(None),
11364            autocomplete: AutocompleteManager::new(),
11365            autocomplete_container: Arc::new(Container::new()),
11366            autocomplete_max_visible: 5,
11367            pending_images: std::sync::Mutex::new(Vec::new()),
11368            theme_manager: Arc::new(ThemeManager::new()),
11369            tui: None,
11370            current_model_id: std::sync::Mutex::new(String::new()),
11371            show_images: std::sync::Mutex::new(true),
11372            cache_miss_notices: std::sync::Mutex::new(false),
11373            history: std::sync::Mutex::new(Vec::new()),
11374            history_index: std::sync::Mutex::new(-1),
11375            history_draft: std::sync::Mutex::new(None),
11376            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11377            scoped_edit: std::sync::Mutex::new(None),
11378            markdown_transformer: std::sync::Mutex::new(None),
11379            extension_session: Arc::new(std::sync::Mutex::new(
11380                rpi_extensions::ExtensionSession::none(),
11381            )),
11382            search: Arc::new(AltScreenSearch::new()),
11383            search_bar: Arc::new(SearchBar::new()),
11384            pending_container: Arc::new(Container::new()),
11385            dequeue_hint: String::new(),
11386            pending_snapshot: std::sync::Mutex::new(
11387                rpi_harness::agent_harness::QueuedMessages::default(),
11388            ),
11389            selection_start: std::sync::Mutex::new(None),
11390            selection_end: std::sync::Mutex::new(None),
11391        });
11392
11393        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
11394        // terminal; instead, exercise the *mutation* half directly against a
11395        // captured chat container via a synthetic message-start event's data.
11396        let assistant = AssistantMessage {
11397            role: rpi_ai::types::AssistantRole,
11398            content: vec![
11399                Content::Thinking(ThinkingContent {
11400                    kind: ThinkingContentType,
11401                    thinking: "Reasoning about the reply.".into(),
11402                    thinking_signature: None,
11403                    redacted: false,
11404                }),
11405                Content::Text(TextContent {
11406                    kind: TextContentType,
11407                    text: "Hello.".into(),
11408                    text_signature: None,
11409                }),
11410                Content::ToolCall(ToolCall {
11411                    kind: ToolCallType,
11412                    id: "tc1".into(),
11413                    name: "bash".into(),
11414                    arguments: serde_json::json!({"command": "echo hi"}),
11415                    thought_signature: None,
11416                    namespace: None,
11417                }),
11418            ],
11419            api: rpi_ai::Api::AnthropicMessages,
11420            provider: "anthropic".into(),
11421            model: "claude-sonnet-5".into(),
11422            response_model: None,
11423            response_id: None,
11424            usage: Usage::zero(),
11425            stop_reason: StopReason::Stop,
11426            deferred: None,
11427            error_message: None,
11428            raw_stop_reason: None,
11429            end_turn: None,
11430            timestamp: 0,
11431        };
11432
11433        // Manually apply the MessageStart assistant branch logic (mirrors the
11434        // drain handler, without needing a TuiAltScreen).
11435        let comp = Arc::new(AssistantMessageComponent::new(
11436            AssistantMessageOptions::default(),
11437        ));
11438        comp.set_streaming(true);
11439        comp.update_blocks(&assistant_blocks(&assistant));
11440        let chat = Arc::new(Container::new());
11441        chat.add_child(comp.clone());
11442        *state.current_assistant.lock().unwrap() = Some(comp);
11443
11444        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
11445        for c in &assistant.content {
11446            if let Content::ToolCall(tc) = c {
11447                let mut tools = state.tool_components.lock().unwrap();
11448                if !tools.contains_key(&tc.id) {
11449                    let tc_comp = Arc::new(ToolExecutionComponent::new(
11450                        &tc.name,
11451                        &tc.arguments.to_string(),
11452                    ));
11453                    tc_comp.set_running();
11454                    chat.add_child(tc_comp.clone());
11455                    tools.insert(tc.id.clone(), tc_comp);
11456                }
11457            }
11458        }
11459
11460        // Assert: the assistant component rendered the text + the thinking
11461        // block (the update_blocks path keeps thinking visible), and a tool
11462        // component was registered.
11463        let rendered = chat.render(80);
11464        let joined: String = rendered.join("\n");
11465        assert!(
11466            joined.contains("Hello."),
11467            "assistant text not rendered: {joined}"
11468        );
11469        assert!(
11470            joined.contains("Reasoning about the reply."),
11471            "thinking block not rendered: {joined}"
11472        );
11473        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
11474        assert!(state.current_assistant.lock().unwrap().is_some());
11475
11476        // Manually apply ToolExecutionEnd (mirrors drain).
11477        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
11478        ended.set_result("hi", false);
11479        assert!(state.tool_components.lock().unwrap().is_empty());
11480
11481        // A running bash panel owns the visible spinner. The global loader is
11482        // hidden until the last concurrent bash tool completes, then restored
11483        // while the agent remains in the Working state.
11484        assert!(state.try_start_working());
11485        assert!(
11486            !state.try_start_working(),
11487            "a second submit must be rejected"
11488        );
11489        state.set_status(RunStatus::Idle);
11490        state.set_status(RunStatus::Working);
11491        // The active loader now renders inside the editor's top border.
11492        assert!(state.editor.working().is_some());
11493        assert_eq!(state.status_container.child_count(), 0);
11494        assert!(state.footer.get_status().is_empty());
11495        state.show_retry(3, 10, 8_000);
11496        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
11497        assert!(retry_status.contains("Retrying (3/10)"));
11498        state.set_status(RunStatus::Working);
11499        {
11500            let mut bash = state.bash_components.lock().unwrap();
11501            bash.insert(
11502                "bash-1".into(),
11503                Arc::new(BashExecutionComponent::new("one")),
11504            );
11505            bash.insert(
11506                "bash-2".into(),
11507                Arc::new(BashExecutionComponent::new("two")),
11508            );
11509        }
11510        state.sync_working_loader_with_bash();
11511        assert_eq!(state.status_container.child_count(), 0);
11512        state.bash_components.lock().unwrap().remove("bash-1");
11513        state.sync_working_loader_with_bash();
11514        assert_eq!(state.status_container.child_count(), 0);
11515        state.bash_components.lock().unwrap().remove("bash-2");
11516        state.sync_working_loader_with_bash();
11517        // The editor-border indicator stays visible across bash panels, so the
11518        // status container stays empty.
11519        assert_eq!(state.status_container.child_count(), 0);
11520        assert!(state.editor.working().is_some());
11521
11522        state.set_status(RunStatus::Aborting);
11523        assert_eq!(state.status_container.child_count(), 0);
11524        assert!(!state.loader.is_running());
11525    }
11526
11527    #[test]
11528    fn fresh_launch_does_not_restore_old_history() {
11529        let fresh = Args::default();
11530        assert!(!launch_restores_history(&fresh));
11531
11532        let continued = Args {
11533            continue_session: true,
11534            ..Args::default()
11535        };
11536        assert!(launch_restores_history(&continued));
11537
11538        let selected = Args {
11539            session: Some("session-id".into()),
11540            ..Args::default()
11541        };
11542        assert!(launch_restores_history(&selected));
11543    }
11544
11545    #[test]
11546    fn context_badge_ignores_aborted_and_error_turns() {
11547        use rpi_ai::types::{Api, StopReason, Usage};
11548
11549        let footer = Arc::new(FooterComponent::new());
11550        footer.set_context_window(100_000);
11551
11552        // A real response fills in the percent (`?` -> `25.0%/100k`).
11553        let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11554        ok.stop_reason = StopReason::Stop;
11555        ok.usage = Usage {
11556            input: 25_000,
11557            ..Usage::zero()
11558        };
11559        record_context_usage(&footer, &ok);
11560        let stats = strip_ansi(&footer.render(120).join("\n"));
11561        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11562
11563        // An aborted turn must not clobber the last good value.
11564        let mut aborted =
11565            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11566        aborted.stop_reason = StopReason::Aborted;
11567        aborted.usage = Usage {
11568            input: 90_000,
11569            ..Usage::zero()
11570        };
11571        record_context_usage(&footer, &aborted);
11572        let stats = strip_ansi(&footer.render(120).join("\n"));
11573        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11574
11575        // An errored turn likewise (its usage is not a real measurement).
11576        let mut errored =
11577            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11578        errored.stop_reason = StopReason::Error;
11579        errored.usage = Usage {
11580            input: 90_000,
11581            ..Usage::zero()
11582        };
11583        record_context_usage(&footer, &errored);
11584        let stats = strip_ansi(&footer.render(120).join("\n"));
11585        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11586    }
11587
11588    #[test]
11589    fn test_short_model_name() {
11590        assert_eq!(
11591            short_model_name("anthropic:claude-sonnet-5"),
11592            "claude-sonnet-5"
11593        );
11594        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
11595    }
11596
11597    #[test]
11598    fn model_selector_items_are_deduplicated_and_provider_qualified() {
11599        use rpi_ai::{Api, Model};
11600
11601        let mut gateway = Model::new(
11602            "gpt-5.6-sol",
11603            "GPT 5.6 Sol",
11604            Api::OpenaiCompletions,
11605            "routeryo-copy",
11606            "https://gateway.example.com",
11607        );
11608        let duplicate = gateway.clone();
11609        let anthropic = Model::new(
11610            "claude-sonnet-5",
11611            "Claude Sonnet 5",
11612            Api::AnthropicMessages,
11613            "anthropic",
11614            "https://api.anthropic.com",
11615        );
11616        gateway.headers = Some(std::collections::BTreeMap::from([(
11617            "authorization".into(),
11618            "Bearer test".into(),
11619        )]));
11620
11621        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
11622        assert_eq!(items.len(), 2);
11623        assert_eq!(items[0].value, "gpt-5.6-sol");
11624        assert_eq!(items[0].label, "GPT 5.6 Sol");
11625        assert_eq!(
11626            items[0].description.as_deref(),
11627            Some("routeryo-copy/gpt-5.6-sol (current)")
11628        );
11629        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
11630    }
11631
11632    #[test]
11633    fn model_selector_search_text_covers_provider_name_and_qualified_id() {
11634        use rpi_ai::{Api, Model};
11635
11636        let anthropic = Model::new(
11637            "claude-sonnet-5",
11638            "Claude Sonnet 5",
11639            Api::AnthropicMessages,
11640            "anthropic",
11641            "https://api.anthropic.com",
11642        );
11643        let items = model_selector_items(&[anthropic], "");
11644        let search = items[0].search_key();
11645        // Mirrors native `getModelSelectorSearchText`: provider-prefixed first,
11646        // then the display name.
11647        assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
11648        assert!(search.contains("Claude Sonnet 5"), "{search}");
11649
11650        // A provider query and a display-name query both match through the
11651        // real fuzzy filter used by the searchable selector.
11652        use rpi_tui::fuzzy_filter;
11653        for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
11654            let matched = fuzzy_filter(&items, query, |item| item.search_key());
11655            assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
11656        }
11657    }
11658
11659    #[test]
11660    fn model_selector_match_accepts_bare_and_qualified_ids() {
11661        use rpi_ai::{Api, Model};
11662
11663        let gateway = Model::new(
11664            "gpt-5.6-sol",
11665            "GPT 5.6 Sol",
11666            Api::OpenaiCompletions,
11667            "routeryo-copy",
11668            "https://gateway.example.com",
11669        );
11670        let anthropic = Model::new(
11671            "claude-sonnet-5",
11672            "Claude Sonnet 5",
11673            Api::AnthropicMessages,
11674            "anthropic",
11675            "https://api.anthropic.com",
11676        );
11677        let catalog = [gateway, anthropic];
11678        assert_eq!(
11679            find_model_selector_match(&catalog, "gpt-5.6-sol")
11680                .unwrap()
11681                .provider,
11682            "routeryo-copy"
11683        );
11684        assert_eq!(
11685            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
11686                .unwrap()
11687                .id,
11688            "gpt-5.6-sol"
11689        );
11690        assert_eq!(
11691            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
11692                .unwrap()
11693                .id,
11694            "claude-sonnet-5"
11695        );
11696        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
11697    }
11698
11699    #[test]
11700    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
11701        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
11702
11703        let failed = AssistantMessage {
11704            role: AssistantRole,
11705            content: Vec::new(),
11706            api: rpi_ai::Api::AnthropicMessages,
11707            provider: "anthropic".into(),
11708            model: "claude-sonnet-5".into(),
11709            response_model: None,
11710            response_id: None,
11711            usage: Usage::zero(),
11712            stop_reason: StopReason::Error,
11713            deferred: None,
11714            error_message: Some("upstream returned 401".into()),
11715            raw_stop_reason: None,
11716            end_turn: None,
11717            timestamp: 0,
11718        };
11719        assert_eq!(
11720            assistant_error_text(&failed).as_deref(),
11721            Some("upstream returned 401")
11722        );
11723
11724        let mut no_detail = failed;
11725        no_detail.error_message = Some("  ".into());
11726        assert_eq!(
11727            assistant_error_text(&no_detail).as_deref(),
11728            Some("Provider request failed.")
11729        );
11730
11731        let mut aborted = no_detail;
11732        aborted.stop_reason = StopReason::Aborted;
11733        aborted.error_message = Some("abort error: Request aborted".into());
11734        assert_eq!(
11735            assistant_error_text(&aborted).as_deref(),
11736            Some("abort error: Request aborted")
11737        );
11738
11739        aborted.error_message = None;
11740        assert_eq!(
11741            assistant_error_text(&aborted).as_deref(),
11742            Some("Request aborted.")
11743        );
11744    }
11745
11746    #[test]
11747    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
11748        assert_eq!(
11749            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
11750            "405\nMethod Not Allowed"
11751        );
11752        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
11753        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
11754        let long = "x".repeat(20_000);
11755        let cleaned = sanitize_error_message(&long);
11756        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
11757        assert!(cleaned.ends_with('…'));
11758    }
11759
11760    #[test]
11761    fn test_cycle_next_model_wraps_around() {
11762        use rpi_ai::{Api, Model};
11763        let mk = |id: &str| {
11764            Model::new(
11765                id,
11766                id,
11767                Api::AnthropicMessages,
11768                "anthropic",
11769                "https://api.anthropic.com",
11770            )
11771        };
11772        let catalog = [mk("a"), mk("b"), mk("c")];
11773        // Next after "a" is "b"; after "c" wraps to "a".
11774        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
11775        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
11776        // An unknown current id falls back to the first model.
11777        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
11778        // Empty catalog yields None.
11779        let empty: Vec<Model> = vec![];
11780        assert!(cycle_next_model(&empty, "a").is_none());
11781    }
11782
11783    #[test]
11784    fn test_autocomplete_slash_suggestions_render() {
11785        // The autocomplete container should render at least one suggestion
11786        // line when the editor holds a `/` prefix, and clear when it doesn't.
11787        let state = Arc::new(TuiState {
11788            current_assistant: std::sync::Mutex::new(None),
11789            tool_components: std::sync::Mutex::new(HashMap::new()),
11790            bash_components: std::sync::Mutex::new(HashMap::new()),
11791            themes_enabled: true,
11792            hide_thinking: std::sync::Mutex::new(false),
11793            tool_outputs_expanded: std::sync::Mutex::new(false),
11794            show_terminal_progress: true,
11795            status: std::sync::Mutex::new(RunStatus::Idle),
11796            js_preparation_cancel: std::sync::Mutex::new(None),
11797            user_bash_cancel: std::sync::Mutex::new(None),
11798            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11799            footer: Arc::new(FooterComponent::new()),
11800            status_container: Arc::new(Container::new()),
11801            chat_container: Arc::new(Container::new()),
11802            loader: Arc::new(Loader::new()),
11803            editor: Arc::new(Editor::simple()),
11804            last_assistant_text: std::sync::Mutex::new(String::new()),
11805            active_selector: std::sync::Mutex::new(None),
11806            active_extension_editor: std::sync::Mutex::new(None),
11807            active_extension_input: std::sync::Mutex::new(None),
11808            active_extension_cancel: std::sync::Mutex::new(None),
11809            autocomplete: AutocompleteManager::new(),
11810            autocomplete_container: Arc::new(Container::new()),
11811            autocomplete_max_visible: 5,
11812            pending_images: std::sync::Mutex::new(Vec::new()),
11813            theme_manager: Arc::new(ThemeManager::new()),
11814            tui: None,
11815            current_model_id: std::sync::Mutex::new(String::new()),
11816            show_images: std::sync::Mutex::new(true),
11817            cache_miss_notices: std::sync::Mutex::new(false),
11818            history: std::sync::Mutex::new(Vec::new()),
11819            history_index: std::sync::Mutex::new(-1),
11820            history_draft: std::sync::Mutex::new(None),
11821            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11822            scoped_edit: std::sync::Mutex::new(None),
11823            markdown_transformer: std::sync::Mutex::new(None),
11824            extension_session: Arc::new(std::sync::Mutex::new(
11825                rpi_extensions::ExtensionSession::none(),
11826            )),
11827            search: Arc::new(AltScreenSearch::new()),
11828            search_bar: Arc::new(SearchBar::new()),
11829            pending_container: Arc::new(Container::new()),
11830            dequeue_hint: String::new(),
11831            pending_snapshot: std::sync::Mutex::new(
11832                rpi_harness::agent_harness::QueuedMessages::default(),
11833            ),
11834            selection_start: std::sync::Mutex::new(None),
11835            selection_end: std::sync::Mutex::new(None),
11836        });
11837        {
11838            let mut combined = CombinedAutocompleteProvider::new();
11839            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
11840                build_builtin_registry().visible_entries(),
11841            )));
11842            state.autocomplete.set_provider(Arc::new(combined));
11843        }
11844
11845        let editor = Arc::new(Editor::simple());
11846        editor.set_text("/he");
11847        editor.set_cursor(0, 3);
11848        refresh_autocomplete(&state, &editor);
11849        let lines = state.autocomplete_container.render(80);
11850        let joined: String = lines.join("\n");
11851        assert!(
11852            joined.contains("/help"),
11853            "slash suggestions not rendered: {joined}"
11854        );
11855
11856        // Clear: no suggestions for plain text.
11857        editor.set_text("hello");
11858        editor.set_cursor(0, 5);
11859        refresh_autocomplete(&state, &editor);
11860        assert!(state.autocomplete_container.render(80).is_empty());
11861    }
11862
11863    #[test]
11864    fn test_select_list_swap_restores_editor() {
11865        // The editor-container swap: opening a selector replaces the editor
11866        // child; closing restores it. Verify the container child count + the
11867        // active_selector flag round-trip.
11868        let state = Arc::new(TuiState {
11869            current_assistant: std::sync::Mutex::new(None),
11870            tool_components: std::sync::Mutex::new(HashMap::new()),
11871            bash_components: std::sync::Mutex::new(HashMap::new()),
11872            themes_enabled: true,
11873            hide_thinking: std::sync::Mutex::new(false),
11874            tool_outputs_expanded: std::sync::Mutex::new(false),
11875            show_terminal_progress: true,
11876            status: std::sync::Mutex::new(RunStatus::Idle),
11877            js_preparation_cancel: std::sync::Mutex::new(None),
11878            user_bash_cancel: std::sync::Mutex::new(None),
11879            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11880            footer: Arc::new(FooterComponent::new()),
11881            status_container: Arc::new(Container::new()),
11882            chat_container: Arc::new(Container::new()),
11883            loader: Arc::new(Loader::new()),
11884            editor: Arc::new(Editor::simple()),
11885            last_assistant_text: std::sync::Mutex::new(String::new()),
11886            active_selector: std::sync::Mutex::new(None),
11887            active_extension_editor: std::sync::Mutex::new(None),
11888            active_extension_input: std::sync::Mutex::new(None),
11889            active_extension_cancel: std::sync::Mutex::new(None),
11890            autocomplete: AutocompleteManager::new(),
11891            autocomplete_container: Arc::new(Container::new()),
11892            autocomplete_max_visible: 5,
11893            pending_images: std::sync::Mutex::new(Vec::new()),
11894            theme_manager: Arc::new(ThemeManager::new()),
11895            tui: None,
11896            current_model_id: std::sync::Mutex::new(String::new()),
11897            show_images: std::sync::Mutex::new(true),
11898            cache_miss_notices: std::sync::Mutex::new(false),
11899            history: std::sync::Mutex::new(Vec::new()),
11900            history_index: std::sync::Mutex::new(-1),
11901            history_draft: std::sync::Mutex::new(None),
11902            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11903            scoped_edit: std::sync::Mutex::new(None),
11904            markdown_transformer: std::sync::Mutex::new(None),
11905            extension_session: Arc::new(std::sync::Mutex::new(
11906                rpi_extensions::ExtensionSession::none(),
11907            )),
11908            search: Arc::new(AltScreenSearch::new()),
11909            search_bar: Arc::new(SearchBar::new()),
11910            pending_container: Arc::new(Container::new()),
11911            dequeue_hint: String::new(),
11912            pending_snapshot: std::sync::Mutex::new(
11913                rpi_harness::agent_harness::QueuedMessages::default(),
11914            ),
11915            selection_start: std::sync::Mutex::new(None),
11916            selection_end: std::sync::Mutex::new(None),
11917        });
11918        let editor_container = Arc::new(Container::new());
11919        let editor = Arc::new(Editor::simple());
11920        editor_container.add_child(editor.clone());
11921        assert!(!state.selector_open());
11922
11923        let tui_terminal = Box::new(ProcessTerminal::new());
11924        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
11925        let list = Arc::new(SelectList::new(
11926            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
11927            5,
11928        ));
11929        open_selector(
11930            &state,
11931            &editor_container,
11932            &editor,
11933            &tui,
11934            list,
11935            SelectorKind::Theme,
11936        );
11937        assert!(state.selector_open());
11938        // list only (editor swapped out).
11939        assert_eq!(editor_container.child_count(), 1);
11940
11941        close_selector(&state, &editor_container, &editor, &tui);
11942        assert!(!state.selector_open());
11943        // editor restored.
11944        assert_eq!(editor_container.child_count(), 1);
11945    }
11946
11947    #[test]
11948    fn test_message_history_browse_restores_draft() {
11949        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
11950        // messages, browse older → newer → back past the newest restores the
11951        // draft the user was typing.
11952        let state = Arc::new(TuiState {
11953            current_assistant: std::sync::Mutex::new(None),
11954            tool_components: std::sync::Mutex::new(HashMap::new()),
11955            bash_components: std::sync::Mutex::new(HashMap::new()),
11956            themes_enabled: true,
11957            hide_thinking: std::sync::Mutex::new(false),
11958            tool_outputs_expanded: std::sync::Mutex::new(false),
11959            show_terminal_progress: true,
11960            status: std::sync::Mutex::new(RunStatus::Idle),
11961            js_preparation_cancel: std::sync::Mutex::new(None),
11962            user_bash_cancel: std::sync::Mutex::new(None),
11963            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11964            footer: Arc::new(FooterComponent::new()),
11965            status_container: Arc::new(Container::new()),
11966            chat_container: Arc::new(Container::new()),
11967            loader: Arc::new(Loader::new()),
11968            editor: Arc::new(Editor::simple()),
11969            last_assistant_text: std::sync::Mutex::new(String::new()),
11970            active_selector: std::sync::Mutex::new(None),
11971            active_extension_editor: std::sync::Mutex::new(None),
11972            active_extension_input: std::sync::Mutex::new(None),
11973            active_extension_cancel: std::sync::Mutex::new(None),
11974            autocomplete: AutocompleteManager::new(),
11975            autocomplete_container: Arc::new(Container::new()),
11976            autocomplete_max_visible: 5,
11977            pending_images: std::sync::Mutex::new(Vec::new()),
11978            theme_manager: Arc::new(ThemeManager::new()),
11979            tui: None,
11980            current_model_id: std::sync::Mutex::new(String::new()),
11981            show_images: std::sync::Mutex::new(true),
11982            cache_miss_notices: std::sync::Mutex::new(false),
11983            history: std::sync::Mutex::new(Vec::new()),
11984            history_index: std::sync::Mutex::new(-1),
11985            history_draft: std::sync::Mutex::new(None),
11986            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11987            scoped_edit: std::sync::Mutex::new(None),
11988            markdown_transformer: std::sync::Mutex::new(None),
11989            extension_session: Arc::new(std::sync::Mutex::new(
11990                rpi_extensions::ExtensionSession::none(),
11991            )),
11992            search: Arc::new(AltScreenSearch::new()),
11993            search_bar: Arc::new(SearchBar::new()),
11994            pending_container: Arc::new(Container::new()),
11995            dequeue_hint: String::new(),
11996            pending_snapshot: std::sync::Mutex::new(
11997                rpi_harness::agent_harness::QueuedMessages::default(),
11998            ),
11999            selection_start: std::sync::Mutex::new(None),
12000            selection_end: std::sync::Mutex::new(None),
12001        });
12002        let editor = Arc::new(Editor::simple());
12003
12004        push_history(&state, "first message");
12005        push_history(&state, "second message");
12006        // Consecutive duplicate is skipped.
12007        push_history(&state, "second message");
12008        push_history(&state, "   "); // empty → skipped
12009        assert_eq!(state.history.lock().unwrap().len(), 2);
12010        assert_eq!(state.history.lock().unwrap()[0], "second message");
12011
12012        // User starts typing a fresh prompt.
12013        editor.set_text("half-typed");
12014        editor.set_cursor(0, 11);
12015
12016        // ↑ → most recent.
12017        navigate_history(&state, &editor, -1);
12018        assert_eq!(editor.get_text(), "second message");
12019        assert_eq!(*state.history_index.lock().unwrap(), 0);
12020        // ↑ → older.
12021        navigate_history(&state, &editor, -1);
12022        assert_eq!(editor.get_text(), "first message");
12023        assert_eq!(*state.history_index.lock().unwrap(), 1);
12024        // ↑ past the oldest → stays (no wrap).
12025        navigate_history(&state, &editor, -1);
12026        assert_eq!(editor.get_text(), "first message");
12027        // ↓ → newer.
12028        navigate_history(&state, &editor, 1);
12029        assert_eq!(editor.get_text(), "second message");
12030        // ↓ past the newest → restores the draft.
12031        navigate_history(&state, &editor, 1);
12032        assert_eq!(editor.get_text(), "half-typed");
12033        assert_eq!(*state.history_index.lock().unwrap(), -1);
12034    }
12035
12036    #[test]
12037    fn test_accept_top_suggestion_replaces_prefix() {
12038        // `/he` + Tab → `/help ` (slash command provider inserts a space).
12039        let state = Arc::new(TuiState {
12040            current_assistant: std::sync::Mutex::new(None),
12041            tool_components: std::sync::Mutex::new(HashMap::new()),
12042            bash_components: std::sync::Mutex::new(HashMap::new()),
12043            themes_enabled: true,
12044            hide_thinking: std::sync::Mutex::new(false),
12045            tool_outputs_expanded: std::sync::Mutex::new(false),
12046            show_terminal_progress: true,
12047            status: std::sync::Mutex::new(RunStatus::Idle),
12048            js_preparation_cancel: std::sync::Mutex::new(None),
12049            user_bash_cancel: std::sync::Mutex::new(None),
12050            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12051            footer: Arc::new(FooterComponent::new()),
12052            status_container: Arc::new(Container::new()),
12053            chat_container: Arc::new(Container::new()),
12054            loader: Arc::new(Loader::new()),
12055            editor: Arc::new(Editor::simple()),
12056            last_assistant_text: std::sync::Mutex::new(String::new()),
12057            active_selector: std::sync::Mutex::new(None),
12058            active_extension_editor: std::sync::Mutex::new(None),
12059            active_extension_input: std::sync::Mutex::new(None),
12060            active_extension_cancel: std::sync::Mutex::new(None),
12061            autocomplete: AutocompleteManager::new(),
12062            autocomplete_container: Arc::new(Container::new()),
12063            autocomplete_max_visible: 5,
12064            pending_images: std::sync::Mutex::new(Vec::new()),
12065            theme_manager: Arc::new(ThemeManager::new()),
12066            tui: None,
12067            current_model_id: std::sync::Mutex::new(String::new()),
12068            show_images: std::sync::Mutex::new(true),
12069            cache_miss_notices: std::sync::Mutex::new(false),
12070            history: std::sync::Mutex::new(Vec::new()),
12071            history_index: std::sync::Mutex::new(-1),
12072            history_draft: std::sync::Mutex::new(None),
12073            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12074            scoped_edit: std::sync::Mutex::new(None),
12075            markdown_transformer: std::sync::Mutex::new(None),
12076            extension_session: Arc::new(std::sync::Mutex::new(
12077                rpi_extensions::ExtensionSession::none(),
12078            )),
12079            search: Arc::new(AltScreenSearch::new()),
12080            search_bar: Arc::new(SearchBar::new()),
12081            pending_container: Arc::new(Container::new()),
12082            dequeue_hint: String::new(),
12083            pending_snapshot: std::sync::Mutex::new(
12084                rpi_harness::agent_harness::QueuedMessages::default(),
12085            ),
12086            selection_start: std::sync::Mutex::new(None),
12087            selection_end: std::sync::Mutex::new(None),
12088        });
12089        {
12090            let mut combined = CombinedAutocompleteProvider::new();
12091            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12092                build_builtin_registry().visible_entries(),
12093            )));
12094            state.autocomplete.set_provider(Arc::new(combined));
12095        }
12096        let editor = Arc::new(Editor::simple());
12097        editor.set_text("/he");
12098        editor.set_cursor(0, 3);
12099        refresh_autocomplete(&state, &editor);
12100        let accepted = accept_top_suggestion(&state, &editor);
12101        assert!(accepted, "should accept the top suggestion");
12102        let text = editor.get_text();
12103        assert!(
12104            text.starts_with("/help"),
12105            "editor text should start with /help, got {text}"
12106        );
12107    }
12108
12109    #[test]
12110    fn configured_key_parser_supports_native_notation() {
12111        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
12112        assert_eq!(combo.code, KeyCode::Char('g'));
12113        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
12114        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
12115        assert_eq!(combo.code, KeyCode::BackTab);
12116    }
12117
12118    #[test]
12119    fn double_escape_trigger_has_half_second_window() {
12120        let now = std::time::Instant::now();
12121        assert!(!double_escape_trigger(None, now));
12122        assert!(double_escape_trigger(
12123            Some(now - std::time::Duration::from_millis(500)),
12124            now
12125        ));
12126        assert!(!double_escape_trigger(
12127            Some(now - std::time::Duration::from_millis(501)),
12128            now
12129        ));
12130    }
12131
12132    #[test]
12133    fn pasted_multi_line_block_becomes_one_prompt_not_many() {
12134        // End-to-end shape of the fix: replay the key stream a Windows paste
12135        // produces ("l1\nl2\nl3" + a trailing Enter) through the same pieces
12136        // the key loop uses — the burst discriminator for each Enter and the
12137        // editor for the text — and assert that *no* submit fired while the
12138        // block arrived, then exactly one submit on the user's own Enter.
12139        use std::sync::atomic::{AtomicUsize, Ordering};
12140
12141        let editor = rpi_tui::Editor::simple();
12142        let submits = Arc::new(AtomicUsize::new(0));
12143        let submits_for_cb = submits.clone();
12144        editor.on_submit(Arc::new(move |_text: &str| {
12145            submits_for_cb.fetch_add(1, Ordering::SeqCst);
12146        }));
12147
12148        // Every pasted key is delivered back-to-back, so `now` is unchanged and
12149        // the console queue is never empty until the paste is drained.
12150        let now = std::time::Instant::now();
12151        let mut last_text_key_at: Option<std::time::Instant> = None;
12152        let block = ["l1", "l2", "l3"];
12153        for (index, line) in block.iter().enumerate() {
12154            for ch in line.chars() {
12155                editor.insert(&ch.to_string());
12156                last_text_key_at = Some(now);
12157            }
12158            if index + 1 < block.len() {
12159                // The pasted newline: more input is still queued behind it.
12160                let more_queued = true;
12161                assert!(
12162                    enter_is_paste_burst(last_text_key_at, now, more_queued),
12163                    "pasted newline {index} must not submit"
12164                );
12165                editor.insert("\n");
12166                last_text_key_at = Some(now);
12167            }
12168        }
12169        assert_eq!(editor.get_text(), "l1\nl2\nl3");
12170        assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
12171
12172        // The user's own Enter: a real gap, nothing queued.
12173        let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
12174        assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
12175        editor.handle_key(crossterm::event::KeyEvent::new(
12176            KeyCode::Enter,
12177            KeyModifiers::NONE,
12178        ));
12179        assert_eq!(
12180            submits.load(Ordering::SeqCst),
12181            1,
12182            "typed Enter submits once"
12183        );
12184    }
12185
12186    #[test]
12187    fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
12188        // A Windows paste delivers CRLF; the editor must fold it to LF so the
12189        // rendered block is clean and the cursor arithmetic stays sound.
12190        let editor = rpi_tui::Editor::simple();
12191        editor.insert("alpha\r\nbeta\r\n");
12192        assert_eq!(editor.get_text(), "alpha\nbeta\n");
12193        assert!(!editor.get_text().contains('\r'));
12194    }
12195
12196    #[test]
12197    fn pasted_enter_is_not_a_submit() {
12198        let now = std::time::Instant::now();
12199
12200        // Queued input behind the Enter => paste, regardless of history.
12201        assert!(enter_is_paste_burst(None, now, true));
12202
12203        // Enter immediately after pasted characters => paste (also covers a
12204        // paste whose final line ends with a newline, where nothing is queued).
12205        assert!(enter_is_paste_burst(
12206            Some(now - std::time::Duration::from_millis(1)),
12207            now,
12208            false
12209        ));
12210        assert!(enter_is_paste_burst(
12211            Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
12212            now,
12213            false
12214        ));
12215
12216        // A typed Enter: nothing queued and a real gap since the last key.
12217        assert!(!enter_is_paste_burst(None, now, false));
12218        assert!(!enter_is_paste_burst(
12219            Some(now - PASTE_BURST_GAP),
12220            now,
12221            false
12222        ));
12223        assert!(!enter_is_paste_burst(
12224            Some(now - std::time::Duration::from_millis(120)),
12225            now,
12226            false
12227        ));
12228    }
12229
12230    #[test]
12231    fn paste_probe_ignores_the_enter_keys_own_release() {
12232        use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
12233        let release =
12234            KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
12235
12236        // Windows queues press+release together over RDP: the release behind a
12237        // lone Enter must NOT count as queued paste content.
12238        let mut queued = vec![Event::Key(release)].into_iter();
12239        let (more, stashed) = probe_paste_input(None, || queued.next());
12240        assert!(!more, "a key release alone is not queued paste input");
12241        assert!(stashed.is_none());
12242
12243        // A real follow-up key (the next pasted line) IS queued input and is
12244        // stashed for the caller instead of being lost.
12245        let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
12246        let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
12247        let (more, stashed) = probe_paste_input(None, || queued.next());
12248        assert!(more, "a queued key press is paste continuation");
12249        assert!(matches!(stashed, Some(Event::Key(_))));
12250
12251        // Nothing queued at all.
12252        let (more, stashed) = probe_paste_input(None, || None);
12253        assert!(!more);
12254        assert!(stashed.is_none());
12255
12256        // An already-stashed event counts as queued input, without reading more.
12257        let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
12258            panic!("must not read when an event is already stashed")
12259        });
12260        assert!(more);
12261        assert!(matches!(stashed, Some(Event::Key(_))));
12262    }
12263
12264    // ---- ask_user TUI bridge ----
12265
12266    #[test]
12267    fn ask_user_tool_names_are_recognized() {
12268        assert!(is_ask_user_tool("ask_user"));
12269        assert!(is_ask_user_tool("ask_user_question"));
12270        assert!(!is_ask_user_tool("bash"));
12271    }
12272
12273    #[test]
12274    fn ask_user_args_display_never_leaks_transport_json() {
12275        let text = ask_user_args_display(&serde_json::json!({
12276            "question": "你的代理端口是多少?",
12277            "context": "本机",
12278            "options": ["7890", {"title": "7897", "description": "clash"}],
12279            "suggest": "1080"
12280        }));
12281        assert!(text.contains("你的代理端口是多少?"));
12282        assert!(text.contains("本机"));
12283        assert!(text.contains("Choices: 7890, 7897"));
12284        assert!(text.contains("Suggestions: 1080"));
12285        assert!(!text.trim_start().starts_with('{'));
12286
12287        let multi = ask_user_args_display(&serde_json::json!({
12288            "questions": [
12289                {"id": "a", "question": "Q1"},
12290                {"id": "b", "header": "H", "question": "Q2"}
12291            ]
12292        }));
12293        assert!(multi.contains("Q1"));
12294        assert!(multi.contains("H: Q2"));
12295
12296        assert_eq!(
12297            ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
12298            "Confirmation requested: Deploy?"
12299        );
12300        assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
12301    }
12302
12303    #[test]
12304    fn ask_user_result_and_progress_prefer_readable_content() {
12305        let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
12306        assert!(
12307            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
12308                .contains("端口?")
12309        );
12310        let empty = rpi_agent::AgentToolResult::default();
12311        assert!(
12312            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
12313                .contains("端口?")
12314        );
12315        let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
12316        assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
12317        assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
12318    }
12319
12320    #[test]
12321    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
12322        let flat = rpi_extensions::UiDialogRequest {
12323            request_id: "r1".into(),
12324            tool_call_id: Some("c1".into()),
12325            ui: serde_json::json!({
12326                "kind": "selector",
12327                "id": "proxy_port",
12328                "header": "Proxy",
12329                "question": "端口?",
12330                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
12331                "allowMultiple": true,
12332                "allowFreeform": false,
12333                "suggest": "1080"
12334            }),
12335            raw: serde_json::json!({}),
12336        };
12337        let prompt = parse_ask_user_prompt(&flat);
12338        assert_eq!(prompt.question_id, "proxy_port");
12339        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
12340        assert_eq!(prompt.options.len(), 2);
12341        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
12342        assert!(prompt.allow_multiple);
12343        assert!(!prompt.allow_freeform);
12344        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
12345
12346        let alias = rpi_extensions::UiDialogRequest {
12347            ui: serde_json::json!({
12348                "questions": [{"id": "q", "question": "Q?", "options": []}]
12349            }),
12350            ..flat.clone()
12351        };
12352        let prompt = parse_ask_user_prompt(&alias);
12353        assert_eq!(prompt.question_id, "q");
12354        assert!(prompt.allow_freeform, "no options defaults to freeform");
12355
12356        let confirm = rpi_extensions::UiDialogRequest {
12357            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
12358            ..flat.clone()
12359        };
12360        let prompt = parse_ask_user_prompt(&confirm);
12361        assert_eq!(prompt.kind, "confirm");
12362        assert_eq!(prompt.options.len(), 2);
12363        assert_eq!(prompt.options[0].0, "Yes");
12364    }
12365
12366    #[test]
12367    fn ask_user_bridge_answers_and_cancels_by_request_id() {
12368        let mailbox = rpi_extensions::UiDialogMailbox::new();
12369        mailbox.attach();
12370        let bridge = AskUserBridge::new(mailbox.clone());
12371
12372        // Two concurrent requests must not cross answers.
12373        mailbox
12374            .handle(serde_json::json!({
12375                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
12376                "ui": {"kind": "input", "question": "Port?"}
12377            }))
12378            .unwrap();
12379        mailbox
12380            .handle(serde_json::json!({
12381                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
12382                "ui": {"kind": "input", "question": "Level?"}
12383            }))
12384            .unwrap();
12385
12386        let first = bridge.take_pending().expect("r1 visible");
12387        assert_eq!(first.request_id, "r1");
12388        assert!(
12389            bridge.take_pending().is_none(),
12390            "only one request occupies the input slot"
12391        );
12392        bridge.respond("r1", serde_json::json!({"text": "7897"}));
12393        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
12394
12395        let second = bridge.take_pending().expect("r2 visible");
12396        assert_eq!(second.request_id, "r2");
12397        bridge.cancel("r2");
12398        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
12399
12400        // cancel_all drops anything still queued.
12401        mailbox
12402            .handle(serde_json::json!({
12403                "op": "open", "requestId": "r3",
12404                "ui": {"kind": "input", "question": "X?"}
12405            }))
12406            .unwrap();
12407        bridge.cancel_all();
12408        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
12409
12410        // A detached mailbox rejects new prompts (headless contract).
12411        bridge.shutdown();
12412        assert!(mailbox
12413            .handle(serde_json::json!({
12414                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
12415            }))
12416            .is_err());
12417    }
12418}