Skip to main content

rpi_cli/
interactive_tui.rs

1//! Interactive mode for pi-cli.
2//!
3//! Full-screen terminal UI with a streaming transcript, an editor, a live
4//! status indicator, and tool-execution display. Mirrors the TypeScript
5//! `packages/coding-agent/src/modes/interactive/interactive-mode.ts` event→UI
6//! mapping (`handleEvent`), driven by the live `AgentEvent` stream the harness
7//! emits via the `BroadcastEmitter` installed in [`crate::session`].
8//!
9//! Key architecture facts (see `docs/tui-gap-analysis.md`):
10//! - `TuiAltScreen::start()` still has a readerless companion, so this module
11//!   owns a `spawn_blocking` crossterm `read()` loop for key dispatch and a
12//!   `tokio::spawn` task that drains `broadcast::Receiver<AgentEvent>` into UI
13//!   mutations.
14//! - The layout root is built ONCE at startup (mirrors the TS
15//!   `fullscreenLayoutRoot`); per-message we mutate only `chat_container` /
16//!   `status_container` / `autocomplete_container` children and call
17//!   `request_render(false)` so the differential renderer repaints just the
18//!   changed rows.
19//! - Selectors (`/model` `/session` `/theme`) are implemented by **swapping the
20//!   `editor_container` child** (the TS `showSelector` swap pattern,
21//!   `interactive-mode.ts:4354-4377`) — the `show_overlay` stub is avoided
22//!   entirely. An `active_selector` state field holds the live `SelectList`;
23//!   while it is `Some` the key loop routes to it first and restores the editor
24//!   on done/cancel.
25
26use std::collections::{HashMap, HashSet, VecDeque};
27use std::io::IsTerminal;
28use std::sync::{mpsc as std_mpsc, Arc, Mutex};
29
30use base64::Engine;
31use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
32use tokio::sync::{broadcast, mpsc};
33use tokio_util::sync::CancellationToken;
34
35use rpi_agent::{AgentEvent, AgentMessage};
36use rpi_ai::types::{AssistantMessage, Content, UserMessage};
37use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
38use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
39use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
40#[cfg(test)]
41use rpi_tui::strip_ansi;
42use rpi_tui::{
43    apply_theme_preset, render_diff, AltScreenSearch, AssistantBlock, AssistantMessageComponent,
44    AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
45    BashTruncation, CombinedAutocompleteProvider, Component, Container, DynamicBorder, Editor,
46    EditorOptions, EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode,
47    FooterComponent, Image, ImageOptions, Input, Loader, Markdown, ProcessTerminal, ScrollView,
48    ScrollViewOptions, SearchBar, SearchableSelectList, SelectItem, SelectList, SettingItem,
49    SettingsList, SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer,
50    StackChild, StackEntry, StatusIndicator, Text, ThemeManager, ThemePreset,
51    ToolExecutionComponent, ToolStatus, TuiAltScreen, UserMessageComponent, VStack, WorkingState,
52    TUI,
53};
54use rpi_tui::{bold as tui_bold, theme as current_theme};
55
56#[allow(unused_imports)]
57use rpi_tui::BashStatus;
58
59use crate::args::Args;
60
61/// B5e: the markdown-transformer trait object the assistant-message render path
62/// applies to raw text BEFORE the [`Markdown`] renderer styles it. A plain
63/// `Fn(&str) -> String` (NO `rpi-extensions` types) so `rpi-tui` stays free of
64/// an `rpi-extensions` dep — `rpi-cli` (which already depends on
65/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot` and
66/// hands the trait object to `AssistantMessageComponent::set_markdown_transformer`.
67type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
68
69/// A synchronous rendezvous between the Node runtime-request thread and the
70/// blocking TUI key loop. Node's `ctx.ui.*` methods are promises, so the host
71/// request must remain pending while the user interacts with the native
72/// component. The key loop owns opening/closing components; this bridge only
73/// carries JSON results and cancellation state across the threads.
74#[derive(Clone, Default)]
75struct JsDialogBridge {
76    pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
77    active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
78    /// The one dialog currently installed in the TUI input slot. Other
79    /// requests may remain active while a command is waiting, but a cancel
80    /// notification must never close whichever dialog happens to be visible.
81    visible: Arc<Mutex<Option<String>>>,
82    cancelled_before_open: Arc<Mutex<HashSet<String>>>,
83    closed: Arc<Mutex<bool>>,
84}
85
86struct JsDialogPending {
87    request: JsDialogRequest,
88    result: std_mpsc::Sender<serde_json::Value>,
89}
90
91struct JsDialogActive {
92    result: std_mpsc::Sender<serde_json::Value>,
93    cancel_requested: bool,
94}
95
96#[derive(Clone, Debug)]
97struct JsDialogRequest {
98    id: String,
99    method: String,
100    title: String,
101    message: String,
102    options: Vec<String>,
103    placeholder: Option<String>,
104    prefill: Option<String>,
105}
106
107impl JsDialogRequest {
108    fn parse(args: &serde_json::Value) -> Result<Self, String> {
109        let id = args
110            .get("dialogId")
111            .and_then(serde_json::Value::as_str)
112            .filter(|value| !value.is_empty())
113            .ok_or("ui.dialog missing dialogId")?
114            .to_string();
115        let method = args
116            .get("method")
117            .and_then(serde_json::Value::as_str)
118            .ok_or("ui.dialog missing method")?
119            .to_string();
120        if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
121            return Err(format!("unsupported UI dialog method: {method}"));
122        }
123        let options = args
124            .get("options")
125            .and_then(serde_json::Value::as_array)
126            .map(|values| {
127                values
128                    .iter()
129                    .filter_map(serde_json::Value::as_str)
130                    .map(ToOwned::to_owned)
131                    .collect()
132            })
133            .unwrap_or_default();
134        Ok(Self {
135            id,
136            method,
137            title: args
138                .get("title")
139                .and_then(serde_json::Value::as_str)
140                .unwrap_or_default()
141                .to_string(),
142            message: args
143                .get("message")
144                .and_then(serde_json::Value::as_str)
145                .unwrap_or_default()
146                .to_string(),
147            options,
148            placeholder: args
149                .get("placeholder")
150                .and_then(serde_json::Value::as_str)
151                .map(ToOwned::to_owned),
152            prefill: args
153                .get("prefill")
154                .and_then(serde_json::Value::as_str)
155                .map(ToOwned::to_owned),
156        })
157    }
158}
159
160impl JsDialogBridge {
161    fn handle_runtime_request(
162        &self,
163        action: &str,
164        args: serde_json::Value,
165    ) -> Result<serde_json::Value, String> {
166        match action {
167            "ui.dialog" => self.wait_for_dialog(args),
168            "ui.dialog.cancel" => {
169                let id = args
170                    .get("dialogId")
171                    .and_then(serde_json::Value::as_str)
172                    .ok_or("ui.dialog.cancel missing dialogId")?;
173                self.cancel(id);
174                Ok(serde_json::json!(true))
175            }
176            _ => Err(format!("unsupported capability: {action}")),
177        }
178    }
179
180    fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
181        let request = JsDialogRequest::parse(&args)?;
182        let (sender, receiver) = std_mpsc::channel();
183        // Hold the closed flag while enqueueing. `cancel_all` takes this same
184        // lock before draining pending requests, so shutdown cannot observe
185        // an empty queue and then have this request arrive behind the drain.
186        let _closed = self
187            .closed
188            .lock()
189            .map_err(|_| "JS dialog bridge poisoned")?;
190        if *_closed {
191            return Ok(serde_json::json!({ "cancelled": true }));
192        }
193        let cancelled_before_open = self
194            .cancelled_before_open
195            .lock()
196            .map_err(|_| "JS dialog cancellation state poisoned")?
197            .remove(&request.id);
198        if cancelled_before_open {
199            return Ok(serde_json::json!({ "cancelled": true }));
200        }
201        // Do not hold the cancellation-state lock while taking `pending`:
202        // `take_pending` takes those locks in the opposite order.
203        self.pending
204            .lock()
205            .map_err(|_| "JS dialog pending state poisoned")?
206            .push_back(JsDialogPending {
207                request,
208                result: sender,
209            });
210        drop(_closed);
211        receiver
212            .recv()
213            .map_err(|_| "JS dialog closed before it received an answer".to_string())
214    }
215
216    /// Move one request to the active set. The caller invokes this only when
217    /// the TUI has no other modal occupying the editor slot.
218    fn take_pending(&self) -> Option<JsDialogRequest> {
219        loop {
220            let pending = self.pending.lock().ok()?.pop_front()?;
221            let mut active = self.active.lock().ok()?;
222            // Check cancellation while holding the active lock and insert the
223            // entry in the same critical section. `cancel()` checks `active`
224            // before recording a pre-open cancellation, so it will either see
225            // this entry or leave a marker that we consume here. Checking the
226            // marker before acquiring `active` had a small race where a cancel
227            // could land between the check and insertion and strand the dialog.
228            if self
229                .cancelled_before_open
230                .lock()
231                .ok()?
232                .remove(&pending.request.id)
233            {
234                drop(active);
235                let _ = pending
236                    .result
237                    .send(serde_json::json!({ "cancelled": true }));
238                continue;
239            }
240            active.insert(
241                pending.request.id.clone(),
242                JsDialogActive {
243                    result: pending.result,
244                    cancel_requested: false,
245                },
246            );
247            if let Ok(mut visible) = self.visible.lock() {
248                *visible = Some(pending.request.id.clone());
249            }
250            return Some(pending.request);
251        }
252    }
253
254    fn respond(&self, id: &str, result: serde_json::Value) {
255        if let Ok(mut active) = self.active.lock() {
256            if let Some(entry) = active.remove(id) {
257                let _ = entry.result.send(result);
258            }
259        }
260        if let Ok(mut visible) = self.visible.lock() {
261            if visible.as_deref() == Some(id) {
262                *visible = None;
263            }
264        }
265    }
266
267    fn cancel(&self, id: &str) {
268        if let Ok(mut pending) = self.pending.lock() {
269            if let Some(index) = pending.iter().position(|item| item.request.id == id) {
270                if let Some(item) = pending.remove(index) {
271                    let _ = item.result.send(serde_json::json!({ "cancelled": true }));
272                    return;
273                }
274            }
275        }
276        if let Ok(mut active) = self.active.lock() {
277            if let Some(entry) = active.get_mut(id) {
278                if !entry.cancel_requested {
279                    entry.cancel_requested = true;
280                    let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
281                }
282                return;
283            }
284        }
285        if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
286            cancelled.insert(id.to_string());
287        }
288    }
289
290    fn cancelled_active_ids(&self) -> Vec<String> {
291        self.active
292            .lock()
293            .map(|active| {
294                active
295                    .iter()
296                    .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
297                    .collect()
298            })
299            .unwrap_or_default()
300    }
301
302    fn is_visible(&self, id: &str) -> bool {
303        self.visible
304            .lock()
305            .map(|visible| visible.as_deref() == Some(id))
306            .unwrap_or(false)
307    }
308
309    fn finish(&self, id: &str) {
310        if let Ok(mut active) = self.active.lock() {
311            active.remove(id);
312        }
313        if let Ok(mut visible) = self.visible.lock() {
314            if visible.as_deref() == Some(id) {
315                *visible = None;
316            }
317        }
318    }
319
320    fn cancel_all(&self) {
321        // Keep the closed lock through the queue drains. `wait_for_dialog`
322        // holds it while enqueueing, making the shutdown check + enqueue an
323        // atomic operation with respect to this drain.
324        let Ok(mut closed) = self.closed.lock() else {
325            return;
326        };
327        *closed = true;
328        if let Ok(mut pending) = self.pending.lock() {
329            for item in pending.drain(..) {
330                let _ = item.result.send(serde_json::json!({ "cancelled": true }));
331            }
332        }
333        if let Ok(mut active) = self.active.lock() {
334            for (_, entry) in active.drain() {
335                let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
336            }
337        }
338        if let Ok(mut visible) = self.visible.lock() {
339            *visible = None;
340        }
341        drop(closed);
342    }
343
344    /// Cancel requests owned by one interrupted prompt preparation while
345    /// keeping the bridge available to a replacement Node host.
346    fn cancel_open_requests(&self) {
347        // Keep enqueueing closed until the old host and its preparation
348        // worker have stopped. Otherwise a late runtime request can land just
349        // after the drain and strand its handler thread.
350        let Ok(mut closed) = self.closed.lock() else {
351            return;
352        };
353        *closed = true;
354        if let Ok(mut pending) = self.pending.lock() {
355            for item in pending.drain(..) {
356                let _ = item.result.send(serde_json::json!({ "cancelled": true }));
357            }
358        }
359        if let Ok(mut active) = self.active.lock() {
360            for (_, entry) in active.drain() {
361                let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
362            }
363        }
364        if let Ok(mut visible) = self.visible.lock() {
365            *visible = None;
366        }
367        if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
368            cancelled.clear();
369        }
370        drop(closed);
371    }
372
373    fn reopen(&self) {
374        if let Ok(mut closed) = self.closed.lock() {
375            *closed = false;
376        }
377    }
378}
379
380/// B5e: build the `AssistantMessageComponent` markdown-transformer closure the
381/// render path applies to raw assistant text before styling. Wraps any plugin
382/// `register_markdown_transformer` handlers registered in `snapshot` (chained
383/// in registration order: each handler's output feeds the next). `None` when
384/// no markdown transformers are registered (the component defaults to the
385/// identity transform + this avoids a closure allocation on the hot render
386/// path).
387///
388/// The closure captures an `Arc<RegistrySnapshot>` clone so it outlives the
389/// borrow that built it (the snapshot's `active` flag guards dispatch in
390/// `emit_resources_discover`/event translation; a reloaded session's old
391/// snapshot flips false, so a stale closure no-ops rather than driving a
392/// half-swapped registry — the transformer falls back to the input unchanged
393/// on an inactive snapshot, matching the plugin's per-handler skip-on-error).
394///
395/// This is the cycle-free seam: `rpi-tui` takes a `Fn(&str) -> String` trait
396/// object (no `rpi-extensions` dep); `rpi-cli` (which already depends on
397/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot`. The
398/// calling pattern mirrors `plugin_stub_smoke.rs`'s direct `RenderFn` round-
399/// trip (input `{"markdown":…}` → `render_fn` → reclaim `out` via the plugin's
400/// `free_string` → parse `{"markdown":…}`).
401fn build_markdown_transformer(
402    snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
403) -> Option<MarkdownTransformer> {
404    let snapshot = snapshot?;
405    // Pre-check: if no markdown renderers are registered, return None so the
406    // component uses the identity path (no per-delta closure call). The
407    // renderers list is a per-call `renderers_of` clone; snapshotting it once
408    // here keeps the closure cheap on the hot path.
409    let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
410    if renderers.is_empty() {
411        return None;
412    }
413    Some(Arc::new(move |raw: &str| -> String {
414        transform_markdown_chain(&snapshot, &renderers, raw)
415    }))
416}
417
418/// Drive the markdown-transformer chain for one input string. Each registered
419/// handler receives the previous handler's output (or the raw input for the
420/// first), as a `{"markdown": <text>}` JSON envelope; its `RenderFn` returns
421/// `{"markdown": <transformed>}` (rc=0) or an error (rc!=0). On any failure —
422/// nonzero rc, a panic across the FFI (caught), a missing `markdown` field, or
423/// an inactive snapshot — the chain short-circuits to the current text
424/// unchanged (per-handler skip-on-error, mirroring pi's `runner.ts` fan-out).
425fn transform_markdown_chain(
426    snapshot: &rpi_extensions::RegistrySnapshot,
427    renderers: &[rpi_extensions::RegisteredRenderer],
428    raw: &str,
429) -> String {
430    // A stale snapshot (post-/reload) must not drive a swapped-out registry.
431    // The renderers were captured from this snapshot; if it has gone inactive,
432    // fall back to the raw input so the UI never renders stale-transformed text
433    // from a dead plugin.
434    if !snapshot.is_active() {
435        return raw.to_string();
436    }
437
438    let mut current = raw.to_string();
439    for renderer in renderers {
440        let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
441            Ok(s) => s,
442            Err(_) => return current, // serialize failure — keep current, stop chain
443        };
444        // SAFETY: `render_fn` is a plugin-provided `extern "C" fn` over a
445        // borrowed `StbStringRef` + an out-param. The plugin warrants
446        // `poll`/`render` are non-blocking + thread-safe (the same contract
447        // the tool adapter relies on). `user_data` is the plugin's opaque
448        // pointer, stable for the registry lifetime (the keepalive keeps the
449        // cdylib mapped). We reclaim `out` via the plugin's `free_string`
450        // exactly once. The whole call is `catch_unwind`-wrapped — a plugin
451        // panic must not unwind across the FFI boundary (same policy as the
452        // tool partial cb + the runtime_action trampoline).
453        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
454            let mut out = rpi_plugin_sdk::StbString::empty();
455            let rc = (renderer.render_fn)(
456                rpi_plugin_sdk::StbStringRef::from_str(&input),
457                &mut out as *mut rpi_plugin_sdk::StbString,
458                renderer.user_data,
459            );
460            let text = if rc == 0 {
461                let s = out.to_string_lossy();
462                Some(s)
463            } else {
464                None
465            };
466            // Reclaim the plugin-owned `out` regardless of rc (rc!=0 may still
467            // have written an error JSON the plugin allocated). `free_with` is
468            // idempotent on an empty `StbString`.
469            out.free_with(Some(renderer.plugin_free_string));
470            text
471        }));
472        let out_text = match outcome {
473            Ok(Some(s)) => s,
474            Ok(None) => return current, // rc != 0 — skip this handler, keep current
475            Err(_) => return current,   // panic — skip, keep current (do not abort: the
476                                         // render path is not the action trampoline; a panicking transformer
477                                         // degrades to identity rather than killing the process. Logged via
478                                         // the `tracing` crate's panic hook.)
479        };
480        // Parse `{"markdown": <text>}`; lenient — a missing/non-string field
481        // keeps the current text (skip this handler).
482        let next = serde_json::from_str::<serde_json::Value>(&out_text)
483            .ok()
484            .and_then(|v| {
485                v.get("markdown")
486                    .and_then(|m| m.as_str())
487                    .map(|s| s.to_string())
488            })
489            .unwrap_or(current);
490        current = next;
491    }
492    current
493}
494
495// ===========================================================================
496// Slash commands — trait + registry
497// ===========================================================================
498//
499// Each built-in slash command is one `impl SlashCommand`. The commands are
500// registered at startup into a [`CommandRegistry`] (one source of truth) that
501// serves both dispatch ("given this token, run the command") and autocomplete
502// ("list the visible commands"). This replaces the old two-list + sync-test
503// arrangement, where `handle_slash_command` and `v1_slash_commands()` had to be
504// kept in lock-step by hand.
505//
506// `execute` runs on the blocking key/compose thread (the editor `on_submit`
507// callback and the Ctrl+L hotkey both land there), so it MUST stay synchronous:
508//   - commands needing async (`set_model`/`set_thinking_level`/`set_active_tools`)
509//     `tokio::spawn` the work and return immediately;
510//   - commands needing the main async loop (`compact`/`copy`/`exit`/`clear`/
511//     `user-input`) signal it via `ctx.tx.send(TuiMessage::…)`;
512//   - everything else mutates the chat container + requests a render directly.
513
514/// The borrowed world a slash command runs against. All fields are `Arc` (or a
515/// cheap `String` snapshot), so one `CommandContext` clones freely into each
516/// command without per-capture ceremony — this struct is exactly the set of
517/// `*_for_cb` clones the old submit closure used to make individually.
518#[derive(Clone)]
519struct CommandContext {
520    chat: Arc<Container>,
521    tui: Arc<TuiAltScreen>,
522    tx: mpsc::UnboundedSender<TuiMessage>,
523    state: Arc<TuiState>,
524    editor: Arc<Editor>,
525    editor_container: Arc<Container>,
526    lane: Arc<dyn AgentLane>,
527    model_catalog: Arc<Vec<rpi_ai::Model>>,
528    /// Lane model id snapshot, read once via `lane.get_model().await` BEFORE the
529    /// blocking key loop starts. Selectors/key loop can't await, so they read
530    /// this owned string instead. Semantically unchanged from pre-refactor.
531    lane_model_id: String,
532    cwd: std::path::PathBuf,
533    /// Package resources resolved at startup. An empty set means Pi package
534    /// loading was not explicitly enabled and must remain disabled for all
535    /// interactive theme selectors.
536    package_resources: Arc<crate::packages::PackageResources>,
537    /// Harness resources snapshot (skills + prompt templates) for `/context`.
538    /// Captured once at TUI startup because the blocking submit thread can't
539    /// `.await get_resources()`.
540    resources: Arc<rpi_harness::types::AgentHarnessResources>,
541    /// B5d: the reload context `/reload` drives. `Arc<ReloadContext>` so the
542    /// blocking submit thread can cheaply clone it into the `ReloadCommand`
543    /// without an `.await` (the command can't drive reload directly — it signals
544    /// the main loop via `TuiMessage::ReloadExtensions`, which awaits the shared
545    /// `reload_extension_resources` routine on the async runtime).
546    reload_context: Arc<crate::session::ReloadContext>,
547    /// The product-layer session (`AgentSession`). Commands that need durable
548    /// reads, export, or usage stats go through this instead of reaching into
549    /// the harness directly, so the product surface is one object.
550    session: Arc<crate::agent_session::AgentSession>,
551}
552
553/// One slash command.
554trait SlashCommand: Send + Sync {
555    /// Canonical name, with the leading `/` (e.g. "/model").
556    fn name(&self) -> &str;
557    /// Aliases, also `/`-prefixed. Matched alongside `name()` during dispatch.
558    /// Use [`SlashCommand::alias_visible`] to also surface an alias in the
559    /// `/`-autocomplete list (most aliases stay hidden).
560    fn aliases(&self) -> &'static [&'static str] {
561        &[]
562    }
563    /// Whether the canonical name appears in the `/` autocomplete list. Hidden
564    /// commands (`/context`, `/name`, …) return `false`.
565    fn visible(&self) -> bool {
566        true
567    }
568    /// Aliases that should also appear in the `/` autocomplete list. Defaults to
569    /// none — most aliases (`/q`, `/m`, `/think`, `/resume`, `/v`) are kept off
570    /// the list to keep it short. `/new` and `/quit` override this to surface.
571    fn alias_visible(&self) -> &'static [&'static str] {
572        &[]
573    }
574    /// Description shown in autocomplete and `/help`. A non-empty description is
575    /// required to surface in autocomplete even when `visible()` is true.
576    fn description(&self) -> &'static str {
577        ""
578    }
579    fn description_owned(&self) -> String {
580        self.description().to_string()
581    }
582    /// Execute the command. Only invoked for inputs starting with `/` whose
583    /// first token matches `name()` or an alias. `args` is the whitespace-
584    /// trimmed remainder after the command token ("" when none). Must stay
585    /// synchronous (see the module-level note) — async work goes through
586    /// `ctx.tx.send(TuiMessage::…)` or `tokio::spawn`.
587    fn execute(&self, ctx: &CommandContext, args: &str);
588}
589
590/// Holds all registered slash commands; the single source of truth for both
591/// dispatch and the autocomplete list.
592struct CommandRegistry {
593    commands: Vec<Arc<dyn SlashCommand>>,
594}
595
596impl CommandRegistry {
597    fn new() -> Self {
598        Self {
599            commands: Vec::new(),
600        }
601    }
602
603    fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
604        self.commands.push(cmd);
605    }
606
607    /// Find the command whose `name()` or an alias matches `token` (e.g. "/q").
608    /// `token` is the first whitespace-delimited word of the input, `/`-prefixed.
609    fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
610        self.commands
611            .iter()
612            .find(|c| c.name() == token || c.aliases().contains(&token))
613    }
614
615    /// The autocomplete entries, derived from the registry so it can never drift
616    /// from what dispatch recognizes. Surfaces the canonical name when
617    /// `visible()` + non-empty description, plus any `alias_visible()` entries.
618    /// Order = registration order; built-ins are registered before templates,
619    /// so they win on a fuzzy tie (unchanged).
620    fn visible_entries(&self) -> Vec<SlashCommandEntry> {
621        let mut out: Vec<SlashCommandEntry> = Vec::new();
622        for c in &self.commands {
623            let description = c.description_owned();
624            if c.visible() && !description.is_empty() {
625                out.push(SlashCommandEntry {
626                    name: c.name().into(),
627                    description: description.clone(),
628                });
629            }
630            // Surfaced aliases share the command's description.
631            for alias in c.alias_visible() {
632                out.push(SlashCommandEntry {
633                    name: (*alias).into(),
634                    description: description.clone(),
635                });
636            }
637        }
638        out
639    }
640}
641
642/// Dispatch a ui_prompt_start event to extensions.
643fn dispatch_ui_prompt_event(state: &TuiState) {
644    use rpi_plugin_sdk::EventTag;
645
646    if let Ok(session) = state.extension_session.lock() {
647        if let Some(snapshot) = session.snapshot_arc() {
648            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
649        }
650    }
651}
652
653/// Dispatch a ui_prompt_end event to extensions.
654fn dispatch_ui_prompt_event_end(state: &TuiState) {
655    use rpi_plugin_sdk::EventTag;
656
657    if let Ok(session) = state.extension_session.lock() {
658        if let Some(snapshot) = session.snapshot_arc() {
659            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
660        }
661    }
662}
663
664/// Resolve the command for a `/`-prefixed input and run it, or emit the
665/// unknown-command error if nothing matches. Non-slash text never reaches here
666/// — callers route only `/`-prefixed inputs and send plain text directly.
667fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
668    let mut parts = text.split_whitespace();
669    let token = parts.next().unwrap_or("");
670    let args = parts.collect::<Vec<_>>().join(" ");
671    match registry.find(token) {
672        Some(cmd) => cmd.execute(ctx, &args),
673        None => {
674            add_error_message(
675                &ctx.chat,
676                &format!("Unknown command: {text}. Type /help for available commands."),
677            );
678            ctx.tui.request_render(false);
679        }
680    }
681}
682
683/// Current wall-clock time in milliseconds since the Unix epoch. Used for
684/// `bashExecution` message timestamps (agent-loop messages carry real times, so
685/// the transcript record must too).
686fn now_ms() -> i64 {
687    std::time::SystemTime::now()
688        .duration_since(std::time::UNIX_EPOCH)
689        .map(|d| d.as_millis() as i64)
690        .unwrap_or(0)
691}
692
693/// Parse a submitted line into a user shell command.
694///
695/// Mirrors native pi's `text.startsWith("!")` handling
696/// (`interactive-mode.ts:3225`): `!cmd` runs and is recorded in the session
697/// context, `!!cmd` is excluded from it. Returns `None` for a bare `!` / `!!`
698/// (or whitespace only), so the line falls through to the normal prompt path.
699fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
700    let rest = text.strip_prefix('!')?;
701    let (command, exclude_from_context) = match rest.strip_prefix('!') {
702        Some(command) => (command, true),
703        None => (rest, false),
704    };
705    let command = command.trim();
706    if command.is_empty() {
707        return None;
708    }
709    Some((command, exclude_from_context))
710}
711
712/// Run a `!command` submitted from the editor.
713///
714/// The command executes out-of-band — it never reaches the model. Output
715/// streams into a [`BashExecutionComponent`] in the transcript and the
716/// terminal result is handed to the main loop, which persists it as a
717/// `bashExecution` message (skipped from context for `!!`). Mirrors
718/// `handleBashCommand` (`interactive-mode.ts:6729`).
719fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
720    let comp = Arc::new(BashExecutionComponent::new_with_context(
721        command,
722        exclude_from_context,
723    ));
724    comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
725    ctx.chat.add_child(comp.clone());
726
727    // Register the cancellation slot before spawning so an Esc arriving on the
728    // key thread can never miss the run (native pi's `_bashAbortControllers`).
729    let cancel = ctx.state.begin_user_bash();
730    ctx.tui.request_render(false);
731
732    let env: Arc<dyn rpi_tools::ExecutionEnv> =
733        Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
734    let tui = ctx.tui.clone();
735    let tx = ctx.tx.clone();
736    let cwd = ctx.cwd.clone();
737    let command_owned = command.to_string();
738
739    tokio::spawn(async move {
740        let comp_chunk = comp.clone();
741        let tui_chunk = tui.clone();
742        let on_chunk: Box<
743            dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
744        > = Box::new(move |_chunk, get_progress| {
745            // The capture layer reports the complete tail captured so far, not
746            // a delta; `append_output` replaces its snapshot accordingly.
747            comp_chunk.append_output(&get_progress().output);
748            tui_chunk.request_render(false);
749        });
750        let options = rpi_tools::shell_output::ShellCaptureOptions {
751            cwd: Some(cwd),
752            cancel: Some(&cancel),
753            on_chunk: Some(on_chunk),
754            return_execution_errors: true,
755            ..Default::default()
756        };
757        let result =
758            rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
759                .await;
760
761        // `Ok` carries the real exit code/cancellation; `Err` is a transport
762        // level failure (spawn failure, invalid env) which we surface inline.
763        let (exit_code, cancelled, output, truncated, full_output_path) = match result {
764            Ok(capture) => (
765                capture.exit_code,
766                capture.cancelled,
767                capture.output.clone(),
768                capture.truncated,
769                capture
770                    .full_output_path
771                    .as_ref()
772                    .map(|path| path.to_string_lossy().into_owned()),
773            ),
774            Err(error) => (
775                None,
776                false,
777                format!("Bash command failed: {error}"),
778                false,
779                None,
780            ),
781        };
782        comp.append_output(&output);
783        comp.set_complete(
784            exit_code,
785            cancelled,
786            BashTruncation {
787                truncated,
788                full_output_path: full_output_path.clone(),
789            },
790        );
791        tui.render_now(false);
792
793        let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
794            command: command_owned,
795            output,
796            exit_code,
797            cancelled,
798            truncated,
799            full_output_path,
800            exclude_from_context,
801        }));
802    });
803}
804
805/// Encode a crossterm key into the raw key data consumed by the Node TUI
806/// compatibility layer. Plain keys retain the usual terminal sequences;
807/// modified functional keys use Kitty CSI-u so Shift/Alt/Ctrl combinations are
808/// not collapsed into their unmodified equivalent (notably Shift+Enter).
809fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
810    use crossterm::event::{KeyCode, KeyModifiers};
811
812    let modifiers = key.modifiers;
813    let ctrl = modifiers.contains(KeyModifiers::CONTROL);
814    let shift = modifiers.contains(KeyModifiers::SHIFT);
815    let alt = modifiers.contains(KeyModifiers::ALT);
816    let super_key = modifiers.contains(KeyModifiers::SUPER);
817
818    if modifiers == KeyModifiers::NONE {
819        return match key.code {
820            KeyCode::Char(ch) => ch.to_string(),
821            KeyCode::Enter => "\r".into(),
822            KeyCode::Esc => "\x1b".into(),
823            KeyCode::Backspace => "\x7f".into(),
824            KeyCode::Tab => "\t".into(),
825            // Crossterm represents Shift+Tab as `BackTab` on both Unix
826            // (`ESC[Z`) and Windows. Preserve the canonical terminal form
827            // so the Node keybinding matcher sees `shift+tab`.
828            KeyCode::BackTab => "\x1b[Z".into(),
829            KeyCode::Up => "\x1b[A".into(),
830            KeyCode::Down => "\x1b[B".into(),
831            KeyCode::Right => "\x1b[C".into(),
832            KeyCode::Left => "\x1b[D".into(),
833            KeyCode::Home => "\x1b[H".into(),
834            KeyCode::End => "\x1b[F".into(),
835            KeyCode::PageUp => "\x1b[5~".into(),
836            KeyCode::PageDown => "\x1b[6~".into(),
837            KeyCode::Delete => "\x1b[3~".into(),
838            KeyCode::Insert => "\x1b[2~".into(),
839            KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
840            _ => String::new(),
841        };
842    }
843
844    // Legacy control bytes are what the native `matchesKey` implementation
845    // expects for the common Ctrl+letter actions (Ctrl+C, Ctrl+O, Ctrl+J...).
846    if ctrl && !shift && !alt && !super_key {
847        if let KeyCode::Char(ch) = key.code {
848            if let Some(code) = control_code(ch) {
849                return char::from(code).to_string();
850            }
851        }
852    }
853
854    // Legacy Alt+character input is unambiguous when no other modifier is
855    // present and is accepted by pi's `matchesKey` fallback parser.
856    if alt && !ctrl && !shift && !super_key {
857        if let KeyCode::Char(ch) = key.code {
858            return format!("\x1b{ch}");
859        }
860    }
861
862    // Crossterm has already resolved the keyboard layout for character events
863    // (for example, Windows reports Shift+1 as `Char('!')`). Pass that actual
864    // character through unchanged so custom components receive text instead
865    // of a CSI-u escape sequence. Functional keys and combined modifiers use
866    // CSI-u below so their modifier identity remains available to keybindings.
867    if shift && !ctrl && !alt && !super_key {
868        if let KeyCode::Char(ch) = key.code {
869            return ch.to_string();
870        }
871    }
872
873    if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
874        return sequence;
875    }
876    let Some(codepoint) = key_codepoint(key.code, ctrl) else {
877        return String::new();
878    };
879    kitty_key_sequence(codepoint, modifiers)
880}
881
882fn control_code(ch: char) -> Option<u8> {
883    let ch = ch.to_ascii_lowercase();
884    Some(match ch {
885        '@' | ' ' => 0,
886        'a'..='z' => (ch as u8) & 0x1f,
887        '[' => 0x1b,
888        '\\' => 0x1c,
889        ']' => 0x1d,
890        '^' => 0x1e,
891        '_' | '-' => 0x1f,
892        _ => return None,
893    })
894}
895
896fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
897    use crossterm::event::KeyCode;
898    Some(match code {
899        KeyCode::Char(ch) => {
900            if ctrl {
901                ch.to_ascii_lowercase() as u32
902            } else {
903                ch as u32
904            }
905        }
906        KeyCode::Enter => 13,
907        KeyCode::Esc => 27,
908        KeyCode::Backspace => 127,
909        KeyCode::Tab => 9,
910        // Keep modified BackTab combinations representable through CSI-u;
911        // the unmodified/SHIFT form is handled as the legacy `ESC[Z` above.
912        KeyCode::BackTab => 9,
913        _ => return None,
914    })
915}
916
917fn modified_functional_sequence(
918    code: crossterm::event::KeyCode,
919    modifiers: crossterm::event::KeyModifiers,
920) -> Option<String> {
921    use crossterm::event::{KeyCode, KeyModifiers};
922    // `BackTab` is already a semantic Shift+Tab event. Crossterm normally
923    // includes SHIFT in its modifier bits, but preserving the legacy sequence
924    // for a synthetic event without that bit keeps the adapter portable.
925    if code == KeyCode::BackTab
926        && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
927    {
928        return Some("\x1b[Z".into());
929    }
930    let modifier = kitty_modifier(modifiers);
931    let sequence = match code {
932        KeyCode::Up => format!("\x1b[1;{modifier}A"),
933        KeyCode::Down => format!("\x1b[1;{modifier}B"),
934        KeyCode::Right => format!("\x1b[1;{modifier}C"),
935        KeyCode::Left => format!("\x1b[1;{modifier}D"),
936        KeyCode::Home => format!("\x1b[1;{modifier}H"),
937        KeyCode::End => format!("\x1b[1;{modifier}F"),
938        KeyCode::Insert => format!("\x1b[2;{modifier}~"),
939        KeyCode::Delete => format!("\x1b[3;{modifier}~"),
940        KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
941        KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
942        _ => return None,
943    };
944    Some(sequence)
945}
946
947fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
948    use crossterm::event::KeyModifiers;
949    let mut modifier = 1u8;
950    if modifiers.contains(KeyModifiers::SHIFT) {
951        modifier += 1;
952    }
953    if modifiers.contains(KeyModifiers::ALT) {
954        modifier += 2;
955    }
956    if modifiers.contains(KeyModifiers::CONTROL) {
957        modifier += 4;
958    }
959    if modifiers.contains(KeyModifiers::SUPER) {
960        modifier += 8;
961    }
962    modifier
963}
964
965fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
966    let modifier = kitty_modifier(modifiers);
967    format!("\x1b[{codepoint};{modifier}u")
968}
969
970/// A slash command registered by a native extension. The command metadata is
971/// captured for autocomplete, while the handler is looked up from the live
972/// session on every invocation so `/reload` takes effect without rebuilding
973/// the editor callback.
974struct ExtensionCommand {
975    name: String,
976    description: String,
977    session: crate::session::ExtensionSessionCell,
978}
979
980struct JsExtensionCommand {
981    name: String,
982    session: crate::js_extensions::JsExtensionSession,
983}
984
985impl SlashCommand for JsExtensionCommand {
986    fn name(&self) -> &str {
987        &self.name
988    }
989    fn description(&self) -> &'static str {
990        "JS extension command"
991    }
992    fn description_owned(&self) -> String {
993        "JS extension command".to_string()
994    }
995    fn execute(&self, ctx: &CommandContext, args: &str) {
996        // JS commands may own the terminal for their entire lifetime (for
997        // example pi-btw's fullscreen side thread). Running them inline here
998        // would block the crossterm key thread, so no input could reach the
999        // extension while it is waiting for `ui.custom()` to complete.
1000        let session = self.session.clone();
1001        let command = self.name.trim_start_matches('/').to_string();
1002        let args = args.to_string();
1003        let ctx = ctx.clone();
1004        // The command runs off the key thread. Keep the startup snapshot so a
1005        // late editorText result cannot overwrite text typed while the command
1006        // was in flight.
1007        let initial_editor_text = ctx.editor.get_text();
1008        tokio::task::spawn_blocking(move || {
1009            match session.invoke_command_with_context(
1010                &command,
1011                &args,
1012                serde_json::json!({"editorText": initial_editor_text}),
1013            ) {
1014                Ok(value) => {
1015                    if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
1016                        if ctx.editor.get_text() == initial_editor_text
1017                            && editor_text != initial_editor_text
1018                        {
1019                            let cursor = editor_text.chars().count();
1020                            ctx.editor.set_text(editor_text);
1021                            ctx.editor.set_cursor(0, cursor);
1022                        }
1023                    }
1024                    if let Some(notifications) =
1025                        value.get("notifications").and_then(|v| v.as_array())
1026                    {
1027                        for notification in notifications {
1028                            let message = notification
1029                                .get("message")
1030                                .and_then(|v| v.as_str())
1031                                .unwrap_or_default();
1032                            if message.is_empty() {
1033                                continue;
1034                            }
1035                            match notification.get("level").and_then(|v| v.as_str()) {
1036                                Some("error") => add_error_message(&ctx.chat, message),
1037                                _ => add_note_message(&ctx.chat, message),
1038                            }
1039                        }
1040                    }
1041                    let result = value.get("result").unwrap_or(&value);
1042                    let text = result
1043                        .get("text")
1044                        .and_then(|item| item.as_str())
1045                        .map(str::to_string)
1046                        .or_else(|| result.as_str().map(str::to_string))
1047                        .filter(|text| !text.is_empty() && text != "null");
1048                    if let Some(text) = text {
1049                        add_note_message(&ctx.chat, &text);
1050                    }
1051                }
1052                Err(error) => {
1053                    add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
1054                }
1055            }
1056            ctx.tui.request_render(false);
1057        });
1058    }
1059}
1060
1061impl SlashCommand for ExtensionCommand {
1062    fn name(&self) -> &str {
1063        &self.name
1064    }
1065
1066    fn description(&self) -> &'static str {
1067        "extension command"
1068    }
1069
1070    fn description_owned(&self) -> String {
1071        self.description.clone()
1072    }
1073
1074    fn execute(&self, ctx: &CommandContext, args: &str) {
1075        let result = invoke_extension_command(&self.session, &self.name, args);
1076        handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
1077    }
1078}
1079
1080fn invoke_extension_command(
1081    session: &crate::session::ExtensionSessionCell,
1082    name: &str,
1083    args: &str,
1084) -> Option<serde_json::Value> {
1085    let command = session
1086        .lock()
1087        .ok()
1088        .and_then(|s| s.snapshot_arc())
1089        .and_then(|snap| {
1090            snap.commands()
1091                .iter()
1092                .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
1093                .cloned()
1094        })?;
1095    let input = serde_json::json!({ "args": args, "command": name });
1096    let input = serde_json::to_string(&input).ok()?;
1097    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1098        let mut out = rpi_plugin_sdk::StbString::empty();
1099        let rc = (command.handler)(
1100            rpi_plugin_sdk::StbStringRef::from_str(&input),
1101            &mut out as *mut rpi_plugin_sdk::StbString,
1102            command.user_data,
1103        );
1104        let text = if rc == 0 {
1105            Some(out.to_string_lossy())
1106        } else {
1107            None
1108        };
1109        rpi_extensions::host_free_string(out);
1110        text
1111    }))
1112    .ok()
1113    .flatten()?;
1114    serde_json::from_str(&outcome).ok()
1115}
1116
1117fn handle_extension_ui_result(
1118    result: Option<serde_json::Value>,
1119    ctx: &CommandContext,
1120    session: crate::session::ExtensionSessionCell,
1121    command_name: String,
1122) {
1123    let Some(value) = result else {
1124        add_error_message(&ctx.chat, "Extension command failed.");
1125        ctx.tui.request_render(false);
1126        return;
1127    };
1128    // A cancellation continuation may intentionally return JSON null. Native
1129    // pi resolves the pending promise with `undefined` and does not add a
1130    // visible "null" message to the transcript.
1131    if value.is_null() {
1132        ctx.tui.request_render(false);
1133        return;
1134    }
1135    match value.get("kind").and_then(|v| v.as_str()) {
1136        Some("message") | None => {
1137            let fallback = value.to_string();
1138            let text = value
1139                .get("text")
1140                .and_then(|v| v.as_str())
1141                .unwrap_or(&fallback)
1142                .to_string();
1143            if !text.is_empty() {
1144                add_note_message(&ctx.chat, &text);
1145            }
1146            ctx.tui.request_render(false);
1147        }
1148        Some("selector") => open_extension_selector(ctx, session, command_name, value),
1149        Some("editor") => open_extension_editor(ctx, session, command_name, value),
1150        // Native pi exposes `ctx.ui.input(title, placeholder)` separately
1151        // from the multiline editor. Render it as a focused single-line
1152        // dialog in the swapped input slot.
1153        Some("input") => open_extension_input(ctx, session, command_name, value),
1154        Some(other) => {
1155            add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1156            ctx.tui.request_render(false);
1157        }
1158    }
1159}
1160
1161/// Render the title used by the native pi extension dialogs.  Keeping it in
1162/// the swapped editor container makes the question stay visible while the
1163/// extension waits for the answer, instead of adding a transient chat note.
1164fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1165    let colors = current_theme().colors;
1166    let text = if bold {
1167        tui_bold(title)
1168    } else {
1169        title.to_string()
1170    };
1171    Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1172}
1173
1174fn extension_dialog_hint(label: &str) -> Arc<Text> {
1175    Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1176}
1177
1178/// Markdown-aware version of `extension_dialog_title` for extension dialogs
1179/// that may contain markdown content (tables, lists, bold, etc).
1180fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1181    let text = if bold {
1182        format!("**{}**", title)
1183    } else {
1184        title.to_string()
1185    };
1186    Arc::new(Markdown::new(text, 1, 0))
1187}
1188
1189/// Markdown-aware version of `extension_dialog_hint` for extension dialogs
1190/// that may contain markdown content.
1191fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1192    Arc::new(Markdown::new(label, 1, 0))
1193}
1194
1195/// Take and run the cancellation callback for the active extension dialog.
1196/// Taking it before invoking the callback breaks the temporary Arc cycle: the
1197/// callback owns the command context so it can process a follow-up result.
1198fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1199    let callback = state.active_extension_cancel.lock().unwrap().take();
1200    if let Some(callback) = callback {
1201        callback();
1202        true
1203    } else {
1204        false
1205    }
1206}
1207
1208fn open_extension_input(
1209    ctx: &CommandContext,
1210    session: crate::session::ExtensionSessionCell,
1211    command_name: String,
1212    value: serde_json::Value,
1213) {
1214    let title = value
1215        .get("title")
1216        .and_then(|v| v.as_str())
1217        .filter(|title| !title.is_empty())
1218        .unwrap_or("Input");
1219    let input = value
1220        .get("placeholder")
1221        .and_then(|v| v.as_str())
1222        .map(Input::with_placeholder)
1223        .unwrap_or_default();
1224    let input = Arc::new(input);
1225    if let Some(initial) = value
1226        .get("initialText")
1227        .or_else(|| value.get("text"))
1228        .and_then(|v| v.as_str())
1229    {
1230        input.set_value(initial);
1231    }
1232    input.set_focused(true);
1233
1234    let frame = Arc::new(Container::new());
1235    frame.add_child(Arc::new(DynamicBorder::new()));
1236    frame.add_child(Arc::new(Spacer::new(1)));
1237    frame.add_child(extension_dialog_title(title, false));
1238    frame.add_child(Arc::new(Spacer::new(1)));
1239    frame.add_child(input.clone());
1240    frame.add_child(Arc::new(Spacer::new(1)));
1241    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1242    frame.add_child(Arc::new(Spacer::new(1)));
1243    frame.add_child(Arc::new(DynamicBorder::new()));
1244
1245    *ctx.state.active_extension_editor.lock().unwrap() = None;
1246    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1247    ctx.editor_container.clear();
1248    ctx.editor_container.add_child(frame);
1249
1250    let state = ctx.state.clone();
1251    let ec = ctx.editor_container.clone();
1252    let original = ctx.editor.clone();
1253    let tui = ctx.tui.clone();
1254    let session_submit = session.clone();
1255    let command_submit = command_name.clone();
1256    let ctx_submit = ctx.clone();
1257    input.on_submit(Arc::new(move |text| {
1258        let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1259        let result = invoke_extension_command(
1260            &session_submit,
1261            &command_submit,
1262            &serde_json::to_string(&args).unwrap_or_default(),
1263        );
1264        close_extension_editor(&state, &ec, &original, &tui);
1265        handle_extension_ui_result(
1266            result,
1267            &ctx_submit,
1268            session_submit.clone(),
1269            command_submit.clone(),
1270        );
1271    }));
1272
1273    let state_cancel = ctx.state.clone();
1274    let ec_cancel = ctx.editor_container.clone();
1275    let original_cancel = ctx.editor.clone();
1276    let tui_cancel = ctx.tui.clone();
1277    let session_cancel = session.clone();
1278    let command_cancel = command_name.clone();
1279    let ctx_cancel = ctx.clone();
1280    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1281        let args = serde_json::json!({ "action": "cancel" });
1282        let result = invoke_extension_command(
1283            &session_cancel,
1284            &command_cancel,
1285            &serde_json::to_string(&args).unwrap_or_default(),
1286        );
1287        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1288        handle_extension_ui_result(
1289            result,
1290            &ctx_cancel,
1291            session_cancel.clone(),
1292            command_cancel.clone(),
1293        );
1294    }));
1295
1296    ctx.tui.set_focus(Some(input));
1297    ctx.tui.request_render(false);
1298}
1299
1300fn open_extension_selector(
1301    ctx: &CommandContext,
1302    session: crate::session::ExtensionSessionCell,
1303    command_name: String,
1304    value: serde_json::Value,
1305) {
1306    let items = value
1307        .get("items")
1308        .and_then(|v| v.as_array())
1309        .map(|items| {
1310            items
1311                .iter()
1312                .filter_map(|item| {
1313                    // Native pi's selector accepts `string[]`; the Rust ABI
1314                    // also permits `{value,label,description}` objects.
1315                    let value = if let Some(value) = item.as_str() {
1316                        value
1317                    } else {
1318                        item.get("value")?.as_str()?
1319                    };
1320                    let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1321                    let mut out = SelectItem::new(value, label);
1322                    if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1323                        out = out.with_description(desc);
1324                    }
1325                    Some(out)
1326                })
1327                .collect::<Vec<_>>()
1328        })
1329        .unwrap_or_default();
1330    if items.is_empty() {
1331        add_error_message(&ctx.chat, "Extension selector has no items.");
1332        ctx.tui.request_render(false);
1333        return;
1334    }
1335    let list = Arc::new(SelectList::new(items, 10));
1336    let title = value
1337        .get("title")
1338        .and_then(|v| v.as_str())
1339        .filter(|title| !title.is_empty())
1340        .unwrap_or("Select");
1341    let frame = Arc::new(Container::new());
1342    frame.add_child(Arc::new(DynamicBorder::new()));
1343    frame.add_child(Arc::new(Spacer::new(1)));
1344    frame.add_child(extension_dialog_title(title, true));
1345    frame.add_child(Arc::new(Spacer::new(1)));
1346    frame.add_child(list.clone());
1347    frame.add_child(Arc::new(Spacer::new(1)));
1348    frame.add_child(extension_dialog_hint(
1349        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1350    ));
1351    frame.add_child(Arc::new(Spacer::new(1)));
1352    frame.add_child(Arc::new(DynamicBorder::new()));
1353
1354    let state = ctx.state.clone();
1355    let ec = ctx.editor_container.clone();
1356    let editor = ctx.editor.clone();
1357    let tui = ctx.tui.clone();
1358    let session_select = session.clone();
1359    let command_select = command_name.clone();
1360    let ctx_select = ctx.clone();
1361    list.on_select(Arc::new(move |item| {
1362        let args = serde_json::json!({ "action": "select", "value": item.value });
1363        let result = invoke_extension_command(
1364            &session_select,
1365            &command_select,
1366            &serde_json::to_string(&args).unwrap_or_default(),
1367        );
1368        close_selector(&state, &ec, &editor, &tui);
1369        handle_extension_ui_result(
1370            result,
1371            &ctx_select,
1372            session_select.clone(),
1373            command_select.clone(),
1374        );
1375    }));
1376    let state_cancel = ctx.state.clone();
1377    let ec_cancel = ctx.editor_container.clone();
1378    let editor_cancel = ctx.editor.clone();
1379    let tui_cancel = ctx.tui.clone();
1380    list.on_cancel(Arc::new(move || {
1381        if !run_extension_cancel(&state_cancel) {
1382            close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1383        }
1384    }));
1385    let state_cancel = ctx.state.clone();
1386    let ec_cancel = ctx.editor_container.clone();
1387    let editor_cancel = ctx.editor.clone();
1388    let tui_cancel = ctx.tui.clone();
1389    let session_cancel = session.clone();
1390    let command_cancel = command_name.clone();
1391    let ctx_cancel = ctx.clone();
1392    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1393        let args = serde_json::json!({ "action": "cancel" });
1394        let result = invoke_extension_command(
1395            &session_cancel,
1396            &command_cancel,
1397            &serde_json::to_string(&args).unwrap_or_default(),
1398        );
1399        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1400        handle_extension_ui_result(
1401            result,
1402            &ctx_cancel,
1403            session_cancel.clone(),
1404            command_cancel.clone(),
1405        );
1406    }));
1407    open_selector_with_view(
1408        &ctx.state,
1409        &ctx.editor_container,
1410        &ctx.editor,
1411        &ctx.tui,
1412        SelectorView::List(list),
1413        frame,
1414        SelectorKind::Extension,
1415    );
1416}
1417
1418fn open_extension_editor(
1419    ctx: &CommandContext,
1420    session: crate::session::ExtensionSessionCell,
1421    command_name: String,
1422    value: serde_json::Value,
1423) {
1424    let initial = value
1425        .get("initialText")
1426        .or_else(|| value.get("text"))
1427        .and_then(|v| v.as_str())
1428        .unwrap_or_default()
1429        .to_string();
1430    let title = value
1431        .get("title")
1432        .and_then(|v| v.as_str())
1433        .filter(|title| !title.is_empty())
1434        .unwrap_or("Editor");
1435    let editor = Arc::new(Editor::new(
1436        EditorOptions {
1437            padding_x: 1,
1438            autocomplete_max_visible: 0,
1439            placeholder: value
1440                .get("placeholder")
1441                .and_then(|v| v.as_str())
1442                .map(str::to_string),
1443            initial_text: Some(initial),
1444        },
1445        EditorStyle {
1446            prompt: "> ".to_string(),
1447            placeholder: String::new(),
1448        },
1449        Arc::new(rpi_tui::Keybindings::new()),
1450    ));
1451    editor.set_focused(true);
1452    let frame = Arc::new(Container::new());
1453    frame.add_child(Arc::new(DynamicBorder::new()));
1454    frame.add_child(Arc::new(Spacer::new(1)));
1455    frame.add_child(extension_dialog_title(title, false));
1456    frame.add_child(Arc::new(Spacer::new(1)));
1457    frame.add_child(editor.clone());
1458    frame.add_child(Arc::new(Spacer::new(1)));
1459    frame.add_child(extension_dialog_hint(
1460        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1461    ));
1462    frame.add_child(Arc::new(Spacer::new(1)));
1463    frame.add_child(Arc::new(DynamicBorder::new()));
1464
1465    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1466    *ctx.state.active_extension_input.lock().unwrap() = None;
1467    ctx.editor_container.clear();
1468    ctx.editor_container.add_child(frame);
1469
1470    let state = ctx.state.clone();
1471    let ec = ctx.editor_container.clone();
1472    let original = ctx.editor.clone();
1473    let tui = ctx.tui.clone();
1474    let session_submit = session.clone();
1475    let command_submit = command_name.clone();
1476    let ctx_submit = ctx.clone();
1477    editor.on_submit(Arc::new(move |text| {
1478        let args = serde_json::json!({ "action": "edit", "text": text });
1479        let result = invoke_extension_command(
1480            &session_submit,
1481            &command_submit,
1482            &serde_json::to_string(&args).unwrap_or_default(),
1483        );
1484        close_extension_editor(&state, &ec, &original, &tui);
1485        handle_extension_ui_result(
1486            result,
1487            &ctx_submit,
1488            session_submit.clone(),
1489            command_submit.clone(),
1490        );
1491    }));
1492
1493    let state_cancel = ctx.state.clone();
1494    let ec_cancel = ctx.editor_container.clone();
1495    let original_cancel = ctx.editor.clone();
1496    let tui_cancel = ctx.tui.clone();
1497    let session_cancel = session.clone();
1498    let command_cancel = command_name.clone();
1499    let ctx_cancel = ctx.clone();
1500    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1501        let args = serde_json::json!({ "action": "cancel" });
1502        let result = invoke_extension_command(
1503            &session_cancel,
1504            &command_cancel,
1505            &serde_json::to_string(&args).unwrap_or_default(),
1506        );
1507        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1508        handle_extension_ui_result(
1509            result,
1510            &ctx_cancel,
1511            session_cancel.clone(),
1512            command_cancel.clone(),
1513        );
1514    }));
1515
1516    ctx.tui.set_focus(Some(editor));
1517    ctx.tui.request_render(false);
1518}
1519
1520fn close_extension_editor(
1521    state: &Arc<TuiState>,
1522    editor_container: &Arc<Container>,
1523    editor: &Arc<Editor>,
1524    tui: &Arc<TuiAltScreen>,
1525) {
1526    editor_container.clear();
1527    editor_container.add_child(editor.clone());
1528    state.autocomplete_container.clear();
1529    *state.active_extension_editor.lock().unwrap() = None;
1530    *state.active_extension_input.lock().unwrap() = None;
1531    *state.active_extension_cancel.lock().unwrap() = None;
1532    editor.set_focused(true);
1533    tui.set_focus(Some(editor.clone()));
1534    tui.request_render(false);
1535}
1536
1537/// Open one Node `ctx.ui.*` request in the native editor slot. The Node host
1538/// waits on the runtime response while these callbacks resolve the bridge on
1539/// Enter, selection, or cancellation.
1540fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1541    match request.method.as_str() {
1542        "select" => open_js_selector(ctx, bridge, request, false),
1543        "confirm" => open_js_selector(ctx, bridge, request, true),
1544        "input" => open_js_input(ctx, bridge, request),
1545        "editor" => open_js_editor(ctx, bridge, request),
1546        _ => {
1547            bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1548        }
1549    }
1550}
1551
1552fn open_js_selector(
1553    ctx: &CommandContext,
1554    bridge: Arc<JsDialogBridge>,
1555    request: JsDialogRequest,
1556    confirm: bool,
1557) {
1558    let values = if confirm {
1559        vec!["Yes".to_string(), "No".to_string()]
1560    } else {
1561        request.options.clone()
1562    };
1563    if values.is_empty() {
1564        bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1565        return;
1566    }
1567    let items = values
1568        .iter()
1569        .map(|value| SelectItem::new(value, value))
1570        .collect::<Vec<_>>();
1571    let list = Arc::new(SelectList::new(items, 10));
1572    let frame = Arc::new(Container::new());
1573    frame.add_child(Arc::new(DynamicBorder::new()));
1574    frame.add_child(Arc::new(Spacer::new(1)));
1575    frame.add_child(extension_dialog_title(
1576        if request.title.is_empty() {
1577            if confirm {
1578                "Confirm"
1579            } else {
1580                "Select"
1581            }
1582        } else {
1583            request.title.as_str()
1584        },
1585        true,
1586    ));
1587    if !request.message.is_empty() {
1588        frame.add_child(Arc::new(Spacer::new(1)));
1589        frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1590    }
1591    frame.add_child(Arc::new(Spacer::new(1)));
1592    frame.add_child(list.clone());
1593    frame.add_child(Arc::new(Spacer::new(1)));
1594    frame.add_child(extension_dialog_hint(
1595        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1596    ));
1597    frame.add_child(Arc::new(Spacer::new(1)));
1598    frame.add_child(Arc::new(DynamicBorder::new()));
1599
1600    let id = request.id.clone();
1601    let bridge_select = bridge.clone();
1602    let state_select = ctx.state.clone();
1603    let ec_select = ctx.editor_container.clone();
1604    let editor_select = ctx.editor.clone();
1605    let tui_select = ctx.tui.clone();
1606    list.on_select(Arc::new(move |item| {
1607        let result = if confirm {
1608            serde_json::json!({ "confirmed": item.value == "Yes" })
1609        } else {
1610            serde_json::json!({ "value": item.value })
1611        };
1612        bridge_select.respond(&id, result);
1613        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1614    }));
1615
1616    let id_cancel = request.id.clone();
1617    let bridge_cancel = bridge.clone();
1618    let state_cancel = ctx.state.clone();
1619    let ec_cancel = ctx.editor_container.clone();
1620    let editor_cancel = ctx.editor.clone();
1621    let tui_cancel = ctx.tui.clone();
1622    list.on_cancel(Arc::new(move || {
1623        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1624        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1625    }));
1626
1627    let id_abort = request.id.clone();
1628    let bridge_abort = bridge.clone();
1629    let state_abort = ctx.state.clone();
1630    let ec_abort = ctx.editor_container.clone();
1631    let editor_abort = ctx.editor.clone();
1632    let tui_abort = ctx.tui.clone();
1633    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1634        bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1635        close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1636    }));
1637
1638    open_selector_with_view(
1639        &ctx.state,
1640        &ctx.editor_container,
1641        &ctx.editor,
1642        &ctx.tui,
1643        SelectorView::List(list),
1644        frame,
1645        SelectorKind::Extension,
1646    );
1647}
1648
1649fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1650    let input = request
1651        .placeholder
1652        .as_deref()
1653        .map(Input::with_placeholder)
1654        .unwrap_or_default();
1655    let input = Arc::new(input);
1656    input.set_focused(true);
1657
1658    let frame = Arc::new(Container::new());
1659    frame.add_child(Arc::new(DynamicBorder::new()));
1660    frame.add_child(Arc::new(Spacer::new(1)));
1661    frame.add_child(extension_dialog_title(
1662        if request.title.is_empty() {
1663            "Input"
1664        } else {
1665            &request.title
1666        },
1667        false,
1668    ));
1669    frame.add_child(Arc::new(Spacer::new(1)));
1670    frame.add_child(input.clone());
1671    frame.add_child(Arc::new(Spacer::new(1)));
1672    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1673    frame.add_child(Arc::new(Spacer::new(1)));
1674    frame.add_child(Arc::new(DynamicBorder::new()));
1675
1676    *ctx.state.active_extension_editor.lock().unwrap() = None;
1677    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1678    ctx.editor_container.clear();
1679    ctx.editor_container.add_child(frame);
1680
1681    let id = request.id.clone();
1682    let bridge_submit = bridge.clone();
1683    let state_submit = ctx.state.clone();
1684    let ec_submit = ctx.editor_container.clone();
1685    let editor_submit = ctx.editor.clone();
1686    let tui_submit = ctx.tui.clone();
1687    input.on_submit(Arc::new(move |value| {
1688        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1689        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1690    }));
1691
1692    let id_cancel = request.id.clone();
1693    let bridge_cancel = bridge.clone();
1694    let state_cancel = ctx.state.clone();
1695    let ec_cancel = ctx.editor_container.clone();
1696    let editor_cancel = ctx.editor.clone();
1697    let tui_cancel = ctx.tui.clone();
1698    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1699        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1700        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1701    }));
1702    ctx.tui.set_focus(Some(input));
1703    ctx.tui.request_render(false);
1704}
1705
1706fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1707    let editor = Arc::new(Editor::new(
1708        EditorOptions {
1709            padding_x: 1,
1710            autocomplete_max_visible: 0,
1711            initial_text: request.prefill.clone(),
1712            ..Default::default()
1713        },
1714        EditorStyle {
1715            prompt: "> ".to_string(),
1716            placeholder: String::new(),
1717        },
1718        Arc::new(rpi_tui::Keybindings::new()),
1719    ));
1720    editor.set_focused(true);
1721
1722    let frame = Arc::new(Container::new());
1723    frame.add_child(Arc::new(DynamicBorder::new()));
1724    frame.add_child(Arc::new(Spacer::new(1)));
1725    frame.add_child(extension_dialog_title(
1726        if request.title.is_empty() {
1727            "Editor"
1728        } else {
1729            &request.title
1730        },
1731        false,
1732    ));
1733    frame.add_child(Arc::new(Spacer::new(1)));
1734    frame.add_child(editor.clone());
1735    frame.add_child(Arc::new(Spacer::new(1)));
1736    frame.add_child(extension_dialog_hint(
1737        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1738    ));
1739    frame.add_child(Arc::new(Spacer::new(1)));
1740    frame.add_child(Arc::new(DynamicBorder::new()));
1741
1742    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1743    *ctx.state.active_extension_input.lock().unwrap() = None;
1744    ctx.editor_container.clear();
1745    ctx.editor_container.add_child(frame);
1746
1747    let id = request.id.clone();
1748    let bridge_submit = bridge.clone();
1749    let state_submit = ctx.state.clone();
1750    let ec_submit = ctx.editor_container.clone();
1751    let editor_submit = ctx.editor.clone();
1752    let tui_submit = ctx.tui.clone();
1753    editor.on_submit(Arc::new(move |value| {
1754        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1755        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1756    }));
1757
1758    let id_cancel = request.id.clone();
1759    let bridge_cancel = bridge.clone();
1760    let state_cancel = ctx.state.clone();
1761    let ec_cancel = ctx.editor_container.clone();
1762    let editor_cancel = ctx.editor.clone();
1763    let tui_cancel = ctx.tui.clone();
1764    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1765        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1766        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1767    }));
1768    ctx.tui.set_focus(Some(editor));
1769    ctx.tui.request_render(false);
1770}
1771
1772fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1773    for id in bridge.cancelled_active_ids() {
1774        // Several commands can ask for a dialog concurrently. Only the id
1775        // currently occupying the TUI slot may close the visible component;
1776        // an older cancellation must leave a newer ask dialog untouched.
1777        if !bridge.is_visible(&id) {
1778            bridge.finish(&id);
1779            continue;
1780        }
1781        if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1782            selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1783        } else if ctx.state.extension_dialog_open() {
1784            if !run_extension_cancel(&ctx.state) {
1785                close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1786            }
1787        }
1788        // `respond` normally removes the active entry from the callback. The
1789        // fallback path above can run before a callback was installed, so
1790        // always discard the id after routing the cancellation.
1791        bridge.finish(&id);
1792    }
1793}
1794
1795// ===========================================================================
1796// ask_user — plugin UI-dialog bridge (runtime action 17)
1797// ===========================================================================
1798
1799/// Sentinel option value that switches a freeform-capable selector into the
1800/// single-line input slot (native `allowFreeform` affordance).
1801const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1802
1803/// One in-flight `ask_user` request being shown in the TUI. Unlike
1804/// [`JsDialogBridge`], the plugin side is a synchronous `poll` loop that reads
1805/// answers back through the shared [`rpi_extensions::UiDialogMailbox`], so this
1806/// bridge only tracks which request id currently occupies the input slot.
1807#[derive(Clone)]
1808struct AskUserBridge {
1809    mailbox: rpi_extensions::UiDialogMailbox,
1810    visible: Arc<Mutex<Option<String>>>,
1811}
1812
1813impl AskUserBridge {
1814    fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1815        Self {
1816            mailbox,
1817            visible: Arc::new(Mutex::new(None)),
1818        }
1819    }
1820
1821    /// Mark the interactive consumer as available. Idempotent.
1822    fn attach(&self) {
1823        self.mailbox.attach();
1824    }
1825
1826    /// Take the next pending request and mark it visible. Only the single key
1827    /// loop calls this while no other dialog is open, so the visible-id
1828    /// bookkeeping is race-free without a separate lock.
1829    fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1830        let mut visible = self.visible.lock().ok()?;
1831        if visible.is_some() {
1832            return None;
1833        }
1834        let request = self.mailbox.take_pending()?;
1835        *visible = Some(request.request_id.clone());
1836        Some(request)
1837    }
1838
1839    fn clear_visible(&self, id: &str) {
1840        if let Ok(mut visible) = self.visible.lock() {
1841            if visible.as_deref() == Some(id) {
1842                *visible = None;
1843            }
1844        }
1845    }
1846
1847    /// Record an answer for `id` and release the input slot.
1848    fn respond(&self, id: &str, answer: serde_json::Value) {
1849        let _ = self.mailbox.respond(id, answer);
1850        self.clear_visible(id);
1851    }
1852
1853    /// Cancel `id` and release the input slot.
1854    fn cancel(&self, id: &str) {
1855        let _ = self.mailbox.cancel(id);
1856        self.clear_visible(id);
1857    }
1858
1859    /// Cancel every outstanding request (run abort). Pending prompts from the
1860    /// aborted turn must not surface in a later turn.
1861    fn cancel_all(&self) {
1862        self.mailbox.cancel_all();
1863        if let Ok(mut visible) = self.visible.lock() {
1864            *visible = None;
1865        }
1866    }
1867
1868    /// Detach + cancel everything (TUI shutdown) so a parked plugin `poll`
1869    /// observes a terminal state instead of blocking on a dead session.
1870    fn shutdown(&self) {
1871        self.mailbox.detach();
1872        if let Ok(mut visible) = self.visible.lock() {
1873            *visible = None;
1874        }
1875    }
1876}
1877
1878/// Parsed presentation data for one `ask_user` question. Supports both the
1879/// flat native question contract and the earlier `questions[0]` alias, plus a
1880/// `confirm` summary.
1881#[derive(Clone)]
1882struct AskUserPrompt {
1883    question_id: String,
1884    question: String,
1885    header: Option<String>,
1886    context: Option<String>,
1887    options: Vec<(String, Option<String>)>,
1888    allow_multiple: bool,
1889    allow_freeform: bool,
1890    suggest: Option<String>,
1891    kind: String,
1892}
1893
1894fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1895    keys.iter().find_map(|key| {
1896        value
1897            .get(*key)
1898            .and_then(serde_json::Value::as_str)
1899            .map(str::trim)
1900            .filter(|text| !text.is_empty())
1901            .map(str::to_string)
1902    })
1903}
1904
1905fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1906    keys.iter()
1907        .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1908}
1909
1910fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1911    let mut options = Vec::new();
1912    let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1913        return options;
1914    };
1915    for item in items {
1916        if let Some(title) = item.as_str() {
1917            let title = title.trim();
1918            if !title.is_empty() {
1919                options.push((title.to_string(), None));
1920            }
1921            continue;
1922        }
1923        let Some(object) = item.as_object() else {
1924            continue;
1925        };
1926        let title = ["title", "label", "text", "value", "name"]
1927            .iter()
1928            .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1929            .map(str::trim)
1930            .filter(|title| !title.is_empty());
1931        let Some(title) = title else { continue };
1932        let description = object
1933            .get("description")
1934            .and_then(serde_json::Value::as_str)
1935            .map(str::trim)
1936            .filter(|text| !text.is_empty())
1937            .map(str::to_string);
1938        options.push((title.to_string(), description));
1939    }
1940    options
1941}
1942
1943fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1944    let ui = &request.ui;
1945    let kind = ui
1946        .get("kind")
1947        .and_then(serde_json::Value::as_str)
1948        .unwrap_or("selector")
1949        .to_string();
1950    // A request may carry a single flat question or a `questions[]` array. The
1951    // plugin emits one question per request, but accepting `questions[0]` keeps
1952    // the host compatible with hosts/plugins that batch.
1953    let nested = ui
1954        .get("questions")
1955        .and_then(serde_json::Value::as_array)
1956        .and_then(|items| items.first());
1957    let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1958
1959    let question = ask_user_str(source, &["question", "summary", "title"])
1960        .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1961        .unwrap_or_default();
1962    let question_id = ask_user_str(source, &["id", "questionId"])
1963        .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1964        .unwrap_or_else(|| request.request_id.clone());
1965    let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1966    let context = ask_user_str(source, &["context", "message"])
1967        .or_else(|| ask_user_str(ui, &["context", "message"]));
1968    let mut options = ask_user_options(source);
1969    if options.is_empty() {
1970        options = ask_user_options(ui);
1971    }
1972    if kind == "confirm" && options.is_empty() {
1973        options = vec![("Yes".to_string(), None), ("No".to_string(), None)];
1974    }
1975    let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1976        .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1977        .unwrap_or(false);
1978    // Freeform defaults on when there is nothing to pick from, matching the
1979    // "no options ⇒ free input" contract.
1980    let allow_freeform =
1981        ask_user_bool(source, &["allowFreeform", "allow_freeform", "allow_custom"])
1982            .or_else(|| ask_user_bool(ui, &["allowFreeform", "allow_freeform", "allow_custom"]))
1983            .unwrap_or(options.is_empty());
1984    let suggest = ask_user_str(source, &["suggest", "placeholder"])
1985        .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1986
1987    AskUserPrompt {
1988        question_id,
1989        question,
1990        header,
1991        context,
1992        options,
1993        allow_multiple,
1994        allow_freeform,
1995        suggest,
1996        kind,
1997    }
1998}
1999
2000/// Build the shared header frame for an ask-user prompt.
2001/// Uses markdown-aware rendering for question and context content.
2002fn ask_user_frame(prompt: &AskUserPrompt, body: Arc<dyn Component>, hint: &str) -> Arc<Container> {
2003    let frame = Arc::new(Container::new());
2004    frame.add_child(Arc::new(DynamicBorder::new()));
2005    frame.add_child(Arc::new(Spacer::new(1)));
2006    if let Some(header) = prompt.header.as_deref() {
2007        frame.add_child(extension_dialog_title_md(header, true));
2008    }
2009    if !prompt.question.trim().is_empty() {
2010        frame.add_child(extension_dialog_title_md(
2011            &prompt.question,
2012            prompt.header.is_none(),
2013        ));
2014    }
2015    if let Some(context) = prompt.context.as_deref() {
2016        frame.add_child(extension_dialog_hint_md(context));
2017    }
2018    frame.add_child(Arc::new(Spacer::new(1)));
2019    frame.add_child(body);
2020    frame.add_child(Arc::new(Spacer::new(1)));
2021    frame.add_child(extension_dialog_hint(hint));
2022    frame.add_child(Arc::new(Spacer::new(1)));
2023    frame.add_child(Arc::new(DynamicBorder::new()));
2024    frame
2025}
2026
2027/// Open one `ask_user` request in the TUI input slot.
2028fn open_ask_user_dialog(
2029    ctx: &CommandContext,
2030    bridge: AskUserBridge,
2031    request: rpi_extensions::UiDialogRequest,
2032) {
2033    let prompt = parse_ask_user_prompt(&request);
2034    if prompt.kind == "input" || prompt.options.is_empty() {
2035        open_ask_user_input(ctx, bridge, request, prompt);
2036    } else {
2037        open_ask_user_selector(ctx, bridge, request, prompt);
2038    }
2039}
2040
2041fn open_ask_user_selector(
2042    ctx: &CommandContext,
2043    bridge: AskUserBridge,
2044    request: rpi_extensions::UiDialogRequest,
2045    prompt: AskUserPrompt,
2046) {
2047    let multi = prompt.allow_multiple && prompt.kind != "confirm";
2048    let mut items: Vec<SelectItem> = Vec::new();
2049    for (title, description) in &prompt.options {
2050        let item = SelectItem::new(title, title);
2051        let item = match description.as_deref() {
2052            Some(description) => item.with_description(description),
2053            None => item,
2054        };
2055        items.push(item);
2056    }
2057    // A freeform-capable single selector gets an extra sentinel row that opens
2058    // the input slot for a typed answer.
2059    if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
2060        items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
2061    }
2062    let list = if multi {
2063        Arc::new(SelectList::new_multi(items, 10))
2064    } else {
2065        Arc::new(SelectList::new(items, 10))
2066    };
2067
2068    let request_id = request.request_id.clone();
2069    let state = ctx.state.clone();
2070    let editor_container = ctx.editor_container.clone();
2071    let editor = ctx.editor.clone();
2072    let tui = ctx.tui.clone();
2073
2074    // Single-select: respond with the chosen title, or fall through to the
2075    // input slot for the freeform sentinel.
2076    let bridge_select = bridge.clone();
2077    let request_for_freeform = request.clone();
2078    let prompt_for_freeform = AskUserPrompt {
2079        question_id: prompt.question_id.clone(),
2080        question: prompt.question.clone(),
2081        header: prompt.header.clone(),
2082        context: prompt.context.clone(),
2083        options: Vec::new(),
2084        allow_multiple: false,
2085        allow_freeform: true,
2086        suggest: prompt.suggest.clone(),
2087        kind: "input".to_string(),
2088    };
2089    let state_select = state.clone();
2090    let ec_select = editor_container.clone();
2091    let editor_select = editor.clone();
2092    let tui_select = tui.clone();
2093    let ctx_select = ctx.clone();
2094    list.on_select(Arc::new(move |item: &SelectItem| {
2095        if item.value == ASK_USER_FREEFORM {
2096            close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2097            open_ask_user_input(
2098                &ctx_select,
2099                bridge_select.clone(),
2100                request_for_freeform.clone(),
2101                prompt_for_freeform.clone(),
2102            );
2103            return;
2104        }
2105        bridge_select.respond(
2106            &request_id,
2107            serde_json::json!({
2108                "values": [item.display_value()],
2109                "kind": "selector",
2110            }),
2111        );
2112        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2113    }));
2114
2115    if multi {
2116        let bridge_multi = bridge.clone();
2117        let request_id_multi = request.request_id.clone();
2118        let state_multi = state.clone();
2119        let ec_multi = editor_container.clone();
2120        let editor_multi = editor.clone();
2121        let tui_multi = tui.clone();
2122        list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
2123            let values: Vec<String> = selected
2124                .iter()
2125                .map(|item| item.display_value().to_string())
2126                .collect();
2127            bridge_multi.respond(
2128                &request_id_multi,
2129                serde_json::json!({"values": values, "kind": "selector"}),
2130            );
2131            close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
2132        }));
2133    }
2134
2135    let bridge_cancel = bridge.clone();
2136    let request_id_cancel = request.request_id.clone();
2137    let state_cancel = state.clone();
2138    let ec_cancel = editor_container.clone();
2139    let editor_cancel = editor.clone();
2140    let tui_cancel = tui.clone();
2141    list.on_cancel(Arc::new(move || {
2142        bridge_cancel.cancel(&request_id_cancel);
2143        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2144    }));
2145
2146    let hint = if multi {
2147        "Space toggle · Enter submit · Esc cancel"
2148    } else {
2149        "Enter select · Esc cancel"
2150    };
2151    let frame = ask_user_frame(&prompt, list.clone(), hint);
2152    open_selector_with_view(
2153        &state,
2154        &editor_container,
2155        &editor,
2156        &tui,
2157        SelectorView::List(list),
2158        frame,
2159        SelectorKind::Extension,
2160    );
2161}
2162
2163fn open_ask_user_input(
2164    ctx: &CommandContext,
2165    bridge: AskUserBridge,
2166    request: rpi_extensions::UiDialogRequest,
2167    prompt: AskUserPrompt,
2168) {
2169    let placeholder = prompt
2170        .suggest
2171        .clone()
2172        .unwrap_or_else(|| "Type your answer…".to_string());
2173    let input = Arc::new(Input::with_placeholder(&placeholder));
2174    input.set_focused(true);
2175
2176    let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2177    *ctx.state.active_extension_editor.lock().unwrap() = None;
2178    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2179    ctx.editor_container.clear();
2180    ctx.editor_container.add_child(frame);
2181    ctx.tui.set_focus(Some(input.clone()));
2182    ctx.tui.request_render(false);
2183
2184    let state = ctx.state.clone();
2185    let ec = ctx.editor_container.clone();
2186    let editor = ctx.editor.clone();
2187    let tui = ctx.tui.clone();
2188    let bridge_submit = bridge.clone();
2189    let request_id = request.request_id.clone();
2190    input.on_submit(Arc::new(move |text: &str| {
2191        bridge_submit.respond(
2192            &request_id,
2193            serde_json::json!({
2194                "values": [text],
2195                "value": text,
2196                "text": text,
2197                "kind": "input",
2198            }),
2199        );
2200        close_extension_editor(&state, &ec, &editor, &tui);
2201    }));
2202
2203    let state_cancel = ctx.state.clone();
2204    let ec_cancel = ctx.editor_container.clone();
2205    let editor_cancel = ctx.editor.clone();
2206    let tui_cancel = ctx.tui.clone();
2207    let bridge_cancel = bridge.clone();
2208    let request_id_cancel = request.request_id.clone();
2209    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2210        bridge_cancel.cancel(&request_id_cancel);
2211        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2212    }));
2213}
2214
2215// ---- Built-in command implementations ----
2216
2217struct HelpCommand;
2218impl SlashCommand for HelpCommand {
2219    fn name(&self) -> &'static str {
2220        "/help"
2221    }
2222    fn aliases(&self) -> &'static [&'static str] {
2223        &["/?"]
2224    }
2225    fn description(&self) -> &'static str {
2226        "Show available commands"
2227    }
2228    fn execute(&self, ctx: &CommandContext, _args: &str) {
2229        add_help_message(&ctx.chat);
2230        ctx.tui.request_render(false);
2231    }
2232}
2233
2234struct ClearChatCommand;
2235impl SlashCommand for ClearChatCommand {
2236    fn name(&self) -> &'static str {
2237        "/clear"
2238    }
2239    fn aliases(&self) -> &'static [&'static str] {
2240        &["/new"]
2241    }
2242    // `/new` carries its own weight as a discoverable entry, so surface it.
2243    fn alias_visible(&self) -> &'static [&'static str] {
2244        &["/new"]
2245    }
2246    fn description(&self) -> &'static str {
2247        "Clear the conversation"
2248    }
2249    fn execute(&self, ctx: &CommandContext, _args: &str) {
2250        let _ = ctx.tx.send(TuiMessage::ClearChat);
2251    }
2252}
2253
2254struct ExitCommand;
2255impl SlashCommand for ExitCommand {
2256    fn name(&self) -> &'static str {
2257        "/exit"
2258    }
2259    fn aliases(&self) -> &'static [&'static str] {
2260        &["/quit", "/q"]
2261    }
2262    // `/quit` is surfaced (matches pi's BUILTIN list); `/q` stays a hidden alias.
2263    fn alias_visible(&self) -> &'static [&'static str] {
2264        &["/quit"]
2265    }
2266    fn description(&self) -> &'static str {
2267        "Exit the application"
2268    }
2269    fn execute(&self, ctx: &CommandContext, _args: &str) {
2270        ctx.state.cancel_js_preparation();
2271        let _ = ctx.tx.send(TuiMessage::Exit);
2272    }
2273}
2274
2275struct VersionCommand;
2276impl SlashCommand for VersionCommand {
2277    fn name(&self) -> &'static str {
2278        "/version"
2279    }
2280    fn aliases(&self) -> &'static [&'static str] {
2281        &["/v"]
2282    }
2283    fn description(&self) -> &'static str {
2284        "Show version information"
2285    }
2286    fn execute(&self, ctx: &CommandContext, _args: &str) {
2287        add_version_message(&ctx.chat);
2288        ctx.tui.request_render(false);
2289    }
2290}
2291
2292struct ChangelogCommand;
2293impl SlashCommand for ChangelogCommand {
2294    fn name(&self) -> &'static str {
2295        "/changelog"
2296    }
2297    fn description(&self) -> &'static str {
2298        "Show recent release changes"
2299    }
2300    fn execute(&self, ctx: &CommandContext, _args: &str) {
2301        add_changelog_message(&ctx.chat);
2302        ctx.tui.request_render(false);
2303    }
2304}
2305
2306struct HotkeysCommand;
2307impl SlashCommand for HotkeysCommand {
2308    fn name(&self) -> &'static str {
2309        "/hotkeys"
2310    }
2311    fn description(&self) -> &'static str {
2312        "Show keyboard shortcuts"
2313    }
2314    fn execute(&self, ctx: &CommandContext, _args: &str) {
2315        add_hotkeys_message(&ctx.chat);
2316        ctx.tui.request_render(false);
2317    }
2318}
2319
2320struct ModelCommand;
2321impl SlashCommand for ModelCommand {
2322    fn name(&self) -> &'static str {
2323        "/model"
2324    }
2325    fn aliases(&self) -> &'static [&'static str] {
2326        &["/m"]
2327    }
2328    fn description(&self) -> &'static str {
2329        "Choose a model (selector)"
2330    }
2331    fn execute(&self, ctx: &CommandContext, args: &str) {
2332        let term = args.trim();
2333        if !term.is_empty() {
2334            // /model <name> — direct switch by id (pi handleModelCommand).
2335            //
2336            // Native pi only switches on an EXACT match; a term that matches
2337            // nothing falls through to the selector with the query prefilled
2338            // instead of erroring out (`showModelSelector(searchTerm)`).
2339            if let Some(model) = find_model_selector_match(&ctx.model_catalog, term) {
2340                let model_id = model.id.clone();
2341                ctx.state.set_current_model(&model);
2342                let lane = ctx.lane.clone();
2343                tokio::spawn(async move {
2344                    let _ = lane.set_model(model).await;
2345                });
2346                add_note_message(
2347                    &ctx.chat,
2348                    &format!(
2349                        "Model set to {} — applies to the next message.",
2350                        short_model_name(&model_id)
2351                    ),
2352                );
2353                ctx.tui.request_render(false);
2354                return;
2355            }
2356        }
2357        open_model_selector(
2358            &ctx.state,
2359            &ctx.editor_container,
2360            &ctx.editor,
2361            &ctx.tui,
2362            &ctx.model_catalog,
2363            &ctx.lane,
2364            &ctx.lane_model_id,
2365            &ctx.chat,
2366            (!term.is_empty()).then_some(term),
2367        );
2368    }
2369}
2370
2371struct ThinkingCommand;
2372impl SlashCommand for ThinkingCommand {
2373    fn name(&self) -> &'static str {
2374        "/thinking"
2375    }
2376    fn aliases(&self) -> &'static [&'static str] {
2377        &["/think"]
2378    }
2379    fn description(&self) -> &'static str {
2380        "Set thinking level (selector)"
2381    }
2382    fn execute(&self, ctx: &CommandContext, args: &str) {
2383        let level_name = args.trim();
2384        if !level_name.is_empty() {
2385            // /thinking <level> — direct set (pi supports the param form).
2386            let Some(level) = thinking_level_from_name(level_name) else {
2387                add_error_message(
2388                    &ctx.chat,
2389                    &format!(
2390                        "Unknown thinking level \"{level_name}\". Valid: {}",
2391                        crate::args::VALID_THINKING_LEVELS.join(", ")
2392                    ),
2393                );
2394                ctx.tui.request_render(false);
2395                return;
2396            };
2397            let lane = ctx.lane.clone();
2398            let footer = ctx.state.footer.clone();
2399            tokio::spawn(async move {
2400                let _ = lane.set_thinking_level(level).await;
2401            });
2402            footer.set_thinking_level(Some(thinking_level_name(level)));
2403            add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2404            ctx.tui.request_render(false);
2405            return;
2406        }
2407        open_thinking_selector(
2408            &ctx.state,
2409            &ctx.editor_container,
2410            &ctx.editor,
2411            &ctx.tui,
2412            &ctx.lane,
2413            &ctx.model_catalog,
2414            &ctx.lane_model_id,
2415            &ctx.chat,
2416        );
2417    }
2418}
2419
2420struct ToolsCommand;
2421impl SlashCommand for ToolsCommand {
2422    fn name(&self) -> &'static str {
2423        "/tools"
2424    }
2425    fn description(&self) -> &'static str {
2426        "Toggle tools on/off"
2427    }
2428    fn execute(&self, ctx: &CommandContext, _args: &str) {
2429        open_tools_selector(
2430            &ctx.state,
2431            &ctx.editor_container,
2432            &ctx.editor,
2433            &ctx.tui,
2434            &ctx.lane,
2435            &ctx.chat,
2436        );
2437    }
2438}
2439
2440struct ImagesCommand;
2441impl SlashCommand for ImagesCommand {
2442    fn name(&self) -> &'static str {
2443        "/images"
2444    }
2445    fn description(&self) -> &'static str {
2446        "Toggle inline images"
2447    }
2448    fn execute(&self, ctx: &CommandContext, _args: &str) {
2449        open_images_selector(
2450            &ctx.state,
2451            &ctx.editor_container,
2452            &ctx.editor,
2453            &ctx.tui,
2454            &ctx.chat,
2455        );
2456    }
2457}
2458
2459struct SessionCommand;
2460impl SlashCommand for SessionCommand {
2461    fn name(&self) -> &'static str {
2462        "/session"
2463    }
2464    fn aliases(&self) -> &'static [&'static str] {
2465        &["/resume"]
2466    }
2467    fn description(&self) -> &'static str {
2468        "List saved sessions"
2469    }
2470    fn execute(&self, ctx: &CommandContext, _args: &str) {
2471        open_session_selector(
2472            &ctx.state,
2473            &ctx.editor_container,
2474            &ctx.editor,
2475            &ctx.tui,
2476            &ctx.cwd,
2477            &ctx.tx,
2478        );
2479    }
2480}
2481
2482struct ThemeCommand;
2483impl SlashCommand for ThemeCommand {
2484    fn name(&self) -> &'static str {
2485        "/theme"
2486    }
2487    fn description(&self) -> &'static str {
2488        "Choose a theme (selector)"
2489    }
2490    fn execute(&self, ctx: &CommandContext, args: &str) {
2491        let name = args.trim().to_ascii_lowercase();
2492        if !name.is_empty() {
2493            // /theme <name> — direct apply + persist (matches /settings Theme).
2494            let preset = match name.as_str() {
2495                "light" => ThemePreset::Light,
2496                "monochrome" => ThemePreset::Monochrome,
2497                "dark" => ThemePreset::Dark,
2498                _ => {
2499                    add_error_message(
2500                        &ctx.chat,
2501                        &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2502                    );
2503                    ctx.tui.request_render(false);
2504                    return;
2505                }
2506            };
2507            apply_theme_preset(preset);
2508            let mut settings = crate::settings::load_settings().unwrap_or_default();
2509            settings.theme = Some(name.clone());
2510            let _ = crate::settings::save_settings(&settings);
2511            add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2512            ctx.tui.request_render(false);
2513            ctx.tui.render_now(true);
2514            return;
2515        }
2516        open_theme_selector(
2517            &ctx.state,
2518            &ctx.editor_container,
2519            &ctx.editor,
2520            &ctx.tui,
2521            &ctx.cwd,
2522            &ctx.package_resources,
2523        );
2524    }
2525}
2526
2527struct CompactCommand;
2528impl SlashCommand for CompactCommand {
2529    fn name(&self) -> &'static str {
2530        "/compact"
2531    }
2532    fn description(&self) -> &'static str {
2533        "Compact the conversation"
2534    }
2535    fn execute(&self, ctx: &CommandContext, _args: &str) {
2536        let _ = ctx.tx.send(TuiMessage::Compact);
2537    }
2538}
2539
2540struct CopyCommand;
2541impl SlashCommand for CopyCommand {
2542    fn name(&self) -> &'static str {
2543        "/copy"
2544    }
2545    fn description(&self) -> &'static str {
2546        "Copy last reply to clipboard"
2547    }
2548    fn execute(&self, ctx: &CommandContext, _args: &str) {
2549        let _ = ctx.tx.send(TuiMessage::Copy);
2550    }
2551}
2552
2553struct ExportCommand;
2554impl SlashCommand for ExportCommand {
2555    fn name(&self) -> &'static str {
2556        "/export"
2557    }
2558    fn description(&self) -> &'static str {
2559        "Export session (supports: md, html, jsonl)"
2560    }
2561    fn execute(&self, ctx: &CommandContext, args: &str) {
2562        let format = args.trim().to_lowercase();
2563        if format.is_empty() || format == "md" || format == "markdown" {
2564            let _ = ctx.tx.send(TuiMessage::ExportSession);
2565        } else if format == "html" {
2566            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2567                crate::export::ExportFormat::Html,
2568            ));
2569        } else if format == "jsonl" {
2570            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2571                crate::export::ExportFormat::Jsonl,
2572            ));
2573        } else {
2574            // Invalid format, show error
2575            add_error_message(
2576                &ctx.chat,
2577                &format!(
2578                    "Unknown export format: {}. Supported: md, html, jsonl",
2579                    args.trim()
2580                ),
2581            );
2582            ctx.tui.request_render(false);
2583        }
2584    }
2585}
2586
2587struct ForkCommand;
2588impl SlashCommand for ForkCommand {
2589    fn name(&self) -> &'static str {
2590        "/fork"
2591    }
2592    fn description(&self) -> &'static str {
2593        "Fork the session into a new one"
2594    }
2595    fn execute(&self, ctx: &CommandContext, _args: &str) {
2596        let _ = ctx.tx.send(TuiMessage::ForkSession);
2597    }
2598}
2599
2600/// `/clone` is the native Pi spelling for duplicating the current session.
2601/// Reuse the same durable fork path as `/fork`; both create a child session
2602/// and rebind the live harness to it.
2603struct CloneCommand;
2604impl SlashCommand for CloneCommand {
2605    fn name(&self) -> &'static str {
2606        "/clone"
2607    }
2608    fn description(&self) -> &'static str {
2609        "Duplicate the current session"
2610    }
2611    fn execute(&self, ctx: &CommandContext, _args: &str) {
2612        let _ = ctx.tx.send(TuiMessage::ForkSession);
2613    }
2614}
2615
2616struct TreeCommand;
2617impl SlashCommand for TreeCommand {
2618    fn name(&self) -> &'static str {
2619        "/tree"
2620    }
2621    fn description(&self) -> &'static str {
2622        "Navigate the current session tree"
2623    }
2624    fn execute(&self, ctx: &CommandContext, _args: &str) {
2625        let _ = ctx.tx.send(TuiMessage::OpenTree);
2626    }
2627}
2628
2629struct LoginCommand;
2630impl SlashCommand for LoginCommand {
2631    fn name(&self) -> &'static str {
2632        "/login"
2633    }
2634    fn description(&self) -> &'static str {
2635        "Save an Anthropic API key"
2636    }
2637    fn execute(&self, ctx: &CommandContext, args: &str) {
2638        let key = args.trim();
2639        if key.is_empty() {
2640            add_note_message(&ctx.chat, "Usage: /login <api-key>");
2641        } else {
2642            let result = crate::config::upsert_credential(
2643                "anthropic",
2644                crate::config::Credential::ApiKey {
2645                    key: Some(key.to_string()),
2646                    env: None,
2647                },
2648            );
2649            match result {
2650                Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2651                Err(error) => {
2652                    add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2653                }
2654            }
2655        }
2656        ctx.tui.request_render(false);
2657    }
2658}
2659
2660struct LogoutCommand;
2661impl SlashCommand for LogoutCommand {
2662    fn name(&self) -> &'static str {
2663        "/logout"
2664    }
2665    fn description(&self) -> &'static str {
2666        "Remove saved Anthropic credentials"
2667    }
2668    fn execute(&self, ctx: &CommandContext, _args: &str) {
2669        match crate::config::delete_credential("anthropic") {
2670            Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2671            Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2672            Err(error) => {
2673                add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2674            }
2675        }
2676        ctx.tui.request_render(false);
2677    }
2678}
2679
2680fn set_project_trust_for_command(
2681    cwd: &std::path::Path,
2682    value: Option<bool>,
2683) -> Result<(), crate::config::ConfigError> {
2684    crate::config::set_project_trust(cwd, value)
2685}
2686
2687struct TrustCommand;
2688impl SlashCommand for TrustCommand {
2689    fn name(&self) -> &'static str {
2690        "/trust"
2691    }
2692    fn description(&self) -> &'static str {
2693        "Trust the current project"
2694    }
2695    fn execute(&self, ctx: &CommandContext, args: &str) {
2696        let value = match args.trim().to_ascii_lowercase().as_str() {
2697            "" | "yes" | "y" | "true" => Some(true),
2698            "no" | "n" | "false" => Some(false),
2699            "clear" | "reset" | "none" => None,
2700            _ => {
2701                add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2702                ctx.tui.request_render(false);
2703                return;
2704            }
2705        };
2706        match set_project_trust_for_command(&ctx.cwd, value) {
2707            Ok(()) => {
2708                let label = match value {
2709                    Some(true) => "trusted",
2710                    Some(false) => "untrusted",
2711                    None => "trust decision cleared",
2712                };
2713                add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2714            }
2715            Err(error) => add_error_message(
2716                &ctx.chat,
2717                &format!("Could not save trust decision: {error}"),
2718            ),
2719        }
2720        ctx.tui.request_render(false);
2721    }
2722}
2723
2724struct NameCommand;
2725impl SlashCommand for NameCommand {
2726    fn name(&self) -> &'static str {
2727        "/name"
2728    }
2729    fn description(&self) -> &'static str {
2730        "Set session display name"
2731    }
2732    fn execute(&self, ctx: &CommandContext, args: &str) {
2733        let name = args.trim();
2734        if name.is_empty() {
2735            add_note_message(
2736                &ctx.chat,
2737                "Usage: /name <display name> — sets the current session's name.",
2738            );
2739            ctx.tui.request_render(false);
2740            return;
2741        }
2742        let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2743    }
2744}
2745
2746struct ImportCommand;
2747impl SlashCommand for ImportCommand {
2748    fn name(&self) -> &'static str {
2749        "/import"
2750    }
2751    fn description(&self) -> &'static str {
2752        "Import a session file (path)"
2753    }
2754    fn execute(&self, ctx: &CommandContext, args: &str) {
2755        let path = args.trim();
2756        if path.is_empty() {
2757            add_note_message(
2758                &ctx.chat,
2759                "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2760            );
2761            ctx.tui.request_render(false);
2762            return;
2763        }
2764        let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2765    }
2766}
2767
2768struct SettingsCommand;
2769impl SlashCommand for SettingsCommand {
2770    fn name(&self) -> &'static str {
2771        "/settings"
2772    }
2773    fn description(&self) -> &'static str {
2774        "Open settings menu"
2775    }
2776    fn execute(&self, ctx: &CommandContext, _args: &str) {
2777        open_settings_selector(
2778            &ctx.state,
2779            &ctx.editor_container,
2780            &ctx.editor,
2781            &ctx.tui,
2782            &ctx.lane,
2783            &ctx.model_catalog,
2784            &ctx.lane_model_id,
2785            &ctx.chat,
2786            &ctx.cwd,
2787            &ctx.package_resources,
2788        );
2789    }
2790}
2791
2792struct ScopedModelsCommand;
2793impl SlashCommand for ScopedModelsCommand {
2794    fn name(&self) -> &'static str {
2795        "/scoped-models"
2796    }
2797    fn description(&self) -> &'static str {
2798        "Choose models for Ctrl+M cycling"
2799    }
2800    fn execute(&self, ctx: &CommandContext, _args: &str) {
2801        open_scoped_models_selector(
2802            &ctx.state,
2803            &ctx.editor_container,
2804            &ctx.editor,
2805            &ctx.tui,
2806            &ctx.model_catalog,
2807            &ctx.chat,
2808        );
2809    }
2810}
2811
2812struct ShareCommand;
2813impl SlashCommand for ShareCommand {
2814    fn name(&self) -> &'static str {
2815        "/share"
2816    }
2817    fn description(&self) -> &'static str {
2818        "Share session (gist via gh, or clipboard)"
2819    }
2820    fn execute(&self, ctx: &CommandContext, _args: &str) {
2821        let _ = ctx.tx.send(TuiMessage::ShareSession);
2822    }
2823}
2824
2825struct ArminCommand;
2826impl SlashCommand for ArminCommand {
2827    fn name(&self) -> &'static str {
2828        "/armin"
2829    }
2830    fn description(&self) -> &'static str {
2831        "??? (easter egg)"
2832    }
2833    fn execute(&self, ctx: &CommandContext, _args: &str) {
2834        crate::extras::add_armin(&ctx.chat);
2835        ctx.tui.request_render(false);
2836    }
2837}
2838
2839struct EarendilCommand;
2840impl SlashCommand for EarendilCommand {
2841    fn name(&self) -> &'static str {
2842        "/earendil"
2843    }
2844    fn description(&self) -> &'static str {
2845        "Announcement"
2846    }
2847    fn execute(&self, ctx: &CommandContext, _args: &str) {
2848        crate::extras::add_earendil(&ctx.chat);
2849        ctx.tui.request_render(false);
2850    }
2851}
2852
2853/// `/context` — lists discovered context files, skills, and prompt templates.
2854/// Hidden from autocomplete (needs the resources snapshot to be meaningful as a
2855/// discovery surface; like `/name`, it's recognized-v1 but kept off the list).
2856struct ContextCommand;
2857impl SlashCommand for ContextCommand {
2858    fn name(&self) -> &'static str {
2859        "/context"
2860    }
2861    fn visible(&self) -> bool {
2862        false
2863    }
2864    fn execute(&self, ctx: &CommandContext, _args: &str) {
2865        show_context_panel(&ctx.chat, &ctx.resources);
2866        // Live session stats are async, so ask the main loop to append them.
2867        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2868        ctx.tui.request_render(false);
2869    }
2870}
2871
2872/// `/usage` — token/cost/cache totals read through the product-layer
2873/// `AgentSession`. This is the same numbers the footer shows, but expanded and
2874/// with the cache-hit breakdown that the one-line footer cannot fit.
2875struct UsageCommand;
2876impl SlashCommand for UsageCommand {
2877    fn name(&self) -> &'static str {
2878        "/usage"
2879    }
2880    fn description(&self) -> &'static str {
2881        "Show token, cost, and cache totals for this session"
2882    }
2883    fn execute(&self, ctx: &CommandContext, _args: &str) {
2884        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2885        ctx.tui.request_render(false);
2886    }
2887}
2888
2889/// `/reload` — re-run extension + resource discovery into the LIVE harness
2890/// (B5d): reload the cdylib plugins, invalidate the old `ActionBridge` +
2891/// registry snapshot, rebuild skills/prompts/context/SYSTEM.md/APPEND_SYSTEM.md
2892/// + the `TeeEmitter`, and push the rebuilt state via the B5d harness setters.
2893/// The command itself runs on the blocking submit thread, so it can't drive
2894/// the async `reload_extension_resources` routine directly — it signals the main
2895/// loop via `TuiMessage::ReloadExtensions`, which awaits it on the async runtime.
2896/// (A plugin's `runtime_action(Reload)` signals the same loop via the
2897/// `ReloadMailbox` the TUI installs — the B5d async-reload design avoids the
2898/// self-unmapping race a synchronous plugin-initiated reload would have.)
2899struct ReloadCommand;
2900impl SlashCommand for ReloadCommand {
2901    fn name(&self) -> &'static str {
2902        "/reload"
2903    }
2904    fn description(&self) -> &'static str {
2905        "Reload extensions, skills, prompts"
2906    }
2907    fn execute(&self, ctx: &CommandContext, _args: &str) {
2908        // Signal the main loop. It owns the `&AgentHarness` borrow the
2909        // `reload_extension_resources` routine needs (the blocking submit thread
2910        // only has the context's `Arc<ReloadContext>` + the `Arc<dyn AgentLane>`).
2911        add_note_message(&ctx.chat, "Reloading extensions + resources…");
2912        ctx.tui.request_render(false);
2913        let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2914    }
2915}
2916
2917/// Build the full command registry: active built-ins first (so they win on a
2918/// fuzzy autocomplete tie), then the v1-out-of-scope stubs. Prompt-template
2919/// commands are merged in separately by the autocomplete builder (they dispatch
2920/// via template expansion, not this registry).
2921fn build_builtin_registry() -> CommandRegistry {
2922    let mut r = CommandRegistry::new();
2923    r.register(Arc::new(HelpCommand));
2924    r.register(Arc::new(ClearChatCommand));
2925    r.register(Arc::new(ExitCommand));
2926    r.register(Arc::new(VersionCommand));
2927    r.register(Arc::new(ChangelogCommand));
2928    r.register(Arc::new(ModelCommand));
2929    r.register(Arc::new(ThinkingCommand));
2930    r.register(Arc::new(ToolsCommand));
2931    r.register(Arc::new(ImagesCommand));
2932    r.register(Arc::new(SessionCommand));
2933    r.register(Arc::new(ThemeCommand));
2934    r.register(Arc::new(CompactCommand));
2935    r.register(Arc::new(CopyCommand));
2936    r.register(Arc::new(HotkeysCommand));
2937    r.register(Arc::new(ArminCommand));
2938    r.register(Arc::new(EarendilCommand));
2939    r.register(Arc::new(ContextCommand));
2940    r.register(Arc::new(UsageCommand));
2941    // Recognized but inert in v1 (one struct backs them all). The TS builtins
2942    // out of v1 scope; each carries a description so autocomplete surfaces its
2943    // existence even though running it reports "not supported".
2944    r.register(Arc::new(NameCommand));
2945    r.register(Arc::new(SettingsCommand));
2946    r.register(Arc::new(ScopedModelsCommand));
2947    r.register(Arc::new(ExportCommand));
2948    r.register(Arc::new(ImportCommand));
2949    r.register(Arc::new(ShareCommand));
2950    r.register(Arc::new(ForkCommand));
2951    r.register(Arc::new(CloneCommand));
2952    r.register(Arc::new(TreeCommand));
2953    r.register(Arc::new(TrustCommand));
2954    r.register(Arc::new(LoginCommand));
2955    r.register(Arc::new(LogoutCommand));
2956    r.register(Arc::new(ReloadCommand));
2957    r
2958}
2959
2960fn register_extension_commands(
2961    registry: &mut CommandRegistry,
2962    session: crate::session::ExtensionSessionCell,
2963) {
2964    let commands = session
2965        .lock()
2966        .ok()
2967        .and_then(|s| s.snapshot_arc())
2968        .map(|snap| snap.commands().to_vec())
2969        .unwrap_or_default();
2970    for command in commands {
2971        let name = if command.name.starts_with('/') {
2972            command.name.clone()
2973        } else {
2974            format!("/{}", command.name)
2975        };
2976        if registry.find(&name).is_some() {
2977            continue;
2978        }
2979        registry.register(Arc::new(ExtensionCommand {
2980            name,
2981            description: command.description,
2982            session: session.clone(),
2983        }));
2984    }
2985}
2986
2987fn register_js_extension_commands(
2988    registry: &mut CommandRegistry,
2989    session: Option<crate::js_extensions::JsExtensionSession>,
2990) {
2991    let Some(session) = session else {
2992        return;
2993    };
2994    for command in &session.commands {
2995        let name = if command.starts_with('/') {
2996            command.clone()
2997        } else {
2998            format!("/{command}")
2999        };
3000        if registry.find(&name).is_none() {
3001            registry.register(Arc::new(JsExtensionCommand {
3002                name,
3003                session: session.clone(),
3004            }));
3005        }
3006    }
3007}
3008
3009// ===========================================================================
3010// Channel + helpers
3011// ===========================================================================
3012
3013/// Message type for communication between the key/callback threads and the
3014/// main async loop.
3015enum TuiMessage {
3016    UserInput(String),
3017    OpenTree,
3018    NavigateTree(String),
3019    Exit,
3020    /// Clear the transcript (from `/clear`).
3021    ClearChat,
3022    /// Compact the conversation (from `/compact`).
3023    Compact,
3024    /// Copy the last assistant reply to the clipboard (from `/copy`).
3025    Copy,
3026    /// Hot-switch to another saved session (from the `/session` selector):
3027    /// the payload is the session id the selector's item value carried.
3028    SwitchSession(String),
3029    /// Export the current session to a markdown file (from `/export`).
3030    ExportSession,
3031    /// Export session with specific format
3032    ExportSessionWithFormat(crate::export::ExportFormat),
3033    /// Show live usage/context stats (from `/usage` and `/context`).
3034    ShowUsage,
3035    /// Fork the current session into a new one and switch to it (from `/fork`).
3036    ForkSession,
3037    /// Rename the current session (from `/name <name>`).
3038    SetSessionName(String),
3039    /// Import a JSONL session file into the session dir and switch to it
3040    /// (from `/import <path>`).
3041    ImportSession(String),
3042    /// Share the current session (`/share`): `gh gist create` when the gh CLI
3043    /// is available, otherwise copy the transcript to the clipboard.
3044    ShareSession,
3045    /// `/reload` — re-run extension + resource discovery into the live harness
3046    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
3047    /// mailbox) signal the main loop, which awaits
3048    /// `reload_extension_resources` on the async runtime.
3049    ReloadExtensions,
3050    /// Result returned after Ctrl+G edits a temporary file in an external
3051    /// editor. Handling it on the async loop keeps editor mutation single-
3052    /// threaded with the rest of the TUI state.
3053    ExternalEditorResult(Result<String, String>),
3054    /// A user-initiated `!command` / `!!command` shell run finished. Routed to
3055    /// the main loop so the `bashExecution` transcript record is persisted in
3056    /// order relative to agent runs (native pi's `recordBashResult` /
3057    /// `_pendingBashMessages`).
3058    UserBashFinished(UserBashReport),
3059}
3060
3061/// Terminal result of a user-initiated `!command` shell run.
3062///
3063/// Carries everything needed to build the persisted `bashExecution` message
3064/// plus the `!!` context-exclusion flag. See `start_user_bash`.
3065struct UserBashReport {
3066    command: String,
3067    output: String,
3068    exit_code: Option<i32>,
3069    cancelled: bool,
3070    truncated: bool,
3071    full_output_path: Option<String>,
3072    exclude_from_context: bool,
3073}
3074
3075/// Extract the concatenated text content from an assistant message (mirrors
3076/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
3077fn assistant_text(msg: &AssistantMessage) -> String {
3078    msg.content
3079        .iter()
3080        .filter_map(|c| match c {
3081            Content::Text(t) => Some(t.text.clone()),
3082            _ => None,
3083        })
3084        .collect()
3085}
3086
3087/// The user message's text (Text content or the text blocks of a Blocks
3088/// payload — images are skipped, consistent with the v1 text-only prompt path).
3089fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
3090    msg.content
3091        .iter()
3092        .filter_map(|c| match c {
3093            Content::Text(t) => Some(t.text.clone()),
3094            _ => None,
3095        })
3096        .collect::<Vec<_>>()
3097        .join("\n")
3098}
3099
3100fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
3101    match &msg.content {
3102        rpi_ai::types::UserContent::Text(s) => s.clone(),
3103        rpi_ai::types::UserContent::Blocks(blocks) => blocks
3104            .iter()
3105            .filter_map(|c| match c {
3106                Content::Text(t) => Some(t.text.clone()),
3107                _ => None,
3108            })
3109            .collect(),
3110    }
3111}
3112
3113/// The catalog allowed in the Ctrl+M cycle: the `/scoped-models` set from
3114/// settings.json when present, otherwise every model. The current model is
3115/// always included (fallback) so cycling can never strand the user off-scope.
3116fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
3117    let scoped = crate::settings::load_settings()
3118        .ok()
3119        .and_then(|s| s.scoped_models)
3120        .unwrap_or_default();
3121    if scoped.is_empty() {
3122        return catalog.to_vec();
3123    }
3124    let mut out: Vec<rpi_ai::Model> = catalog
3125        .iter()
3126        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
3127        .cloned()
3128        .collect();
3129    // Never strand the user: if the current model isn't in scope, keep it.
3130    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
3131        if let Some(cur) = catalog
3132            .iter()
3133            .find(|m| m.id.eq_ignore_ascii_case(current_id))
3134        {
3135            out.push(cur.clone());
3136        }
3137    }
3138    out
3139}
3140
3141/// Interactive `/settings` menu.
3142///
3143/// A [`SettingsList`] of editable settings (native pi's `SettingsSelectorComponent`):
3144/// Enter/Space cycles a row's value in place, and the theme/model/thinking/scope
3145/// rows open a sub-selector that replaces the active selector (the
3146/// `active_selector` slot is single). Value changes apply immediately where the
3147/// running TUI can honor them and are always persisted to `settings.json`, so
3148/// rows marked "applies on restart" take effect next launch.
3149fn open_settings_selector(
3150    state: &Arc<TuiState>,
3151    editor_container: &Arc<Container>,
3152    editor: &Arc<Editor>,
3153    tui: &Arc<TuiAltScreen>,
3154    lane: &Arc<dyn AgentLane>,
3155    catalog: &[rpi_ai::Model],
3156    lane_model_id: &str,
3157    chat: &Arc<Container>,
3158    cwd: &std::path::Path,
3159    package_resources: &Arc<crate::packages::PackageResources>,
3160) {
3161    let settings = crate::settings::load_settings().unwrap_or_default();
3162    let items = settings_menu_items(&settings, state, lane_model_id);
3163    let list = Arc::new(SettingsList::new(items));
3164
3165    // ---- submenu rows ----
3166    // These reuse the existing sub-selectors, which already persist their own
3167    // choices. The settings menu is replaced by the sub-selector (single
3168    // `active_selector` slot); cancelling the sub-selector returns to the editor.
3169    let state_sel = state.clone();
3170    let ec_sel = editor_container.clone();
3171    let editor_sel = editor.clone();
3172    let tui_sel = tui.clone();
3173    let lane_sel = lane.clone();
3174    let catalog_sel = catalog.to_vec();
3175    let lane_model_sel = lane_model_id.to_string();
3176    let chat_sel = chat.clone();
3177    let cwd_sel = cwd.to_path_buf();
3178    let package_resources_sel = package_resources.clone();
3179    list.on_select(Arc::new(move |item| match item.key.as_str() {
3180        "theme" => open_settings_theme_selector(
3181            &state_sel,
3182            &ec_sel,
3183            &editor_sel,
3184            &tui_sel,
3185            &chat_sel,
3186            &cwd_sel,
3187            &package_resources_sel,
3188        ),
3189        "model" => open_settings_model_selector(
3190            &state_sel,
3191            &ec_sel,
3192            &editor_sel,
3193            &tui_sel,
3194            &lane_sel,
3195            &catalog_sel,
3196            &lane_model_sel,
3197            &chat_sel,
3198        ),
3199        "thinking" => open_settings_thinking_selector(
3200            &state_sel,
3201            &ec_sel,
3202            &editor_sel,
3203            &tui_sel,
3204            &lane_sel,
3205            &catalog_sel,
3206            &lane_model_sel,
3207            &chat_sel,
3208        ),
3209        "scoped-models" => open_scoped_models_selector(
3210            &state_sel,
3211            &ec_sel,
3212            &editor_sel,
3213            &tui_sel,
3214            &catalog_sel,
3215            &chat_sel,
3216        ),
3217        _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3218    }));
3219
3220    // ---- value rows ----
3221    // Apply live where possible, then persist so the choice survives a restart.
3222    let state_change = state.clone();
3223    let chat_change = chat.clone();
3224    let tui_change = tui.clone();
3225    list.on_change(Arc::new(move |key, value| {
3226        let mut settings = crate::settings::load_settings().unwrap_or_default();
3227        let applied = apply_setting_change(&state_change, key, value, &mut settings);
3228        let saved = crate::settings::save_settings(&settings);
3229        if let Some(note) = applied {
3230            let suffix = if saved.is_ok() { "" } else { " (not saved)" };
3231            add_note_message(&chat_change, &format!("{note}{suffix}"));
3232        }
3233        tui_change.request_render(false);
3234    }));
3235
3236    let state_cancel = state.clone();
3237    let ec_cancel = editor_container.clone();
3238    let editor_cancel = editor.clone();
3239    let tui_cancel = tui.clone();
3240    list.on_cancel(Arc::new(move || {
3241        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3242    }));
3243
3244    open_selector_with_view(
3245        state,
3246        editor_container,
3247        editor,
3248        tui,
3249        SelectorView::Settings(list.clone()),
3250        list,
3251        SelectorKind::Settings,
3252    );
3253}
3254
3255/// Build the `/settings` rows from the loaded settings plus the live TUI state.
3256///
3257/// Descriptions carry "(applies on restart)" for values resolved once at
3258/// startup, so the menu never implies an effect the running session cannot
3259/// apply.
3260fn settings_menu_items(
3261    settings: &crate::settings::Settings,
3262    state: &Arc<TuiState>,
3263    lane_model_id: &str,
3264) -> Vec<SettingItem> {
3265    let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
3266    let current_model = settings
3267        .default_model
3268        .clone()
3269        .unwrap_or_else(|| lane_model_id.to_string());
3270    let current_thinking = settings
3271        .default_thinking_level
3272        .clone()
3273        .unwrap_or_else(|| "(default)".to_string());
3274    let scoped_desc = match &settings.scoped_models {
3275        Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
3276        _ => "all models".to_string(),
3277    };
3278    let hide_thinking = state.hide_thinking();
3279    let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
3280    let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
3281    let http_idle = settings
3282        .http_idle_timeout_ms()
3283        .map(|ms| {
3284            if ms == 0 {
3285                "disabled".to_string()
3286            } else {
3287                format!("{}s", ms / 1000)
3288            }
3289        })
3290        .unwrap_or_else(|| "5m".to_string());
3291
3292    vec![
3293        SettingItem::new("theme", "Theme", &current_theme)
3294            .with_description("Color theme for the interface")
3295            .with_submenu(),
3296        SettingItem::new("model", "Default model", &current_model)
3297            .with_description("Saved default model for new sessions")
3298            .with_submenu(),
3299        SettingItem::new("thinking", "Default thinking", &current_thinking)
3300            .with_description("Saved default thinking level")
3301            .with_submenu(),
3302        SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
3303            .with_description("Models enabled for Ctrl+M cycling")
3304            .with_submenu(),
3305        SettingItem::new(
3306            "hide-thinking",
3307            "Hide thinking",
3308            bool_setting_value(hide_thinking),
3309        )
3310        .with_description("Hide thinking blocks in assistant responses")
3311        .with_values(&["true", "false"]),
3312        SettingItem::new(
3313            "show-images",
3314            "Show images",
3315            bool_setting_value(show_images),
3316        )
3317        .with_description("Render images inline in the terminal")
3318        .with_values(&["true", "false"]),
3319        SettingItem::new(
3320            "cache-miss-notices",
3321            "Cache miss notices",
3322            bool_setting_value(show_cache_miss),
3323        )
3324        .with_description("Transcript notices for prompt-cache costs")
3325        .with_values(&["true", "false"]),
3326        SettingItem::new(
3327            "quiet-startup",
3328            "Quiet startup",
3329            bool_setting_value(settings.quiet_startup.unwrap_or(false)),
3330        )
3331        .with_description("Disable the verbose startup listing (applies on restart)")
3332        .with_values(&["true", "false"]),
3333        SettingItem::new(
3334            "terminal-progress",
3335            "Terminal progress",
3336            bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
3337        )
3338        .with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
3339        .with_values(&["true", "false"]),
3340        SettingItem::new(
3341            "fullscreen-copy-on-select",
3342            "Fullscreen copy on select",
3343            bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
3344        )
3345        .with_description("Copy selected text automatically in fullscreen mode")
3346        .with_values(&["true", "false"]),
3347        SettingItem::new(
3348            "double-escape-action",
3349            "Double-escape action",
3350            settings
3351                .double_escape_action
3352                .clone()
3353                .unwrap_or_else(|| "tree".to_string())
3354                .as_str(),
3355        )
3356        .with_description("Esc Esc with an empty editor (applies on restart)")
3357        .with_values(&["tree", "fork", "none"]),
3358        SettingItem::new(
3359            "editor-padding",
3360            "Editor padding",
3361            settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
3362        )
3363        .with_description("Horizontal editor padding, 0-3 (applies on restart)")
3364        .with_values(&["0", "1", "2", "3"]),
3365        SettingItem::new(
3366            "autocomplete-max-items",
3367            "Autocomplete max items",
3368            settings
3369                .autocomplete_max_visible
3370                .unwrap_or(5)
3371                .to_string()
3372                .as_str(),
3373        )
3374        .with_description("Max autocomplete rows, 3-20 (applies on restart)")
3375        .with_values(&["3", "5", "7", "10", "15", "20"]),
3376        SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
3377            .with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
3378            .with_values(&["30s", "1m", "5m", "disabled"]),
3379    ]
3380}
3381
3382/// `"true"` / `"false"` for a settings row value.
3383fn bool_setting_value(value: bool) -> &'static str {
3384    if value {
3385        "true"
3386    } else {
3387        "false"
3388    }
3389}
3390
3391/// Apply one `/settings` value change to the running TUI and to `settings`.
3392///
3393/// Returns a note to show in the transcript when the change is user-visible;
3394/// `None` for changes that only take effect on the next launch.
3395fn apply_setting_change(
3396    state: &Arc<TuiState>,
3397    key: &str,
3398    value: &str,
3399    settings: &mut crate::settings::Settings,
3400) -> Option<String> {
3401    let as_bool = || value == "true";
3402    match key {
3403        // Live: hide/show thinking blocks in the transcript immediately.
3404        "hide-thinking" => {
3405            state.set_hide_thinking(as_bool());
3406            settings.hide_thinking_block = Some(as_bool());
3407            Some(format!(
3408                "Thinking blocks {}.",
3409                if as_bool() { "hidden" } else { "shown" }
3410            ))
3411        }
3412        // Live: the image flag is consulted when images would be rendered.
3413        "show-images" => {
3414            if let Ok(mut guard) = state.show_images.lock() {
3415                *guard = as_bool();
3416            }
3417            settings.show_images = Some(as_bool());
3418            Some(format!(
3419                "Inline images {}.",
3420                if as_bool() { "enabled" } else { "disabled" }
3421            ))
3422        }
3423        // Live: read from `state` once per cache-miss notice.
3424        "cache-miss-notices" => {
3425            if let Ok(mut guard) = state.cache_miss_notices.lock() {
3426                *guard = as_bool();
3427            }
3428            settings.show_cache_miss_notices = Some(as_bool());
3429            Some(format!(
3430                "Cache miss notices {}.",
3431                if as_bool() { "enabled" } else { "disabled" }
3432            ))
3433        }
3434        // Live: read from settings on each selection mouse-up.
3435        "fullscreen-copy-on-select" => {
3436            settings.fullscreen_copy_on_select = Some(as_bool());
3437            Some(format!(
3438                "Copy on select {}.",
3439                if as_bool() { "enabled" } else { "disabled" }
3440            ))
3441        }
3442        // Persisted only: resolved once at startup.
3443        "quiet-startup" => {
3444            settings.quiet_startup = Some(as_bool());
3445            None
3446        }
3447        "terminal-progress" => {
3448            settings.show_terminal_progress = Some(as_bool());
3449            None
3450        }
3451        "double-escape-action" => {
3452            settings.double_escape_action = Some(value.to_string());
3453            None
3454        }
3455        "editor-padding" => {
3456            settings.editor_padding_x = value.parse().ok();
3457            None
3458        }
3459        "autocomplete-max-items" => {
3460            settings.autocomplete_max_visible = value.parse().ok();
3461            None
3462        }
3463        "http-idle-timeout" => {
3464            settings.http_idle_timeout = Some(match value {
3465                "disabled" => serde_json::Value::String("disabled".to_string()),
3466                "30s" => serde_json::json!(30_000),
3467                "1m" => serde_json::json!(60_000),
3468                _ => serde_json::json!(300_000),
3469            });
3470            None
3471        }
3472        _ => None,
3473    }
3474}
3475
3476/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
3477fn open_settings_theme_selector(
3478    state: &Arc<TuiState>,
3479    editor_container: &Arc<Container>,
3480    editor: &Arc<Editor>,
3481    tui: &Arc<TuiAltScreen>,
3482    chat: &Arc<Container>,
3483    cwd: &std::path::Path,
3484    package_resources: &Arc<crate::packages::PackageResources>,
3485) {
3486    let mut items = vec![
3487        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3488        SelectItem::new("light", "Light").with_description("Light background"),
3489        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3490    ];
3491    if state.themes_enabled {
3492        for path in package_resources.theme_files() {
3493            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3494                items.push(SelectItem::new(name, name).with_description("Package theme"));
3495            }
3496        }
3497    }
3498    let list = Arc::new(SelectList::new(items, 10));
3499
3500    let state_sel = state.clone();
3501    let ec_sel = editor_container.clone();
3502    let editor_sel = editor.clone();
3503    let tui_sel = tui.clone();
3504    let chat_sel = chat.clone();
3505    let cwd_sel = cwd.to_path_buf();
3506    let package_resources_sel = package_resources.clone();
3507    list.on_select(Arc::new(move |item| {
3508        let preset = match item.value.as_str() {
3509            "light" => Some(ThemePreset::Light),
3510            "monochrome" => Some(ThemePreset::Monochrome),
3511            "dark" => Some(ThemePreset::Dark),
3512            name => {
3513                if state_sel.themes_enabled {
3514                    if let Ok(custom) = crate::packages::load_theme_with_resources(
3515                        &cwd_sel,
3516                        name,
3517                        &package_resources_sel,
3518                    ) {
3519                        rpi_tui::global_theme_manager().set(custom.clone());
3520                        state_sel.theme_manager.set(custom);
3521                    }
3522                }
3523                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3524                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3525                tui_sel.render_now(true);
3526                return;
3527            }
3528        };
3529        let Some(preset) = preset else { return };
3530        apply_theme_preset(preset);
3531        state_sel.theme_manager.apply_preset(preset);
3532        let mut settings = crate::settings::load_settings().unwrap_or_default();
3533        settings.theme = Some(item.value.clone());
3534        let saved = crate::settings::save_settings(&settings);
3535        add_note_message(
3536            &chat_sel,
3537            &format!(
3538                "Theme set to {} (saved{})",
3539                item.label,
3540                if saved.is_ok() { "" } else { ", not saved" },
3541            ),
3542        );
3543        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3544        tui_sel.render_now(true);
3545    }));
3546    let state_cancel = state.clone();
3547    let ec_cancel = editor_container.clone();
3548    let editor_cancel = editor.clone();
3549    let tui_cancel = tui.clone();
3550    list.on_cancel(Arc::new(move || {
3551        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3552    }));
3553
3554    open_selector(
3555        state,
3556        editor_container,
3557        editor,
3558        tui,
3559        list,
3560        SelectorKind::Settings,
3561    );
3562}
3563
3564/// Choose the default model AND persist it (`/settings` → Default model):
3565/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
3566/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
3567fn open_settings_model_selector(
3568    state: &Arc<TuiState>,
3569    editor_container: &Arc<Container>,
3570    editor: &Arc<Editor>,
3571    tui: &Arc<TuiAltScreen>,
3572    lane: &Arc<dyn AgentLane>,
3573    catalog: &[rpi_ai::Model],
3574    lane_model_id: &str,
3575    chat: &Arc<Container>,
3576) {
3577    let items = model_selector_items(catalog, lane_model_id);
3578    if items.is_empty() {
3579        add_note_message(chat, "No models in the catalog.");
3580        tui.request_render(false);
3581        return;
3582    }
3583    let list = Arc::new(SelectList::new(items, 10));
3584
3585    let catalog_arc = catalog.to_vec();
3586    let state_sel = state.clone();
3587    let ec_sel = editor_container.clone();
3588    let editor_sel = editor.clone();
3589    let tui_sel = tui.clone();
3590    let chat_sel = chat.clone();
3591    let lane_sel = lane.clone();
3592    list.on_select(Arc::new(move |item| {
3593        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3594            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3595            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3596            return;
3597        };
3598        state_sel.set_current_model(&model);
3599        let lane = lane_sel.clone();
3600        tokio::spawn(async move {
3601            let _ = lane.set_model(model).await;
3602        });
3603        let mut settings = crate::settings::load_settings().unwrap_or_default();
3604        settings.default_model = Some(item.value.clone());
3605        let saved = crate::settings::save_settings(&settings);
3606        add_note_message(
3607            &chat_sel,
3608            &format!(
3609                "Default model set to {} (saved{}",
3610                short_model_name(&item.value),
3611                if saved.is_ok() { ")" } else { ", not saved)" },
3612            ),
3613        );
3614        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3615    }));
3616    let state_cancel = state.clone();
3617    let ec_cancel = editor_container.clone();
3618    let editor_cancel = editor.clone();
3619    let tui_cancel = tui.clone();
3620    list.on_cancel(Arc::new(move || {
3621        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3622    }));
3623
3624    open_searchable_selector(
3625        state,
3626        editor_container,
3627        editor,
3628        tui,
3629        Some("Default model"),
3630        Some("Type to filter by name, provider, or id"),
3631        list,
3632        SelectorKind::Settings,
3633    );
3634}
3635
3636/// Convert the authenticated runtime catalog into selector rows. Keep the
3637/// model id as the value so `/model <id>` and the selection callback share one
3638/// lookup path, while making the provider visible for OpenAI-compatible
3639/// gateways where the same model id may exist at multiple endpoints.
3640fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3641    let mut seen = std::collections::HashSet::new();
3642    catalog
3643        .iter()
3644        .filter(|m| {
3645            seen.insert((
3646                m.api.clone(),
3647                m.provider.to_ascii_lowercase(),
3648                m.id.to_ascii_lowercase(),
3649            ))
3650        })
3651        .map(|m| {
3652            let label = if m.name.is_empty() {
3653                short_model_name(&m.id)
3654            } else {
3655                m.name.clone()
3656            };
3657            let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3658                && m.provider.eq_ignore_ascii_case("anthropic")
3659            {
3660                m.id.clone()
3661            } else {
3662                format!("{}/{}", m.provider, m.id)
3663            };
3664            let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3665                " (current)"
3666            } else {
3667                ""
3668            };
3669            // Native pi ranks provider-prefixed queries first, so the bare id
3670            // is not the leading token (`getModelSelectorSearchText`).
3671            let name = if m.name.is_empty() {
3672                String::new()
3673            } else {
3674                format!(" {}", m.name)
3675            };
3676            let search_text = format!(
3677                "{} {}/{} {} {}{}",
3678                m.provider, m.provider, m.id, m.provider, m.id, name
3679            );
3680            SelectItem::new(&m.id, &label)
3681                .with_description(&format!("{identity}{marker}"))
3682                .with_search_text(&search_text)
3683        })
3684        .collect()
3685}
3686
3687/// Resolve a selector input by either bare model id or the qualified
3688/// `provider/model` identity shown for gateway models. This keeps manual
3689/// `/model ...` input consistent with the rows rendered by the selector.
3690fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3691    let (provider, id) = input
3692        .split_once('/')
3693        .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3694        .map_or((None, input), |(provider, id)| (Some(provider), id));
3695    catalog
3696        .iter()
3697        .find(|model| {
3698            model.id.eq_ignore_ascii_case(id)
3699                && provider.map_or(true, |provider| {
3700                    model.provider.eq_ignore_ascii_case(provider)
3701                        || (provider.eq_ignore_ascii_case("anthropic")
3702                            && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3703                })
3704        })
3705        .cloned()
3706}
3707
3708/// Choose the default thinking level AND persist it (`/settings` → Default
3709/// thinking): applies live via `lane.set_thinking_level` and saves
3710/// `defaultThinkingLevel` to settings.json.
3711fn open_settings_thinking_selector(
3712    state: &Arc<TuiState>,
3713    editor_container: &Arc<Container>,
3714    editor: &Arc<Editor>,
3715    tui: &Arc<TuiAltScreen>,
3716    lane: &Arc<dyn AgentLane>,
3717    catalog: &[rpi_ai::Model],
3718    lane_model_id: &str,
3719    chat: &Arc<Container>,
3720) {
3721    let model = catalog
3722        .iter()
3723        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3724    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3725        .map(|m| m.supported_thinking_levels())
3726        .unwrap_or_else(|| {
3727            use rpi_ai::types::ThinkingLevel::*;
3728            vec![Off, Minimal, Low, Medium, High]
3729        });
3730    let mut items: Vec<SelectItem> = Vec::new();
3731    for lvl in &levels {
3732        let name = thinking_level_name(*lvl);
3733        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3734    }
3735    if items.is_empty() {
3736        add_note_message(chat, "This model has no supported thinking levels.");
3737        tui.request_render(false);
3738        return;
3739    }
3740    let list = Arc::new(SelectList::new(items, 10));
3741
3742    let state_sel = state.clone();
3743    let ec_sel = editor_container.clone();
3744    let editor_sel = editor.clone();
3745    let tui_sel = tui.clone();
3746    let chat_sel = chat.clone();
3747    let lane_sel = lane.clone();
3748    list.on_select(Arc::new(move |item| {
3749        let Some(level) = thinking_level_from_name(&item.value) else {
3750            add_note_message(
3751                &chat_sel,
3752                &format!("Unknown thinking level: {}.", item.label),
3753            );
3754            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3755            return;
3756        };
3757        let lane = lane_sel.clone();
3758        let footer_sel = state_sel.footer.clone();
3759        tokio::spawn(async move {
3760            let _ = lane.set_thinking_level(level).await;
3761        });
3762        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3763        let mut settings = crate::settings::load_settings().unwrap_or_default();
3764        settings.default_thinking_level = Some(item.value.clone());
3765        let saved = crate::settings::save_settings(&settings);
3766        add_note_message(
3767            &chat_sel,
3768            &format!(
3769                "Default thinking set to {} (saved{}",
3770                item.label,
3771                if saved.is_ok() { ")" } else { ", not saved)" },
3772            ),
3773        );
3774        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3775    }));
3776    let state_cancel = state.clone();
3777    let ec_cancel = editor_container.clone();
3778    let editor_cancel = editor.clone();
3779    let tui_cancel = tui.clone();
3780    list.on_cancel(Arc::new(move || {
3781        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3782    }));
3783
3784    open_selector(
3785        state,
3786        editor_container,
3787        editor,
3788        tui,
3789        list,
3790        SelectorKind::Settings,
3791    );
3792}
3793
3794/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
3795/// item toggles it in the in-progress set (the selector stays open); Esc saves
3796/// the set to settings.json and closes. The active scoped set is echoed after
3797/// each toggle so the user sees the current selection.
3798fn open_scoped_models_selector(
3799    state: &Arc<TuiState>,
3800    editor_container: &Arc<Container>,
3801    editor: &Arc<Editor>,
3802    tui: &Arc<TuiAltScreen>,
3803    catalog: &[rpi_ai::Model],
3804    chat: &Arc<Container>,
3805) {
3806    if catalog.is_empty() {
3807        add_note_message(chat, "No models in the catalog.");
3808        tui.request_render(false);
3809        return;
3810    }
3811    // Seed the edit set from the saved scoped models.
3812    let seed: Vec<String> = crate::settings::load_settings()
3813        .ok()
3814        .and_then(|s| s.scoped_models)
3815        .unwrap_or_default();
3816    *state.scoped_edit.lock().unwrap() = Some(seed);
3817
3818    let mut items: Vec<SelectItem> = Vec::new();
3819    for m in catalog {
3820        items.push(SelectItem::new(&m.id, &m.id));
3821    }
3822    let list = Arc::new(SelectList::new(items, 10));
3823
3824    let state_sel = state.clone();
3825    let chat_sel = chat.clone();
3826    let tui_sel = tui.clone();
3827    list.on_select(Arc::new(move |item| {
3828        // Toggle the model in the in-progress set; the selector stays open.
3829        let mut set = state_sel.scoped_edit.lock().unwrap();
3830        let set = set.get_or_insert_with(Vec::new);
3831        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3832            set.remove(pos);
3833            add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3834        } else {
3835            set.push(item.value.clone());
3836            add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3837        }
3838        tui_sel.request_render(false);
3839    }));
3840    let state_cancel = state.clone();
3841    let ec_cancel = editor_container.clone();
3842    let editor_cancel = editor.clone();
3843    let tui_cancel = tui.clone();
3844    let chat_cancel = chat.clone();
3845    list.on_cancel(Arc::new(move || {
3846        // Save the edited set to settings.json and close.
3847        let set = state_cancel
3848            .scoped_edit
3849            .lock()
3850            .unwrap()
3851            .take()
3852            .unwrap_or_default();
3853        let mut settings = crate::settings::load_settings().unwrap_or_default();
3854        settings.scoped_models = if set.is_empty() {
3855            None
3856        } else {
3857            Some(set.clone())
3858        };
3859        match crate::settings::save_settings(&settings) {
3860            Ok(()) => {
3861                if set.is_empty() {
3862                    add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3863                } else {
3864                    add_note_message(
3865                        &chat_cancel,
3866                        &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3867                    );
3868                }
3869            }
3870            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3871        }
3872        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3873    }));
3874
3875    open_selector(
3876        state,
3877        editor_container,
3878        editor,
3879        tui,
3880        list,
3881        SelectorKind::ScopedModels,
3882    );
3883}
3884
3885/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
3886/// PATH, create a gist of the exported markdown; otherwise fall back to the
3887/// clipboard (best-effort) and note the local path.
3888async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3889    use std::process::Stdio;
3890
3891    // Reuse the export builder for the transcript text.
3892    let tree = harness.session().view("main");
3893    let entries = match tree
3894        .find_entries(&EntryQuery {
3895            entry_type: None,
3896            custom_type: None,
3897            // Exports append entries top-to-bottom, so use chronological order
3898            // instead of the session query default (newest-first).
3899            order: Some(EntryOrder::OldestFirst),
3900            limit: None,
3901            cursor: None,
3902        })
3903        .await
3904    {
3905        Ok(e) => e,
3906        Err(e) => {
3907            add_error_message(chat, &format!("Could not read session: {e}"));
3908            return;
3909        }
3910    };
3911    let mut md = String::from("# Session\n\n");
3912    for e in entries {
3913        let Entry::Message(me) = e else { continue };
3914        match &me.message {
3915            AgentMessage::User(u) => {
3916                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3917            }
3918            AgentMessage::Assistant(a) => {
3919                let text = assistant_text(a);
3920                if !text.is_empty() {
3921                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3922                }
3923            }
3924            _ => {}
3925        }
3926    }
3927
3928    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
3929    let gh = std::process::Command::new("gh")
3930        .arg("gist")
3931        .arg("create")
3932        .arg("--filename")
3933        .arg("session.md")
3934        .arg("-")
3935        .stdin(Stdio::piped())
3936        .stdout(Stdio::piped())
3937        .stderr(Stdio::null())
3938        .spawn();
3939    if let Ok(mut child) = gh {
3940        use std::io::Write;
3941        if let Some(mut stdin) = child.stdin.take() {
3942            let _ = stdin.write_all(md.as_bytes());
3943            let _ = stdin.flush();
3944        }
3945        let out = child.wait_with_output().ok();
3946        if let Some(out) = out {
3947            if out.status.success() {
3948                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3949                add_note_message(chat, &format!("Shared session: {url}"));
3950                return;
3951            }
3952        }
3953        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3954    } else {
3955        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3956    }
3957    // Clipboard fallback (or transcript echo when the clipboard feature is off).
3958    if copy_to_clipboard(&md) {
3959        add_note_message(chat, "Session transcript copied to the clipboard.");
3960    } else {
3961        add_note_message(
3962            chat,
3963            "Clipboard unavailable — use /export to write the transcript to a file.",
3964        );
3965    }
3966}
3967
3968// Session export is driven through the product-layer `AgentSession`
3969// (`crate::agent_session`), which owns format selection + default filenames —
3970// the TUI loop calls `AgentSession::export*` directly, so there is no separate
3971// helper here.
3972
3973/// Fork the current session into a new JSONL session and switch to it (TS
3974/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
3975/// session the user continues in). Uses the repo's `fork_typed`, then swaps
3976/// the harness backing and renders the (empty-ish) fork transcript.
3977/// Hot-switch the harness to another saved session: abort any in-flight run,
3978/// open the target session file, swap the durable backing, and re-render the
3979/// transcript from the new history (mirrors pi's `/session` resume-in-place).
3980/// Shared by the `/session` selector, `/import`, and `/fork`. The current
3981/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
3982async fn switch_to_session(
3983    harness: &AgentHarness,
3984    lane: &Arc<dyn AgentLane>,
3985    id: &str,
3986    cwd: &std::path::Path,
3987    chat: &Arc<Container>,
3988    state: &Arc<TuiState>,
3989) -> bool {
3990    if *state.status.lock().unwrap() == RunStatus::Working {
3991        state.set_status(RunStatus::Aborting);
3992        let _ = lane.abort().await;
3993    }
3994    let cwd_str = cwd.to_string_lossy().to_string();
3995    match crate::session::open_session_by_id(id, &cwd_str).await {
3996        Ok(new_session) => {
3997            let _ = harness.set_session(new_session).await;
3998            chat.clear();
3999            add_welcome_message(chat);
4000            render_session_history(
4001                harness,
4002                chat,
4003                state.markdown_transformer(),
4004                Some(state.extension_session.clone()),
4005            )
4006            .await;
4007            state.set_status(RunStatus::Idle);
4008            add_note_message(chat, &format!("Switched to session {id}."));
4009            true
4010        }
4011        Err(e) => {
4012            state.set_status(RunStatus::Idle);
4013            add_error_message(chat, &format!("Could not open session {id}: {e}"));
4014            false
4015        }
4016    }
4017}
4018
4019/// `/import <path>`: copy a JSONL session file into the default session dir,
4020/// then hot-switch to it (the file name becomes its id — matching the
4021/// selector/`open_session_by_id` containment rules).
4022async fn import_session(
4023    harness: &AgentHarness,
4024    lane: &Arc<dyn AgentLane>,
4025    path: &str,
4026    cwd: &std::path::Path,
4027    chat: &Arc<Container>,
4028    state: &Arc<TuiState>,
4029) {
4030    use std::path::Path as FsPath;
4031
4032    let src = FsPath::new(path);
4033    if !src.is_file() {
4034        add_error_message(chat, &format!("Import source not found: {path}"));
4035        return;
4036    }
4037    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
4038        add_error_message(chat, "Import source has no file name.");
4039        return;
4040    };
4041    if !fname.ends_with(".jsonl") {
4042        add_error_message(chat, "Import source must be a .jsonl session file.");
4043        return;
4044    }
4045    let dir = crate::session::default_session_dir(cwd);
4046    if let Err(e) = std::fs::create_dir_all(&dir) {
4047        add_error_message(chat, &format!("Could not create session dir: {e}"));
4048        return;
4049    }
4050    let dest = dir.join(fname);
4051    match std::fs::copy(src, &dest) {
4052        Ok(_) => {
4053            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
4054            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
4055                add_note_message(chat, &format!("Imported session from {path}"));
4056            }
4057        }
4058        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
4059    }
4060}
4061
4062async fn fork_session(
4063    harness: &AgentHarness,
4064    cwd: &std::path::Path,
4065    chat: &Arc<Container>,
4066    state: &Arc<TuiState>,
4067) {
4068    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
4069    use rpi_tools::FileSystem;
4070
4071    let cwd_str = cwd.to_string_lossy().to_string();
4072    let dir = crate::session::default_session_dir(cwd);
4073    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
4074    let fs: Arc<dyn FileSystem> = env.clone();
4075    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
4076        fs,
4077        sessions_root: dir.to_string_lossy().into_owned(),
4078        clock: Arc::new(rpi_harness::session::memory::SystemClock),
4079        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
4080    });
4081    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
4082    // it from the session list by the current session's id.
4083    let id = harness.session().storage().metadata().id.clone();
4084    let metas = match crate::session::list_session_metadata(&cwd_str).await {
4085        Ok(m) => m,
4086        Err(e) => {
4087            add_error_message(chat, &format!("Could not list sessions: {e}"));
4088            return;
4089        }
4090    };
4091    let Some(source) = metas.iter().find(|m| m.id == id) else {
4092        add_error_message(chat, &format!("Current session {id} not found on disk."));
4093        return;
4094    };
4095    let fork_storage = match repo
4096        .fork_typed(
4097            source,
4098            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
4099                id: None,
4100                parent_session_id: Some(source.id.clone()),
4101                cwd: cwd_str.clone(),
4102                metadata: None,
4103            },
4104            &rpi_harness::session::types::ForkOptions::default(),
4105        )
4106        .await
4107    {
4108        Ok(s) => s,
4109        Err(e) => {
4110            add_error_message(chat, &format!("Could not fork session: {e}"));
4111            return;
4112        }
4113    };
4114    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
4115    let _ = harness.set_session(new_session).await;
4116    chat.clear();
4117    add_welcome_message(chat);
4118    render_session_history(
4119        harness,
4120        chat,
4121        state.markdown_transformer(),
4122        Some(state.extension_session.clone()),
4123    )
4124    .await;
4125    state.set_status(RunStatus::Idle);
4126    add_note_message(chat, "Forked into a new session.");
4127}
4128
4129/// Render the restored session's prior transcript (user + assistant messages)
4130/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
4131/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
4132/// any session read failure just starts with an empty transcript.
4133///
4134/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
4135/// the identity path. Each restored assistant component installs it so replayed
4136/// history renders through the same `register_markdown_transformer` handlers
4137/// the live stream does.
4138async fn render_session_history(
4139    harness: &AgentHarness,
4140    chat: &Arc<Container>,
4141    transformer: Option<MarkdownTransformer>,
4142    extension_session: Option<crate::session::ExtensionSessionCell>,
4143) {
4144    let tree = harness.session().view("main");
4145    let entries = match tree
4146        .find_entries(&EntryQuery {
4147            entry_type: None,
4148            custom_type: None,
4149            // Session queries default to newest-first for selectors and
4150            // pagination. The transcript appends children top-to-bottom, so
4151            // restored history must explicitly be chronological.
4152            order: Some(EntryOrder::OldestFirst),
4153            limit: None,
4154            cursor: None,
4155        })
4156        .await
4157    {
4158        Ok(e) => e,
4159        Err(_) => return,
4160    };
4161    let mut rendered_any = false;
4162    for e in entries {
4163        match e {
4164            Entry::Message(me) => match &me.message {
4165                AgentMessage::User(u) => {
4166                    add_user_message(chat, &user_message_text(u));
4167                    rendered_any = true;
4168                }
4169                AgentMessage::Assistant(a) => {
4170                    let comp = Arc::new(AssistantMessageComponent::new(
4171                        AssistantMessageOptions::default(),
4172                    ));
4173                    if let Some(t) = &transformer {
4174                        comp.set_markdown_transformer(Some(t.clone()));
4175                    }
4176                    comp.update_blocks(&assistant_blocks(a));
4177                    chat.add_child(comp);
4178                    // Single trailing spacer: the next transcript entry (user or
4179                    // assistant) follows one blank line below.
4180                    chat.add_child(Arc::new(Spacer::new(1)));
4181                    if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
4182                        add_note_message(chat, &text);
4183                    }
4184                    if let Some(error) = assistant_error_text(a) {
4185                        add_error_message(chat, &error);
4186                    }
4187                    rendered_any = true;
4188                }
4189                AgentMessage::ToolResult(result) => {
4190                    // Tool results are persisted as separate message entries,
4191                    // not as part of the assistant text. Restore them as
4192                    // completed tool panels so resumed sessions show the
4193                    // command output as well as the user's prompts.
4194                    let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
4195                    comp.set_result(&tool_result_message_text(result), result.is_error);
4196                    if tool_result_requests_markdown(result.details.as_ref()) {
4197                        comp.set_result_markdown(true);
4198                    }
4199                    chat.add_child(comp);
4200                    chat.add_child(Arc::new(Spacer::new(1)));
4201                    rendered_any = true;
4202                }
4203                AgentMessage::Custom(custom) => {
4204                    if let Some(session) = &extension_session {
4205                        if let Some(component) = extension_message_component(
4206                            session,
4207                            &custom.role,
4208                            &serde_json::json!({
4209                                "customType": custom.role,
4210                                "content": custom.content,
4211                                "details": custom.data,
4212                            }),
4213                            transformer.clone(),
4214                        ) {
4215                            chat.add_child(component);
4216                            chat.add_child(Arc::new(Spacer::new(1)));
4217                            rendered_any = true;
4218                            continue;
4219                        }
4220                    }
4221                    add_note_message(chat, &custom_message_fallback(&custom));
4222                    rendered_any = true;
4223                }
4224            },
4225            Entry::Compaction(compaction) => {
4226                add_note_message(
4227                    chat,
4228                    &format!(
4229                        "Compacted {} tokens: {}",
4230                        compaction.tokens_before, compaction.summary
4231                    ),
4232                );
4233                rendered_any = true;
4234            }
4235            Entry::BranchSummary(summary) => {
4236                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
4237                rendered_any = true;
4238            }
4239            Entry::Custom(custom) => {
4240                let rendered = extension_session.as_ref().and_then(|session| {
4241                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
4242                });
4243                if let Some(component) = rendered {
4244                    chat.add_child(component);
4245                    chat.add_child(Arc::new(Spacer::new(1)));
4246                    rendered_any = true;
4247                } else if let Some(text) =
4248                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
4249                {
4250                    add_note_message(chat, &text);
4251                    rendered_any = true;
4252                }
4253            }
4254            Entry::ModelChange(change) => {
4255                add_note_message(
4256                    chat,
4257                    &format!("Model changed to {}:{}", change.provider, change.model_id),
4258                );
4259                rendered_any = true;
4260            }
4261            Entry::ThinkingLevel(change) => {
4262                add_note_message(
4263                    chat,
4264                    &format!("Thinking level: {:?}", change.thinking_level),
4265                );
4266                rendered_any = true;
4267            }
4268            Entry::ActiveTools(change) => {
4269                add_note_message(
4270                    chat,
4271                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
4272                );
4273                rendered_any = true;
4274            }
4275        }
4276    }
4277    if rendered_any {
4278        // No trailing spacer here — each entry already adds its own trailing
4279        // Spacer(1), so an extra would double the bottom gap.
4280    }
4281}
4282
4283fn invoke_extension_renderer(
4284    session: &crate::session::ExtensionSessionCell,
4285    kind: rpi_extensions::RegisteredRendererKind,
4286    payload: &serde_json::Value,
4287) -> Option<serde_json::Value> {
4288    let snapshot = session.lock().ok()?.snapshot_arc()?;
4289    let input = serde_json::to_string(payload).ok()?;
4290    for renderer in snapshot.renderers_of(kind) {
4291        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4292            let mut out = rpi_plugin_sdk::StbString::empty();
4293            let rc = (renderer.render_fn)(
4294                rpi_plugin_sdk::StbStringRef::from_str(&input),
4295                &mut out as *mut rpi_plugin_sdk::StbString,
4296                renderer.user_data,
4297            );
4298            let text = if rc == 0 {
4299                Some(out.to_string_lossy())
4300            } else {
4301                None
4302            };
4303            out.free_with(Some(renderer.plugin_free_string));
4304            text
4305        }))
4306        .ok()
4307        .flatten();
4308        let Some(text) = outcome else { continue };
4309        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
4310            return Some(value);
4311        }
4312    }
4313    None
4314}
4315
4316fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
4317    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
4318        let text = lines
4319            .iter()
4320            .filter_map(|line| line.as_str())
4321            .collect::<Vec<_>>()
4322            .join("\n");
4323        return Some(Arc::new(Text::new(text, 0, 0)));
4324    }
4325    let text = value.get("text").and_then(|v| v.as_str())?;
4326    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4327        let component = Arc::new(AssistantMessageComponent::new(
4328            AssistantMessageOptions::default(),
4329        ));
4330        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4331        Some(component)
4332    } else {
4333        Some(Arc::new(Text::new(text, 0, 0)))
4334    }
4335}
4336
4337fn extension_message_component(
4338    session: &crate::session::ExtensionSessionCell,
4339    custom_type: &str,
4340    payload: &serde_json::Value,
4341    transformer: Option<MarkdownTransformer>,
4342) -> Option<Arc<dyn rpi_tui::Component>> {
4343    let value = invoke_extension_renderer(
4344        session,
4345        rpi_extensions::RegisteredRendererKind::Message,
4346        payload,
4347    )?;
4348    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4349        let text = value.get("text").and_then(|v| v.as_str())?;
4350        let component = Arc::new(AssistantMessageComponent::new(
4351            AssistantMessageOptions::default(),
4352        ));
4353        if let Some(transformer) = transformer {
4354            component.set_markdown_transformer(Some(transformer));
4355        }
4356        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4357        return Some(component);
4358    }
4359    extension_text_component(&value)
4360        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4361}
4362
4363/// Render usage from a completed assistant message through the registered
4364/// message renderers. Hosts without a token-usage renderer return `None`.
4365fn extension_usage_text(
4366    session: Option<&crate::session::ExtensionSessionCell>,
4367    usage: &rpi_ai::types::Usage,
4368) -> Option<String> {
4369    let session = session?;
4370    let payload = serde_json::json!({
4371        "customType": "token-usage",
4372        "usage": usage,
4373    });
4374    let value = invoke_extension_renderer(
4375        session,
4376        rpi_extensions::RegisteredRendererKind::Message,
4377        &payload,
4378    )?;
4379    value
4380        .get("text")
4381        .and_then(|value| value.as_str())
4382        .filter(|text| !text.trim().is_empty())
4383        .map(ToOwned::to_owned)
4384}
4385
4386fn extension_entry_component(
4387    session: &crate::session::ExtensionSessionCell,
4388    custom_type: &str,
4389    data: Option<serde_json::Value>,
4390) -> Option<Arc<dyn rpi_tui::Component>> {
4391    let payload = serde_json::json!({
4392        "customType": custom_type,
4393        "data": data,
4394    });
4395    let value = invoke_extension_renderer(
4396        session,
4397        rpi_extensions::RegisteredRendererKind::Entry,
4398        &payload,
4399    )?;
4400    extension_text_component(&value)
4401}
4402
4403/// Project an assistant message's content into the provider-free
4404/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
4405/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
4406/// are rendered by their own components in the transcript.
4407/// Whether startup intentionally opened a session that already has history.
4408fn launch_restores_history(args: &Args) -> bool {
4409    args.continue_session
4410        || args.resume
4411        || args.session.is_some()
4412        || args.session_id.is_some()
4413        || args.fork.is_some()
4414}
4415
4416fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4417    msg.content
4418        .iter()
4419        .filter_map(|c| match c {
4420            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4421            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4422            Content::Image(image) => base64::engine::general_purpose::STANDARD
4423                .decode(&image.data)
4424                .ok()
4425                .filter(|data| !data.is_empty())
4426                .map(AssistantBlock::Image),
4427            _ => None,
4428        })
4429        .collect()
4430}
4431
4432fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4433    let content = custom
4434        .content
4435        .iter()
4436        .filter_map(|item| match item {
4437            Content::Text(text) => Some(text.text.as_str()),
4438            _ => None,
4439        })
4440        .collect::<Vec<_>>()
4441        .join("\n");
4442    if content.is_empty() {
4443        format!("{}: {}", custom.role, custom.data)
4444    } else {
4445        format!("{}: {}", custom.role, content)
4446    }
4447}
4448
4449/// Path to the enclosing repository's `.git/HEAD`, if any.
4450fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
4451    let mut dir = Some(cwd);
4452    while let Some(current) = dir {
4453        let head = current.join(".git").join("HEAD");
4454        if head.exists() {
4455            return Some(head);
4456        }
4457        dir = current.parent();
4458    }
4459    None
4460}
4461
4462/// Current git branch for `cwd`, or `None` outside a repository / on error.
4463/// Reads `.git/HEAD` directly (no `git` subprocess) so the footer can call it
4464/// cheaply at startup.
4465fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
4466    let mut dir = Some(cwd);
4467    while let Some(current) = dir {
4468        let head = current.join(".git").join("HEAD");
4469        if let Ok(contents) = std::fs::read_to_string(&head) {
4470            let trimmed = contents.trim();
4471            if let Some(reference) = trimmed.strip_prefix("ref: ") {
4472                let branch = reference
4473                    .rsplit('/')
4474                    .next()
4475                    .unwrap_or(reference)
4476                    .trim()
4477                    .to_string();
4478                if !branch.is_empty() {
4479                    return Some(branch);
4480                }
4481            }
4482            if !trimmed.is_empty() {
4483                return Some(trimmed.chars().take(7).collect());
4484            }
4485        }
4486        dir = current.parent();
4487    }
4488    None
4489}
4490
4491/// The name displayed for a model id (last path segment / after the final
4492/// `:`), to keep the footer compact.
4493fn short_model_name(id: &str) -> String {
4494    id.rsplit([':', '/'])
4495        .next()
4496        .filter(|s| !s.is_empty())
4497        .unwrap_or(id)
4498        .to_string()
4499}
4500
4501// ===========================================================================
4502// Streaming run status
4503// ===========================================================================
4504
4505/// The live status of the agent run, fed to the footer + status slot.
4506#[derive(Clone, Copy, PartialEq, Eq)]
4507enum RunStatus {
4508    Idle,
4509    Working,
4510    Aborting,
4511}
4512
4513/// Which selector overlay (if any) is currently swapped into the editor slot.
4514#[derive(Clone, Copy, PartialEq, Eq)]
4515enum SelectorKind {
4516    /// `/model` — available models (live switch via `lane.set_model`).
4517    Model,
4518    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
4519    Thinking,
4520    /// `/tools` — toggle builtin tools on/off.
4521    Tools,
4522    /// `/images` — toggle inline image rendering.
4523    Images,
4524    /// `/session` — browse and switch saved JSONL sessions.
4525    Session,
4526    /// `/theme` — dark / light / monochrome presets applied live.
4527    Theme,
4528    /// `/scoped-models` — multi-toggle Ctrl+M cycle scope.
4529    ScopedModels,
4530    /// `/settings` — interactive settings menu (and its sub-selectors).
4531    Settings,
4532    /// `/tree` — navigate to an existing entry in the current session.
4533    Tree,
4534    /// Extension-provided selector; uses the same keyboard contract.
4535    Extension,
4536}
4537
4538/// The component receiving keys while a selector is open.
4539///
4540/// Plain selectors route straight to the [`SelectList`]; searchable ones route
4541/// through [`SearchableSelectList`] so typing filters instead of being ignored.
4542/// Both variants expose the underlying list for callback registration and
4543/// selection inspection.
4544#[derive(Clone)]
4545enum SelectorView {
4546    /// A bare `SelectList` (no search box).
4547    List(Arc<SelectList>),
4548    /// A `SelectList` wrapped with a fuzzy-filter input.
4549    Searchable(Arc<SearchableSelectList>),
4550    /// The `/settings` menu: value rows cycle in place, submenu rows open a
4551    /// nested selector.
4552    Settings(Arc<SettingsList>),
4553}
4554
4555impl SelectorView {
4556    /// Route a key to the appropriate child.
4557    fn handle_key(&self, key: KeyEvent) {
4558        match self {
4559            Self::List(list) => list.handle_key(key),
4560            Self::Searchable(searchable) => searchable.handle_key(key),
4561            Self::Settings(settings) => settings.handle_key(key),
4562        }
4563    }
4564}
4565
4566/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
4567/// and the render-tick task.
4568struct TuiState {
4569    /// The in-flight streaming assistant message (cleared on finalize).
4570    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4571    /// Tool-execution components keyed by `tool_call_id`.
4572    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4573    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
4574    /// generic tool map so bash output streams into a `BashExecutionComponent`
4575    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
4576    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4577    /// Whether package/custom themes may be selected in this session.
4578    themes_enabled: bool,
4579    /// Persisted display preference toggled by Ctrl+T.
4580    hide_thinking: std::sync::Mutex<bool>,
4581    /// Global tool-output expansion preference toggled by Ctrl+O.
4582    tool_outputs_expanded: std::sync::Mutex<bool>,
4583    /// Whether the native-style terminal progress indicator is enabled.
4584    show_terminal_progress: bool,
4585    /// Run status for the status indicator + interrupt routing.
4586    status: std::sync::Mutex<RunStatus>,
4587    /// Extension status registry (`SetStatus` runtime action). The render tick
4588    /// repaints the footer only when `ext_status_revision` moved.
4589    ext_status: rpi_extensions::ExtensionStatusMailbox,
4590    /// Revision of `ext_status` as of the last footer write.
4591    ext_status_revision: std::sync::atomic::AtomicU64,
4592    /// Cancellation signal for the short phase that starts the persistent JS
4593    /// host and runs `before_agent_start`. The key thread can trigger this
4594    /// directly while the async message loop is awaiting the blocking worker.
4595    js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4596    /// Cancellation signal for a user-initiated `!command` / `!!command` shell
4597    /// run. `Some` while the command executes; Esc/Ctrl+C cancels it. `None`
4598    /// when no user bash is running.
4599    user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
4600    /// `bashExecution` messages produced by `!command` while an agent run was
4601    /// in flight. Flushed to the lane on `AgentEnd` so transcript order matches
4602    /// native pi's `_pendingBashMessages` (never spliced mid-turn).
4603    pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
4604    /// The footer, updated live by the drain task.
4605    footer: Arc<FooterComponent>,
4606    /// The status-container (status slot in the dock) — cleared/filled with a
4607    /// loader while a run is active.
4608    status_container: Arc<Container>,
4609    /// The chat transcript container.
4610    chat_container: Arc<Container>,
4611    /// The active loader shown while `Working`.
4612    loader: Arc<Loader>,
4613    /// The editor, so status changes can drive its top-border working
4614    /// indicator (the spinner + elapsed time shown inside the input box border).
4615    editor: Arc<Editor>,
4616    /// The last finalized assistant text (for `/copy`). Updated by the drain
4617    /// task on `MessageEnd` / `AgentEnd`.
4618    last_assistant_text: std::sync::Mutex<String>,
4619    /// The active selector overlay, swapped into the editor slot. `Some` while
4620    /// a selector is open; the key loop routes to it first and restores the
4621    /// editor on done/cancel.
4622    active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
4623    /// Extension-provided editor currently occupying the input slot.
4624    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4625    /// Single-line input currently occupying the input slot for an extension.
4626    active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4627    /// Callback used to resolve an extension dialog with a cancellation action.
4628    active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4629    /// The autocomplete manager (slash + @file providers) consulted on every
4630    /// editor keystroke.
4631    autocomplete: AutocompleteManager,
4632    /// The container rendered above the editor holding the live autocomplete
4633    /// suggestion list (cleared when there are no suggestions).
4634    autocomplete_container: Arc<Container>,
4635    /// Maximum number of autocomplete rows rendered above the editor.
4636    autocomplete_max_visible: usize,
4637    /// Images queued from clipboard paste and attached to the next prompt.
4638    pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4639    /// The owned theme manager — `/theme` applies presets here. The global
4640    /// `theme()` is read-only after OnceLock init, so per-instance state is the
4641    /// only way to apply a preset at runtime.
4642    theme_manager: Arc<ThemeManager>,
4643    /// The alt-screen handle, held so `set_status` can reflect run state in the
4644    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
4645    /// that never call `set_status` with a title.
4646    tui: Option<Arc<TuiAltScreen>>,
4647    /// The model id currently shown in the footer + used as the Ctrl+M
4648    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
4649    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
4650    current_model_id: std::sync::Mutex<String>,
4651    /// Whether inline image rendering is enabled (`/images` toggle). Stored
4652    /// even though image wiring is minimal this pass — the flag is consulted
4653    /// where images would be shown and echoed back by `/images`.
4654    show_images: std::sync::Mutex<bool>,
4655    /// Transcript notices for prompt-cache costs and provider recovery
4656    /// diagnostics (native pi `showCacheMissNotices`, default `false`).
4657    cache_miss_notices: std::sync::Mutex<bool>,
4658    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
4659    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
4660    history: std::sync::Mutex<Vec<String>>,
4661    /// Browse index while recalling history: -1 = not browsing, 0 = most
4662    /// recent, 1 = older, … Reset to -1 on every submit.
4663    history_index: std::sync::Mutex<isize>,
4664    /// The editor text captured when entering browse mode, restored when the
4665    /// user navigates back past the newest entry (TS `historyDraft`).
4666    history_draft: std::sync::Mutex<Option<String>>,
4667    /// Full cache-waste tracker (pi `detectCacheMiss`/`cache-stats.ts`):
4668    /// counts and prices prompt-cache misses across turns using the same
4669    /// noise floor, idle-gap, and model-change logic as the batch scan.
4670    cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
4671    /// The in-progress scoped-models selection while the `/scoped-models`
4672    /// selector is open (toggle per item, Esc saves). `None` when not editing.
4673    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4674    /// B5e: the live assistant-markdown transformer, built from the current
4675    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
4676    /// when no markdown transformers are registered (identity render path).
4677    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
4678    /// closure no-ops once its snapshot's `active` flag flips false) and
4679    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
4680    /// transform takes effect on the visible streaming message immediately.
4681    /// New assistant components pick up whatever closure is current at
4682    /// construction time via [`install_markdown_transformer`].
4683    markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4684    /// Live extension registry used by message/entry renderer dispatch.
4685    extension_session: crate::session::ExtensionSessionCell,
4686    /// Transcript search handler (Ctrl+Shift+F).
4687    search: Arc<AltScreenSearch>,
4688    /// Search bar component shown when search is active.
4689    search_bar: Arc<SearchBar>,
4690    /// Dock slot listing queued steering/follow-up messages while a run is
4691    /// active (native pi's `pendingMessagesContainer`). Empty when nothing is
4692    /// queued, so it renders zero rows and never affects the layout.
4693    pending_container: Arc<Container>,
4694    /// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`),
4695    /// shown in the pending-messages hint row.
4696    dequeue_hint: String,
4697    /// Last queue snapshot rendered into `pending_container`. Lets
4698    /// `set_pending_queue` skip redundant rebuilds + frame requests when the
4699    /// polled queue is unchanged.
4700    pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
4701    /// Fullscreen selection tracking for auto-copy.
4702    selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4703    selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4704}
4705
4706/// How many submitted messages are kept for ↑ recall (mirrors the TS
4707/// editor's 100-entry cap).
4708const HISTORY_LIMIT: usize = 100;
4709
4710/// Keep a few rows of overlap so page scrolling preserves visual context,
4711/// matching the upstream fullscreen viewport behavior.
4712const PAGE_SCROLL_OVERLAP: usize = 4;
4713
4714/// Native pi scrolls a small chunk for each wheel notch rather than moving the
4715/// transcript one physical row at a time. Three lines stays precise while
4716/// avoiding the sluggish feel of the previous implementation.
4717const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4718
4719/// Parse the compact key notation used by native Pi settings (for example
4720/// `ctrl+g`, `shift+tab`, or `escape`) into crossterm's representation.
4721fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4722    let mut modifiers = KeyModifiers::NONE;
4723    let mut key = None;
4724    for part in value.trim().to_ascii_lowercase().split('+') {
4725        match part {
4726            "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4727            "shift" => modifiers |= KeyModifiers::SHIFT,
4728            "alt" | "option" => modifiers |= KeyModifiers::ALT,
4729            "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4730            part if !part.is_empty() => key = Some(part.to_string()),
4731            _ => {}
4732        }
4733    }
4734    let key = key?;
4735    let code = match key.as_str() {
4736        "esc" | "escape" => KeyCode::Esc,
4737        "enter" | "return" => KeyCode::Enter,
4738        "tab" => {
4739            if modifiers.contains(KeyModifiers::SHIFT) {
4740                return Some(rpi_tui::KeyCombo::new(
4741                    KeyCode::BackTab,
4742                    modifiers & !KeyModifiers::SHIFT,
4743                ));
4744            }
4745            KeyCode::Tab
4746        }
4747        "backspace" | "back" => KeyCode::Backspace,
4748        "delete" | "del" => KeyCode::Delete,
4749        "up" | "arrowup" => KeyCode::Up,
4750        "down" | "arrowdown" => KeyCode::Down,
4751        "left" | "arrowleft" => KeyCode::Left,
4752        "right" | "arrowright" => KeyCode::Right,
4753        "home" => KeyCode::Home,
4754        "end" => KeyCode::End,
4755        "pageup" | "page-up" => KeyCode::PageUp,
4756        "pagedown" | "page-down" => KeyCode::PageDown,
4757        "space" => KeyCode::Char(' '),
4758        "f1" => KeyCode::F(1),
4759        "f2" => KeyCode::F(2),
4760        "f3" => KeyCode::F(3),
4761        "f4" => KeyCode::F(4),
4762        "f5" => KeyCode::F(5),
4763        "f6" => KeyCode::F(6),
4764        "f7" => KeyCode::F(7),
4765        "f8" => KeyCode::F(8),
4766        "f9" => KeyCode::F(9),
4767        "f10" => KeyCode::F(10),
4768        "f11" => KeyCode::F(11),
4769        "f12" => KeyCode::F(12),
4770        value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4771        _ => return None,
4772    };
4773    Some(rpi_tui::KeyCombo::new(code, modifiers))
4774}
4775
4776fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4777    let mut bindings = rpi_tui::Keybindings::new();
4778    let settings = crate::settings::load_settings().unwrap_or_default();
4779    let Some(overrides) = settings.keybindings else {
4780        rpi_tui::set_keybindings(bindings.clone());
4781        return Arc::new(bindings);
4782    };
4783    let known: &[(&str, rpi_tui::KeybindingId)] = &[
4784        ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4785        ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4786        ("app.exit", rpi_tui::keybindings::keys::EXIT),
4787        ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4788        (
4789            "app.model.cycleForward",
4790            rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4791        ),
4792        ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4793        (
4794            "app.thinking.toggle",
4795            rpi_tui::keybindings::keys::THINKING_TOGGLE,
4796        ),
4797        (
4798            "app.editor.external",
4799            rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4800        ),
4801        (
4802            "app.thinking.cycle",
4803            rpi_tui::keybindings::keys::THINKING_CYCLE,
4804        ),
4805        (
4806            "app.clipboard.pasteImage",
4807            rpi_tui::keybindings::keys::PASTE_IMAGE,
4808        ),
4809        ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4810    ];
4811    for (name, id) in known {
4812        let Some(value) = overrides.get(*name) else {
4813            continue;
4814        };
4815        let values: Vec<String> = match value {
4816            serde_json::Value::String(value) => vec![value.clone()],
4817            serde_json::Value::Array(values) => values
4818                .iter()
4819                .filter_map(|v| v.as_str().map(str::to_string))
4820                .collect(),
4821            serde_json::Value::Null => Vec::new(),
4822            _ => continue,
4823        };
4824        let combos: Vec<_> = values
4825            .iter()
4826            .filter_map(|value| parse_configured_key(value))
4827            .collect();
4828        if values.is_empty() || !combos.is_empty() {
4829            bindings.set(id, combos);
4830        }
4831    }
4832    rpi_tui::set_keybindings(bindings.clone());
4833    Arc::new(bindings)
4834}
4835
4836fn keybinding_matches(
4837    bindings: &rpi_tui::Keybindings,
4838    event: &crossterm::event::KeyEvent,
4839    id: rpi_tui::KeybindingId,
4840) -> bool {
4841    if bindings.matches(event, id) {
4842        return true;
4843    }
4844    // crossterm reports Shift+Tab as BackTab on some terminals and as Tab
4845    // plus Shift on others. Treat both forms as the same configured action.
4846    if event.code == KeyCode::BackTab {
4847        let normalized =
4848            crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4849        bindings.matches(&normalized, id)
4850    } else {
4851        false
4852    }
4853}
4854
4855/// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`), used in
4856/// the pending-messages hint row. Mirrors native pi's `getAppKeyDisplay`;
4857/// falls back to native pi's platform default when nothing is configured.
4858fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
4859    let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
4860    if keys.is_empty() {
4861        return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
4862    }
4863    keys.iter()
4864        .map(|combo| combo.display())
4865        .collect::<Vec<_>>()
4866        .join("/")
4867}
4868
4869fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4870    last.is_some_and(|previous| {
4871        now.duration_since(previous) <= std::time::Duration::from_millis(500)
4872    })
4873}
4874
4875/// How long to wait for queued console input before treating a bare Enter as a
4876/// real submit. Pasted input is already in the queue, so this only needs to
4877/// cover scheduler latency — small enough that a human Enter feels instant.
4878const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4879
4880/// Maximum gap between a pasted character and the next pasted key. A human
4881/// cannot deliver two keypresses this fast, so anything tighter is paste.
4882const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4883
4884/// Whether a bare Enter is pasted content (insert a newline) rather than a
4885/// submit.
4886///
4887/// `crossterm` 0.27 only implements bracketed paste on Unix; on Windows the
4888/// console event source never produces `Event::Paste`, so a pasted block
4889/// arrives as plain key events with a bare Enter per line. Two timing signals
4890/// separate a pasted Enter from a typed one:
4891///
4892/// - `more_queued`: real key input is already sitting in the console input
4893///   queue. Callers must feed this through [`probe_paste_input`], which drops
4894///   the key's own Release events — Windows queues press+release together over
4895///   RDP, and a bare poll cannot tell that Release apart from paste content.
4896/// - `last_text_key_at`: the Enter lands within [`PASTE_BURST_GAP`] of the
4897///   preceding pasted character, which also catches a paste that *ends* with a
4898///   newline (nothing queued behind it, but it was not typed by hand).
4899///
4900/// NOTE: the caller gates this on `cfg!(windows)`. On Unix, bracketed paste
4901/// delivers real pastes as `Event::Paste`, and the timing heuristic would
4902/// misclassify a remote/mobile client's coalesced text+Enter burst as paste
4903/// ("回车变成了换行").
4904fn enter_is_paste_burst(
4905    last_text_key_at: Option<std::time::Instant>,
4906    now: std::time::Instant,
4907    more_queued: bool,
4908) -> bool {
4909    if more_queued {
4910        return true;
4911    }
4912    last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4913}
4914
4915/// Decide whether more *real* input is queued behind the current key, for the
4916/// Windows paste-burst check.
4917///
4918/// Windows emits a `KeyEventKind::Release` for every press, usually queued
4919/// right behind the `Press`. A bare non-blocking poll therefore reports "more
4920/// input" for a lone Enter — which made a remote/RDP Enter look like a paste
4921/// burst. This drains and discards release events and returns the first
4922/// *meaningful* event it read (stashed so the caller's loop still processes it)
4923/// plus whether any real input was queued. `next_event` returns `None` when
4924/// nothing is available within the probe window.
4925fn probe_paste_input(
4926    pending: Option<Event>,
4927    mut next_event: impl FnMut() -> Option<Event>,
4928) -> (bool, Option<Event>) {
4929    let mut pending = pending;
4930    let mut more_queued = pending.is_some();
4931    while !more_queued {
4932        match next_event() {
4933            Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
4934            Some(ev) => {
4935                pending = Some(ev);
4936                more_queued = true;
4937            }
4938            None => break,
4939        }
4940    }
4941    (more_queued, pending)
4942}
4943
4944/// Milliseconds since the last text key, for the [`enter_is_paste_burst`] trace
4945/// line (`-` when there was no preceding text key).
4946fn gap_ms(last_text_key_at: Option<std::time::Instant>) -> String {
4947    match last_text_key_at {
4948        Some(previous) => std::time::Instant::now()
4949            .duration_since(previous)
4950            .as_millis()
4951            .to_string(),
4952        None => "-".to_string(),
4953    }
4954}
4955
4956fn transcript_page_size(viewport_height: usize) -> i32 {
4957    viewport_height
4958        .saturating_sub(PAGE_SCROLL_OVERLAP)
4959        .max(1)
4960        .min(i32::MAX as usize) as i32
4961}
4962
4963fn should_dispatch_key(kind: KeyEventKind) -> bool {
4964    kind != KeyEventKind::Release
4965}
4966
4967/// Last-resort quit, run on the key worker thread.
4968///
4969/// The async loop owns `run_prompt_streaming(..).await` for the whole run, so a
4970/// queued [`TuiMessage::Exit`] is not read while a run is in flight. When the
4971/// user asks to quit a second time — the first Ctrl+C only reached `Aborting` —
4972/// treat the run as wedged, restore the terminal, and exit.
4973///
4974/// `130` is the conventional `128 + SIGINT` status, so a wrapper script can tell
4975/// a forced quit from a clean one.
4976fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
4977    // Best effort: leave the alternate buffer and restore cooked mode so the
4978    // user's shell is usable afterwards. `stop` also joins the render
4979    // scheduler, so no in-flight frame can repaint over the restored screen.
4980    tui.stop(Default::default());
4981    eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
4982    std::process::exit(130);
4983}
4984
4985/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
4986fn format_tokens(n: i64) -> String {
4987    if n >= 1_000_000 {
4988        format!("{:.1}M", n as f64 / 1_000_000.0)
4989    } else if n >= 1_000 {
4990        format!("{:.1}K", n as f64 / 1_000.0)
4991    } else {
4992        n.to_string()
4993    }
4994}
4995
4996/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
4997/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
4998/// resets the browse state so a fresh prompt never resumes mid-history.
4999fn push_history(state: &Arc<TuiState>, text: &str) {
5000    let trimmed = text.trim().to_string();
5001    if trimmed.is_empty() {
5002        return;
5003    }
5004    let mut history = state.history.lock().unwrap();
5005    if history.first() == Some(&trimmed) {
5006        return;
5007    }
5008    history.insert(0, trimmed);
5009    history.truncate(HISTORY_LIMIT);
5010    *state.history_index.lock().unwrap() = -1;
5011    *state.history_draft.lock().unwrap() = None;
5012}
5013
5014/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
5015/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
5016/// current editor text as the draft; navigating back past the newest entry
5017/// restores that draft.
5018fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
5019    let history = state.history.lock().unwrap();
5020    if history.is_empty() {
5021        return;
5022    }
5023    let mut index = state.history_index.lock().unwrap();
5024    let new_index = *index - direction as isize;
5025    if new_index < -1 || new_index >= history.len() as isize {
5026        return;
5027    }
5028    if *index == -1 && new_index >= 0 {
5029        // Entering browse mode: stash the current input.
5030        *state.history_draft.lock().unwrap() = Some(editor.get_text());
5031    }
5032    *index = new_index;
5033    if new_index == -1 {
5034        // Exited browse mode: restore the draft (or clear if there was none).
5035        let draft = state.history_draft.lock().unwrap().take();
5036        match draft {
5037            Some(d) => {
5038                let len = d.len();
5039                editor.set_text(&d);
5040                editor.set_cursor(0, len);
5041            }
5042            None => editor.set_text(""),
5043        }
5044    } else {
5045        let text = history[new_index as usize].clone();
5046        let len = text.len();
5047        editor.set_text(&text);
5048        editor.set_cursor(0, len);
5049    }
5050}
5051
5052impl TuiState {
5053    fn begin_js_preparation(&self) -> CancellationToken {
5054        let cancellation = CancellationToken::new();
5055        if let Some(previous) = self
5056            .js_preparation_cancel
5057            .lock()
5058            .unwrap()
5059            .replace(cancellation.clone())
5060        {
5061            previous.cancel();
5062        }
5063        cancellation
5064    }
5065
5066    fn finish_js_preparation(&self) {
5067        self.js_preparation_cancel.lock().unwrap().take();
5068    }
5069
5070    fn cancel_js_preparation(&self) -> bool {
5071        let cancellation = self.js_preparation_cancel.lock().unwrap().take();
5072        if let Some(cancellation) = cancellation {
5073            cancellation.cancel();
5074            true
5075        } else {
5076            false
5077        }
5078    }
5079
5080    /// Whether a user-initiated `!command` shell run is currently active.
5081    fn user_bash_running(&self) -> bool {
5082        self.user_bash_cancel.lock().unwrap().is_some()
5083    }
5084
5085    /// Register a cancellation token for a new user bash run, cancelling any
5086    /// previous one (mirrors `begin_js_preparation`).
5087    fn begin_user_bash(&self) -> CancellationToken {
5088        let cancellation = CancellationToken::new();
5089        if let Some(previous) = self
5090            .user_bash_cancel
5091            .lock()
5092            .unwrap()
5093            .replace(cancellation.clone())
5094        {
5095            previous.cancel();
5096        }
5097        cancellation
5098    }
5099
5100    /// Clear the user-bash slot. Called when the command finishes so the
5101    /// running guard and the Esc router both see idle state.
5102    fn finish_user_bash(&self) {
5103        self.user_bash_cancel.lock().unwrap().take();
5104    }
5105
5106    /// Cancel a running user bash command. Returns `true` when one was active
5107    /// (mirrors `cancel_js_preparation`). The spawned task clears the slot
5108    /// itself once `execute_shell_with_capture` returns.
5109    fn cancel_user_bash(&self) -> bool {
5110        let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
5111        if let Some(cancellation) = cancellation {
5112            cancellation.cancel();
5113            true
5114        } else {
5115            false
5116        }
5117    }
5118
5119    fn set_status(&self, status: RunStatus) {
5120        *self.status.lock().unwrap() = status;
5121        self.apply_status(status);
5122    }
5123
5124    /// The dock container listing queued messages (native pi's
5125    /// `pendingMessagesContainer`).
5126    fn pending_container(&self) -> &Arc<Container> {
5127        &self.pending_container
5128    }
5129
5130    /// Render the current queue into the pending dock slot. Mirrors native
5131    /// pi's `updatePendingMessagesDisplay`: `Steering: <text>` /
5132    /// `Follow-up: <text>` rows (dim) plus a `↳ <key> to edit all queued
5133    /// messages` hint. A no-op when the snapshot is unchanged, so the poll
5134    /// caller can invoke it freely without forcing frames.
5135    fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
5136        {
5137            let mut current = self.pending_snapshot.lock().unwrap();
5138            if *current == messages {
5139                return;
5140            }
5141            *current = messages.clone();
5142        }
5143        let container = &self.pending_container;
5144        container.clear();
5145        if messages.is_empty() {
5146            return;
5147        }
5148        let colors = current_theme().colors;
5149        container.add_child(Arc::new(Spacer::new(1)));
5150        for text in &messages.steering {
5151            container.add_child(Arc::new(Text::new(
5152                colors.muted.fg(&format!("Steering: {text}")),
5153                1,
5154                0,
5155            )));
5156        }
5157        for text in &messages.follow_up {
5158            container.add_child(Arc::new(Text::new(
5159                colors.muted.fg(&format!("Follow-up: {text}")),
5160                1,
5161                0,
5162            )));
5163        }
5164        container.add_child(Arc::new(Text::new(
5165            colors.muted.fg(&format!(
5166                "↳ {} to edit all queued messages",
5167                self.dequeue_hint
5168            )),
5169            1,
5170            0,
5171        )));
5172    }
5173
5174    /// Atomically reserve the single interactive run slot. The editor callback
5175    /// runs on a different thread from the async prompt loop, so checking and
5176    /// setting in separate steps would allow rapid Enter presses to queue more
5177    /// than one operation.
5178    fn try_start_working(&self) -> bool {
5179        let mut status = self.status.lock().unwrap();
5180        if *status != RunStatus::Idle {
5181            return false;
5182        }
5183        *status = RunStatus::Working;
5184        drop(status);
5185        self.apply_status(RunStatus::Working);
5186        true
5187    }
5188
5189    fn apply_status(&self, status: RunStatus) {
5190        match status {
5191            RunStatus::Working => {
5192                // The status dock already shows the active loader. Keep the
5193                // footer focused on the model and shortcuts instead of
5194                // repeating a second `Working…` at the bottom of the TUI.
5195                self.footer.set_status("");
5196                // Reflect the in-flight turn in the terminal window/tab title
5197                // (OSC 2). No-op when `tui` is absent (unit tests).
5198                if let Some(tui) = &self.tui {
5199                    tui.set_title("🦀π rpi ⟳");
5200                }
5201                self.status_container.clear();
5202                // The working indicator renders inside the editor's top border.
5203                // `show_terminal_progress` disables it entirely.
5204                if self.show_terminal_progress {
5205                    self.editor.set_working(Some(WorkingState {
5206                        frame: 0,
5207                        started_at: std::time::Instant::now(),
5208                        message: "Working…".to_string(),
5209                    }));
5210                } else {
5211                    self.editor.set_working(None);
5212                }
5213            }
5214            RunStatus::Aborting => {
5215                self.footer.set_status("Aborting…");
5216                // Do not leave a frozen "Working" spinner on screen after the
5217                // render tick intentionally stops advancing in this state.
5218                self.loader.stop();
5219                self.status_container.clear();
5220                self.editor.set_working(None);
5221            }
5222            RunStatus::Idle => {
5223                self.footer.set_status("");
5224                if let Some(tui) = &self.tui {
5225                    tui.set_title("🦀π rpi");
5226                }
5227                self.loader.stop();
5228                self.status_container.clear();
5229                self.editor.set_working(None);
5230            }
5231        }
5232    }
5233
5234    /// Push the extension status registry into the footer when it changed.
5235    /// Returns `true` when the footer was rewritten (the caller then requests a
5236    /// repaint — an idle session would otherwise never redraw).
5237    fn sync_extension_status(&self) -> bool {
5238        let revision = self.ext_status.revision();
5239        if self
5240            .ext_status_revision
5241            .load(std::sync::atomic::Ordering::SeqCst)
5242            == revision
5243        {
5244            return false;
5245        }
5246        self.ext_status_revision
5247            .store(revision, std::sync::atomic::Ordering::SeqCst);
5248        self.footer.set_extension_status(&self.ext_status.text());
5249        true
5250    }
5251
5252    /// The bash panel has its own `Running...` spinner. Keep the global
5253    /// `Working...` loader out of the status slot while any bash tool is active
5254    /// so the same operation is not presented as two simultaneous loaders.
5255    fn sync_working_loader_with_bash(&self) {
5256        if *self.status.lock().unwrap() != RunStatus::Working {
5257            return;
5258        }
5259
5260        // The working indicator now lives inside the editor's top border and
5261        // stays visible for the whole Working state, so no status-slot swap is
5262        // needed when bash panels (which render their own spinner in the
5263        // transcript) appear or disappear.
5264        self.status_container.clear();
5265    }
5266
5267    fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
5268        *self.status.lock().unwrap() = RunStatus::Working;
5269        self.footer.set_status("");
5270        if let Some(tui) = &self.tui {
5271            tui.set_title("🦀π rpi ↻");
5272        }
5273        self.loader.stop();
5274        self.status_container.clear();
5275        self.status_container
5276            .add_child(Arc::new(StatusIndicator::retry(
5277                attempt,
5278                max_retries,
5279                std::time::Duration::from_millis(delay_ms),
5280            )));
5281    }
5282
5283    /// Whether a selector overlay is currently open (routes keys to it first).
5284    fn selector_open(&self) -> bool {
5285        self.active_selector.lock().unwrap().is_some()
5286    }
5287
5288    fn extension_editor_open(&self) -> bool {
5289        self.active_extension_editor.lock().unwrap().is_some()
5290    }
5291
5292    fn extension_input_open(&self) -> bool {
5293        self.active_extension_input.lock().unwrap().is_some()
5294    }
5295
5296    fn extension_dialog_open(&self) -> bool {
5297        self.extension_editor_open() || self.extension_input_open()
5298    }
5299
5300    fn set_hide_thinking(&self, hide: bool) {
5301        *self.hide_thinking.lock().unwrap() = hide;
5302        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5303            comp.set_hide_thinking(hide);
5304        }
5305    }
5306
5307    fn hide_thinking(&self) -> bool {
5308        *self.hide_thinking.lock().unwrap()
5309    }
5310
5311    fn toggle_thinking(&self) -> bool {
5312        let next = !self.hide_thinking();
5313        self.set_hide_thinking(next);
5314        next
5315    }
5316
5317    fn toggle_tool_outputs(&self) -> bool {
5318        let next = !*self.tool_outputs_expanded.lock().unwrap();
5319        *self.tool_outputs_expanded.lock().unwrap() = next;
5320        for comp in self.tool_components.lock().unwrap().values() {
5321            comp.set_expanded(next);
5322        }
5323        for comp in self.bash_components.lock().unwrap().values() {
5324            comp.set_expanded(next);
5325        }
5326        next
5327    }
5328
5329    /// The model id currently tracked as active (footer + Ctrl+M anchor).
5330    fn current_model_id(&self) -> String {
5331        self.current_model_id.lock().unwrap().clone()
5332    }
5333
5334    /// Update the tracked model id + footer label after a switch (live or
5335    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
5336    fn set_current_model(&self, model: &rpi_ai::Model) {
5337        *self.current_model_id.lock().unwrap() = model.id.clone();
5338        self.footer.set_model(&short_model_name(&model.id));
5339        // A model switch changes the context window, so the `%/{window}` badge
5340        // must rescale against the same last-reported token count.
5341        self.footer.set_context_window(model.context_window as i64);
5342    }
5343
5344    /// B5e: read a clone of the current assistant-markdown transformer (if any).
5345    /// New assistant components call this at construction so they render with
5346    /// whatever plugin `register_markdown_transformer` handlers are live.
5347    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
5348        self.markdown_transformer.lock().unwrap().clone()
5349    }
5350
5351    /// B5e: swap the live transformer. Used at startup (install the first
5352    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
5353    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
5354    /// transform should take effect on the VISIBLE streaming message too, so
5355    /// this re-installs on the in-flight `current_assistant` component — its
5356    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
5357    /// `None` clears the transform (identity), e.g. a reload that unregisters
5358    /// every markdown transformer.
5359    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
5360        *self.markdown_transformer.lock().unwrap() = transformer.clone();
5361        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5362            comp.set_markdown_transformer(transformer);
5363        }
5364    }
5365
5366    fn queue_image(&self, image: rpi_ai::types::ImageContent) {
5367        self.pending_images.lock().unwrap().push(image);
5368    }
5369
5370    fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
5371        std::mem::take(&mut *self.pending_images.lock().unwrap())
5372    }
5373}
5374
5375// ===========================================================================
5376// interactive_tui — the entry point
5377// ===========================================================================
5378
5379#[derive(Debug, PartialEq)]
5380struct TuiStartupSettings {
5381    editor_padding_x: usize,
5382    autocomplete_max_visible: usize,
5383    hide_thinking: bool,
5384    quiet_startup: bool,
5385    show_terminal_progress: bool,
5386    /// Render images inline (`terminal.showImages` / flat `showImages`).
5387    show_images: bool,
5388    /// Transcript notices for prompt-cache costs (`showCacheMissNotices`).
5389    cache_miss_notices: bool,
5390}
5391
5392fn preferred_project_setting<T>(
5393    project_settings: &[crate::settings::Settings],
5394    field: impl Fn(&crate::settings::Settings) -> Option<T>,
5395) -> Option<T> {
5396    project_settings.iter().find_map(field)
5397}
5398
5399fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
5400    verbose || !quiet_startup
5401}
5402
5403fn git_only_update_resources(
5404    mut resources: crate::packages::PackageResources,
5405) -> crate::packages::PackageResources {
5406    resources
5407        .packages
5408        .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
5409    resources
5410}
5411
5412/// Resolve TUI-only settings with native Pi's project-over-global precedence.
5413/// `project_settings` must be ordered `.rpi` then `.pi`; an explicit `Some`
5414/// wins even when the value is `false` or zero.
5415fn resolve_tui_startup_settings(
5416    global: &crate::settings::Settings,
5417    project_settings: &[crate::settings::Settings],
5418    project_trusted: bool,
5419) -> TuiStartupSettings {
5420    let project_settings = if project_trusted {
5421        project_settings
5422    } else {
5423        &[]
5424    };
5425    let editor_padding_x =
5426        preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
5427            .or(global.editor_padding_x)
5428            .unwrap_or(1)
5429            .min(16);
5430    let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
5431        settings.autocomplete_max_visible
5432    })
5433    .or(global.autocomplete_max_visible)
5434    .unwrap_or(5)
5435    .clamp(1, 20);
5436    let hide_thinking =
5437        preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
5438            .or(global.hide_thinking_block)
5439            .unwrap_or(false);
5440    let quiet_startup =
5441        preferred_project_setting(project_settings, |settings| settings.quiet_startup)
5442            .or(global.quiet_startup)
5443            .unwrap_or(false);
5444    let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
5445        settings.show_terminal_progress()
5446    })
5447    .or_else(|| global.show_terminal_progress())
5448    .unwrap_or(true);
5449    let show_images =
5450        preferred_project_setting(project_settings, |settings| settings.show_images())
5451            .or_else(|| global.show_images())
5452            .unwrap_or(true);
5453    let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
5454        settings.show_cache_miss_notices
5455    })
5456    .or(global.show_cache_miss_notices)
5457    .unwrap_or(false);
5458
5459    TuiStartupSettings {
5460        editor_padding_x,
5461        autocomplete_max_visible,
5462        hide_thinking,
5463        quiet_startup,
5464        show_terminal_progress,
5465        show_images,
5466        cache_miss_notices,
5467    }
5468}
5469
5470/// TUI-based interactive mode.
5471///
5472/// `event_rx` carries the live `AgentEvent` stream (installed by
5473/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
5474/// fn), it falls back to a blocking, await-final-text path.
5475///
5476/// `model_catalog` is the read-only catalog the `/model` selector displays.
5477///
5478/// This implementation mirrors the TypeScript `InteractiveMode` class:
5479/// build the layout root once, drain `AgentEvent`s into UI mutations that
5480/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
5481/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
5482/// autocomplete are layered on via the editor-container swap pattern.
5483pub async fn interactive_tui(
5484    harness: &AgentHarness,
5485    event_rx: Option<broadcast::Receiver<AgentEvent>>,
5486    args: &Args,
5487    model_catalog: Vec<rpi_ai::Model>,
5488    initial: Option<String>,
5489    extra_messages: &[String],
5490    initial_images: Vec<rpi_ai::types::ImageContent>,
5491    theme: Option<&str>,
5492    no_themes: bool,
5493    reload_context: &crate::session::ReloadContext,
5494) -> i32 {
5495    let lane: Arc<dyn AgentLane> = harness.lane("main");
5496    // Reuse the startup snapshot captured before provider/session setup. An
5497    // extension may mutate the process cwd while loading; that must not change
5498    // which project settings or package update roots this TUI observes.
5499    let cwd = reload_context.cwd.clone();
5500    let saved_settings = crate::settings::load_settings().unwrap_or_default();
5501    let project_trusted = reload_context.project_trusted;
5502    let project_settings = if project_trusted {
5503        crate::settings::load_project_settings(&cwd)
5504    } else {
5505        Vec::new()
5506    };
5507    let tui_settings =
5508        resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5509    let editor_padding_x = tui_settings.editor_padding_x;
5510    let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5511    let mut hide_thinking = tui_settings.hide_thinking;
5512    let show_terminal_progress = tui_settings.show_terminal_progress;
5513    let show_images = tui_settings.show_images;
5514    let cache_miss_notices = tui_settings.cache_miss_notices;
5515    let quiet_startup = tui_settings.quiet_startup;
5516    let update_checks_enabled =
5517        !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
5518
5519    // Session display preferences (durable, latest-wins) override the
5520    // settings-file defaults. These are per-session facts stored as custom
5521    // entries (see `rpi_harness::session::values`), so resuming a session with
5522    // `-c`/`-r` restores the thinking/tool-output display the user last chose
5523    // there — independent of global settings. Absent keys leave the settings
5524    // value untouched.
5525    let session_values = rpi_harness::session::SessionValues::load(harness.session())
5526        .await
5527        .unwrap_or_default();
5528    if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5529        hide_thinking = persisted;
5530    }
5531
5532    // Resolve the active model once, up front. The full id feeds the TuiState
5533    // tracking field + the selectors/key loop (which run on a blocking thread
5534    // and can't await `lane.get_model()`); the short name feeds the footer.
5535    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5536    let model_name = short_model_name(&lane_model_id);
5537
5538    // Snapshot startup capabilities for the welcome screen. Both accessors
5539    // return defensive clones, so rendering this summary does not retain a
5540    // harness lock or trigger a second resource scan.
5541    let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5542    // AgentHarness uses an empty active-name list as the default "all tools"
5543    // state. Do not expose that implementation sentinel as `Tools (0) none`
5544    // in the welcome banner (it is especially visible on the first prompt).
5545    if active_tool_names.is_empty() {
5546        active_tool_names = harness
5547            .get_tools()
5548            .await
5549            .unwrap_or_default()
5550            .into_iter()
5551            .map(|tool| tool.tool.schema().name.clone())
5552            .collect();
5553    }
5554    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5555    let skill_names: Vec<String> = resources_snapshot
5556        .skills
5557        .as_deref()
5558        .unwrap_or(&[])
5559        .iter()
5560        .map(|skill| skill.name.clone())
5561        .collect();
5562
5563    // Resolve package resources for @file autocomplete + session discovery.
5564    let package_resources = reload_context.package_resources.clone();
5565
5566    // Channel between the key/callback threads and the main async loop.
5567    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
5568
5569    // Apply the saved theme before constructing transcript components. Some
5570    // components keep styled text, so doing this after the welcome banner left
5571    // the first screen in the dark palette until it was rebuilt.
5572    let theme_manager = Arc::new(ThemeManager::new());
5573    if let Some(preset) = match theme {
5574        Some("light") => Some(ThemePreset::Light),
5575        Some("monochrome") => Some(ThemePreset::Monochrome),
5576        Some("dark") => Some(ThemePreset::Dark),
5577        _ => None,
5578    } {
5579        apply_theme_preset(preset);
5580        theme_manager.apply_preset(preset);
5581    } else if !no_themes {
5582        if let Some(name) = theme {
5583            match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
5584                Ok(custom) => {
5585                    rpi_tui::global_theme_manager().set(custom.clone());
5586                    theme_manager.set(custom);
5587                }
5588                Err(error) => {
5589                    eprintln!("warning: could not load package theme `{name}`: {error}");
5590                }
5591            }
5592        }
5593    }
5594
5595    // ---- TUI + containers ----
5596    let terminal = Box::new(ProcessTerminal::new());
5597    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
5598    let js_dialog_bridge = Arc::new(JsDialogBridge::default());
5599    // Plugin `ask_user` prompts (runtime action 17). Attaching marks this
5600    // session as interactive so a detached/headless host fails loudly instead
5601    // of returning a fabricated answer. The mailbox is session-scoped (held in
5602    // `ReloadContext`) so in-flight prompts survive a `/reload`.
5603    let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
5604    ask_user_bridge.attach();
5605    tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
5606
5607    if let Some(js) = &reload_context.js_extension_session {
5608        if let Err(error) = js.install_ui_runtime(tui.clone()) {
5609            eprintln!("warning: could not enable JS custom UI bridge: {error}");
5610        } else if let Some(js_active) = js.active_tools() {
5611            // Apply the discovery-time JS subset while preserving Rust
5612            // built-ins already active in the harness lane. The real TUI
5613            // lifecycle reconciliation runs after the key worker starts below.
5614            let js_names = js.tool_names();
5615            let mut active = lane.get_active_tools().await.unwrap_or_default();
5616            active.retain(|name| {
5617                crate::session::tool_name_allowed(name, args)
5618                    && !js_names.iter().any(|js_name| js_name == name)
5619            });
5620            active.extend(js_active.into_iter().filter(|name| {
5621                js_names.iter().any(|js_name| js_name == name)
5622                    && crate::session::tool_name_allowed(name, args)
5623            }));
5624            active = crate::session::filter_active_tool_names(active, args);
5625            let _ = lane.set_active_tools(active).await;
5626            active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5627        }
5628    }
5629
5630    let chat_container = Arc::new(Container::new());
5631    if let Some(js) = &reload_context.js_extension_session {
5632        // Tool contexts do not have a command-result envelope. Route
5633        // `ctx.ui.notify()` through the live transcript so notifications from
5634        // tools such as ask_user_question are visible immediately.
5635        let chat_notify = chat_container.clone();
5636        let tui_notify = tui.clone();
5637        if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
5638            if action != "ui.notify" {
5639                return Err(format!("unsupported capability: {action}"));
5640            }
5641            let message = args
5642                .get("message")
5643                .and_then(serde_json::Value::as_str)
5644                .unwrap_or_default();
5645            if !message.is_empty() {
5646                if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
5647                    add_error_message(&chat_notify, message);
5648                } else {
5649                    add_note_message(&chat_notify, message);
5650                }
5651                tui_notify.request_render(false);
5652            }
5653            Ok(serde_json::json!(true))
5654        })) {
5655            if args.verbose {
5656                eprintln!("warning: could not enable JS notification bridge: {error}");
5657            }
5658        }
5659    }
5660    if should_show_startup_listing(args.verbose, quiet_startup) {
5661        add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5662    }
5663
5664    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
5665    // banner + the earendil announcement once, then write the sentinel. The TS
5666    // original is a multi-step dialog (theme picker + analytics opt-in); this
5667    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
5668    // analytics deferred — no telemetry wiring). See `extras.rs`.
5669    crate::extras::maybe_first_time_setup(&chat_container);
5670
5671    // A --continue/--resume/--session launch opens on an existing JSONL
5672    // session — render its prior user/assistant transcript so the user sees
5673    // where they left off (tool executions are skipped: their live display
5674    // belongs to the current run, and replaying old results would be noise).
5675    let initial_transformer = build_markdown_transformer(
5676        reload_context
5677            .extension_session
5678            .lock()
5679            .unwrap()
5680            .snapshot_arc(),
5681    );
5682    // A normal launch creates a fresh session and must not replay records from
5683    // another/project harness. Only explicit restore/fork modes render prior
5684    // conversation history. This fixes stale prompts appearing every startup.
5685    if launch_restores_history(args) {
5686        render_session_history(
5687            &harness,
5688            &chat_container,
5689            initial_transformer.clone(),
5690            Some(reload_context.extension_session.clone()),
5691        )
5692        .await;
5693    }
5694
5695    // `document_container` wraps the welcome header + chat so the scrollview
5696    // follows the whole transcript (mirrors TS `documentContainer`).
5697    let document_container = Arc::new(Container::new());
5698    document_container.add_child(chat_container.clone());
5699    // Add bottom padding to the output area for visual breathing room.
5700    document_container.add_child(Arc::new(Spacer::new(1)));
5701
5702    let scroll_view = Arc::new(ScrollView::new(
5703        document_container.clone(),
5704        ScrollViewOptions {
5705            follow: FollowMode::End,
5706            primary: true,
5707            overscroll: OverscrollMode::Chain,
5708            // Native pi keeps transcript chrome out of the way. Our Auto mode
5709            // has no hide timer yet and therefore became effectively permanent
5710            // after the first wheel event, unlike the upstream experience.
5711            scrollbar: ScrollbarMode::Hidden,
5712            ..Default::default()
5713        },
5714    ));
5715
5716    // ---- Editor ----
5717    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
5718    // editor renders full-width `─` top/bottom borders with padding-only lines
5719    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
5720    let keybindings = configured_keybindings();
5721    let editor = Arc::new(Editor::new(
5722        EditorOptions {
5723            padding_x: editor_padding_x,
5724            autocomplete_max_visible,
5725            ..Default::default()
5726        },
5727        EditorStyle::default(),
5728        keybindings.clone(),
5729    ));
5730
5731    // Bash-mode border: a `!`-prefixed draft recolors the editor border to the
5732    // bash accent (native pi's `updateEditorBorderColor` / `isBashMode`). The
5733    // editor fires `on_change` for keystrokes and for programmatic
5734    // `clear`/`set_text`, so the color also resets after a `!command` submits.
5735    {
5736        let editor_for_change = editor.clone();
5737        editor.on_change(Arc::new(move |text: &str| {
5738            let colors = current_theme().colors;
5739            let is_bash = text.trim_start().starts_with('!');
5740            editor_for_change.set_border_color(if is_bash {
5741                Some(colors.bash_mode)
5742            } else {
5743                None
5744            });
5745        }));
5746    }
5747
5748    // ---- Footer + status ----
5749    let footer = Arc::new(FooterComponent::new());
5750    footer.set_model(&model_name);
5751    footer.set_cwd(&cwd.to_string_lossy());
5752    footer.set_git_branch(git_branch_for(&cwd).as_deref());
5753    if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5754        footer.set_context_window(m.context_window as i64);
5755    }
5756    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");
5757
5758    // Live git-branch refresh (native fs-watch on `.git/HEAD`): a checkout or
5759    // commit updates the footer's branch without a manual refresh.
5760    let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5761        let footer_ref = footer.clone();
5762        let cwd_ref = cwd.clone();
5763        crate::fs_watch::watch_path(head, move || {
5764            footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5765        })
5766    });
5767
5768    let status_container = Arc::new(Container::new());
5769    let loader = Arc::new(Loader::with_text("Working…"));
5770
5771    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
5772    // Prompt templates discovered at session build (Part A2) are surfaced as
5773    // `/`-prefixed entries alongside the built-in slash commands: typing
5774    // `/<name>` in the editor expands the template (mirrors pi
5775    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
5776    // the template's frontmatter description (or a fallback) so the autocomplete
5777    // popover shows what each template does.
5778    //
5779    // We snapshot the full resources once (skills + prompt-templates): the
5780    // autocomplete builder consumes the templates, and the `/context` command
5781    // (fired from the blocking submit handler, which can't `.await`) reads the
5782    // snapshot to render the discovered-resources panel without touching the
5783    // harness async accessor.
5784    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5785        .prompt_templates
5786        .clone()
5787        .unwrap_or_default()
5788        .iter()
5789        .map(|t| SlashCommandEntry {
5790            name: format!("/{}", t.name),
5791            description: t
5792                .description
5793                .clone()
5794                .unwrap_or_else(|| "Expand prompt template".to_string()),
5795        })
5796        .collect();
5797    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5798        Arc::new(resources_snapshot);
5799    // Build the built-in command registry once — the single source of truth for
5800    // both dispatch and the built-in autocomplete entries. The discovered
5801    // prompt-template commands are merged into the autocomplete list separately
5802    // (they dispatch via template expansion, not the registry); built-ins come
5803    // first so they win on a fuzzy tie.
5804    let mut command_registry = build_builtin_registry();
5805    register_extension_commands(
5806        &mut command_registry,
5807        reload_context.extension_session.clone(),
5808    );
5809    register_js_extension_commands(
5810        &mut command_registry,
5811        reload_context.js_extension_session.clone(),
5812    );
5813    let registry = Arc::new(command_registry);
5814    let mut all_slash_commands = registry.visible_entries();
5815    all_slash_commands.extend(template_slash_commands);
5816    let autocomplete = AutocompleteManager::new();
5817    {
5818        let mut combined = CombinedAutocompleteProvider::new();
5819        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5820            all_slash_commands,
5821        )));
5822        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5823            cwd.clone(),
5824        )));
5825        autocomplete.set_provider(Arc::new(combined));
5826    }
5827    let autocomplete_container = Arc::new(Container::new());
5828
5829    let tool_outputs_expanded = session_values
5830        .get_as::<bool>("display.tool_outputs_expanded")
5831        .unwrap_or(false);
5832    let state = Arc::new(TuiState {
5833        current_assistant: std::sync::Mutex::new(None),
5834        tool_components: std::sync::Mutex::new(HashMap::new()),
5835        bash_components: std::sync::Mutex::new(HashMap::new()),
5836        themes_enabled: !no_themes,
5837        hide_thinking: std::sync::Mutex::new(hide_thinking),
5838        tool_outputs_expanded: std::sync::Mutex::new(tool_outputs_expanded),
5839        show_terminal_progress,
5840        status: std::sync::Mutex::new(RunStatus::Idle),
5841        ext_status: reload_context.ext_status.clone(),
5842        ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
5843        js_preparation_cancel: std::sync::Mutex::new(None),
5844        user_bash_cancel: std::sync::Mutex::new(None),
5845        pending_bash_messages: std::sync::Mutex::new(Vec::new()),
5846        footer: footer.clone(),
5847        status_container: status_container.clone(),
5848        chat_container: chat_container.clone(),
5849        loader: loader.clone(),
5850        editor: editor.clone(),
5851        last_assistant_text: std::sync::Mutex::new(String::new()),
5852        active_selector: std::sync::Mutex::new(None),
5853        active_extension_editor: std::sync::Mutex::new(None),
5854        active_extension_input: std::sync::Mutex::new(None),
5855        active_extension_cancel: std::sync::Mutex::new(None),
5856        autocomplete,
5857        autocomplete_container: autocomplete_container.clone(),
5858        autocomplete_max_visible,
5859        pending_images: std::sync::Mutex::new(Vec::new()),
5860        theme_manager,
5861        tui: Some(tui.clone()),
5862        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5863        show_images: std::sync::Mutex::new(show_images),
5864        cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
5865        history: std::sync::Mutex::new(Vec::new()),
5866        history_index: std::sync::Mutex::new(-1),
5867        history_draft: std::sync::Mutex::new(None),
5868        cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5869        scoped_edit: std::sync::Mutex::new(None),
5870        markdown_transformer: std::sync::Mutex::new(initial_transformer),
5871        extension_session: reload_context.extension_session.clone(),
5872        search: Arc::new(AltScreenSearch::new()),
5873        search_bar: Arc::new(SearchBar::new()),
5874        pending_container: Arc::new(Container::new()),
5875        dequeue_hint: pending_dequeue_hint(&keybindings),
5876        pending_snapshot: std::sync::Mutex::new(
5877            rpi_harness::agent_harness::QueuedMessages::default(),
5878        ),
5879        selection_start: std::sync::Mutex::new(None),
5880        selection_end: std::sync::Mutex::new(None),
5881    });
5882
5883    // Capture the model catalog + cwd for the selector builders + the key loop
5884    // (the callbacks fire on blocking threads and need owned data).
5885    let model_catalog_arc = Arc::new(model_catalog.clone());
5886    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5887
5888    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
5889    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
5890    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
5891    //
5892    // The scrollview gets `basis(0)` so the constrained stack allocator starts
5893    // it at zero height and grows it to fill the space the dock does not need
5894    // — this keeps the dock (editor borders + footer) pinned to the bottom and
5895    // never shrinks it below the editor's 3 rows (top + content + bottom). The
5896    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
5897    // never clip the input panel below its minimum.
5898    let editor_container = Arc::new(Container::new());
5899    editor_container.add_child(editor.clone());
5900
5901    let dock = Arc::new(VStack::from_children(vec![
5902        StackChild::Entry(StackEntry::new(state.pending_container().clone())),
5903        StackChild::Entry(StackEntry::new(status_container.clone())),
5904        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5905        // NOTE: no Spacer here. The scroll content already ends with a blank line
5906        // (`document_container`), and a second one in the dock stacked into a
5907        // two-row gap between the last message and the input box. One row of
5908        // breathing room is the whole safe area now; the transcript keeps its
5909        // own padding so a long message never touches the editor border.
5910        StackChild::Entry(
5911            StackEntry::new(editor_container.clone())
5912                .shrink(0)
5913                .min_size(3),
5914        ),
5915        StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5916        StackChild::Entry(StackEntry::new(footer.clone())),
5917    ]));
5918
5919    let root = VStack::from_children(vec![
5920        StackChild::Entry(
5921            StackEntry::new(scroll_view.clone())
5922                .basis(0)
5923                .grow(1)
5924                .shrink(1)
5925                .min_size(1),
5926        ),
5927        StackChild::Entry(StackEntry::new(dock).shrink(1)),
5928    ]);
5929
5930    tui.set_layout_root(Some(Arc::new(root)));
5931    tui.set_focus(Some(editor.clone()));
5932    editor.set_focused(true);
5933
5934    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
5935    //
5936    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
5937    // the old version made individually) + the registry, then routes `/`-text
5938    // through `dispatch_slash` and sends plain text directly. Each command's
5939    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
5940    // chat mutation) — the handler itself stays a thin router.
5941    //
5942    // One `CommandContext` is built and cloned for both the submit handler and
5943    // the key loop (Ctrl+L routes `/model` through the same registry); all
5944    // fields are `Arc`/cheap, so the clones are free.
5945    //
5946    // The product-layer `AgentSession` wraps a clone of the harness (the
5947    // harness handle is cheap and shares the session/bus), so `/usage`,
5948    // `/export`, and future product commands go through one surface.
5949    let scoped_models = args
5950        .model
5951        .as_deref()
5952        .map(crate::agent_session::ScopedModel::parse)
5953        .into_iter()
5954        .collect();
5955    let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5956        harness.clone(),
5957        model_catalog.clone(),
5958        scoped_models,
5959        cwd.clone(),
5960    ));
5961    let ctx = CommandContext {
5962        chat: chat_container.clone(),
5963        tui: tui.clone(),
5964        tx: tx.clone(),
5965        state: state.clone(),
5966        editor: editor.clone(),
5967        editor_container: editor_container.clone(),
5968        lane: lane.clone(),
5969        model_catalog: model_catalog_arc.clone(),
5970        lane_model_id: lane_model_id.clone(),
5971        cwd: cwd.clone(),
5972        package_resources: package_resources.clone(),
5973        resources: resources_arc.clone(),
5974        reload_context: Arc::new(reload_context.clone()),
5975        session: agent_session.clone(),
5976    };
5977
5978    if let Some(js) = &reload_context.js_extension_session {
5979        let bridge = js_dialog_bridge.clone();
5980        if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5981            bridge.handle_runtime_request(action, args)
5982        })) {
5983            eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5984        }
5985    }
5986
5987    let ctx_for_cb = ctx.clone();
5988    let registry_for_cb = registry.clone();
5989    editor.on_submit(Arc::new(move |text: &str| {
5990        let text = text.trim();
5991        if text.is_empty() {
5992            return;
5993        }
5994
5995        if text.starts_with('/') {
5996            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
5997            return;
5998        }
5999
6000        // User-initiated shell mode: `!command` runs in the background and is
6001        // recorded in the session context; `!!command` is excluded from it.
6002        // Handled before the agent prompt path so it never reaches the model.
6003        // Mirrors `interactive-mode.ts:3225`.
6004        if let Some((command, exclude_from_context)) = parse_user_bash(text) {
6005            if ctx_for_cb.state.user_bash_running() {
6006                add_error_message(
6007                    &ctx_for_cb.chat,
6008                    "A bash command is already running. Press Esc to cancel it first.",
6009                );
6010                ctx_for_cb.tui.request_render(false);
6011                return;
6012            }
6013            start_user_bash(&ctx_for_cb, command, exclude_from_context);
6014            return;
6015        }
6016
6017        let run_status = *ctx_for_cb.state.status.lock().unwrap();
6018        if run_status != RunStatus::Idle {
6019            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
6020            let lane = ctx_for_cb.lane.clone();
6021            let chat = ctx_for_cb.chat.clone();
6022            let tui = ctx_for_cb.tui.clone();
6023            let state = ctx_for_cb.state.clone();
6024            tokio::spawn(async move {
6025                // Queue immediately while the agent loop is still running.
6026                // Routing this through the TUI's main channel delayed it until
6027                // `prompt_text()` returned, after the loop's drain points had
6028                // passed, so the queued message appeared to disappear.
6029                //
6030                // Native pi's `steer()` enqueues unconditionally, even after
6031                // the run ends, and the message stays in the queue for the
6032                // next run. This matches the TS design and avoids the race
6033                // between `activeRun` clearing and the status check.
6034                if let Err(error) = lane.steer(message).await {
6035                    add_error_message(&chat, &format!("Could not queue message: {error}"));
6036                    tui.render_now(false);
6037                    return;
6038                }
6039                // Surface the queued entry in the pending dock immediately;
6040                // the drain-time refresh only fires on the next agent event.
6041                refresh_pending_messages(&state, &lane).await;
6042                tui.request_render(false);
6043            });
6044            // A queued prompt is echoed only once the loop actually consumes
6045            // it: the run's `AgentEvent::MessageStart` renders the user bubble
6046            // (native pi renders `message_start` for user messages). Until
6047            // then it is surfaced by the pending-messages display, so the
6048            // transcript never shows a message that is still waiting.
6049            ctx_for_cb.tui.request_render(false);
6050            return;
6051        }
6052
6053        if !ctx_for_cb.state.try_start_working() {
6054            return;
6055        }
6056
6057        // Render the user bubble here, NOT from `AgentEvent::MessageStart`.
6058        //
6059        // The harness persists the prompt itself and then drives
6060        // `run_agent_loop` with an EMPTY prompts vec (so the prompt is not
6061        // double-counted in the provider context), which means the loop emits
6062        // no `message_start` for it — see
6063        // `directly_sent_prompt_emits_no_user_message_start` in
6064        // `crates/pi-harness/tests/harness_run_e2e.rs`. Queued steering /
6065        // follow-up prompts DO get a `message_start` (the loop drains those
6066        // itself), so those are deliberately rendered from the event instead.
6067        add_user_message(&ctx_for_cb.chat, text);
6068        // A new prompt starts a fresh interaction at the tail even when the
6069        // user had scrolled up to inspect older output.
6070        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
6071            scroll.scroll_to_end();
6072        }
6073        ctx_for_cb.tui.request_render(false);
6074        // Remember the message for ↑ recall (slash commands are not part of
6075        // the replayable message history).
6076        push_history(&ctx_for_cb.state, text);
6077        if ctx_for_cb
6078            .tx
6079            .send(TuiMessage::UserInput(text.to_string()))
6080            .is_err()
6081        {
6082            ctx_for_cb.state.set_status(RunStatus::Idle);
6083        }
6084    }));
6085
6086    // Turn on the native-pi frame throttle before the first frame: from here on
6087    // `request_render(false)` parks a frame and the scheduler thread paints at
6088    // most one per `MIN_RENDER_INTERVAL_MS` (16 ms). A provider that streams one
6089    // `MessageUpdate` per delta would otherwise force a full transcript
6090    // re-render + full-viewport terminal write per token, which is what made
6091    // long output saturate the terminal (and its GPU compositor). Immediate
6092    // renders (selectors, the first frame, `render_now(..)`) are unaffected.
6093    tui.start_render_scheduler();
6094    tui.start_readerless();
6095
6096    // Consume local self-update results even when network checks are disabled.
6097    // Keep package discovery separate so package managers and Git remotes
6098    // cannot delay an rpi update notice or a prior helper failure.
6099    let mut update_check_handles = Vec::new();
6100    let rpi_chat = chat_container.clone();
6101    let rpi_tui = tui.clone();
6102    update_check_handles.push(tokio::spawn(async move {
6103        let report = crate::updates::check_rpi_startup().await;
6104        if !report.is_empty() {
6105            add_update_notices(&rpi_chat, &report);
6106        }
6107        rpi_tui.request_render(false);
6108    }));
6109
6110    if update_checks_enabled {
6111        let update_args = args.clone();
6112        let update_cwd = cwd.clone();
6113        let update_project_trusted = project_trusted;
6114        let chat = chat_container.clone();
6115        let tui = tui.clone();
6116        update_check_handles.push(tokio::spawn(async move {
6117            let resources = crate::session::package_resources_for_update_check(
6118                &update_args,
6119                &update_cwd,
6120                update_project_trusted,
6121            );
6122            let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
6123            {
6124                Ok(npm_command) => {
6125                    crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
6126                        Some(&resources),
6127                        &npm_command,
6128                        update_project_trusted.then_some(update_cwd.as_path()),
6129                    )
6130                    .await
6131                }
6132                Err(error) => {
6133                    add_error_message(
6134                        &chat,
6135                        &format!("npm package update checks disabled: {error}"),
6136                    );
6137                    let git_resources = git_only_update_resources(resources);
6138                    crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
6139                }
6140            };
6141            if !report.is_empty() {
6142                add_update_notices(&chat, &report);
6143            }
6144            tui.request_render(false);
6145        }));
6146    }
6147
6148    // ---- Streaming drain task ----
6149    let drain_handle = if let Some(rx) = event_rx {
6150        let tui_drain = tui.clone();
6151        let state_drain = state.clone();
6152        let chat_drain = chat_container.clone();
6153        let lane_drain = lane.clone();
6154        Some(tokio::spawn(async move {
6155            drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
6156        }))
6157    } else {
6158        None
6159    };
6160
6161    // ---- B5d: plugin→TUI reload bridge ----
6162    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
6163    // (its cdylib would be unmapped while the call frame is still on the stack).
6164    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
6165    // (an `UnboundedSender<()>`); this task drains those signals and forwards
6166    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
6167    // `reload_extension_resources` routine asynchronously. The mailbox is the
6168    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
6169    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
6170    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
6171    reload_context.mailbox.install(reload_sig_tx);
6172    let reload_tx = tx.clone();
6173    let reload_bridge_handle = tokio::spawn(async move {
6174        while reload_sig_rx.recv().await.is_some() {
6175            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
6176                break; // main loop gone — stop forwarding
6177            }
6178        }
6179    });
6180
6181    // ---- Render-tick task (advances the loader spinner while Working) ----
6182    //
6183    // The `Loader` only advances its frame on render; without a periodic
6184    // `request_render` the spinner visibly freezes between events.
6185    let tui_tick = tui.clone();
6186    let state_tick = state.clone();
6187    let tick_handle = tokio::spawn(async move {
6188        // 80ms per frame — native pi's `DEFAULT_INTERVAL_MS`, i.e. a full
6189        // 10-frame cycle every 800ms. The tick only *requests a repaint*; the
6190        // frame is advanced by `Editor::render` (mirroring `Loader::render`),
6191        // so streaming output — which schedules a frame per token — spins it
6192        // faster, and this interval is the floor when the model is silent.
6193        //
6194        // Advancing here as well would double-step every tick and make the idle
6195        // spinner run at 40ms/frame, twice native pi's rate.
6196        let mut interval = tokio::time::interval(std::time::Duration::from_millis(
6197            rpi_tui::loader::SPINNER_FRAME_MS,
6198        ));
6199        interval.tick().await; // discard immediate
6200        loop {
6201            interval.tick().await;
6202            // Extension status (langfuse ✓ …, …) — cheap revision check, and the
6203            // only reason an idle session repaints its footer.
6204            if state_tick.sync_extension_status() {
6205                tui_tick.request_render(false);
6206            }
6207            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
6208            if working {
6209                // A live transcript panel — a running bash command *or* a
6210                // running tool call — computes its elapsed readout from
6211                // `Instant::now()` at render time. A frame that reuses the
6212                // cached scroll content never rebuilds it, so the readout
6213                // froze at the second of the last full rebuild (reported: a
6214                // `search` call stuck at "2.0s" with no way to tell it was
6215                // still running). Rebuild while any such panel is live.
6216                let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
6217                let has_live_panel = {
6218                    let tools = state_tick.tool_components.lock().unwrap();
6219                    transcript_has_live_panel(bash_present, &tools)
6220                };
6221                if has_live_panel {
6222                    tui_tick.request_render(false);
6223                } else {
6224                    // Only the dock loader animates. Keep the already-rendered
6225                    // transcript instead of rebuilding a long history at 12.5
6226                    // frames per second.
6227                    tui_tick.request_render_reusing_scroll_content();
6228                }
6229            }
6230        }
6231    });
6232
6233    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
6234    let running = Arc::new(std::sync::Mutex::new(true));
6235    let running_key = running.clone();
6236    let tx_for_key = tx.clone();
6237    let tui_for_key = tui.clone();
6238    let editor_for_key = editor.clone();
6239    let scroll_for_key = scroll_view.clone();
6240    let lane_for_key = lane.clone();
6241    let state_for_key = state.clone();
6242    let model_catalog_for_key = model_catalog_arc.clone();
6243    let js_for_key = reload_context.js_extension_session.clone();
6244    let js_dialog_for_key = js_dialog_bridge.clone();
6245    let ask_user_for_key = ask_user_bridge.clone();
6246    // Ctrl+L routes through the same registry as `/model` (one path, not two),
6247    // so the key loop needs the same `CommandContext` + registry the submit
6248    // handler uses. All fields are `Arc`/cheap, so this clone is free.
6249    let ctx_for_key = ctx.clone();
6250    let registry_for_key = registry.clone();
6251    let keybindings_for_key = keybindings.clone();
6252    let double_escape_action = crate::settings::load_settings()
6253        .ok()
6254        .and_then(|settings| settings.double_escape_action)
6255        .unwrap_or_else(|| "tree".to_string())
6256        .to_ascii_lowercase();
6257
6258    let key_handle = tokio::task::spawn_blocking(move || {
6259        let mut last_escape_time = None;
6260        // Paste-burst tracking (see the bare-Enter guard below): the instant of
6261        // the most recent key event that could have been pasted text.
6262        let mut last_text_key_at: Option<std::time::Instant> = None;
6263        // One event peeked at by the paste-burst probe (which must discard a
6264        // key's Release but never lose a meaningful event it read). Restored
6265        // at the top of the next loop iteration.
6266        let mut pending_event: Option<Event> = None;
6267        if crate::key_trace::enabled() {
6268            crate::key_trace::note(&format!(
6269                "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
6270                std::process::id(),
6271                std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
6272                crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
6273            ));
6274        }
6275        loop {
6276            if !*running_key.lock().unwrap() {
6277                break;
6278            }
6279            if !state_for_key.selector_open()
6280                && !state_for_key.extension_dialog_open()
6281                && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
6282            {
6283                if let Some(request) = js_dialog_for_key.take_pending() {
6284                    open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
6285                } else if let Some(request) = ask_user_for_key.take_pending() {
6286                    open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
6287                }
6288            }
6289            cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
6290            let ev = if let Some(ev) = pending_event.take() {
6291                ev
6292            } else {
6293                // `event::read()` blocks indefinitely. Poll first so shutdown can
6294                // stop and join this worker even when no further key arrives.
6295                match crossterm::event::poll(std::time::Duration::from_millis(50)) {
6296                    Ok(true) => {}
6297                    Ok(false) => continue,
6298                    Err(_) => {
6299                        state_for_key.cancel_js_preparation();
6300                        let _ = tx_for_key.send(TuiMessage::Exit);
6301                        break;
6302                    }
6303                }
6304                let Ok(ev) = crossterm::event::read() else {
6305                    state_for_key.cancel_js_preparation();
6306                    let _ = tx_for_key.send(TuiMessage::Exit);
6307                    break;
6308                };
6309                ev
6310            };
6311            // `Event::Resize` is delivered as its own event (not a Key). With
6312            // `start_readerless` there is no competing terminal-reader thread to
6313            // handle it, so refresh the cached terminal size here and force a
6314            // full redraw so the constrained layout re-fits the new dimensions.
6315            if let Event::Resize(_cols, _rows) = ev {
6316                tui_for_key.refresh_size();
6317                if let Some(js) = &js_for_key {
6318                    if js.custom_active() {
6319                        let _ = js.send_custom_resize(_cols as usize, _rows as usize);
6320                        tui_for_key.request_render(false);
6321                    }
6322                }
6323                continue;
6324            }
6325            // Mouse wheel scrolls the transcript (pi supports wheel
6326            // scrolling). Previously every non-Key event was dropped, so a
6327            // wheel had zero effect — "滚动还是不行".
6328            if let Event::Mouse(m) = ev {
6329                use crossterm::event::MouseEventKind;
6330                match m.kind {
6331                    MouseEventKind::ScrollUp => {
6332                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
6333                        if scroll_for_key.scroll_by(delta) != delta {
6334                            tui_for_key.request_render_reusing_scroll_content();
6335                        }
6336                    }
6337                    MouseEventKind::ScrollDown => {
6338                        let delta = MOUSE_WHEEL_SCROLL_LINES;
6339                        if scroll_for_key.scroll_by(delta) != delta {
6340                            tui_for_key.request_render_reusing_scroll_content();
6341                        }
6342                    }
6343                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
6344                        // Start selection tracking. `selection_end` doubles as the
6345                        // "a real drag happened" marker: a plain tap never sets
6346                        // it, so it can never overwrite the clipboard.
6347                        *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
6348                        *state_for_key.selection_end.lock().unwrap() = None;
6349                    }
6350                    MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
6351                        // Motion with the button held = a genuine selection drag.
6352                        // (Terminals only report this with button-event mouse
6353                        // tracking, which `ProcessTerminal` enables.)
6354                        if state_for_key.selection_start.lock().unwrap().is_some() {
6355                            *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
6356                        }
6357                    }
6358                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
6359                        // End selection and auto-copy if enabled
6360                        if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
6361                            let end = (m.column, m.row);
6362
6363                            // Only auto-copy when the pointer actually MOVED with
6364                            // the button held (`Drag` set `selection_end`). A plain
6365                            // click/tap — even one whose Down/Up cells differ by a
6366                            // pixel over RDP — must not touch the clipboard: it used
6367                            // to fire `extract_selected_text`, which copies whatever
6368                            // rendered TUI line sat under the pointer, silently
6369                            // clobbering the user's real clipboard so the next paste
6370                            // returned that stale text.
6371                            let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
6372                            *state_for_key.selection_end.lock().unwrap() = Some(end);
6373
6374                            // Check if auto-copy is enabled
6375                            let auto_copy = crate::settings::load_settings()
6376                                .ok()
6377                                .and_then(|s| s.fullscreen_copy_on_select)
6378                                .unwrap_or(true);
6379
6380                            if auto_copy && is_drag {
6381                                // Try to extract selected text from chat container
6382                                if let Some(selected_text) =
6383                                    extract_selected_text(&state_for_key.chat_container, start, end)
6384                                {
6385                                    if !selected_text.trim().is_empty() {
6386                                        let _ = copy_to_clipboard(&selected_text);
6387                                    }
6388                                }
6389                            }
6390                        }
6391                        *state_for_key.selection_start.lock().unwrap() = None;
6392                        *state_for_key.selection_end.lock().unwrap() = None;
6393                    }
6394                    _ => {}
6395                }
6396                continue;
6397            }
6398            let Event::Key(key) = ev else {
6399                if let Event::Paste(text) = ev {
6400                    crate::key_trace::note(&format!(
6401                        "paste event ({} bytes, bracketed paste supported)",
6402                        text.len()
6403                    ));
6404                    let candidate = text.trim().trim_matches(['\"', '\'']);
6405                    let path = std::path::PathBuf::from(candidate);
6406                    if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
6407                        if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
6408                            add_image_preview(&state_for_key.chat_container, &image);
6409                            state_for_key.queue_image(image);
6410                            add_note_message(
6411                                &state_for_key.chat_container,
6412                                "Dropped image attached to the next prompt.",
6413                            );
6414                            tui_for_key.request_render(false);
6415                            continue;
6416                        }
6417                    }
6418                    editor_for_key.insert(&text);
6419                    refresh_autocomplete(&state_for_key, &editor_for_key);
6420                    tui_for_key.request_render_reusing_scroll_content();
6421                }
6422                continue;
6423            };
6424            // Drop releases but preserve Repeat so holding arrows, Backspace,
6425            // PageUp, etc. behaves naturally. Windows emits Press + Release
6426            // for a tap; terminals with keyboard enhancement may additionally
6427            // emit Repeat while a key is held.
6428            if !should_dispatch_key(key.kind) {
6429                continue;
6430            }
6431            // Diagnostic trace (off unless RPI_DEBUG_KEYS is set): records what
6432            // actually arrived before any routing decision, so a client that
6433            // sends LF for Enter is visible as `Char('j') mods=CONTROL`.
6434            crate::key_trace::key(&key, "");
6435
6436            // Prompt preparation runs on a blocking worker before the agent
6437            // lane owns the turn. Cancel it directly: an abort queued only to
6438            // the lane cannot wake a JS factory or lifecycle hook that never
6439            // resolves. This check precedes custom/dialog routing because
6440            // those components may themselves have been opened by the hook.
6441            let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
6442                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
6443                || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
6444            if prompt_abort && state_for_key.cancel_js_preparation() {
6445                state_for_key.set_status(RunStatus::Aborting);
6446                if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
6447                    close_extension_editor(
6448                        &state_for_key,
6449                        &ctx_for_key.editor_container,
6450                        &editor_for_key,
6451                        &tui_for_key,
6452                    );
6453                }
6454                js_dialog_for_key.cancel_open_requests();
6455                tui_for_key.set_render_suspended(false);
6456                tui_for_key.request_render(false);
6457                continue;
6458            }
6459
6460            if let Some(js) = &js_for_key {
6461                if js.custom_active() {
6462                    let visible = js.custom_accepts_input();
6463                    let data = key_event_to_input(key);
6464                    if !data.is_empty() {
6465                        // A visible custom owns the whole key stream, so its
6466                        // acknowledgement is unnecessary and would add a
6467                        // synchronous round-trip to every keystroke. Hidden
6468                        // overlays need the consume result to decide whether the
6469                        // outer editor should see the key.
6470                        if visible {
6471                            let _ = js.send_custom_input(&data);
6472                            continue;
6473                        }
6474                        let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
6475                        // A hidden component only keeps raw listeners alive (for
6476                        // example ask_user_question's reopen shortcut); an
6477                        // unconsumed key continues through the outer editor.
6478                        if consumed {
6479                            tui_for_key.request_render_reusing_scroll_content();
6480                            continue;
6481                        }
6482                    }
6483                }
6484            }
6485
6486            // Ctrl+C cancels an open selector before it reaches the global
6487            // abort/exit handler. Route through Esc so selector callbacks run.
6488            if key.modifiers == KeyModifiers::CONTROL
6489                && key.code == KeyCode::Char('c')
6490                && state_for_key.selector_open()
6491            {
6492                let selector = state_for_key
6493                    .active_selector
6494                    .lock()
6495                    .unwrap()
6496                    .clone()
6497                    .expect("selector_open guaranteed Some")
6498                    .0;
6499                selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6500                tui_for_key.request_render_reusing_scroll_content();
6501                continue;
6502            }
6503
6504            // Extension dialogs own the input slot while awaiting a result.
6505            // Esc and Ctrl+C both resolve the pending command with cancel;
6506            // all other keys go to the active native editor/input widget.
6507            if state_for_key.extension_dialog_open() {
6508                let cancel = key.code == KeyCode::Esc
6509                    || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
6510                if cancel {
6511                    if !run_extension_cancel(&state_for_key) {
6512                        close_extension_editor(
6513                            &state_for_key,
6514                            &ctx_for_key.editor_container,
6515                            &editor_for_key,
6516                            &tui_for_key,
6517                        );
6518                    }
6519                } else if let Some(extension_editor) = state_for_key
6520                    .active_extension_editor
6521                    .lock()
6522                    .unwrap()
6523                    .clone()
6524                {
6525                    extension_editor.handle_key(key);
6526                } else if let Some(extension_input) =
6527                    state_for_key.active_extension_input.lock().unwrap().clone()
6528                {
6529                    extension_input.handle_key(key);
6530                }
6531                tui_for_key.request_render_reusing_scroll_content();
6532                continue;
6533            }
6534
6535            // 0. Ctrl+C: copy the selection when the editor has one (pi
6536            //    `tui.input.copy`); otherwise abort an active run, or exit
6537            //    when idle. Open selectors and extension dialogs are handled
6538            //    above so their cancellation callbacks get first chance.
6539            if keybinding_matches(
6540                &keybindings_for_key,
6541                &key,
6542                rpi_tui::keybindings::keys::CLEAR,
6543            ) {
6544                if !state_for_key.selector_open() && editor_for_key.has_selection() {
6545                    editor_for_key.copy_selection();
6546                    continue;
6547                }
6548                let status = *state_for_key.status.lock().unwrap();
6549                match status {
6550                    RunStatus::Working => {
6551                        state_for_key.set_status(RunStatus::Aborting);
6552                        // Drop any outstanding ask_user prompt from this turn.
6553                        ask_user_for_key.cancel_all();
6554                        // Restore the editor if an extension dialog was occupying it.
6555                        if !run_extension_cancel(&state_for_key)
6556                            && state_for_key.extension_dialog_open()
6557                        {
6558                            close_extension_editor(
6559                                &state_for_key,
6560                                &ctx_for_key.editor_container,
6561                                &editor_for_key,
6562                                &tui_for_key,
6563                            );
6564                        }
6565                        let lane = lane_for_key.clone();
6566                        tokio::spawn(async move {
6567                            let _ = lane.abort().await;
6568                        });
6569                    }
6570                    // A held Ctrl+C can emit Repeat immediately after Press.
6571                    // Keep waiting for the in-flight cancellation instead of
6572                    // treating that repeat as a request to exit the process.
6573                    RunStatus::Aborting => {
6574                        // A second, *deliberate* Ctrl+C. The first press only
6575                        // got as far as `Aborting`, which means the abort has
6576                        // not landed yet — and the async loop is still inside
6577                        // `run_prompt_streaming(..).await`, so a queued
6578                        // `TuiMessage::Exit` would not be read until the run
6579                        // returns, which is exactly what is not happening.
6580                        //
6581                        // Exit here instead: the TUI must never be unquittable
6582                        // (a provider request or tool that never returns would
6583                        // otherwise trap the user forever). Auto-repeat is
6584                        // filtered out, so holding the key through a slow abort
6585                        // cannot kill the process by accident.
6586                        if key.kind != KeyEventKind::Repeat {
6587                            emergency_exit(&tui_for_key);
6588                        }
6589                    }
6590                    RunStatus::Idle => {
6591                        let _ = tx_for_key.send(TuiMessage::Exit);
6592                    }
6593                }
6594                continue;
6595            }
6596
6597            // 1. A selector overlay is open → route to it first. Only Esc
6598            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
6599            //    escape to the selector; on done/cancel the selector callbacks
6600            //    restore the editor and clear `active_selector`.
6601            if state_for_key.selector_open() {
6602                // Esc always cancels the selector (even with modifiers off).
6603                // Route through `SelectList::handle_key(Esc)` so the list's
6604                // `on_cancel` fires (the `/scoped-models` toggle selector saves
6605                // its edits there) — the old shortcut called `close_selector`
6606                // directly and skipped the callback.
6607                if key.code == KeyCode::Esc {
6608                    let (selector, _kind) = state_for_key
6609                        .active_selector
6610                        .lock()
6611                        .unwrap()
6612                        .clone()
6613                        .expect("selector_open guaranteed Some");
6614                    selector.handle_key(key);
6615                    continue;
6616                }
6617                let (selector, _kind) = state_for_key
6618                    .active_selector
6619                    .lock()
6620                    .unwrap()
6621                    .clone()
6622                    .expect("selector_open guaranteed Some");
6623                selector.handle_key(key);
6624                tui_for_key.request_render_reusing_scroll_content();
6625                continue;
6626            }
6627
6628            // 2a. Ctrl+D: pi's deleteCharForward inside the editor (mirrors
6629            //     `tui.editor.deleteCharForward`), and EOF-quit on an empty
6630            //     editor. With a run active, abort it first (same as Ctrl+C)
6631            //     so the key is never a no-op while a stuck command runs.
6632            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
6633                let status = *state_for_key.status.lock().unwrap();
6634                match status {
6635                    RunStatus::Working => {
6636                        state_for_key.set_status(RunStatus::Aborting);
6637                        ask_user_for_key.cancel_all();
6638                        if !run_extension_cancel(&state_for_key)
6639                            && state_for_key.extension_dialog_open()
6640                        {
6641                            close_extension_editor(
6642                                &state_for_key,
6643                                &ctx_for_key.editor_container,
6644                                &editor_for_key,
6645                                &tui_for_key,
6646                            );
6647                        }
6648                        let lane = lane_for_key.clone();
6649                        tokio::spawn(async move {
6650                            let _ = lane.abort().await;
6651                        });
6652                        continue;
6653                    }
6654                    RunStatus::Aborting => continue,
6655                    RunStatus::Idle => {}
6656                }
6657                if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
6658                    // Editor holds text — delete the char forward (pi parity).
6659                    editor_for_key.handle_key(key);
6660                    refresh_autocomplete(&state_for_key, &editor_for_key);
6661                    tui_for_key.request_render_reusing_scroll_content();
6662                    continue;
6663                }
6664                let _ = tx_for_key.send(TuiMessage::Exit);
6665                continue;
6666            }
6667
6668            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
6669            //     selector is open Esc already cancelled it above; when idle,
6670            //     Esc falls through to the editor (no-op-ish). Only fire while
6671            //     Working so an idle Esc doesn't abort a non-existent run.
6672            if keybinding_matches(
6673                &keybindings_for_key,
6674                &key,
6675                rpi_tui::keybindings::keys::INTERRUPT,
6676            ) {
6677                let status = *state_for_key.status.lock().unwrap();
6678                if status == RunStatus::Working {
6679                    state_for_key.set_status(RunStatus::Aborting);
6680                    ask_user_for_key.cancel_all();
6681                    if !run_extension_cancel(&state_for_key)
6682                        && state_for_key.extension_dialog_open()
6683                    {
6684                        close_extension_editor(
6685                            &state_for_key,
6686                            &ctx_for_key.editor_container,
6687                            &editor_for_key,
6688                            &tui_for_key,
6689                        );
6690                    }
6691                    let lane = lane_for_key.clone();
6692                    tokio::spawn(async move {
6693                        let _ = lane.abort().await;
6694                    });
6695                    continue;
6696                }
6697                // A running `!command` takes Esc next (native pi's `onEscape`
6698                // precedence: streaming → bash → bash-mode editor →
6699                // double-escape). The run slot stays occupied until the capture
6700                // resolves, so a second Esc is a no-op rather than a
6701                // double-escape trigger.
6702                if state_for_key.user_bash_running() {
6703                    state_for_key.cancel_user_bash();
6704                    tui_for_key.request_render(false);
6705                    continue;
6706                }
6707                if status == RunStatus::Idle
6708                    && editor_for_key.get_text().trim().is_empty()
6709                    && double_escape_action != "none"
6710                {
6711                    let now = std::time::Instant::now();
6712                    if double_escape_trigger(last_escape_time, now) {
6713                        last_escape_time = None;
6714                        match double_escape_action.as_str() {
6715                            "tree" => {
6716                                let _ = tx_for_key.send(TuiMessage::OpenTree);
6717                            }
6718                            "fork" => {
6719                                let _ = tx_for_key.send(TuiMessage::ForkSession);
6720                            }
6721                            _ => {}
6722                        }
6723                    } else {
6724                        last_escape_time = Some(now);
6725                    }
6726                }
6727                continue;
6728            }
6729
6730            // 2c. Ctrl+G: edit the current draft in the user's external
6731            // editor, matching native Pi's VISUAL/EDITOR fallback chain.
6732            if keybinding_matches(
6733                &keybindings_for_key,
6734                &key,
6735                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
6736            ) {
6737                launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
6738                continue;
6739            }
6740
6741            // 2d. Ctrl+O: toggle all tool output panels between compact and
6742            // expanded rendering (native Pi's global output toggle).
6743            if keybinding_matches(
6744                &keybindings_for_key,
6745                &key,
6746                rpi_tui::keybindings::keys::TOOLS_EXPAND,
6747            ) {
6748                let expanded = state_for_key.toggle_tool_outputs();
6749                // Persist per-session so a later `-c`/`-r` restores it.
6750                let session = ctx_for_key.session.clone();
6751                tokio::spawn(async move {
6752                    let _ = session
6753                        .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
6754                        .await;
6755                });
6756                tui_for_key.request_render(false);
6757                continue;
6758            }
6759
6760            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
6761            if keybinding_matches(
6762                &keybindings_for_key,
6763                &key,
6764                rpi_tui::keybindings::keys::THINKING_TOGGLE,
6765            ) {
6766                let hide = state_for_key.toggle_thinking();
6767                // Persist per-session so a later `-c`/`-r` restores it.
6768                let session = ctx_for_key.session.clone();
6769                tokio::spawn(async move {
6770                    let _ = session
6771                        .set_value("display.hide_thinking", serde_json::json!(hide))
6772                        .await;
6773                });
6774                tui_for_key.request_render(false);
6775                continue;
6776            }
6777
6778            // 2f. Ctrl+M: cycle to the next model in the catalog after the one
6779            //     currently tracked in `current_model_id`, apply it live via
6780            //     `lane.set_model` (takes effect on the next user message — the
6781            //     in-flight run's config is already snapshotted), and update the
6782            //     footer. `set_model` is async so it runs on a spawned task.
6783            if keybinding_matches(
6784                &keybindings_for_key,
6785                &key,
6786                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6787            ) {
6788                let current = state_for_key.current_model_id();
6789                // Cycle within the `/scoped-models` set (settings.json) when
6790                // configured; otherwise the full catalog.
6791                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
6792                if let Some(next) = cycle_next_model(&scope, &current) {
6793                    state_for_key.set_current_model(&next);
6794                    let lane = lane_for_key.clone();
6795                    tokio::spawn(async move {
6796                        let _ = lane.set_model(next).await;
6797                    });
6798                    tui_for_key.request_render_reusing_scroll_content();
6799                }
6800                continue;
6801            }
6802
6803            // 2f. Shift+Tab / BackTab: cycle the current model's supported
6804            // thinking levels, matching native Pi's thinking-level shortcut.
6805            if keybinding_matches(
6806                &keybindings_for_key,
6807                &key,
6808                rpi_tui::keybindings::keys::THINKING_CYCLE,
6809            ) {
6810                let lane = lane_for_key.clone();
6811                let catalog = model_catalog_for_key.clone();
6812                let state = state_for_key.clone();
6813                tokio::spawn(async move {
6814                    let Ok(current_model) = lane.get_model().await else {
6815                        return;
6816                    };
6817                    let levels = catalog
6818                        .iter()
6819                        .find(|model| {
6820                            model.provider == current_model.provider && model.id == current_model.id
6821                        })
6822                        .map(|model| model.supported_thinking_levels())
6823                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6824                    if levels.is_empty() {
6825                        return;
6826                    }
6827                    let current = lane
6828                        .get_thinking_level()
6829                        .await
6830                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6831                    let next = levels
6832                        .iter()
6833                        .position(|level| *level == current)
6834                        .map(|index| levels[(index + 1) % levels.len()])
6835                        .unwrap_or(levels[0]);
6836                    if lane.set_thinking_level(next).await.is_ok() {
6837                        state
6838                            .footer
6839                            .set_thinking_level(Some(thinking_level_name(next)));
6840                        state.tui.as_ref().map(|tui| tui.request_render(false));
6841                    }
6842                });
6843                continue;
6844            }
6845
6846            // Ctrl+V (or a configured paste-image key): if the system
6847            // clipboard holds an image, queue it for the next prompt;
6848            // otherwise paste the clipboard TEXT into the editor. Previously
6849            // a text-only clipboard fell through to the editor's Ctrl+V,
6850            // which yanks from the internal KILL RING — not the system
6851            // clipboard. On platforms without bracketed-paste `Event::Paste`
6852            // (Windows console, some remote clients) that meant Ctrl+V pasted
6853            // stale/other content instead of the user's real clipboard
6854            // ("不是系统的粘贴的内容").
6855            if keybinding_matches(
6856                &keybindings_for_key,
6857                &key,
6858                rpi_tui::keybindings::keys::PASTE_IMAGE,
6859            ) {
6860                match read_clipboard_image() {
6861                    Ok(Some(image)) => {
6862                        add_image_preview(&state_for_key.chat_container, &image);
6863                        state_for_key.queue_image(image);
6864                        add_note_message(
6865                            &state_for_key.chat_container,
6866                            "Clipboard image attached to the next prompt.",
6867                        );
6868                        tui_for_key.request_render(false);
6869                        continue;
6870                    }
6871                    Ok(None) | Err(_) => {}
6872                }
6873                // The arboard text fallback is Windows-only. On Unix a real
6874                // paste arrives as `Event::Paste` (bracketed paste is enabled
6875                // by `ProcessTerminal::enter_raw_mode`), so reading the
6876                // SERVER's clipboard here would paste stale TUI content on a
6877                // remote/mobile client — copy-on-select can have written the
6878                // welcome "Skills (…)" line into the server clipboard, and
6879                // Ctrl+V on the client returns that instead of the user's own
6880                // clipboard ("不是系统的粘贴的内容"). On Windows the console
6881                // never emits `Event::Paste`, so the arboard read is the only
6882                // paste source and must stay.
6883                if cfg!(windows) {
6884                    if let Some(text) = read_clipboard_text() {
6885                        if !text.is_empty() {
6886                            editor_for_key.insert(&text);
6887                            refresh_autocomplete(&state_for_key, &editor_for_key);
6888                            tui_for_key.request_render_reusing_scroll_content();
6889                            continue;
6890                        }
6891                    }
6892                }
6893            }
6894
6895            // 3. Ctrl+Shift+F: open transcript search
6896            if key
6897                .modifiers
6898                .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6899                && key.code == KeyCode::Char('F')
6900            {
6901                state_for_key.search.activate();
6902                state_for_key.search_bar.set_visible(true);
6903                state_for_key.search_bar.set_query("");
6904                state_for_key.search_bar.set_match_info(0, 0);
6905                tui_for_key.request_render(false);
6906                continue;
6907            }
6908
6909            // Handle search mode input
6910            if state_for_key.search.is_active() {
6911                let search_bar = state_for_key.search_bar.clone();
6912                match key.code {
6913                    KeyCode::Esc => {
6914                        state_for_key.search.deactivate();
6915                        search_bar.set_visible(false);
6916                        tui_for_key.request_render(false);
6917                        continue;
6918                    }
6919                    KeyCode::Enter => {
6920                        if key.modifiers.contains(KeyModifiers::SHIFT) {
6921                            state_for_key.search.previous_match();
6922                        } else {
6923                            state_for_key.search.next_match();
6924                        }
6925                        // Update search bar with current match info
6926                        let match_index = state_for_key.search.get_match_index();
6927                        let match_count = state_for_key.search.get_match_count();
6928                        search_bar.set_match_info(match_index, match_count);
6929                        tui_for_key.request_render(false);
6930                        continue;
6931                    }
6932                    KeyCode::Backspace => {
6933                        state_for_key.search.backspace();
6934                        // Re-search with updated query
6935                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6936                        state_for_key.search.find_matches(&lines);
6937                        // Update search bar
6938                        let query = state_for_key.search.get_query();
6939                        let match_count = state_for_key.search.get_match_count();
6940                        search_bar.set_query(&query);
6941                        search_bar.set_match_info(0, match_count);
6942                        tui_for_key.request_render(false);
6943                        continue;
6944                    }
6945                    KeyCode::Char(c) => {
6946                        state_for_key.search.append_char(c);
6947                        // Re-search with updated query
6948                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6949                        state_for_key.search.find_matches(&lines);
6950                        // Update search bar
6951                        let query = state_for_key.search.get_query();
6952                        let match_count = state_for_key.search.get_match_count();
6953                        search_bar.set_query(&query);
6954                        search_bar.set_match_info(0, match_count);
6955                        tui_for_key.request_render(false);
6956                        continue;
6957                    }
6958                    _ => continue,
6959                }
6960            }
6961
6962            // 3. Ctrl+L: open the model selector. Routed through the `/model`
6963            //    command so the hotkey and the slash command share one path
6964            //    (TS binds Ctrl+L to model-select).
6965            if keybinding_matches(
6966                &keybindings_for_key,
6967                &key,
6968                rpi_tui::keybindings::keys::MODEL_SELECT,
6969            ) {
6970                if let Some(cmd) = registry_for_key.find("/model") {
6971                    cmd.execute(&ctx_for_key, "");
6972                }
6973                continue;
6974            }
6975
6976            // 4. Tab: accept the top autocomplete suggestion (if any).
6977            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6978                if accept_top_suggestion(&state_for_key, &editor_for_key) {
6979                    tui_for_key.request_render_reusing_scroll_content();
6980                }
6981                continue;
6982            }
6983
6984            // 5. Global transcript scroll. PageUp/PageDown use the actual
6985            // viewport height with four rows of overlap (upstream behavior),
6986            // while Home/End jump to the transcript boundaries.
6987            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6988                let delta = -transcript_page_size(scroll_for_key.viewport_height());
6989                if scroll_for_key.scroll_by(delta) != delta {
6990                    tui_for_key.request_render_reusing_scroll_content();
6991                }
6992                continue;
6993            }
6994            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6995                let delta = transcript_page_size(scroll_for_key.viewport_height());
6996                if scroll_for_key.scroll_by(delta) != delta {
6997                    tui_for_key.request_render_reusing_scroll_content();
6998                }
6999                continue;
7000            }
7001            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
7002                scroll_for_key.scroll_to_start();
7003                tui_for_key.request_render_reusing_scroll_content();
7004                continue;
7005            }
7006            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
7007                scroll_for_key.scroll_to_end();
7008                tui_for_key.request_render_reusing_scroll_content();
7009                continue;
7010            }
7011
7012            // 5b. ↑/↓ browse submitted-message history when the editor is
7013            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
7014            //     without the surprise of replacing typed text. When the
7015            //     editor holds content, ↑/↓ fall through to cursor movement
7016            //     (typing "hello", pressing ↑ at the start, must never swap
7017            //     the draft for a history entry — reported as "text
7018            //     disappeared"). Once browsing, ↓ walks back and restores the
7019            //     draft.
7020            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
7021                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7022                if editor_for_key.get_text().is_empty() || browsing {
7023                    navigate_history(&state_for_key, &editor_for_key, -1);
7024                    tui_for_key.request_render_reusing_scroll_content();
7025                    continue;
7026                }
7027            }
7028            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
7029                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7030                if editor_for_key.get_text().is_empty() || browsing {
7031                    navigate_history(&state_for_key, &editor_for_key, 1);
7032                    tui_for_key.request_render_reusing_scroll_content();
7033                    continue;
7034                }
7035            }
7036
7037            // `app.message.dequeue` (Alt+Q on Windows, Alt+Up elsewhere):
7038            // restore every queued steering/follow-up message into the editor
7039            // so it can be edited before resending. Mirrors native pi's
7040            // `handleDequeue`; the pending-messages display shows the hint.
7041            if keybinding_matches(
7042                &keybindings_for_key,
7043                &key,
7044                rpi_tui::keybindings::keys::DEQUEUE,
7045            ) {
7046                // Handled here, on the key worker — NOT via the mailbox. The
7047                // async main loop parks inside `run_prompt_streaming(..).await`
7048                // for the whole run, and a queue only exists *while* a run is
7049                // in flight, so a `TuiMessage::Dequeue` sat unread until the
7050                // run finished (after the lane had already consumed the queue).
7051                // Alt+Q therefore looked like a no-op while it was most needed.
7052                // Spawn the drain so it lands while the user is still looking at
7053                // the queue, exactly like the Alt+Enter `follow_up` path above.
7054                tokio::spawn(restore_queued_messages_to_editor(
7055                    lane_for_key.clone(),
7056                    editor_for_key.clone(),
7057                    state_for_key.clone(),
7058                    tui_for_key.clone(),
7059                ));
7060                continue;
7061            }
7062
7063            // Alt+Enter queues a follow-up while a run is active. It is
7064            // handled here because Editor treats only a bare Enter as submit;
7065            // idle Alt+Enter keeps the normal prompt behavior.
7066            if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
7067                let prompt = editor_for_key.get_text().trim().to_string();
7068                if prompt.is_empty() {
7069                    continue;
7070                }
7071                editor_for_key.clear();
7072                let status = *state_for_key.status.lock().unwrap();
7073                if status == RunStatus::Idle {
7074                    if state_for_key.try_start_working() {
7075                        // Direct send: the harness emits no user `message_start`
7076                        // for it, so render the bubble here (see the submit
7077                        // handler in `interactive_tui`).
7078                        add_user_message(&state_for_key.chat_container, &prompt);
7079                        push_history(&state_for_key, &prompt);
7080                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
7081                    }
7082                } else {
7083                    // Follow-ups are echoed by the loop's
7084                    // `AgentEvent::MessageStart` once they are consumed; while
7085                    // they wait, the pending-messages display shows them.
7086                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
7087                    let lane = lane_for_key.clone();
7088                    let chat = state_for_key.chat_container.clone();
7089                    let tui = tui_for_key.clone();
7090                    let state = state_for_key.clone();
7091                    tokio::spawn(async move {
7092                        if let Err(error) = lane.follow_up(message).await {
7093                            add_error_message(&chat, &format!("Could not queue message: {error}"));
7094                            tui.request_render(false);
7095                            return;
7096                        }
7097                        refresh_pending_messages(&state, &lane).await;
7098                        tui.request_render(false);
7099                    });
7100                }
7101                tui_for_key.request_render(false);
7102                continue;
7103            }
7104
7105            // ---- Paste-burst coalescing (Windows only) ----------------------
7106            // `crossterm` 0.27 implements bracketed paste ONLY on Unix: its
7107            // Windows console event source (`ReadConsoleInputW`) never emits
7108            // `Event::Paste`, so `?2004h` buys nothing there and a pasted block
7109            // arrives as ordinary key events with a bare Enter per line. The
7110            // editor treats a bare Enter as "submit", so one paste became N
7111            // messages. See [`enter_is_paste_burst`] for the discriminator.
7112            //
7113            // On Unix the timing heuristic is both unnecessary AND harmful: a
7114            // remote/mobile client (SSH, soft keyboard) coalesces typed text +
7115            // Enter into one network burst, so the Enter lands within
7116            // [`PASTE_BURST_GAP`] of the last character and the heuristic
7117            // misclassifies it as pasted content — "回车变成了换行". Real pastes
7118            // on Unix arrive as `Event::Paste` (bracketed paste is enabled by
7119            // `ProcessTerminal::enter_raw_mode`), so gating to Windows restores
7120            // remote Enter without losing the Windows paste fix.
7121            if cfg!(windows)
7122                && key.modifiers.is_empty()
7123                && matches!(
7124                    key.code,
7125                    KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
7126                )
7127            {
7128                // Probe for a *real* queued key. Windows queues each key's
7129                // Release event directly behind its Press, and a bare
7130                // non-blocking poll cannot tell the two apart — treating that
7131                // Release as "more input" made every Enter on a remote/RDP
7132                // console look like a paste burst ("回车变成了换行"). See
7133                // [`probe_paste_input`].
7134                let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
7135                    if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
7136                        crossterm::event::read().ok()
7137                    } else {
7138                        None
7139                    }
7140                });
7141                pending_event = stashed;
7142                if enter_is_paste_burst(last_text_key_at, std::time::Instant::now(), more_queued) {
7143                    crate::key_trace::note(&format!(
7144                        "enter-decision -> newline (paste burst) gap_ms={} more_queued={more_queued}",
7145                        gap_ms(last_text_key_at),
7146                    ));
7147                    // Pasted newline: insert it and keep the remaining queued
7148                    // events flowing through this same path.
7149                    editor_for_key.insert("\n");
7150                    last_text_key_at = Some(std::time::Instant::now());
7151                    refresh_autocomplete(&state_for_key, &editor_for_key);
7152                    tui_for_key.request_render_reusing_scroll_content();
7153                    continue;
7154                }
7155                crate::key_trace::note(&format!(
7156                    "enter-decision -> submit gap_ms={} more_queued={more_queued}",
7157                    gap_ms(last_text_key_at),
7158                ));
7159                // A real submit ends the burst so a follow-up Enter is not
7160                // mistaken for paste continuation.
7161                last_text_key_at = None;
7162            } else if matches!(
7163                key.code,
7164                KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
7165            ) && !key
7166                .modifiers
7167                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
7168            {
7169                // Unmodified text keys extend the burst. Ctrl/Alt chords
7170                // (Ctrl+C, Alt+Enter, …) are commands, not paste content.
7171                last_text_key_at = Some(std::time::Instant::now());
7172            } else {
7173                // Arrows, Esc, Ctrl/Alt chords, PageUp/Down, … — not paste
7174                // content; break the burst.
7175                last_text_key_at = None;
7176            }
7177
7178            // 6. Otherwise forward to the editor + refresh autocomplete. The
7179            //    editor maps unmodified Enter and the encodings a remote client
7180            //    may send for it (`Char('\n')`/`Char('\r')`, and the raw LF
7181            //    crossterm reports as `Ctrl+J`) to submit, so a remote Enter
7182            //    sends instead of inserting a newline.
7183            editor_for_key.handle_key(key);
7184            refresh_autocomplete(&state_for_key, &editor_for_key);
7185            tui_for_key.request_render_reusing_scroll_content();
7186        }
7187    });
7188
7189    // ---- Session lifecycle (P1): SessionStart ----
7190    // The TUI + key worker are up and the session is ready to accept input.
7191    // Notify subscribed extensions now so they can initialize session-scoped
7192    // state. A veto here is **advisory** (the session is already up) — warn and
7193    // continue. No-op when no extension subscribes.
7194    if let Some(reason) = crate::session::dispatch_session_event_async(
7195        reload_context,
7196        rpi_plugin_sdk::EventTag::SessionStart,
7197    )
7198    .await
7199    {
7200        tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
7201    }
7202
7203    // ---- Initial prompts (run before reading from the channel) ----
7204    let mut prompts: Vec<String> = Vec::new();
7205    if let Some(init) = initial {
7206        prompts.push(init);
7207    }
7208    for m in extra_messages {
7209        prompts.push(m.clone());
7210    }
7211    // ---- Continue a run that recovery repaired, the way native pi does ----
7212    // This happens before any user input, and only when nothing was passed on the
7213    // command line: an explicit `-p` means the user has already said what they
7214    // want next. Driven here rather than inside the harness so the run's output
7215    // goes through the same streaming/render path as any other run, and so
7216    // startup is never blocked by a run the user cannot see.
7217    if prompts.is_empty() && lane.has_pending_resume().await {
7218        run_prompt_streaming(
7219            &lane,
7220            "",
7221            true,
7222            &tui,
7223            &state,
7224            drain_handle.is_some(),
7225            reload_context.js_extension_session.as_ref(),
7226            &js_dialog_bridge,
7227            args,
7228            Vec::new(),
7229        )
7230        .await;
7231    }
7232
7233    let mut images = initial_images;
7234    for prompt in prompts {
7235        if !*running.lock().unwrap() {
7236            break;
7237        }
7238        // The harness emits no user `message_start` for a directly-sent prompt
7239        // (it drives the loop with an empty prompts vec), so the initial
7240        // `-p` / `--prompt` bubbles are rendered here.
7241        add_user_message(&chat_container, &prompt);
7242        tui.request_render(false);
7243        run_prompt_streaming(
7244            &lane,
7245            &prompt,
7246            false,
7247            &tui,
7248            &state,
7249            drain_handle.is_some(),
7250            reload_context.js_extension_session.as_ref(),
7251            &js_dialog_bridge,
7252            args,
7253            std::mem::take(&mut images),
7254        )
7255        .await;
7256    }
7257
7258    // ---- Main loop: process submitted input + lifecycle messages ----
7259    loop {
7260        if !*running.lock().unwrap() {
7261            break;
7262        }
7263        match rx.recv().await {
7264            Some(TuiMessage::UserInput(prompt)) => {
7265                // Clear the editor so the next prompt starts fresh (the submit
7266                // handler runs on the blocking key thread and can't mutate the
7267                // editor state safely there; clearing here, on the async loop,
7268                // keeps it on one thread).
7269                editor.clear();
7270                let prompt_images = state.take_pending_images();
7271                if !prompt_images.is_empty() {
7272                    add_note_message(
7273                        &chat_container,
7274                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
7275                    );
7276                }
7277                run_prompt_streaming(
7278                    &lane,
7279                    &prompt,
7280                    false,
7281                    &tui,
7282                    &state,
7283                    drain_handle.is_some(),
7284                    reload_context.js_extension_session.as_ref(),
7285                    &js_dialog_bridge,
7286                    args,
7287                    prompt_images,
7288                )
7289                .await;
7290            }
7291            Some(TuiMessage::ExternalEditorResult(result)) => {
7292                match result {
7293                    Ok(text) => {
7294                        let cursor = text.chars().count();
7295                        editor.set_text(&text);
7296                        editor.set_cursor(0, cursor);
7297                        add_note_message(&chat_container, "Draft updated from external editor.");
7298                    }
7299                    Err(error) => add_error_message(&chat_container, &error),
7300                }
7301                tui.request_render(false);
7302            }
7303            Some(TuiMessage::UserBashFinished(report)) => {
7304                // Free the run slot first: the guard and the Esc router both
7305                // read it, so a finished command must never block the next one.
7306                state.finish_user_bash();
7307                let message = rpi_harness::messages::bash_execution_message(
7308                    report.command,
7309                    report.output,
7310                    report.exit_code,
7311                    report.cancelled,
7312                    report.truncated,
7313                    report.full_output_path,
7314                    Some(report.exclude_from_context),
7315                    now_ms(),
7316                );
7317                // While a run is in flight the message is deferred so it is not
7318                // spliced into the middle of a turn's tool sequence; it is
7319                // flushed at `AgentEnd` (native pi `_pendingBashMessages`).
7320                if *state.status.lock().unwrap() == RunStatus::Idle {
7321                    if let Err(error) = lane.append_message(message).await {
7322                        add_error_message(
7323                            &chat_container,
7324                            &format!("Could not record bash result: {error}"),
7325                        );
7326                        tui.request_render(false);
7327                    }
7328                } else {
7329                    state.pending_bash_messages.lock().unwrap().push(message);
7330                }
7331            }
7332            Some(TuiMessage::OpenTree) => {
7333                if *state.status.lock().unwrap() != RunStatus::Idle {
7334                    add_note_message(
7335                        &chat_container,
7336                        "Wait for the current run to finish before opening the tree.",
7337                    );
7338                    tui.request_render(false);
7339                } else {
7340                    open_tree_selector(
7341                        &harness,
7342                        &state,
7343                        &editor_container,
7344                        &editor,
7345                        &tui,
7346                        &chat_container,
7347                        &tx,
7348                    )
7349                    .await;
7350                }
7351            }
7352            Some(TuiMessage::NavigateTree(entry_id)) => {
7353                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
7354                    Ok(result) => match result.outcome {
7355                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
7356                            chat_container.clear();
7357                            add_welcome_message(&chat_container);
7358                            render_session_history(
7359                                &harness,
7360                                &chat_container,
7361                                state.markdown_transformer(),
7362                                Some(state.extension_session.clone()),
7363                            )
7364                            .await;
7365                            add_note_message(
7366                                &chat_container,
7367                                "Moved to the selected session entry.",
7368                            );
7369                        }
7370                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
7371                            add_error_message(&chat_container, &error.message);
7372                        }
7373                        _ => add_note_message(
7374                            &chat_container,
7375                            "The selected entry could not be opened.",
7376                        ),
7377                    },
7378                    Err(error) => add_error_message(
7379                        &chat_container,
7380                        &format!("Could not navigate session tree: {error}"),
7381                    ),
7382                }
7383                tui.request_render(false);
7384            }
7385            Some(TuiMessage::ClearChat) => {
7386                chat_container.clear();
7387                add_welcome_message(&chat_container);
7388                tui.request_render(false);
7389            }
7390            Some(TuiMessage::Compact) => {
7391                run_compact(&lane, &tui, &state).await;
7392            }
7393            Some(TuiMessage::Copy) => {
7394                copy_last_assistant(&state, &chat_container);
7395                tui.request_render(false);
7396            }
7397            Some(TuiMessage::Exit) => {
7398                *running.lock().unwrap() = false;
7399                break;
7400            }
7401            Some(TuiMessage::SwitchSession(id)) => {
7402                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
7403                tui.request_render(false);
7404            }
7405            Some(TuiMessage::ImportSession(path)) => {
7406                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
7407                tui.request_render(false);
7408            }
7409            Some(TuiMessage::ShareSession) => {
7410                share_session(&harness, &chat_container).await;
7411                tui.request_render(false);
7412            }
7413            Some(TuiMessage::SetSessionName(name)) => {
7414                let outcome = harness.session().set_name(Some(&name)).await;
7415                match outcome {
7416                    Ok(_) => add_note_message(
7417                        &chat_container,
7418                        &format!("Session renamed to \"{name}\"."),
7419                    ),
7420                    Err(e) => add_error_message(
7421                        &chat_container,
7422                        &format!("Could not rename session: {e}"),
7423                    ),
7424                }
7425                tui.request_render(false);
7426            }
7427            Some(TuiMessage::ExportSession) => {
7428                let file_name = agent_session
7429                    .default_export_file_name(crate::export::ExportFormat::Markdown)
7430                    .await;
7431                let path = cwd.join(&file_name);
7432                match agent_session.export_to_markdown(&path).await {
7433                    Ok(_) => add_note_message(
7434                        &chat_container,
7435                        &format!("Exported session to {}", path.display()),
7436                    ),
7437                    Err(e) => {
7438                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
7439                    }
7440                }
7441                tui.request_render(false);
7442            }
7443            Some(TuiMessage::ExportSessionWithFormat(format)) => {
7444                let file_name = agent_session.default_export_file_name(format.clone()).await;
7445                let path = cwd.join(&file_name);
7446                match agent_session.export(format, &path).await {
7447                    Ok(_) => add_note_message(
7448                        &chat_container,
7449                        &format!("Exported session to {}", path.display()),
7450                    ),
7451                    Err(e) => {
7452                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
7453                    }
7454                }
7455                tui.request_render(false);
7456            }
7457            Some(TuiMessage::ShowUsage) => {
7458                let stats = agent_session.usage_stats().await.ok();
7459                let total = agent_session.total_usage().await.ok();
7460                let snapshot = agent_session.snapshot().await.ok();
7461                match stats {
7462                    Some(stats) => {
7463                        let mut lines = vec![
7464                            "## Usage".to_string(),
7465                            String::new(),
7466                            format!("- messages: {}", stats.message_count),
7467                            format!("- total tokens: {}", stats.total_tokens),
7468                            format!("- cached tokens: {}", stats.cached_tokens),
7469                            format!("- uncached tokens: {}", stats.uncached_tokens),
7470                            format!("- cost: ${:.4}", stats.cost_total),
7471                        ];
7472                        match stats.cache_hit_ratio() {
7473                            Some(ratio) => {
7474                                lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
7475                            }
7476                            None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
7477                        }
7478                        if let Some(total) = total {
7479                            lines.push(String::new());
7480                            lines.push(format!(
7481                                "- last cumulative input/output: {} / {}",
7482                                total.input, total.output
7483                            ));
7484                        }
7485                        if let Some(snapshot) = snapshot {
7486                            lines.push(String::new());
7487                            lines.push(format!("- lane: `{}`", snapshot.lane));
7488                            lines.push(format!(
7489                                "- leaf: `{}`",
7490                                snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
7491                            ));
7492                            if let Some(active) = &snapshot.active {
7493                                lines.push(format!(
7494                                    "- active operation: {} (`{}`)",
7495                                    active.kind.as_str(),
7496                                    active.run_id
7497                                ));
7498                            }
7499                        }
7500                        add_note_message(&chat_container, &lines.join("\n"));
7501                    }
7502                    None => {
7503                        add_error_message(&chat_container, "Could not read session usage stats.")
7504                    }
7505                }
7506                tui.request_render(false);
7507            }
7508            Some(TuiMessage::ForkSession) => {
7509                fork_session(&harness, &cwd, &chat_container, &state).await;
7510                tui.request_render(false);
7511            }
7512            Some(TuiMessage::ReloadExtensions) => {
7513                // B5d: drive the shared reload routine on the async runtime,
7514                // then surface the outcome. `reload_context` was passed into
7515                // `interactive_tui` and is the same `Arc<ReloadContext>` the
7516                // `ReloadCommand` + the plugin mailbox both route through —
7517                // clone the `Arc` out so the borrow of `harness` (the main
7518                // loop's `&AgentHarness`) lives across the await.
7519                let reload_ctx = ctx.reload_context.clone();
7520                add_note_message(&chat_container, "Reloading extensions + resources…");
7521                tui.request_render(false);
7522                let outcome =
7523                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
7524                // B5e: the reload swapped a fresh `ExtensionSession` into the
7525                // context's cell. Rebuild the markdown transformer from that
7526                // fresh snapshot and install it on the in-flight streaming
7527                // component (so a reloaded plugin's transformer takes effect on
7528                // the visible message immediately) + future components (they
7529                // read `state.markdown_transformer()` at construction). The old
7530                // closure no-ops once its snapshot's `active` flag flips false
7531                // (reload already did that before the swap).
7532                let fresh_transformer = build_markdown_transformer(
7533                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
7534                );
7535                state.set_markdown_transformer_with_reinstall(fresh_transformer);
7536                if outcome.had_errors {
7537                    // Hard failure: the summary itself carries the error detail
7538                    // (e.g. "Settings reload failed: …"), so render it as an
7539                    // error without an extra stderr pointer.
7540                    add_error_message(&chat_container, &outcome.summary);
7541                } else if outcome.had_warnings {
7542                    // Reload succeeded but produced soft diagnostics (skill
7543                    // shadowing, name validation, …). The details are printed
7544                    // to stderr; keep this as an informational note so a
7545                    // successful reload isn't shown as a red ✗ error.
7546                    add_note_message(
7547                        &chat_container,
7548                        &format!(
7549                            "{} (with warnings — see stderr for details).",
7550                            outcome.summary
7551                        ),
7552                    );
7553                } else {
7554                    add_note_message(&chat_container, &outcome.summary);
7555                }
7556                tui.request_render(false);
7557            }
7558            None => break,
7559        }
7560    }
7561
7562    // ---- Shutdown ----
7563    // Wake any Node `ctx.ui.*` request that is still waiting on the dialog
7564    // bridge before joining the key worker and restoring the terminal.
7565    js_dialog_bridge.cancel_all();
7566    ask_user_bridge.shutdown();
7567    *running.lock().unwrap() = false;
7568    for handle in update_check_handles {
7569        handle.abort();
7570        let _ = handle.await;
7571    }
7572    // A hidden custom input listener can leave the key worker blocked on a
7573    // synchronous Node response. Stop the host first so transport shutdown
7574    // wakes that request before we join the worker.
7575    if let Some(js) = &reload_context.js_extension_session {
7576        js.shutdown();
7577    }
7578    // The input worker checks `running` at least every 50ms. Join it before
7579    // restoring cooked mode so no late event read races terminal cleanup.
7580    let _ = key_handle.await;
7581    tick_handle.abort();
7582    if let Some(handle) = drain_handle {
7583        handle.abort();
7584    }
7585    // Drop the reload bridge: clearing the mailbox closes the signal channel,
7586    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
7587    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
7588    reload_context.mailbox.clear();
7589    reload_bridge_handle.abort();
7590    tui.stop(Default::default());
7591
7592    // Native pi prints how to resume the session after an interactive exit so
7593    // a terminal that scrolled away can be recovered. Mirrors
7594    // `formatResumeCommand` (id + `--session-dir` only when non-default).
7595    let session_id = harness.session().storage().metadata().id;
7596    if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
7597        println!("\x1b[2mTo resume this session:\x1b[0m {command}");
7598    }
7599    println!("\nGoodbye!");
7600    let _ = args;
7601
7602    0
7603}
7604
7605// ===========================================================================
7606// Run a single prompt (streaming or blocking)
7607// ===========================================================================
7608
7609/// Prepare the session's persistent Node host immediately before a real prompt
7610/// enters the agent loop. The first call starts the lazy host; later calls run
7611/// `before_agent_start` again on that host so each prompt sees current state.
7612/// Keeping startup here leaves an idle TUI free of a Node child while still
7613/// giving the lifecycle hook the fully installed UI bridge.
7614async fn ensure_js_runtime_before_prompt(
7615    js: Option<&crate::js_extensions::JsExtensionSession>,
7616    lane: &Arc<dyn AgentLane>,
7617    state: &Arc<TuiState>,
7618    dialog_bridge: &JsDialogBridge,
7619    args: &Args,
7620) -> bool {
7621    let Some(js) = js else {
7622        return true;
7623    };
7624    let cancellation = state.begin_js_preparation();
7625    let worker_cancellation = cancellation.clone();
7626    let js_for_start = js.clone();
7627    let mut worker = tokio::task::spawn_blocking(move || {
7628        js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
7629    });
7630    let result = tokio::select! {
7631        result = &mut worker => result,
7632        _ = cancellation.cancelled() => {
7633            dialog_bridge.cancel_open_requests();
7634            let js_for_cancel = js.clone();
7635            let _ = tokio::task::spawn_blocking(move || {
7636                js_for_cancel.cancel_prompt_preparation();
7637            }).await;
7638            worker.await
7639        }
7640    };
7641    let was_cancelled = cancellation.is_cancelled();
7642    if was_cancelled {
7643        dialog_bridge.cancel_open_requests();
7644        let js_for_cancel = js.clone();
7645        let _ = tokio::task::spawn_blocking(move || {
7646            js_for_cancel.cancel_prompt_preparation();
7647        })
7648        .await;
7649        state.finish_js_preparation();
7650        dialog_bridge.reopen();
7651        return false;
7652    }
7653    state.finish_js_preparation();
7654    match result {
7655        Ok(Ok(())) => {
7656            // The lifecycle hook can change the JS-only active tool set once
7657            // it sees the real TUI context. Merge that subset with the Rust
7658            // built-ins while applying the command-line tool policy.
7659            if let Some(js_active) = js.active_tools() {
7660                let js_names = js.tool_names();
7661                let mut active = lane.get_active_tools().await.unwrap_or_default();
7662                active.retain(|name| {
7663                    crate::session::tool_name_allowed(name, args)
7664                        && !js_names.iter().any(|js_name| js_name == name)
7665                });
7666                active.extend(js_active.into_iter().filter(|name| {
7667                    js_names.iter().any(|js_name| js_name == name)
7668                        && crate::session::tool_name_allowed(name, args)
7669                }));
7670                active = crate::session::filter_active_tool_names(active, args);
7671                let _ = lane.set_active_tools(active).await;
7672            }
7673        }
7674        Ok(Err(error)) => {
7675            if args.verbose {
7676                eprintln!("warning: could not start JS extension runtime: {error}");
7677            }
7678        }
7679        Err(error) => {
7680            if args.verbose {
7681                eprintln!("warning: JS extension runtime worker failed: {error}");
7682            }
7683        }
7684    }
7685    true
7686}
7687
7688fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
7689    std::thread::spawn(move || {
7690        let file = match tempfile::Builder::new()
7691            .prefix("rpi-draft-")
7692            .suffix(".md")
7693            .tempfile()
7694        {
7695            Ok(file) => file,
7696            Err(error) => {
7697                let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7698                    "Could not create editor file: {error}"
7699                ))));
7700                return;
7701            }
7702        };
7703        if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
7704            let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7705                "Could not write editor file: {error}"
7706            ))));
7707            return;
7708        }
7709        let editor = std::env::var("RPI_EXTERNAL_EDITOR")
7710            .ok()
7711            .filter(|value| !value.trim().is_empty())
7712            .or_else(|| std::env::var("VISUAL").ok())
7713            .or_else(|| std::env::var("EDITOR").ok())
7714            .unwrap_or_else(|| {
7715                if cfg!(windows) {
7716                    "notepad".to_string()
7717                } else {
7718                    "nano".to_string()
7719                }
7720            });
7721        let status = std::process::Command::new(&editor)
7722            .arg(file.path())
7723            .status();
7724        let result = match status {
7725            Ok(status) if status.success() => std::fs::read_to_string(file.path())
7726                .map_err(|error| format!("Could not read editor file: {error}")),
7727            Ok(status) => Err(format!("External editor exited with {status}")),
7728            Err(error) => Err(format!(
7729                "Could not launch external editor `{editor}`: {error}"
7730            )),
7731        };
7732        let _ = tx.send(TuiMessage::ExternalEditorResult(result));
7733    });
7734}
7735
7736/// Apply the authoritative run result when it wins the race with the async
7737/// event drain, then detach the live component from further partial updates.
7738fn reconcile_streamed_assistant_completion(
7739    current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
7740    last_assistant_text: &Mutex<String>,
7741    final_message: Option<&AssistantMessage>,
7742) {
7743    let final_blocks = final_message.map(assistant_blocks);
7744    let final_text = final_message.map(assistant_text);
7745    let component = current_assistant.lock().unwrap().take();
7746
7747    if let Some(component) = component {
7748        if let Some(blocks) = final_blocks.as_deref() {
7749            component.update_blocks(blocks);
7750        }
7751        component.set_streaming(false);
7752    }
7753
7754    if let Some(text) = final_text.filter(|text| !text.is_empty()) {
7755        *last_assistant_text.lock().unwrap() = text;
7756    }
7757}
7758
7759/// Drive a single prompt through the lane. When `streaming` is true, the
7760/// `AgentEvent` drain task renders the response live and the completed outcome
7761/// reconciles its final snapshot. When false (no `event_rx`), this falls back
7762/// to the blocking await-final-text path.
7763async fn run_prompt_streaming(
7764    lane: &Arc<dyn AgentLane>,
7765    prompt: &str,
7766    resume: bool,
7767    tui: &Arc<TuiAltScreen>,
7768    state: &Arc<TuiState>,
7769    streaming: bool,
7770    js: Option<&crate::js_extensions::JsExtensionSession>,
7771    dialog_bridge: &JsDialogBridge,
7772    args: &Args,
7773    images: Vec<rpi_ai::types::ImageContent>,
7774) {
7775    // The persistent Node host is intentionally started at the first real
7776    // prompt. By this point the TUI key worker and all UI/runtime handlers are
7777    // live, so a `before_agent_start` hook may safely open a native dialog. A
7778    // session with no prompt never starts Node merely to render its welcome
7779    // screen; JS commands/tools still trigger the same lazy ensure path.
7780    // Preparation is part of the active turn. Mark it working before Node can
7781    // block so Ctrl+C, Ctrl+D, and Esc all retain their documented abort
7782    // semantics for initial argv prompts as well as editor submissions.
7783    state.set_status(RunStatus::Working);
7784    tui.request_render(false);
7785    if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
7786        state.set_status(RunStatus::Idle);
7787        tui.request_render(false);
7788        return;
7789    }
7790
7791    let outcome = if resume {
7792        // Continuing an interrupted run: no new user turn, just the provider call
7793        // the interrupted run never reached. Native pi behaves the same way.
7794        match lane.resume_pending().await {
7795            Ok(Some(result)) => Ok(result),
7796            // Nothing to resume after all: leave the status alone and let the
7797            // normal prompt path take over.
7798            Ok(None) => {
7799                state.set_status(RunStatus::Idle);
7800                tui.request_render(false);
7801                return;
7802            }
7803            Err(error) => Err(error),
7804        }
7805    } else {
7806        lane.prompt_text(prompt, images).await
7807    };
7808
7809    if streaming {
7810        // Broadcast delivery is asynchronous: the harness result can resolve
7811        // before the drain task processes MessageEnd. Reconcile from the
7812        // authoritative outcome before detaching the component so the final
7813        // streamed tail cannot be left at an earlier partial snapshot.
7814        let final_message = match &outcome {
7815            Ok(result) => match &result.outcome {
7816                HarnessRunOutcome::Completed { final_message, .. }
7817                | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7818                HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7819                HarnessRunOutcome::Suspended { .. } => None,
7820            },
7821            Err(_) => None,
7822        };
7823        reconcile_streamed_assistant_completion(
7824            &state.current_assistant,
7825            &state.last_assistant_text,
7826            final_message,
7827        );
7828    }
7829
7830    state.set_status(RunStatus::Idle);
7831    // Guarantee the pending-messages display reconciles at the end of every
7832    // run/prompt — covers aborts and the non-streaming path, where no
7833    // `AgentEnd` event necessarily reaches the drain task.
7834    refresh_pending_messages(state, lane).await;
7835
7836    // Authoritative context-badge fallback. The streaming drain normally
7837    // updates it from `MessageEnd`, but a `Lagged` broadcast or the blocking
7838    // path can skip that event; reconcile from the run outcome so the badge
7839    // always reflects the newest real response.
7840    if let Ok(result) = &outcome {
7841        let final_message: Option<&AssistantMessage> = match &result.outcome {
7842            HarnessRunOutcome::Completed { final_message, .. }
7843            | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7844            HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7845            HarnessRunOutcome::Suspended { .. } => None,
7846        };
7847        if let Some(a) = final_message {
7848            record_context_usage(&state.footer, a);
7849        }
7850    }
7851
7852    match outcome {
7853        Ok(result) => match &result.outcome {
7854            HarnessRunOutcome::Failed {
7855                error,
7856                final_message,
7857                ..
7858            } => {
7859                // Only add an error line if the stream did NOT already render
7860                // an assistant message for it (drain task leaves
7861                // current_assistant Some only on an abrupt end).
7862                // A final assistant error is emitted by the event drain only
7863                // in streaming mode. In regular mode there is no drain task,
7864                // so suppressing this branch merely hides 405/auth/network
7865                // diagnostics from the user.
7866                let already_rendered = streaming && final_message.is_some();
7867                if !already_rendered {
7868                    let msg = final_message
7869                        .as_ref()
7870                        .and_then(|m| m.error_message.clone())
7871                        .unwrap_or_else(|| format!("{error:?}"));
7872                    add_error_message(&state.chat_container, &msg);
7873                }
7874            }
7875            HarnessRunOutcome::Suspended { .. } => {
7876                add_error_message(
7877                    &state.chat_container,
7878                    "Run suspended (deferred) — resume is not supported in v1.",
7879                );
7880            }
7881            HarnessRunOutcome::Aborted { final_message, .. } => {
7882                // Aborted runs render their own partial/final message via the
7883                // stream; only add a note on the blocking fallback path.
7884                if !streaming {
7885                    add_error_message(&state.chat_container, "Request aborted.");
7886                    let _ = final_message; // (rendered by the stream in streaming mode)
7887                }
7888            }
7889            HarnessRunOutcome::Completed { final_message, .. } => {
7890                if !streaming {
7891                    let text = assistant_text(final_message);
7892                    if !text.is_empty() {
7893                        add_assistant_message_blocking(
7894                            &state.chat_container,
7895                            &text,
7896                            state.markdown_transformer(),
7897                        );
7898                        *state.last_assistant_text.lock().unwrap() = text;
7899                    }
7900                }
7901            }
7902        },
7903        Err(e) => {
7904            add_error_message(&state.chat_container, &e.to_string());
7905        }
7906    }
7907
7908    tui.request_render(false);
7909}
7910
7911/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
7912/// Reports the outcome as a transcript note; v1's compaction summarizes the
7913/// session in place, so no streaming display is wired (compaction emits no
7914/// `AgentEvent`s — only the harness bus `RunEnd`).
7915async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
7916    state.set_status(RunStatus::Working);
7917    tui.request_render(false);
7918    match lane.compact(None).await {
7919        Ok(_) => {
7920            add_note_message(&state.chat_container, "Conversation compacted.");
7921            // The last assistant usage describes the pre-compaction context;
7922            // reset the badge to `?` until the next response reports the new
7923            // (much smaller) prompt size — mirrors pi's `percent: null`.
7924            state.footer.set_context_tokens(None);
7925        }
7926        Err(e) => {
7927            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
7928        }
7929    }
7930    state.set_status(RunStatus::Idle);
7931    tui.request_render(false);
7932}
7933
7934/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
7935/// when no clipboard is available (or the `clipboard` feature is off), prints a
7936/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
7937fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
7938    let text = state.last_assistant_text.lock().unwrap().clone();
7939    if text.is_empty() {
7940        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
7941        return;
7942    }
7943    if copy_to_clipboard(&text) {
7944        add_note_message(chat, "Copied last reply to the clipboard.");
7945    } else {
7946        // Clipboard unavailable — print the text to the transcript so the user
7947        // can select/copy it manually (degrades gracefully in headless envs).
7948        let preview: String = text.chars().take(200).collect();
7949        add_note_message(
7950            chat,
7951            &format!(
7952                "Clipboard unavailable. Last reply: {preview}{}",
7953                if text.chars().count() > 200 {
7954                    "…"
7955                } else {
7956                    ""
7957                }
7958            ),
7959        );
7960    }
7961}
7962
7963/// Extract text from the chat container within the given screen coordinates.
7964/// Returns None if the selection is invalid or empty.
7965fn extract_selected_text(
7966    chat_container: &Arc<Container>,
7967    start: (u16, u16),
7968    end: (u16, u16),
7969) -> Option<String> {
7970    use rpi_tui::Component;
7971
7972    // Get the rendered lines from the chat container
7973    let lines = chat_container.render(80); // Use a reasonable width
7974    if lines.is_empty() {
7975        return None;
7976    }
7977
7978    // Normalize coordinates (ensure start is before end)
7979    let (start_row, end_row) = if start.1 <= end.1 {
7980        (start.1 as usize, end.1 as usize)
7981    } else {
7982        (end.1 as usize, start.1 as usize)
7983    };
7984
7985    // Clamp to valid range
7986    let start_row = start_row.min(lines.len().saturating_sub(1));
7987    let end_row = end_row.min(lines.len().saturating_sub(1));
7988
7989    if start_row > end_row {
7990        return None;
7991    }
7992
7993    // Extract the selected lines
7994    let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7995
7996    if selected_lines.is_empty() {
7997        return None;
7998    }
7999
8000    Some(selected_lines.join("\n"))
8001}
8002
8003/// Best-effort clipboard write. Enabled only with the `clipboard` feature
8004/// (`arboard`) on non-Android platforms; otherwise returns `false` so the
8005/// caller degrades to a hint.
8006#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8007fn copy_to_clipboard(text: &str) -> bool {
8008    match arboard::Clipboard::new() {
8009        Ok(mut cb) => cb.set_text(text).is_ok(),
8010        Err(_) => false,
8011    }
8012}
8013
8014#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8015fn copy_to_clipboard(_text: &str) -> bool {
8016    false
8017}
8018
8019/// Best-effort clipboard text read for Ctrl+V paste. The optional `clipboard`
8020/// feature keeps headless builds free of platform clipboard dependencies;
8021/// on Android the OS owns clipboard access so this degrades to `None`.
8022#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8023fn read_clipboard_text() -> Option<String> {
8024    let mut clipboard = arboard::Clipboard::new().ok()?;
8025    clipboard.get_text().ok()
8026}
8027
8028#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8029fn read_clipboard_text() -> Option<String> {
8030    None
8031}
8032
8033/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
8034/// `ImageContent` contract. The optional clipboard feature keeps headless
8035/// builds free of platform clipboard dependencies.
8036#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8037fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8038    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
8039    let image = match clipboard.get_image() {
8040        Ok(image) => image,
8041        Err(_) => return Ok(None),
8042    };
8043    let width =
8044        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
8045    let height =
8046        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
8047    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
8048        return Err("clipboard image dimensions are outside the supported range".into());
8049    }
8050    let mut bytes = Vec::new();
8051    {
8052        let mut encoder = png::Encoder::new(&mut bytes, width, height);
8053        encoder.set_color(png::ColorType::Rgba);
8054        encoder.set_depth(png::BitDepth::Eight);
8055        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
8056        writer
8057            .write_image_data(&image.bytes)
8058            .map_err(|e| e.to_string())?;
8059    }
8060    Ok(Some(rpi_ai::types::ImageContent {
8061        kind: rpi_ai::types::ImageContentType,
8062        data: base64::engine::general_purpose::STANDARD.encode(bytes),
8063        mime_type: "image/png".into(),
8064    }))
8065}
8066
8067fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
8068    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
8069        let mut options = ImageOptions::default();
8070        options.width = Some(48);
8071        options.alt_text = Some("Attached image".into());
8072        chat.add_child(Arc::new(Image::from_data(bytes, options)));
8073        chat.add_child(Arc::new(Spacer::new(1)));
8074    }
8075}
8076
8077#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8078fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8079    Ok(None)
8080}
8081
8082/// Blocking fallback (no `event_rx`): render the final assistant text as a
8083/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
8084/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
8085/// the identity path. The blocking path only fires when `event_rx` is absent,
8086/// so it shares the same transformer the streaming path installs on its
8087/// components.
8088fn add_assistant_message_blocking(
8089    container: &Arc<Container>,
8090    text: &str,
8091    transformer: Option<MarkdownTransformer>,
8092) {
8093    if text.is_empty() {
8094        return;
8095    }
8096    let msg = Arc::new(AssistantMessageComponent::new(
8097        AssistantMessageOptions::default(),
8098    ));
8099    if let Some(t) = &transformer {
8100        msg.set_markdown_transformer(Some(t.clone()));
8101    }
8102    msg.update_text(text);
8103    container.add_child(msg);
8104    container.add_child(Arc::new(Spacer::new(1)));
8105}
8106
8107// ===========================================================================
8108// AgentEvent drain task — the streaming core
8109// ===========================================================================
8110
8111/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
8112/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
8113/// lifetime of the TUI.
8114/// Re-read the lane's queue and update the pending-messages dock slot.
8115/// Cheap (two short mutex locks) and a no-op when the snapshot is unchanged,
8116/// so it is safe to call on every relevant event.
8117async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
8118    if let Ok(queued) = lane.queued_messages().await {
8119        state.set_pending_queue(queued);
8120    }
8121}
8122
8123/// Whether the transcript holds a panel that repaints its elapsed readout from
8124/// the clock on every frame: a running bash command, or a tool call still in
8125/// [`ToolStatus::Running`]. Such a panel needs a real transcript rebuild each
8126/// render tick — a frame that reuses the cached scroll content would leave its
8127/// timer frozen at the second of the last full rebuild.
8128fn transcript_has_live_panel(
8129    bash_components_present: bool,
8130    tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
8131) -> bool {
8132    bash_components_present
8133        || tool_components
8134            .values()
8135            .any(|component| component.status() == ToolStatus::Running)
8136}
8137
8138/// Prepend restored queued messages to the draft the user may already have
8139/// typed (native pi's `restoreQueuedMessagesToEditor` places them first).
8140fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
8141    if current.trim().is_empty() {
8142        queued_text.to_string()
8143    } else {
8144        format!("{queued_text}\n\n{current}")
8145    }
8146}
8147
8148/// `app.message.dequeue`: drain every queued steering/follow-up message out of
8149/// the lane and restore it to the editor so the user can edit before resending
8150/// (native pi's `restoreQueuedMessagesToEditor`).
8151///
8152/// Runs on a spawned task rather than through the [`TuiMessage`] mailbox: the
8153/// async main loop owns `run_prompt_streaming(..).await` for the whole run, and
8154/// a queue only exists *while* a run is in flight — so a `TuiMessage::Dequeue`
8155/// was never serviced until the run ended, after the lane had already consumed
8156/// the queue. That made Alt+Q a no-op while it was most needed.
8157async fn restore_queued_messages_to_editor(
8158    lane: Arc<dyn AgentLane>,
8159    editor: Arc<Editor>,
8160    state: Arc<TuiState>,
8161    tui: Arc<TuiAltScreen>,
8162) {
8163    match lane.clear_queue().await {
8164        Ok(queued) => {
8165            let all: Vec<String> = queued
8166                .steering
8167                .iter()
8168                .chain(queued.follow_up.iter())
8169                .cloned()
8170                .collect();
8171            if !all.is_empty() {
8172                let queued_text = all.join("\n\n");
8173                let current = editor.get_text();
8174                let combined = merge_queued_into_draft(&queued_text, &current);
8175                let cursor = combined.chars().count();
8176                editor.set_text(&combined);
8177                editor.set_cursor(0, cursor);
8178            }
8179            // The lane drained the queue; refresh so the dock drops the rows.
8180            refresh_pending_messages(&state, &lane).await;
8181        }
8182        Err(error) => {
8183            add_error_message(
8184                &state.chat_container,
8185                &format!("Could not restore queued messages: {error}"),
8186            );
8187        }
8188    }
8189    tui.request_render(false);
8190}
8191
8192async fn drain_agent_events(
8193    mut rx: broadcast::Receiver<AgentEvent>,
8194    tui: Arc<TuiAltScreen>,
8195    state: Arc<TuiState>,
8196    chat: Arc<Container>,
8197    lane: Arc<dyn AgentLane>,
8198) {
8199    loop {
8200        match rx.recv().await {
8201            Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
8202            Err(broadcast::error::RecvError::Lagged(_)) => {
8203                // We dropped some intermediate deltas; the next MessageUpdate/
8204                // MessageEnd carries a full partial snapshot so the UI re-syncs.
8205                continue;
8206            }
8207            Err(broadcast::error::RecvError::Closed) => break,
8208        }
8209    }
8210}
8211
8212/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
8213/// (`interactive-mode.ts:3068-3396`).
8214async fn handle_agent_event(
8215    event: AgentEvent,
8216    tui: &Arc<TuiAltScreen>,
8217    state: &Arc<TuiState>,
8218    chat: &Arc<Container>,
8219    lane: &Arc<dyn AgentLane>,
8220) {
8221    match event {
8222        AgentEvent::AgentStart => {
8223            state.set_status(RunStatus::Working);
8224            tui.request_render(false);
8225        }
8226
8227        AgentEvent::AgentEnd { .. } => {
8228            // Finalize any still-streaming assistant message.
8229            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8230                comp.set_streaming(false);
8231            }
8232            state.set_status(RunStatus::Idle);
8233            // Flush any `!command` results that completed mid-run: the run's
8234            // tool sequence is closed, so appending now keeps transcript order
8235            // intact (native pi's `flushPendingBashMessages`).
8236            let pending: Vec<AgentMessage> =
8237                std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
8238            for message in pending {
8239                if let Err(error) = lane.append_message(message).await {
8240                    add_error_message(chat, &format!("Could not record bash result: {error}"));
8241                }
8242            }
8243            // The run drained the queue; refresh so consumed entries drop out
8244            // of the pending-messages display.
8245            refresh_pending_messages(state, lane).await;
8246            tui.request_render(false);
8247        }
8248
8249        AgentEvent::RetryScheduled {
8250            attempt,
8251            max_retries,
8252            delay_ms,
8253            ..
8254        } => {
8255            state.show_retry(attempt, max_retries, delay_ms);
8256            tui.request_render(false);
8257        }
8258
8259        AgentEvent::TurnStart => {
8260            // A new turn: reset the streaming-assistant guard so the next
8261            // MessageStart creates a fresh component.
8262            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8263                comp.set_streaming(false);
8264            }
8265        }
8266
8267        AgentEvent::TurnEnd {
8268            message,
8269            tool_results,
8270        } => {
8271            // Finalize the assistant message for this turn.
8272            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8273                if let AgentMessage::Assistant(a) = &message {
8274                    comp.update_blocks(&assistant_blocks(a));
8275                }
8276                comp.set_streaming(false);
8277            }
8278            // Any tool results whose components were never ended by a
8279            // ToolExecutionEnd get a static rendering here (best-effort). The
8280            // normal path removes the component via ToolExecutionEnd; this is
8281            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
8282            let tools = state.tool_components.lock().unwrap();
8283            for tr in &tool_results {
8284                if tools.contains_key(&tr.tool_call_id) {
8285                    // Will be removed below via ToolExecutionEnd in the normal
8286                    // path; leave as-is if still present.
8287                    let _ = tr;
8288                }
8289            }
8290            drop(tools);
8291            tui.request_render(false);
8292        }
8293
8294        AgentEvent::MessageStart { message } => match message {
8295            AgentMessage::Assistant(a) => {
8296                let comp = Arc::new(AssistantMessageComponent::new(
8297                    AssistantMessageOptions::default(),
8298                ));
8299                // B5e: install the live markdown transformer so the plugin's
8300                // `register_markdown_transformer` handlers apply from the very
8301                // first streamed delta. `set_streaming` before the transform
8302                // install is fine (transform fires on `update_blocks`, below).
8303                if let Some(t) = state.markdown_transformer() {
8304                    comp.set_markdown_transformer(Some(t));
8305                }
8306                comp.set_hide_thinking(state.hide_thinking());
8307                comp.set_streaming(true);
8308                // Render text AND thinking blocks in order (the old path fed
8309                // only the concatenated text, so thinking blocks never showed).
8310                comp.update_blocks(&assistant_blocks(&a));
8311                chat.add_child(comp.clone());
8312                // Spacer(1) separates this assistant turn from the next entry;
8313                // the component itself adds no leading spacer.
8314                chat.add_child(Arc::new(Spacer::new(1)));
8315                *state.current_assistant.lock().unwrap() = Some(comp);
8316                tui.request_render(false);
8317            }
8318            AgentMessage::Custom(custom) => {
8319                let payload = serde_json::json!({
8320                    "customType": custom.role,
8321                    "content": custom.content,
8322                    "details": custom.data,
8323                    "expanded": false,
8324                    "outputPad": 1,
8325                });
8326                if let Some(component) = extension_message_component(
8327                    &state.extension_session,
8328                    &custom.role,
8329                    &payload,
8330                    state.markdown_transformer(),
8331                ) {
8332                    chat.add_child(component);
8333                    chat.add_child(Arc::new(Spacer::new(1)));
8334                    tui.request_render(false);
8335                } else {
8336                    add_note_message(chat, &custom_message_fallback(&custom));
8337                    tui.request_render(false);
8338                }
8339            }
8340            // Queued (steering / follow-up) user messages are rendered here,
8341            // not at queue time, so a message that is still waiting never looks
8342            // delivered. The loop drains those itself and emits
8343            // `message_start` for them (`agent_loop.rs` pending-message drain).
8344            //
8345            // A DIRECTLY-sent prompt does NOT reach this arm: the harness
8346            // persists it up front and runs the loop with an empty prompts vec,
8347            // so no `message_start` is emitted for it — the submit handler
8348            // renders that bubble instead.
8349            AgentMessage::User(user) => {
8350                add_user_message(chat, &user_message_text(&user));
8351                // A consumed entry must drop out of the pending display.
8352                refresh_pending_messages(state, lane).await;
8353                tui.request_render(false);
8354            }
8355            // ToolResult / other starts are echoed via the tool-execution
8356            // components; ignore the dupes.
8357            _ => {}
8358        },
8359
8360        AgentEvent::MessageUpdate {
8361            message,
8362            assistant_message_event,
8363        } => {
8364            {
8365                // `message` is the shared partial snapshot
8366                // (`Arc<AssistantMessage>`), so read through it instead of
8367                // cloning the whole growing message once per delta. The bare
8368                // block preserves the original nesting.
8369                let a: &rpi_ai::types::AssistantMessage = &message;
8370                let text = assistant_text(a);
8371                let mut saw_bash_tool_call = false;
8372                // Scan content for finalized tool calls → proactively create
8373                // tool components (TS shows the tool as soon as the assistant
8374                // emits the ToolCall; ToolExecutionStart coalesces if it
8375                // already exists).
8376                for c in &a.content {
8377                    if let Content::ToolCall(tc) = c {
8378                        // Streaming providers may expose a placeholder tool
8379                        // call before its name has arrived. It is not a real
8380                        // tool panel and must not leave an empty first row.
8381                        if tc.name.trim().is_empty() {
8382                            continue;
8383                        }
8384                        if tc.name == "bash" {
8385                            // Bash has a dedicated component. Create it here as
8386                            // well as on ToolExecutionStart because the tool
8387                            // call can become visible in a MessageUpdate first.
8388                            // Keeping it in the bash map lets Start coalesce
8389                            // with this panel instead of appending a second one.
8390                            let command = tc
8391                                .arguments
8392                                .get("command")
8393                                .and_then(|v| v.as_str())
8394                                .unwrap_or("");
8395                            // Streaming tool-call arguments may still be `{}`
8396                            // here. Do not create a running bash panel until
8397                            // the lifecycle start event provides the command;
8398                            // otherwise the spinner renders first and the
8399                            // actual `$ command` header appears one frame
8400                            // later.
8401                            if command.trim().is_empty() {
8402                                continue;
8403                            }
8404                            saw_bash_tool_call = true;
8405                            let mut bash = state.bash_components.lock().unwrap();
8406                            if let Some(existing) = bash.get(&tc.id) {
8407                                // Tool-call arguments arrive incrementally.
8408                                // Refresh the running panel from every full
8409                                // assistant snapshot instead of leaving its
8410                                // header on the first partial command.
8411                                existing.set_command(command);
8412                            } else {
8413                                let comp = Arc::new(BashExecutionComponent::new(command));
8414                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8415                                chat.add_child(comp.clone());
8416                                bash.insert(tc.id.clone(), comp);
8417                            }
8418                        } else {
8419                            let mut tools = state.tool_components.lock().unwrap();
8420                            let display_args = if is_ask_user_tool(&tc.name) {
8421                                ask_user_args_display(&tc.arguments)
8422                            } else {
8423                                tc.arguments.to_string()
8424                            };
8425                            if let Some(existing) = tools.get(&tc.id) {
8426                                // Regular tool arguments stream incrementally
8427                                // too, so refresh their live header as soon as
8428                                // a more complete snapshot arrives.
8429                                if !display_args.trim().is_empty() && display_args.trim() != "{}" {
8430                                    existing.set_args(&display_args);
8431                                }
8432                            } else {
8433                                let comp =
8434                                    Arc::new(ToolExecutionComponent::new(&tc.name, &display_args));
8435                                if is_ask_user_tool(&tc.name) {
8436                                    comp.set_display_title("ASK USER");
8437                                }
8438                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8439                                comp.set_running();
8440                                chat.add_child(comp.clone());
8441                                tools.insert(tc.id.clone(), comp);
8442                            }
8443                        }
8444                    }
8445                }
8446                // MessageUpdate can expose the finalized bash call before
8447                // ToolExecutionStart arrives. Hide the global `Working…`
8448                // loader immediately when creating that bash panel; otherwise
8449                // it briefly appears alongside the panel's `Running…` spinner.
8450                if saw_bash_tool_call {
8451                    state.sync_working_loader_with_bash();
8452                }
8453                let _ = assistant_message_event; // snapshot already applied via `a`
8454                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
8455                    // Stream the full block list (text + thinking) each update
8456                    // so thinking blocks render live as they arrive.
8457                    comp.update_blocks(&assistant_blocks(a));
8458                }
8459                *state.last_assistant_text.lock().unwrap() = text;
8460                tui.request_render(false);
8461            }
8462        }
8463
8464        AgentEvent::MessageEnd { message } => {
8465            if let AgentMessage::Assistant(a) = &message {
8466                let text = assistant_text(a);
8467                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8468                    comp.update_blocks(&assistant_blocks(a));
8469                    comp.set_streaming(false);
8470                }
8471                // Cache the finalized text for `/copy`.
8472                if !text.is_empty() {
8473                    *state.last_assistant_text.lock().unwrap() = text;
8474                }
8475                // Cache-miss notice (`maybeShowCacheMissNotice`): the previous
8476                // turn's input established a cacheable prefix; a prompt that
8477                // re-reads less than it should means the prefix was re-billed.
8478                // Uses the shared cache-stats logic (noise floor, idle gap,
8479                // model change) and prices the miss when known.
8480                let usage = &a.usage;
8481                // Footer usage totals + cache hit rate (pi footer.ts).
8482                state.footer.add_usage(
8483                    usage.input,
8484                    usage.output,
8485                    usage.cache_read,
8486                    usage.cache_write,
8487                    usage.cost.total,
8488                );
8489                let denom = usage.input + usage.cache_read + usage.cache_write;
8490                if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
8491                    state
8492                        .footer
8493                        .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
8494                }
8495                // Context-window badge (`?/512k` → `63.2%/512k`): the provider
8496                // reports the prompt token count for this request, which is the
8497                // context size the footer displays.
8498                record_context_usage(&state.footer, a);
8499                let miss = state
8500                    .cache_tracker
8501                    .lock()
8502                    .unwrap()
8503                    .observe(a, &rpi_harness::cache_stats::NoPrices);
8504                // Native pi gates these behind `showCacheMissNotices`
8505                // (default `false`); the tracker still runs so the footer's
8506                // cache-hit rate stays accurate either way.
8507                let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
8508                if let Some(miss) = miss.filter(|_| cache_miss_notices) {
8509                    let cost = if miss.missed_cost > 0.0 {
8510                        format!(" (~${:.4})", miss.missed_cost)
8511                    } else {
8512                        String::new()
8513                    };
8514                    add_note_message(
8515                        &state.chat_container,
8516                        &format!(
8517                            "Cache miss: {} tokens re-billed{}",
8518                            format_tokens(miss.missed_tokens),
8519                            cost
8520                        ),
8521                    );
8522                }
8523                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
8524                    add_note_message(chat, &text);
8525                }
8526                // Error assistant messages carry the provider diagnostic in
8527                // `error_message`, not in text content. The assistant
8528                // component is empty for these messages, so surface the
8529                // diagnostic as a visible error row in the transcript.
8530                if let Some(error) = assistant_error_text(a) {
8531                    add_error_message(chat, &error);
8532                }
8533            }
8534            tui.request_render(false);
8535        }
8536
8537        AgentEvent::ToolExecutionStart {
8538            tool_call_id,
8539            tool_name,
8540            args,
8541        } => {
8542            // Ignore placeholder lifecycle events emitted before the
8543            // provider has supplied a tool name.
8544            if tool_name.trim().is_empty() {
8545                return;
8546            }
8547            if tool_name == "bash" {
8548                // Bash streams into a dedicated BashExecutionComponent (command
8549                // header + live preview + exit/truncation status) rather than a
8550                // generic ToolExecutionComponent. The command comes from the
8551                // `command` field of the bash tool args.
8552                let command = args
8553                    .get("command")
8554                    .and_then(|v| v.as_str())
8555                    .unwrap_or("")
8556                    .to_string();
8557                // Bash arguments can still be `{}` when the lifecycle event
8558                // races the streamed tool-call argument finalization. Defer
8559                // the panel until a later start event carries the command.
8560                if command.trim().is_empty() {
8561                    return;
8562                }
8563                let mut bash_map = state.bash_components.lock().unwrap();
8564                if let Some(existing) = bash_map.get(&tool_call_id) {
8565                    // A ToolExecutionUpdate already created the panel (fast
8566                    // command — Update can arrive before Start); backfill the
8567                    // command header instead of adding a SECOND panel, which
8568                    // used to stack an empty "$ " box above the real one.
8569                    existing.set_command(&command);
8570                } else {
8571                    let comp = Arc::new(BashExecutionComponent::new(command));
8572                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8573                    chat.add_child(comp.clone());
8574                    bash_map.insert(tool_call_id.clone(), comp);
8575                }
8576            } else {
8577                let _comp = {
8578                    let mut tools = state.tool_components.lock().unwrap();
8579                    if let Some(existing) = tools.get(&tool_call_id) {
8580                        if is_ask_user_tool(&tool_name) {
8581                            existing.set_display_title("ASK USER");
8582                            existing.set_args(&ask_user_args_display(&args));
8583                        } else {
8584                            existing.set_args(&args.to_string());
8585                        }
8586                        existing.clone()
8587                    } else {
8588                        let display_args = if is_ask_user_tool(&tool_name) {
8589                            ask_user_args_display(&args)
8590                        } else {
8591                            args.to_string()
8592                        };
8593                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8594                        if is_ask_user_tool(&tool_name) {
8595                            comp.set_display_title("ASK USER");
8596                        }
8597                        comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8598                        // A `read` of a SKILL.md renders as native Pi's
8599                        // `[skill] <name>` invocation box (custom-message
8600                        // background, collapsed to one line, Ctrl+O expands the
8601                        // skill markdown) instead of a generic READ tool panel.
8602                        if let Some(skill) = skill_tool_name(&tool_name, &args) {
8603                            comp.set_skill_name(skill);
8604                        }
8605                        comp.set_running();
8606                        chat.add_child(comp.clone());
8607                        tools.insert(tool_call_id.clone(), comp.clone());
8608                        comp
8609                    }
8610                };
8611            }
8612            state.sync_working_loader_with_bash();
8613            tui.request_render(false);
8614        }
8615
8616        AgentEvent::ToolExecutionUpdate {
8617            tool_call_id,
8618            tool_name,
8619            args,
8620            partial_result,
8621        } => {
8622            if tool_name.trim().is_empty() {
8623                return;
8624            }
8625            let partial_text = if is_ask_user_tool(&tool_name) {
8626                ask_user_progress_text(&args, &partial_result)
8627            } else {
8628                tool_result_text(&partial_result)
8629            };
8630            let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
8631            if tool_name == "bash" {
8632                // Append the streamed chunk to the bash component's preview.
8633                // RAW text (no single-line collapsing) — the old
8634                // `summarize_tool_result` folded every newline into a `⏎`
8635                // glyph, cramming e.g. `ls -la`'s listing onto one line.
8636                let chunk = partial_text;
8637                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
8638                    // Lifecycle updates can contain a more complete args object
8639                    // than the snapshot that created this running panel.
8640                    if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
8641                        bash.set_command(command);
8642                    }
8643                    if has_partial_payload {
8644                        bash.append_output(&chunk);
8645                    }
8646                } else if has_partial_payload {
8647                    // ToolExecutionStart is emitted before a tool can run.
8648                    // Ignore an out-of-order partial until that event gives us
8649                    // the real command, rather than showing a spinner above an
8650                    // empty `$ ` header. Normal updates are handled by the
8651                    // component created in ToolExecutionStart.
8652                }
8653            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
8654                if let Some(skill) = skill_tool_name(&tool_name, &args) {
8655                    comp.set_skill_name(skill);
8656                }
8657                if is_ask_user_tool(&tool_name) {
8658                    comp.set_display_title("ASK USER");
8659                    comp.set_args(&ask_user_args_display(&args));
8660                } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
8661                    comp.set_args(&args.to_string());
8662                }
8663                // Raw multi-line text — read/ls-style tools must show their
8664                // full content, not the single-line ⏎-folded summary.
8665                if has_partial_payload {
8666                    comp.set_result(&partial_text, false);
8667                    apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
8668                }
8669            } else if has_partial_payload {
8670                // No component yet — create a running one so the partial shows.
8671                // Empty callbacks are common before ToolExecutionStart; wait
8672                // for Start so the first panel has the real arguments instead
8673                // of an empty `TOOLS` box.
8674                let display_args = if is_ask_user_tool(&tool_name) {
8675                    ask_user_args_display(&args)
8676                } else {
8677                    args.to_string()
8678                };
8679                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8680                if is_ask_user_tool(&tool_name) {
8681                    comp.set_display_title("ASK USER");
8682                }
8683                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8684                if let Some(skill) = skill_tool_name(&tool_name, &args) {
8685                    comp.set_skill_name(skill);
8686                }
8687                comp.set_running();
8688                comp.set_result(&partial_text, false);
8689                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
8690                chat.add_child(comp.clone());
8691                state
8692                    .tool_components
8693                    .lock()
8694                    .unwrap()
8695                    .insert(tool_call_id.clone(), comp.clone());
8696            }
8697            state.sync_working_loader_with_bash();
8698            tui.request_render(false);
8699        }
8700
8701        AgentEvent::ToolExecutionEnd {
8702            tool_call_id,
8703            tool_name,
8704            result,
8705            is_error,
8706        } => {
8707            if tool_name.trim().is_empty() {
8708                return;
8709            }
8710            if tool_name == "bash" {
8711                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
8712                if let Some(bash) = bash {
8713                    finalize_bash(&bash, &result, is_error);
8714                } else {
8715                    // Bash ended without a Start/Update — render a finalized
8716                    // component directly from the result text.
8717                    let command = result
8718                        .details
8719                        .get("command")
8720                        .and_then(|v| v.as_str())
8721                        .unwrap_or("")
8722                        .to_string();
8723                    if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
8724                        state.sync_working_loader_with_bash();
8725                        // Edge case: empty bash result. Still render immediately
8726                        // to clear the panel state for the user.
8727                        tui.render_now(false);
8728                        return;
8729                    }
8730                    let comp = Arc::new(BashExecutionComponent::new(command));
8731                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8732                    comp.append_output(&tool_result_text(&result));
8733                    finalize_bash(&comp, &result, is_error);
8734                    chat.add_child(comp);
8735                }
8736            } else {
8737                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
8738                if let Some(comp) = comp {
8739                    let result_text = if is_ask_user_tool(&tool_name) {
8740                        ask_user_result_text(&result)
8741                    } else {
8742                        tool_result_text(&result)
8743                    };
8744                    comp.set_result(&result_text, is_error);
8745                    if !is_ask_user_tool(&tool_name) {
8746                        if tool_result_requests_markdown(Some(&result.details)) {
8747                            comp.set_result_markdown(true);
8748                        }
8749                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8750                    }
8751                } else {
8752                    // Tool ended without a Start/Update (e.g. a very fast tool):
8753                    // render a finalized component directly.
8754                    let comp = Arc::new(ToolExecutionComponent::new(
8755                        &tool_name,
8756                        &if is_ask_user_tool(&tool_name) {
8757                            ask_user_args_display(&serde_json::Value::Null)
8758                        } else {
8759                            "".to_string()
8760                        },
8761                    ));
8762                    if is_ask_user_tool(&tool_name) {
8763                        comp.set_display_title("ASK USER");
8764                    }
8765                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8766                    let result_text = if is_ask_user_tool(&tool_name) {
8767                        ask_user_result_text(&result)
8768                    } else {
8769                        tool_result_text(&result)
8770                    };
8771                    comp.set_result(&result_text, is_error);
8772                    if !is_ask_user_tool(&tool_name) {
8773                        if tool_result_requests_markdown(Some(&result.details)) {
8774                            comp.set_result_markdown(true);
8775                        }
8776                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8777                    }
8778                    chat.add_child(comp.clone());
8779                }
8780            }
8781            state.sync_working_loader_with_bash();
8782            // Bash tool completion is user-visible: immediate render ensures the
8783            // result is displayed even when the scheduler thread is busy or the
8784            // throttle window has not elapsed. This single immediate frame does
8785            // not regress the streaming-output throttle that keeps CPU/GPU use
8786            // low (bash completions are discrete events, not per-token bursts).
8787            tui.render_now(false);
8788        }
8789    }
8790}
8791
8792/// Update the footer's context-window badge from one assistant response.
8793///
8794/// `Usage::context_tokens()` is the prompt size the provider reported for that
8795/// request — the value pi's footer renders as `{percent}%/{window}`. Aborted
8796/// and errored turns are skipped (their usage is not a real context
8797/// measurement, mirroring pi's `lastAssistantUsageInfo`) so the last valid
8798/// count stays on screen instead of flickering to `?`.
8799fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
8800    if message.stop_reason == rpi_ai::StopReason::Aborted
8801        || message.stop_reason == rpi_ai::StopReason::Error
8802    {
8803        return;
8804    }
8805    let tokens = message.usage.context_tokens();
8806    if tokens > 0 {
8807        footer.set_context_tokens(Some(tokens));
8808    }
8809}
8810
8811/// Return the diagnostic carried by a failed or aborted provider request.
8812/// Providers may omit `error_message`; keep a stable fallback so a terminal
8813/// request failure can never render as an empty transcript turn.
8814fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
8815    let fallback = match message.stop_reason {
8816        rpi_ai::StopReason::Error => "Provider request failed.",
8817        rpi_ai::StopReason::Aborted => "Request aborted.",
8818        _ => return None,
8819    };
8820    Some(
8821        message
8822            .error_message
8823            .as_deref()
8824            .filter(|text| !text.trim().is_empty())
8825            .unwrap_or(fallback)
8826            .to_string(),
8827    )
8828}
8829
8830/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
8831/// tool result and mark the component complete. Mirrors the TS bash finalize
8832/// path; only the fields `BashExecutionComponent` needs are read.
8833fn finalize_bash(
8834    comp: &Arc<BashExecutionComponent>,
8835    result: &rpi_agent::AgentToolResult,
8836    is_error: bool,
8837) {
8838    // The exit code isn't in details directly (TS carries it elsewhere); use
8839    // `is_error` as the error signal and 0/1 as a best-effort exit code.
8840    let exit_code = if is_error { Some(1) } else { Some(0) };
8841    let truncated = result
8842        .details
8843        .get("truncation")
8844        .and_then(|t| t.get("truncated"))
8845        .and_then(|v| v.as_bool())
8846        .unwrap_or(false);
8847    let full_output_path = result
8848        .details
8849        .get("full_output_path")
8850        .and_then(|v| v.as_str())
8851        .map(|s| s.to_string());
8852    let truncation = BashTruncation {
8853        truncated,
8854        full_output_path,
8855    };
8856    // Some tool backends attach the authoritative command to result details.
8857    // Backfill it before completing so this is a fallback, never the first
8858    // opportunity for the UI to show the whole command.
8859    if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
8860        comp.set_command(command);
8861    }
8862    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
8863    comp.set_complete(exit_code, cancelled, truncation);
8864}
8865
8866/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
8867/// display-diff string, render it with colors and attach to the component so
8868/// the changes show in the transcript. `write` has no diff (details: Null) and
8869/// stays a plain summary.
8870fn apply_edit_diff(
8871    comp: &Arc<ToolExecutionComponent>,
8872    tool_name: &str,
8873    details: &serde_json::Value,
8874    tui: &Arc<TuiAltScreen>,
8875) {
8876    if tool_name != "edit" {
8877        return;
8878    }
8879    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
8880        return;
8881    };
8882    if diff_text.is_empty() {
8883        return;
8884    }
8885    let width = tui.width();
8886    let lines = render_diff(diff_text, width);
8887    comp.set_diff(lines);
8888}
8889
8890/// Whether a tool result opts into markdown rendering of its body.
8891///
8892/// A tool signals this by setting `details.markdown = true` on its result
8893/// (the `rpi-todo` extension does this for its progress bar + table payload).
8894/// The host then hands the body to the markdown renderer instead of printing
8895/// it as plain text — see `ToolExecutionComponent::set_result_markdown`.
8896/// Absent or non-`true` details keep the existing plain-text rendering, so
8897/// every other tool is unaffected.
8898fn tool_result_requests_markdown(details: Option<&serde_json::Value>) -> bool {
8899    details
8900        .and_then(|details| details.get("markdown"))
8901        .and_then(|value| value.as_bool())
8902        .unwrap_or(false)
8903}
8904
8905/// The skill name when `tool_name` is a `read` of a `SKILL.md` file, else
8906/// `None`. The name is the `SKILL.md` parent directory's basename (matching
8907/// native Pi's skill-file convention). Ordinary markdown/document reads
8908/// return `None` and remain regular `READ` tool panels.
8909fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
8910    if tool_name != "read" {
8911        return None;
8912    }
8913    let path = args.get("path").and_then(|value| value.as_str())?;
8914    let normalized = path.replace('\\', "/");
8915    let file_name = normalized.rsplit('/').next()?;
8916    if !file_name.eq_ignore_ascii_case("SKILL.md") {
8917        return None;
8918    }
8919    normalized
8920        .trim_end_matches('/')
8921        .rsplit('/')
8922        .nth(1)
8923        .filter(|name| !name.is_empty())
8924        .map(str::to_string)
8925}
8926
8927/// The raw multi-line text of a tool result (no single-line collapsing). The
8928/// bash panel needs the original line structure — the old path fed it through
8929/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
8930/// crammed e.g. `ls -la`'s whole listing onto one line.
8931fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
8932    use rpi_agent::TextContentOrImage;
8933    let mut parts: Vec<String> = Vec::new();
8934    for c in &result.content {
8935        if let TextContentOrImage::Text(t) = c {
8936            parts.push(t.text.clone());
8937        }
8938    }
8939    parts.join("\n")
8940}
8941
8942/// Empty progress callbacks are valid (notably before a tool's start event),
8943/// but they do not contain anything useful to render. Defer those callbacks so
8944/// the first tool panel is created from `ToolExecutionStart` with real args.
8945fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
8946    !text.trim().is_empty() || !result.details.is_null()
8947}
8948
8949/// Tool names rendered through the dedicated `ASK USER` panel instead of a
8950/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
8951fn is_ask_user_tool(tool_name: &str) -> bool {
8952    matches!(tool_name, "ask_user" | "ask_user_question")
8953}
8954
8955/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
8956/// transport JSON must never leak into the transcript.
8957fn ask_user_args_display(args: &serde_json::Value) -> String {
8958    if args.is_null() {
8959        return String::new();
8960    }
8961    if let Some(summary) = args
8962        .get("summary")
8963        .and_then(serde_json::Value::as_str)
8964        .map(str::trim)
8965        .filter(|text| !text.is_empty())
8966    {
8967        return format!("Confirmation requested: {summary}");
8968    }
8969
8970    let mut lines: Vec<String> = Vec::new();
8971    let questions: Vec<&serde_json::Value> = args
8972        .get("questions")
8973        .and_then(serde_json::Value::as_array)
8974        .filter(|items| !items.is_empty())
8975        .map(|items| items.iter().collect())
8976        .unwrap_or_else(|| vec![args]);
8977
8978    for question in questions {
8979        let prompt = question
8980            .get("question")
8981            .and_then(serde_json::Value::as_str)
8982            .or_else(|| args.get("question").and_then(serde_json::Value::as_str))
8983            .map(str::trim)
8984            .unwrap_or_default();
8985        if prompt.is_empty() {
8986            continue;
8987        }
8988        if let Some(header) = question
8989            .get("header")
8990            .and_then(serde_json::Value::as_str)
8991            .map(str::trim)
8992            .filter(|text| !text.is_empty())
8993        {
8994            lines.push(format!("{header}: {prompt}"));
8995        } else {
8996            lines.push(prompt.to_string());
8997        }
8998        let context = question
8999            .get("context")
9000            .and_then(serde_json::Value::as_str)
9001            .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
9002            .map(str::trim)
9003            .filter(|text| !text.is_empty());
9004        if let Some(context) = context {
9005            lines.push(format!("  {context}"));
9006        }
9007        let options = question
9008            .get("options")
9009            .and_then(serde_json::Value::as_array)
9010            .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
9011        if let Some(options) = options {
9012            let labels: Vec<String> = options
9013                .iter()
9014                .filter_map(|option| {
9015                    option
9016                        .get("title")
9017                        .and_then(serde_json::Value::as_str)
9018                        .or_else(|| option.as_str())
9019                        .map(str::trim)
9020                        .filter(|label| !label.is_empty())
9021                        .map(str::to_string)
9022                })
9023                .collect();
9024            if !labels.is_empty() {
9025                lines.push(format!("Choices: {}", labels.join(", ")));
9026            }
9027        }
9028        if let Some(suggest) = question
9029            .get("suggest")
9030            .and_then(serde_json::Value::as_str)
9031            .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
9032            .map(str::trim)
9033            .filter(|text| !text.is_empty())
9034        {
9035            lines.push(format!("Suggestions: {suggest}"));
9036        }
9037    }
9038
9039    lines.join("\n")
9040}
9041
9042/// Progress text while an ask_user tool is pending. Prefer the plugin's own
9043/// readable `content`, otherwise show the question so the panel is not blank.
9044fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
9045    let text = tool_result_text(result);
9046    if !text.trim().is_empty() {
9047        return text;
9048    }
9049    let args_text = ask_user_args_display(args);
9050    if args_text.trim().is_empty() {
9051        "Waiting for your answer…".to_string()
9052    } else {
9053        args_text
9054    }
9055}
9056
9057/// Final result text for an ask_user tool. The plugin returns a human-readable
9058/// answer summary in `content`; never surface the transport JSON.
9059fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
9060    let text = tool_result_text(result);
9061    if text.trim().is_empty() {
9062        "Answer recorded.".to_string()
9063    } else {
9064        text
9065    }
9066}
9067
9068// ===========================================================================
9069// Selectors — editor-container swap (TS showSelector pattern)
9070// ===========================================================================
9071
9072/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
9073/// hiding the editor while the selector is open. Records the selector in
9074/// `state.active_selector` so the key loop routes to it.
9075fn open_selector(
9076    state: &Arc<TuiState>,
9077    editor_container: &Arc<Container>,
9078    editor: &Arc<Editor>,
9079    tui: &Arc<TuiAltScreen>,
9080    list: Arc<SelectList>,
9081    kind: SelectorKind,
9082) {
9083    open_selector_with_view(
9084        state,
9085        editor_container,
9086        editor,
9087        tui,
9088        SelectorView::List(list.clone()),
9089        list,
9090        kind,
9091    );
9092}
9093
9094/// Open a searchable selector: the list plus a fuzzy-filter search box.
9095///
9096/// Returns the wrapper so callers can prefill the query (native pi's
9097/// `initialSearchInput`). Mirrors `SelectSubmenu` with `searchable: true`.
9098fn open_searchable_selector(
9099    state: &Arc<TuiState>,
9100    editor_container: &Arc<Container>,
9101    editor: &Arc<Editor>,
9102    tui: &Arc<TuiAltScreen>,
9103    title: Option<&str>,
9104    description: Option<&str>,
9105    list: Arc<SelectList>,
9106    kind: SelectorKind,
9107) -> Arc<SearchableSelectList> {
9108    let searchable = Arc::new(SearchableSelectList::new(title, description, list));
9109    open_selector_with_view(
9110        state,
9111        editor_container,
9112        editor,
9113        tui,
9114        SelectorView::Searchable(searchable.clone()),
9115        searchable.clone(),
9116        kind,
9117    );
9118    // `TuiAltScreen::set_focus` only records the focused component; components
9119    // manage their own focus flag. Without this the search caret never renders
9120    // (and IME candidate positioning stays on the editor's old caret).
9121    searchable.set_focused(true);
9122    searchable
9123}
9124
9125/// Open a selector with an optional framed view. Native extension selectors
9126/// wrap the list with a title and hint while built-in selectors keep the list
9127/// as the complete view.
9128///
9129/// `view_router` receives keys (a bare list, or a searchable wrapper) while
9130/// `view` is what renders — the two are the same object for every built-in
9131/// selector, but extension selectors frame a plain list.
9132fn open_selector_with_view<V: Component + 'static>(
9133    state: &Arc<TuiState>,
9134    editor_container: &Arc<Container>,
9135    editor: &Arc<Editor>,
9136    tui: &Arc<TuiAltScreen>,
9137    view_router: SelectorView,
9138    view: Arc<V>,
9139    kind: SelectorKind,
9140) {
9141    // Dispatch ui_prompt_start event
9142    dispatch_ui_prompt_event(state);
9143
9144    // Unfocus the editor so its cursor marker doesn't render behind the list.
9145    editor.set_focused(false);
9146    // A selector replaces the editor slot. Drop stale slash/@file
9147    // suggestions so they cannot reappear after the selector closes.
9148    state.autocomplete_container.clear();
9149    // Swap: clear the container and add the selector view.
9150    editor_container.clear();
9151    editor_container.add_child(view.clone());
9152    *state.active_selector.lock().unwrap() = Some((view_router, kind));
9153    let focused: Arc<dyn Component> = view;
9154    tui.set_focus(Some(focused));
9155    tui.request_render(false);
9156}
9157
9158/// Restore the editor into the `editor_container` and clear the active
9159/// selector. Called by selector `on_cancel` and the Esc handler.
9160fn close_selector(
9161    state: &Arc<TuiState>,
9162    editor_container: &Arc<Container>,
9163    editor: &Arc<Editor>,
9164    tui: &Arc<TuiAltScreen>,
9165) {
9166    // Dispatch ui_prompt_end event
9167    dispatch_ui_prompt_event_end(state);
9168
9169    editor_container.clear();
9170    editor_container.add_child(editor.clone());
9171    state.autocomplete_container.clear();
9172    editor.set_focused(true);
9173    *state.active_selector.lock().unwrap() = None;
9174    *state.active_extension_cancel.lock().unwrap() = None;
9175    tui.set_focus(Some(editor.clone()));
9176    tui.request_render(false);
9177}
9178
9179/// Build + open the `/model` selector. Items are the resolved catalog (display
9180/// label = model name; description = id), with the current model marked.
9181/// Selecting applies the model **live** via `lane.set_model` (takes effect on
9182/// the next user message — the in-flight run's config is already snapshotted),
9183/// updates the footer, and notes the next-prompt effect.
9184fn open_model_selector(
9185    state: &Arc<TuiState>,
9186    editor_container: &Arc<Container>,
9187    editor: &Arc<Editor>,
9188    tui: &Arc<TuiAltScreen>,
9189    catalog: &[rpi_ai::Model],
9190    lane: &Arc<dyn AgentLane>,
9191    lane_model_id: &str,
9192    chat: &Arc<Container>,
9193    initial_search: Option<&str>,
9194) -> Option<Arc<SearchableSelectList>> {
9195    let items = model_selector_items(catalog, lane_model_id);
9196    if items.is_empty() {
9197        add_note_message(
9198            chat,
9199            "No models in the catalog. Use --model at startup to select one.",
9200        );
9201        tui.request_render(false);
9202        return None;
9203    }
9204    let list = Arc::new(SelectList::new(items, 10));
9205
9206    // Capture the catalog + lane so the on_select closure can resolve the
9207    // chosen Model and apply it. `on_select` fires on the blocking key thread,
9208    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
9209    let catalog_arc = catalog.to_vec();
9210    let state_sel = state.clone();
9211    let ec_sel = editor_container.clone();
9212    let editor_sel = editor.clone();
9213    let tui_sel = tui.clone();
9214    let chat_sel = chat.clone();
9215    let lane_sel = lane.clone();
9216    list.on_select(Arc::new(move |item| {
9217        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
9218            add_note_message(
9219                &chat_sel,
9220                &format!("Model {} not found in catalog.", item.label),
9221            );
9222            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9223            return;
9224        };
9225        state_sel.set_current_model(&model);
9226        let lane = lane_sel.clone();
9227        tokio::spawn(async move {
9228            let _ = lane.set_model(model).await;
9229        });
9230        add_note_message(
9231            &chat_sel,
9232            &format!(
9233                "Model set to {} — applies to the next message.",
9234                short_model_name(&item.value)
9235            ),
9236        );
9237        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9238    }));
9239    let state_cancel = state.clone();
9240    let ec_cancel = editor_container.clone();
9241    let editor_cancel = editor.clone();
9242    let tui_cancel = tui.clone();
9243    list.on_cancel(Arc::new(move || {
9244        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9245    }));
9246
9247    let searchable = open_searchable_selector(
9248        state,
9249        editor_container,
9250        editor,
9251        tui,
9252        Some("Select model"),
9253        Some("Type to filter by name, provider, or id"),
9254        list,
9255        SelectorKind::Model,
9256    );
9257    if let Some(term) = initial_search {
9258        searchable.set_search_text(term);
9259    }
9260    Some(searchable)
9261}
9262
9263/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
9264/// Returns `None` only when the catalog is empty or the current id isn't
9265/// found (in which case the first entry is returned — a no-op if it IS the
9266/// current). Used by the Ctrl+M model-cycle hotkey.
9267fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9268    if catalog.is_empty() {
9269        return None;
9270    }
9271    let idx = catalog
9272        .iter()
9273        .position(|m| m.id.eq_ignore_ascii_case(current_id));
9274    match idx {
9275        Some(i) => {
9276            let next = (i + 1) % catalog.len();
9277            Some(catalog[next].clone())
9278        }
9279        None => Some(catalog[0].clone()),
9280    }
9281}
9282
9283/// Build + open the `/session` selector. Lists JSONL session files under the
9284/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
9285/// fallback), newest-first, labelled with the session name or first prompt so
9286/// two sessions are distinguishable. Header-only sessions are skipped: there is
9287/// nothing to switch to.
9288fn open_session_selector(
9289    state: &Arc<TuiState>,
9290    editor_container: &Arc<Container>,
9291    editor: &Arc<Editor>,
9292    tui: &Arc<TuiAltScreen>,
9293    cwd: &std::path::Path,
9294    tx: &mpsc::UnboundedSender<TuiMessage>,
9295) {
9296    let dir = crate::session::default_session_dir(cwd);
9297    let mut items: Vec<SelectItem> = Vec::new();
9298    for path in crate::session::list_session_files_sync(&dir) {
9299        let summary = crate::session::summarize_session_file(&path);
9300        if summary.empty {
9301            continue;
9302        }
9303        let stem = path
9304            .file_stem()
9305            .and_then(|s| s.to_str())
9306            .unwrap_or("(unnamed)")
9307            .to_string();
9308        let label = crate::session::session_display_label(&summary);
9309        // The file stem is `<timestamp>_<uuid>`; the id tail disambiguates rows
9310        // that share a timestamp.
9311        let id_tail = stem.rsplit('_').next().unwrap_or(&stem);
9312        let short_id = crate::session::short_session_id(id_tail);
9313        let description = format!(
9314            "{} · {}",
9315            crate::session::format_session_bytes(summary.bytes),
9316            short_id,
9317        );
9318        items.push(
9319            SelectItem::new(&stem, &label)
9320                .with_description(&description)
9321                .with_search_text(&format!("{label} {short_id} {stem}")),
9322        );
9323    }
9324    if items.is_empty() {
9325        add_note_message(
9326            &state.chat_container,
9327            "No saved sessions found. Sessions are created automatically in interactive mode.",
9328        );
9329        tui.request_render(false);
9330        return;
9331    }
9332    let mut list = SelectList::new(items, 10);
9333    list.set_layout(rpi_tui::SelectListLayoutOptions {
9334        min_primary_column_width: Some(56),
9335        max_primary_column_width: Some(72),
9336        truncate_primary: None,
9337    });
9338    let list = Arc::new(list);
9339
9340    let state_sel = state.clone();
9341    let ec_sel = editor_container.clone();
9342    let editor_sel = editor.clone();
9343    let tui_sel = tui.clone();
9344    let tx_sel = tx.clone();
9345    list.on_select(Arc::new(move |item| {
9346        // Close the selector first, then ask the async loop to hot-switch:
9347        // opening the session file + swapping the harness backing is async
9348        // (repo list/open) and must not run on the blocking key thread.
9349        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9350        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
9351    }));
9352    let state_cancel = state.clone();
9353    let ec_cancel = editor_container.clone();
9354    let editor_cancel = editor.clone();
9355    let tui_cancel = tui.clone();
9356    list.on_cancel(Arc::new(move || {
9357        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9358    }));
9359
9360    open_searchable_selector(
9361        state,
9362        editor_container,
9363        editor,
9364        tui,
9365        Some("Resume session"),
9366        Some("Type to filter sessions"),
9367        list,
9368        SelectorKind::Session,
9369    );
9370}
9371
9372fn custom_entry_display_text(
9373    custom_type: &str,
9374    data: Option<&serde_json::Value>,
9375) -> Option<String> {
9376    let data = data?;
9377    let text = data
9378        .get("summary")
9379        .or_else(|| data.get("text"))
9380        .or_else(|| data.get("output"))
9381        .and_then(|value| value.as_str())
9382        .filter(|value| !value.trim().is_empty())?;
9383    let label = match custom_type {
9384        "compactionSummary" => "Compaction summary",
9385        "branchSummary" => "Branch summary",
9386        "bashExecution" => "Command output",
9387        other => other,
9388    };
9389    Some(format!("{label}: {text}"))
9390}
9391
9392/// Open a selector for the current session's persisted entry tree. Selecting a
9393/// message moves the main lane leaf to that entry, then the caller reloads the
9394/// visible branch from durable storage.
9395async fn open_tree_selector(
9396    harness: &AgentHarness,
9397    state: &Arc<TuiState>,
9398    editor_container: &Arc<Container>,
9399    editor: &Arc<Editor>,
9400    tui: &Arc<TuiAltScreen>,
9401    chat: &Arc<Container>,
9402    tx: &mpsc::UnboundedSender<TuiMessage>,
9403) {
9404    let entries = match harness
9405        .session()
9406        .view("main")
9407        .find_entries(&EntryQuery {
9408            order: Some(EntryOrder::OldestFirst),
9409            ..Default::default()
9410        })
9411        .await
9412    {
9413        Ok(entries) => entries,
9414        Err(error) => {
9415            add_error_message(chat, &format!("Could not read session tree: {error}"));
9416            tui.request_render(false);
9417            return;
9418        }
9419    };
9420    let current = harness.session().get_leaf_id().await.ok().flatten();
9421    let items: Vec<SelectItem> = entries
9422        .iter()
9423        .map(|entry| {
9424            let label = if current.as_deref() == Some(entry.id()) {
9425                format!("{} #{} (current)", entry.entry_type(), entry.seq())
9426            } else {
9427                format!("{} #{}", entry.entry_type(), entry.seq())
9428            };
9429            let short_id = &entry.id()[..entry.id().len().min(12)];
9430            // Search on the entry type, sequence, and id so `/tree` stays
9431            // usable in long sessions.
9432            let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
9433            SelectItem::new(entry.id(), &label)
9434                .with_description(short_id)
9435                .with_search_text(&search_text)
9436        })
9437        .collect();
9438    if items.is_empty() {
9439        add_note_message(chat, "The current session has no entries to navigate.");
9440        tui.request_render(false);
9441        return;
9442    }
9443    let list = Arc::new(SelectList::new(items, 12));
9444    let state_sel = state.clone();
9445    let ec_sel = editor_container.clone();
9446    let editor_sel = editor.clone();
9447    let tui_sel = tui.clone();
9448    let tx_sel = tx.clone();
9449    list.on_select(Arc::new(move |item| {
9450        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
9451        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9452    }));
9453    let state_cancel = state.clone();
9454    let ec_cancel = editor_container.clone();
9455    let editor_cancel = editor.clone();
9456    let tui_cancel = tui.clone();
9457    list.on_cancel(Arc::new(move || {
9458        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9459    }));
9460    open_searchable_selector(
9461        state,
9462        editor_container,
9463        editor,
9464        tui,
9465        Some("Session tree"),
9466        Some("Type to filter by entry type, sequence, or id"),
9467        list,
9468        SelectorKind::Tree,
9469    );
9470}
9471
9472/// Build + open the `/theme` selector. Built-in presets and enabled package
9473/// themes are shown; selecting applies the theme live and re-renders.
9474fn open_theme_selector(
9475    state: &Arc<TuiState>,
9476    editor_container: &Arc<Container>,
9477    editor: &Arc<Editor>,
9478    tui: &Arc<TuiAltScreen>,
9479    cwd: &std::path::Path,
9480    package_resources: &Arc<crate::packages::PackageResources>,
9481) {
9482    let mut items = vec![
9483        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
9484        SelectItem::new("light", "Light").with_description("Light background"),
9485        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
9486    ];
9487    if state.themes_enabled {
9488        for path in package_resources.theme_files() {
9489            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
9490                items.push(SelectItem::new(name, name).with_description("Package theme"));
9491            }
9492        }
9493    }
9494    let list = Arc::new(SelectList::new(items, 10));
9495
9496    let state_sel = state.clone();
9497    let ec_sel = editor_container.clone();
9498    let editor_sel = editor.clone();
9499    let tui_sel = tui.clone();
9500    let chat_sel = state.chat_container.clone();
9501    let cwd_sel = cwd.to_path_buf();
9502    let package_resources_sel = package_resources.clone();
9503    list.on_select(Arc::new(move |item| {
9504        let preset = match item.value.as_str() {
9505            "light" => Some(ThemePreset::Light),
9506            "monochrome" => Some(ThemePreset::Monochrome),
9507            "dark" => Some(ThemePreset::Dark),
9508            name => {
9509                if state_sel.themes_enabled {
9510                    if let Ok(custom) = crate::packages::load_theme_with_resources(
9511                        &cwd_sel,
9512                        name,
9513                        &package_resources_sel,
9514                    ) {
9515                        rpi_tui::global_theme_manager().set(custom.clone());
9516                        state_sel.theme_manager.set(custom);
9517                    }
9518                }
9519                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9520                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9521                tui_sel.render_now(true);
9522                return;
9523            }
9524        };
9525        let Some(preset) = preset else { return };
9526        apply_theme_preset(preset);
9527        state_sel.theme_manager.apply_preset(preset);
9528        // A quick accent note so the user sees the change registered even if
9529        // the terminal's own colors mask the preset difference.
9530        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9531        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9532        tui_sel.render_now(true);
9533    }));
9534    let state_cancel = state.clone();
9535    let ec_cancel = editor_container.clone();
9536    let editor_cancel = editor.clone();
9537    let tui_cancel = tui.clone();
9538    list.on_cancel(Arc::new(move || {
9539        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9540    }));
9541
9542    open_selector(
9543        state,
9544        editor_container,
9545        editor,
9546        tui,
9547        list,
9548        SelectorKind::Theme,
9549    );
9550}
9551
9552// ===========================================================================
9553// Feasible selectors — /thinking, /tools, /images
9554// ===========================================================================
9555
9556/// One-line descriptions for each thinking level, ported from
9557/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
9558fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9559    use rpi_ai::types::ThinkingLevel::*;
9560    match level {
9561        Off => "Off — No reasoning",
9562        Minimal => "Minimal — Brief reasoning (~1k tokens)",
9563        Low => "Low — Light reasoning (~1k tokens)",
9564        Medium => "Medium — Moderate reasoning (~80% of max)",
9565        High => "High — Extensive reasoning (~95% of max)",
9566        Xhigh => "Xhigh — Near-maximal reasoning",
9567        Max => "Max — Maximum reasoning",
9568    }
9569}
9570
9571/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
9572/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
9573fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9574    use rpi_ai::types::ThinkingLevel::*;
9575    match level {
9576        Off => "off",
9577        Minimal => "minimal",
9578        Low => "low",
9579        Medium => "medium",
9580        High => "high",
9581        Xhigh => "xhigh",
9582        Max => "max",
9583    }
9584}
9585
9586/// Parse a thinking-level name back to the enum (case-insensitive). Returns
9587/// `None` for an unknown name; used by the `/thinking` selector callback.
9588fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
9589    use rpi_ai::types::ThinkingLevel::*;
9590    match name.to_ascii_lowercase().as_str() {
9591        "off" => Some(Off),
9592        "minimal" => Some(Minimal),
9593        "low" => Some(Low),
9594        "medium" => Some(Medium),
9595        "high" => Some(High),
9596        "xhigh" => Some(Xhigh),
9597        "max" => Some(Max),
9598        _ => None,
9599    }
9600}
9601
9602/// Build + open the `/thinking` selector. Items are the levels the current
9603/// model supports (`Model::supported_thinking_levels`), each with a
9604/// description; the current level (read beforehand via `lane.get_thinking_level`)
9605/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
9606///
9607/// `on_select` fires on the blocking key thread, so it can't await
9608/// `lane.get_thinking_level()` to know the current level — the opener resolves
9609/// it first (best-effort) and preselects; the toggle on_select just applies
9610/// whatever was picked.
9611fn open_thinking_selector(
9612    state: &Arc<TuiState>,
9613    editor_container: &Arc<Container>,
9614    editor: &Arc<Editor>,
9615    tui: &Arc<TuiAltScreen>,
9616    lane: &Arc<dyn AgentLane>,
9617    catalog: &[rpi_ai::Model],
9618    lane_model_id: &str,
9619    chat: &Arc<Container>,
9620) {
9621    // Find the current model in the catalog to read its supported levels. If
9622    // absent, fall back to all levels so the selector still opens.
9623    let model = catalog
9624        .iter()
9625        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
9626    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
9627        .map(|m| m.supported_thinking_levels())
9628        .unwrap_or_else(|| {
9629            use rpi_ai::types::ThinkingLevel::*;
9630            vec![Off, Minimal, Low, Medium, High]
9631        });
9632    let mut items: Vec<SelectItem> = Vec::new();
9633    for lvl in &levels {
9634        let name = thinking_level_name(*lvl);
9635        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
9636    }
9637    if items.is_empty() {
9638        add_note_message(chat, "This model has no supported thinking levels.");
9639        tui.request_render(false);
9640        return;
9641    }
9642    let list = Arc::new(SelectList::new(items, 10));
9643
9644    let state_sel = state.clone();
9645    let ec_sel = editor_container.clone();
9646    let editor_sel = editor.clone();
9647    let tui_sel = tui.clone();
9648    let chat_sel = chat.clone();
9649    let lane_sel = lane.clone();
9650    list.on_select(Arc::new(move |item| {
9651        let Some(level) = thinking_level_from_name(&item.value) else {
9652            add_note_message(
9653                &chat_sel,
9654                &format!("Unknown thinking level: {}.", item.label),
9655            );
9656            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9657            return;
9658        };
9659        let lane = lane_sel.clone();
9660        let footer_sel = state_sel.footer.clone();
9661        tokio::spawn(async move {
9662            let _ = lane.set_thinking_level(level).await;
9663        });
9664        // Reflect the chosen level in the footer's model suffix (pi parity:
9665        // `model • thinking off` / `model • medium`). The shown text for the
9666        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
9667        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
9668        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
9669        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9670    }));
9671    let state_cancel = state.clone();
9672    let ec_cancel = editor_container.clone();
9673    let editor_cancel = editor.clone();
9674    let tui_cancel = tui.clone();
9675    list.on_cancel(Arc::new(move || {
9676        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9677    }));
9678
9679    open_selector(
9680        state,
9681        editor_container,
9682        editor,
9683        tui,
9684        list,
9685        SelectorKind::Thinking,
9686    );
9687}
9688
9689/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
9690/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
9691/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
9692/// — the blocking key thread can't await) and selecting a tool **toggles** it
9693/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
9694fn open_tools_selector(
9695    state: &Arc<TuiState>,
9696    editor_container: &Arc<Container>,
9697    editor: &Arc<Editor>,
9698    tui: &Arc<TuiAltScreen>,
9699    lane: &Arc<dyn AgentLane>,
9700    chat: &Arc<Container>,
9701) {
9702    // Best-effort read of the current active set. The opener runs on the async
9703    // runtime (it's called from the main loop's channel dispatch or the submit
9704    // closure that lives on the blocking thread — but `handle.block_on` is safe
9705    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
9706    let mut active = match tokio::runtime::Handle::try_current() {
9707        Ok(h) => h
9708            .block_on(async { lane.get_active_tools().await })
9709            .unwrap_or_default(),
9710        Err(_) => Vec::new(),
9711    };
9712    // An empty active set is the harness sentinel for "all registered tools"
9713    // (the selector only exposes built-ins). Expand it before rendering and
9714    // toggling so the first `/tools` visit does not show every tool as off or
9715    // accidentally reduce the active set to the one item selected.
9716    if active.is_empty() {
9717        active = crate::session::BUILTIN_TOOL_NAMES
9718            .iter()
9719            .map(|name| (*name).to_string())
9720            .collect();
9721    }
9722    let mut items: Vec<SelectItem> = Vec::new();
9723    for name in crate::session::BUILTIN_TOOL_NAMES {
9724        let on = active.iter().any(|a| a == name);
9725        let label = if on {
9726            format!("{name} (on)")
9727        } else {
9728            (*name).to_string()
9729        };
9730        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
9731    }
9732    let list = Arc::new(SelectList::new(items, 10));
9733
9734    // Capture the active set so on_select can toggle without re-reading.
9735    let active_captured = active.clone();
9736    let state_sel = state.clone();
9737    let ec_sel = editor_container.clone();
9738    let editor_sel = editor.clone();
9739    let tui_sel = tui.clone();
9740    let chat_sel = chat.clone();
9741    let lane_sel = lane.clone();
9742    list.on_select(Arc::new(move |item| {
9743        let mut next = active_captured.clone();
9744        if let Some(pos) = next.iter().position(|a| a == &item.value) {
9745            next.remove(pos);
9746        } else {
9747            next.push(item.value.clone());
9748        }
9749        let on = next.iter().any(|a| a == &item.value);
9750        let lane = lane_sel.clone();
9751        let next_clone = next.clone();
9752        tokio::spawn(async move {
9753            let _ = lane.set_active_tools(next_clone).await;
9754        });
9755        let list_str = if next.is_empty() {
9756            "(none)".to_string()
9757        } else {
9758            next.join(", ")
9759        };
9760        add_note_message(
9761            &chat_sel,
9762            &format!(
9763                "{} {} — active tools: {}",
9764                item.value,
9765                if on { "enabled" } else { "disabled" },
9766                list_str
9767            ),
9768        );
9769        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9770    }));
9771    let state_cancel = state.clone();
9772    let ec_cancel = editor_container.clone();
9773    let editor_cancel = editor.clone();
9774    let tui_cancel = tui.clone();
9775    list.on_cancel(Arc::new(move || {
9776        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9777    }));
9778
9779    open_selector(
9780        state,
9781        editor_container,
9782        editor,
9783        tui,
9784        list,
9785        SelectorKind::Tools,
9786    );
9787}
9788
9789/// Build + open the `/images` selector (Yes/No). Stores the choice in
9790/// `state.show_images` and notes it. Image wiring is minimal this pass — the
9791/// flag is consulted where images would be shown and echoed back here.
9792fn open_images_selector(
9793    state: &Arc<TuiState>,
9794    editor_container: &Arc<Container>,
9795    editor: &Arc<Editor>,
9796    tui: &Arc<TuiAltScreen>,
9797    chat: &Arc<Container>,
9798) {
9799    let current = *state.show_images.lock().unwrap();
9800    let items = vec![
9801        SelectItem::new("yes", "Yes").with_description(if current {
9802            "Inline images (current)"
9803        } else {
9804            "Inline images"
9805        }),
9806        SelectItem::new("no", "No").with_description(if current {
9807            "Placeholder only"
9808        } else {
9809            "Placeholder only (current)"
9810        }),
9811    ];
9812    let list = Arc::new(SelectList::new(items, 5));
9813
9814    let state_sel = state.clone();
9815    let ec_sel = editor_container.clone();
9816    let editor_sel = editor.clone();
9817    let tui_sel = tui.clone();
9818    let chat_sel = chat.clone();
9819    list.on_select(Arc::new(move |item| {
9820        let on = item.value == "yes";
9821        *state_sel.show_images.lock().unwrap() = on;
9822        add_note_message(
9823            &chat_sel,
9824            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
9825        );
9826        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9827    }));
9828    let state_cancel = state.clone();
9829    let ec_cancel = editor_container.clone();
9830    let editor_cancel = editor.clone();
9831    let tui_cancel = tui.clone();
9832    list.on_cancel(Arc::new(move || {
9833        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9834    }));
9835
9836    open_selector(
9837        state,
9838        editor_container,
9839        editor,
9840        tui,
9841        list,
9842        SelectorKind::Images,
9843    );
9844}
9845
9846// ===========================================================================
9847// Autocomplete
9848// ===========================================================================
9849
9850/// Refresh the autocomplete suggestion list from the current editor text +
9851/// cursor. Renders the suggestions into `autocomplete_container` (above the
9852/// editor) or clears it when there are none.
9853fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
9854    let text = editor.get_text();
9855    let cursor = editor_cursor_offset(editor, &text);
9856    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
9857    render_autocomplete(state, suggestions);
9858}
9859
9860/// Convert the editor's logical `(row, byte-column)` caret into the absolute
9861/// byte offset expected by autocomplete providers.
9862fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
9863    let (row, col) = editor.cursor_position();
9864    let mut offset = 0;
9865    for (index, line) in text.split('\n').enumerate() {
9866        if index == row {
9867            return (offset + col.min(line.len())).min(text.len());
9868        }
9869        offset = offset.saturating_add(line.len() + 1);
9870    }
9871    text.len()
9872}
9873
9874/// Restore an editor caret from an absolute byte offset after autocomplete
9875/// replaces a span in a multi-line draft.
9876fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
9877    let offset = offset.min(text.len());
9878    let mut consumed = 0;
9879    for (row, line) in text.split('\n').enumerate() {
9880        let end = consumed + line.len();
9881        if offset <= end {
9882            editor.set_cursor(row, offset - consumed);
9883            return;
9884        }
9885        consumed = end + 1;
9886    }
9887    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
9888    editor.set_cursor(
9889        last_row,
9890        text.rsplit('\n').next().map(str::len).unwrap_or(0),
9891    );
9892}
9893
9894/// Render (or clear) the autocomplete suggestion list into the container.
9895fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
9896    state.autocomplete_container.clear();
9897    let Some(sugg) = suggestions else {
9898        return;
9899    };
9900    if sugg.items.is_empty() {
9901        return;
9902    }
9903    // Build a compact list: top item marked with `→`, rest with `  `.
9904    // Cap the list so the dock doesn't swallow the transcript.
9905    let accent = state.theme_manager.get().colors.accent;
9906    let muted = state.theme_manager.get().colors.muted;
9907    for (i, item) in sugg
9908        .items
9909        .iter()
9910        .take(state.autocomplete_max_visible)
9911        .enumerate()
9912    {
9913        let prefix = if i == 0 { "→ " } else { "  " };
9914        let label = item.display_text();
9915        let line = if i == 0 {
9916            format!(
9917                "{prefix}{} {}",
9918                accent.fg(label),
9919                muted.fg(item.description.as_deref().unwrap_or(""))
9920            )
9921        } else {
9922            format!(
9923                "{prefix}{} {}",
9924                muted.fg(label),
9925                muted.fg(item.description.as_deref().unwrap_or(""))
9926            )
9927        };
9928        state
9929            .autocomplete_container
9930            .add_child(Arc::new(Text::new(line, 1, 0)));
9931    }
9932}
9933
9934/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
9935/// suggestion text, reposition the caret, and clear the suggestion list.
9936/// Returns `true` if a suggestion was accepted.
9937fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
9938    let text = editor.get_text();
9939    let cursor = editor_cursor_offset(editor, &text);
9940    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
9941        return false;
9942    };
9943    let Some(top) = sugg.items.first() else {
9944        return false;
9945    };
9946    // Replace the [start, end) span with the suggestion text. `start`/`end`
9947    // are byte offsets emitted by the providers on char boundaries, so the
9948    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
9949    let start = sugg.start.min(text.len());
9950    let end = sugg.end.min(text.len());
9951    let mut replaced = String::with_capacity(text.len() + top.text.len());
9952    replaced.push_str(&text[..start]);
9953    replaced.push_str(&top.text);
9954    // Keep the text AFTER the replaced span (mid-line completion: replacing
9955    // `[start, end)` must not drop the rest of the line).
9956    replaced.push_str(&text[end..]);
9957    if top.insert_space && !replaced.ends_with('/') {
9958        replaced.push(' ');
9959    }
9960    // New caret position: after the inserted text (byte offset; the editor
9961    // snaps `set_cursor` to a char boundary as a safety net).
9962    let new_cursor = replaced.len().min(
9963        start
9964            + top.text.len()
9965            + if top.insert_space && !top.text.ends_with('/') {
9966                1
9967            } else {
9968                0
9969            },
9970    );
9971    editor.set_text(&replaced);
9972    set_editor_cursor_offset(editor, &replaced, new_cursor);
9973    state.autocomplete_container.clear();
9974    true
9975}
9976
9977// ===========================================================================
9978// Transcript message helpers
9979// ===========================================================================
9980
9981/// Add the welcome header to the chat container.
9982fn add_welcome_message(container: &Arc<Container>) {
9983    add_welcome_message_with_capabilities(container, &[], &[]);
9984}
9985
9986/// Add the startup welcome header and a compact snapshot of active tools and
9987/// discovered skills. The snapshot reflects the harness configuration used by
9988/// the first turn, including tools contributed by extensions.
9989fn add_welcome_message_with_capabilities(
9990    container: &Arc<Container>,
9991    active_tools: &[String],
9992    skills: &[String],
9993) {
9994    let c = current_theme().colors;
9995    // The mark, wordmark and language mark live in `crate::brand`. See that
9996    // module for why the layout is built from single-width characters only.
9997    container.add_child(Arc::new(crate::brand::BrandLockup::new()));
9998    container.add_child(Arc::new(Spacer::new(1)));
9999    // The mark carries the product name, so the line under it is the tagline
10000    // rather than a second logotype.
10001    let title = c.muted.fg("interactive TUI · library-first agent runtime");
10002    container.add_child(Arc::new(Text::new(title, 1, 0)));
10003    container.add_child(Arc::new(Spacer::new(1)));
10004    container.add_child(Arc::new(Text::new(
10005        c.dim.fg("Type your message and press Enter to send."),
10006        1,
10007        0,
10008    )));
10009    let hint = c
10010        .dim
10011        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
10012    container.add_child(Arc::new(Text::new(hint, 1, 0)));
10013    container.add_child(Arc::new(Spacer::new(1)));
10014    container.add_child(Arc::new(Text::new(
10015        welcome_capability_line("Tools", active_tools),
10016        1,
10017        0,
10018    )));
10019    container.add_child(Arc::new(Text::new(
10020        welcome_capability_line("Skills", skills),
10021        1,
10022        0,
10023    )));
10024    container.add_child(Arc::new(DynamicBorder::new()));
10025}
10026
10027/// Append update notices inside the live transcript. Fullscreen mode clears
10028/// pre-TUI stdout/stderr, so update state must be represented by components.
10029fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
10030    let colors = current_theme().colors;
10031    let group = Arc::new(Container::new());
10032
10033    for warning in &report.warnings {
10034        let body = format!(
10035            "{}\n{} {}{}",
10036            colors.error.fg(&warning.message),
10037            colors.muted.fg("Run"),
10038            colors.accent.fg(&warning.command),
10039            colors.muted.fg(" to retry.")
10040        );
10041        add_update_panel(&group, "Update Failed", &body, colors.error);
10042    }
10043
10044    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
10045        let body = format!(
10046            "{} {}{}",
10047            colors
10048                .muted
10049                .fg(&format!("New version {} is available. Run", notice.latest)),
10050            colors.accent.fg(&notice.command),
10051            colors.muted.fg(".")
10052        );
10053        add_update_panel(&group, "Update Available", &body, colors.warning);
10054    }
10055
10056    let package_notices = report
10057        .notices
10058        .iter()
10059        .filter(|notice| notice.name != "rpi")
10060        .collect::<Vec<_>>();
10061    if !package_notices.is_empty() {
10062        let command = package_notices[0].command.as_str();
10063        let mut lines = vec![format!(
10064            "{} {}{}",
10065            colors.muted.fg("Package updates are available. Run"),
10066            colors.accent.fg(command),
10067            colors.muted.fg(".")
10068        )];
10069        lines.push(colors.muted.fg("Packages:"));
10070        lines.extend(
10071            package_notices
10072                .into_iter()
10073                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
10074        );
10075        add_update_panel(
10076            &group,
10077            "Package Updates Available",
10078            &lines.join("\n"),
10079            colors.warning,
10080        );
10081    }
10082
10083    // Other transcript producers append concurrently. Add the fully built
10084    // group in one operation so card borders and content cannot interleave
10085    // with user, assistant, tool, or extension messages.
10086    if group.child_count() > 0 {
10087        container.add_child(group);
10088    }
10089}
10090
10091fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
10092    container.add_child(Arc::new(Spacer::new(1)));
10093    container.add_child(Arc::new(DynamicBorder::with_color(color)));
10094    container.add_child(Arc::new(Text::new(
10095        format!("{}\n{body}", color.fg(&tui_bold(title))),
10096        1,
10097        0,
10098    )));
10099    container.add_child(Arc::new(DynamicBorder::with_color(color)));
10100}
10101
10102fn welcome_capability_line(label: &str, names: &[String]) -> String {
10103    let c = current_theme().colors;
10104    let value = if names.is_empty() {
10105        "none".to_string()
10106    } else {
10107        names.join(" · ")
10108    };
10109    format!(
10110        "{} {}",
10111        c.accent.fg(&format!("{label} ({})", names.len())),
10112        c.muted.fg(&value)
10113    )
10114}
10115
10116/// Add the `/help` command listing to the chat container.
10117fn add_help_message(container: &Arc<Container>) {
10118    let c = current_theme().colors;
10119    // Section header + a thin themed rule, then a two-column command table:
10120    // `cmd` in accent, `— desc` in muted. The old single-space layout made
10121    // the description column wander depending on command length.
10122    container.add_child(Arc::new(Text::new(
10123        c.md_heading.fg(&tui_bold("📚 Available Commands")),
10124        1,
10125        0,
10126    )));
10127    container.add_child(Arc::new(Spacer::new(1)));
10128
10129    let cmds: &[(&str, &str)] = &[
10130        ("/help, /?", "Show this help message"),
10131        ("/clear, /new", "Clear the conversation"),
10132        ("/exit, /quit, /q", "Exit the application"),
10133        ("/version, /v", "Show version information"),
10134        ("/changelog", "Show recent release changes"),
10135        ("/model, /m", "Choose a model (live switch)"),
10136        ("/thinking, /think", "Set reasoning depth (selector)"),
10137        ("/tools", "Toggle built-in tools on/off"),
10138        ("/images", "Toggle inline image rendering"),
10139        ("/session", "List saved sessions"),
10140        ("/theme", "Choose a theme (selector)"),
10141        ("/compact", "Compact the conversation"),
10142        ("/copy", "Copy last reply to clipboard"),
10143        ("/hotkeys", "Show keyboard shortcuts"),
10144        ("/armin", "🐾 Easter egg"),
10145        ("/earendil", "Earendil announcement"),
10146    ];
10147    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10148    for (cmd, desc) in cmds {
10149        let row = format!(
10150            "  {:<cmd_w$}  {}  {}",
10151            c.accent.fg(cmd),
10152            c.dim.fg("—"),
10153            c.muted.fg(desc)
10154        );
10155        container.add_child(Arc::new(Text::new(row, 1, 0)));
10156    }
10157    container.add_child(Arc::new(Spacer::new(1)));
10158}
10159
10160/// Add the `/version` block to the chat container.
10161fn add_version_message(container: &Arc<Container>) {
10162    let c = current_theme().colors;
10163    container.add_child(Arc::new(Text::new(
10164        c.md_heading.fg(&tui_bold("📦 Version Information")),
10165        1,
10166        0,
10167    )));
10168    container.add_child(Arc::new(Spacer::new(1)));
10169    // Use the crate version (kept in sync via `version.workspace = true`)
10170    // instead of the stale hardcoded "v0.1.2".
10171    container.add_child(Arc::new(Text::new(
10172        format!(
10173            "  {} {}",
10174            c.muted.fg("rpi-cli"),
10175            c.text.fg(&format!("v{}", crate::VERSION))
10176        ),
10177        1,
10178        0,
10179    )));
10180    container.add_child(Arc::new(Text::new(
10181        format!(
10182            "  {}",
10183            c.dim.fg("Rust implementation of pi coding agent TUI")
10184        ),
10185        1,
10186        0,
10187    )));
10188    container.add_child(Arc::new(Spacer::new(1)));
10189}
10190
10191/// Add a compact `/changelog` block to the chat container. Keep this local to
10192/// the binary so the command remains useful in installed builds without a
10193/// source checkout or a network request.
10194fn add_changelog_message(container: &Arc<Container>) {
10195    let c = current_theme().colors;
10196    container.add_child(Arc::new(Text::new(
10197        c.md_heading.fg(&tui_bold("Recent Changes")),
10198        1,
10199        0,
10200    )));
10201    container.add_child(Arc::new(Spacer::new(1)));
10202    let entries = [
10203        (
10204            "Native parity phase 1",
10205            "models, images, trust, export, and JSON events",
10206        ),
10207        (
10208            "TUI controls",
10209            "external editor, thinking levels, and tool output toggles",
10210        ),
10211        (
10212            "Provider auth",
10213            "OpenAI-compatible API key aliases and gateway headers",
10214        ),
10215    ];
10216    for (release, summary) in entries {
10217        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
10218        container.add_child(Arc::new(Text::new(row, 1, 0)));
10219    }
10220    container.add_child(Arc::new(Text::new(
10221        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
10222        1,
10223        0,
10224    )));
10225    container.add_child(Arc::new(Spacer::new(1)));
10226}
10227
10228/// Add the `/hotkeys` block to the chat container.
10229fn add_hotkeys_message(container: &Arc<Container>) {
10230    let c = current_theme().colors;
10231    container.add_child(Arc::new(Text::new(
10232        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
10233        1,
10234        0,
10235    )));
10236    container.add_child(Arc::new(Spacer::new(1)));
10237    let keys: &[(&str, &str)] = &[
10238        ("Enter", "Send message"),
10239        ("Shift+Enter", "New line"),
10240        ("Tab", "Accept autocomplete suggestion"),
10241        ("Ctrl+A / Ctrl+E", "Line start / end"),
10242        (
10243            "Ctrl+K / Ctrl+U",
10244            "Kill to end / start of line (Ctrl+Y yanks)",
10245        ),
10246        ("Ctrl+- / Ctrl+R", "Undo / redo"),
10247        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
10248        ("Alt+Backspace", "Kill previous word"),
10249        ("Ctrl+C", "Abort a run, or exit when idle"),
10250        ("Esc", "Abort a running prompt"),
10251        ("Ctrl+L", "Open model selector"),
10252        ("Ctrl+M", "Cycle to the next model (live)"),
10253        ("Ctrl+O", "Expand/collapse all tool output"),
10254        ("Ctrl+T", "Show/hide reasoning blocks"),
10255        ("PageUp/Down", "Scroll transcript by one page"),
10256        ("Home / End", "Jump to transcript start / latest output"),
10257    ];
10258    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10259    for (key, desc) in keys {
10260        let row = format!(
10261            "  {:<key_w$}  {}  {}",
10262            c.accent.fg(key),
10263            c.dim.fg("—"),
10264            c.muted.fg(desc)
10265        );
10266        container.add_child(Arc::new(Text::new(row, 1, 0)));
10267    }
10268    container.add_child(Arc::new(Spacer::new(1)));
10269}
10270
10271/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
10272/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
10273/// plain `> text` echo. A trailing Spacer(1) separates it from the next
10274// transcript entry (every entry contributes one trailing spacer so
10275// consecutive turns are separated by exactly one blank line).
10276fn add_user_message(container: &Arc<Container>, text: &str) {
10277    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
10278    container.add_child(Arc::new(Spacer::new(1)));
10279}
10280
10281/// Add an error message to the chat container.
10282/// Collect all rendered lines from the chat container for search.
10283fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
10284    let width = 80; // Default width for search; actual width varies by terminal
10285    chat_container.render(width)
10286}
10287
10288/// Build the shell command that resumes `session_id` in this project, or
10289/// `None` when there is nothing to resume. Mirrors native pi's
10290/// `formatResumeCommand`: `rpi --session <id>`, prefixing
10291/// `--session-dir <dir>` only when a non-default directory was requested.
10292fn format_resume_command(
10293    session_id: &str,
10294    cwd: &std::path::Path,
10295    session_dir: Option<&std::path::Path>,
10296) -> Option<String> {
10297    if session_id.is_empty() {
10298        return None;
10299    }
10300    let mut args = vec![crate::APP_NAME.to_string()];
10301    if let Some(dir) = session_dir {
10302        let default = crate::session::default_session_dir(cwd);
10303        if dir != default.as_path() {
10304            args.push("--session-dir".to_string());
10305            args.push(quote_shell_arg(&dir.to_string_lossy()));
10306        }
10307    }
10308    args.push("--session".to_string());
10309    args.push(session_id.to_string());
10310    Some(args.join(" "))
10311}
10312
10313/// Quote a shell argument when it contains whitespace or shell metacharacters
10314/// (mirrors native pi's `quoteIfNeeded`).
10315fn quote_shell_arg(value: &str) -> String {
10316    let needs_quoting = value.is_empty()
10317        || value
10318            .chars()
10319            .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
10320    if needs_quoting {
10321        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
10322    } else {
10323        value.to_string()
10324    }
10325}
10326
10327fn add_error_message(container: &Arc<Container>, text: &str) {
10328    let c = current_theme().colors;
10329    let text = sanitize_error_message(text);
10330    container.add_child(Arc::new(Text::new(
10331        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
10332        1,
10333        0,
10334    )));
10335    container.add_child(Arc::new(Spacer::new(1)));
10336}
10337
10338/// Keep provider diagnostics printable in the transcript. HTTP error bodies
10339/// may contain carriage returns, terminal escapes, or an unexpectedly large
10340/// JSON payload; letting those bytes reach the renderer can corrupt the input
10341/// row or make the whole frame exceed terminal limits.
10342fn sanitize_error_message(text: &str) -> String {
10343    const MAX_ERROR_CHARS: usize = 16 * 1024;
10344    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
10345    let mut count = 0;
10346    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
10347    let mut escape_mode = 0u8;
10348    for ch in text.chars() {
10349        if escape_mode != 0 {
10350            match escape_mode {
10351                1 if ch == '[' => escape_mode = 2,
10352                1 if ch == ']' => escape_mode = 3,
10353                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
10354                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
10355                3 if ch == '\x07' => escape_mode = 0,
10356                _ => {}
10357            }
10358            continue;
10359        }
10360        if ch == '\x1b' {
10361            escape_mode = 1;
10362            continue;
10363        }
10364        if count >= MAX_ERROR_CHARS {
10365            result.push_str("…");
10366            break;
10367        }
10368        match ch {
10369            '\n' | '\t' => {
10370                result.push(ch);
10371                count += 1;
10372            }
10373            '\r' => {}
10374            c if c.is_control() => {}
10375            c => {
10376                result.push(c);
10377                count += 1;
10378            }
10379        }
10380    }
10381    if result.trim().is_empty() {
10382        "Provider request failed.".to_string()
10383    } else {
10384        result
10385    }
10386}
10387
10388/// Add a neutral note (e.g. unsupported-command message) to the chat container.
10389fn add_note_message(container: &Arc<Container>, text: &str) {
10390    let c = current_theme().colors;
10391    container.add_child(Arc::new(Text::new(
10392        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
10393        1,
10394        0,
10395    )));
10396    container.add_child(Arc::new(Spacer::new(1)));
10397}
10398
10399/// Render the `/context` panel: a transcript message listing the discovered
10400/// context files, skills, and prompt templates loaded for this session
10401/// (Part A resource discovery). Reads the harness resources snapshot captured
10402/// at TUI startup (the blocking submit handler can't `await get_resources()`.
10403///
10404/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
10405/// via `/reload`); here it's a transcript note rather than an overlay since the
10406/// resource set is session-static between `/reload`s (deferred).
10407fn show_context_panel(
10408    chat: &Arc<Container>,
10409    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
10410) {
10411    let skills = resources.skills.as_deref().unwrap_or(&[]);
10412    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
10413    let mut lines: Vec<String> = Vec::new();
10414    lines.push("📂 Discovered resources for this session:".into());
10415
10416    if skills.is_empty() {
10417        lines.push(
10418            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
10419        );
10420    } else {
10421        lines.push(format!("  Skills ({}):", skills.len()));
10422        for s in skills {
10423            let marker = if s.disable_model_invocation == Some(true) {
10424                " [hidden]"
10425            } else {
10426                ""
10427            };
10428            let desc: String = s.description.chars().take(72).collect();
10429            lines.push(format!("    • {}{marker} — {desc}", s.name));
10430        }
10431    }
10432
10433    if templates.is_empty() {
10434        lines.push(
10435            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
10436        );
10437    } else {
10438        lines.push(format!("  Prompt templates ({}):", templates.len()));
10439        for t in templates {
10440            let desc = t
10441                .description
10442                .as_deref()
10443                .unwrap_or("(no description)")
10444                .chars()
10445                .take(72)
10446                .collect::<String>();
10447            lines.push(format!("    • /{} — {desc}", t.name));
10448        }
10449    }
10450    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
10451    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
10452    lines.push(
10453        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
10454            .into(),
10455    );
10456    let body = lines.join("\n");
10457    container_note_block(chat, &body);
10458}
10459
10460/// Append a multi-line neutral note (header line + body) to the chat container.
10461fn container_note_block(container: &Arc<Container>, body: &str) {
10462    for line in body.lines() {
10463        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
10464    }
10465    container.add_child(Arc::new(Spacer::new(1)));
10466}
10467
10468// ===========================================================================
10469// TUI support + entry detection
10470// ===========================================================================
10471
10472/// Check if the terminal supports TUI mode.
10473pub fn is_tui_supported() -> bool {
10474    std::io::stdout().is_terminal()
10475}
10476
10477// Keep the `Color` import used (theme accent rendering in autocomplete).
10478#[allow(unused_imports)]
10479use rpi_tui::Color as _Color;
10480
10481#[cfg(test)]
10482mod tests {
10483    use super::*;
10484    use rpi_tui::Component;
10485
10486    #[test]
10487    fn markdown_flag_only_comes_from_an_explicit_true() {
10488        // Tools opt in by setting `details.markdown = true` (rpi-todo does).
10489        assert!(tool_result_requests_markdown(Some(
10490            &serde_json::json!({"kind": "todo", "markdown": true})
10491        )));
10492        // Absence, a false value, or a non-bool value keeps plain rendering,
10493        // so every other tool is unaffected.
10494        assert!(!tool_result_requests_markdown(None));
10495        assert!(!tool_result_requests_markdown(Some(
10496            &serde_json::json!({"kind": "todo"})
10497        )));
10498        assert!(!tool_result_requests_markdown(Some(
10499            &serde_json::json!({"markdown": false})
10500        )));
10501        assert!(!tool_result_requests_markdown(Some(
10502            &serde_json::json!({"markdown": "true"})
10503        )));
10504    }
10505
10506    /// Minimal `TuiState` for unit tests that only touch state flags. Mirrors
10507    /// the explicit literals other tests build, but keeps one copy in sync.
10508    fn test_tui_state() -> Arc<TuiState> {
10509        Arc::new(TuiState {
10510            current_assistant: std::sync::Mutex::new(None),
10511            tool_components: std::sync::Mutex::new(HashMap::new()),
10512            bash_components: std::sync::Mutex::new(HashMap::new()),
10513            themes_enabled: true,
10514            hide_thinking: std::sync::Mutex::new(false),
10515            tool_outputs_expanded: std::sync::Mutex::new(false),
10516            show_terminal_progress: true,
10517            status: std::sync::Mutex::new(RunStatus::Idle),
10518            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10519            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10520            js_preparation_cancel: std::sync::Mutex::new(None),
10521            user_bash_cancel: std::sync::Mutex::new(None),
10522            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10523            footer: Arc::new(FooterComponent::new()),
10524            status_container: Arc::new(Container::new()),
10525            chat_container: Arc::new(Container::new()),
10526            loader: Arc::new(Loader::new()),
10527            editor: Arc::new(Editor::simple()),
10528            last_assistant_text: std::sync::Mutex::new(String::new()),
10529            active_selector: std::sync::Mutex::new(None),
10530            active_extension_editor: std::sync::Mutex::new(None),
10531            active_extension_input: std::sync::Mutex::new(None),
10532            active_extension_cancel: std::sync::Mutex::new(None),
10533            autocomplete: AutocompleteManager::new(),
10534            autocomplete_container: Arc::new(Container::new()),
10535            autocomplete_max_visible: 5,
10536            pending_images: std::sync::Mutex::new(Vec::new()),
10537            theme_manager: Arc::new(ThemeManager::new()),
10538            tui: None,
10539            current_model_id: std::sync::Mutex::new(String::new()),
10540            show_images: std::sync::Mutex::new(true),
10541            cache_miss_notices: std::sync::Mutex::new(false),
10542            history: std::sync::Mutex::new(Vec::new()),
10543            history_index: std::sync::Mutex::new(-1),
10544            history_draft: std::sync::Mutex::new(None),
10545            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10546            scoped_edit: std::sync::Mutex::new(None),
10547            markdown_transformer: std::sync::Mutex::new(None),
10548            extension_session: Arc::new(std::sync::Mutex::new(
10549                rpi_extensions::ExtensionSession::none(),
10550            )),
10551            search: Arc::new(AltScreenSearch::new()),
10552            search_bar: Arc::new(SearchBar::new()),
10553            pending_container: Arc::new(Container::new()),
10554            dequeue_hint: String::new(),
10555            pending_snapshot: std::sync::Mutex::new(
10556                rpi_harness::agent_harness::QueuedMessages::default(),
10557            ),
10558            selection_start: std::sync::Mutex::new(None),
10559            selection_end: std::sync::Mutex::new(None),
10560        })
10561    }
10562
10563    #[test]
10564    fn live_panel_repaints_while_a_tool_or_bash_is_running() {
10565        // No panels → only the dock loader animates (reuse the cached
10566        // transcript instead of rebuilding a long history).
10567        let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
10568        assert!(!transcript_has_live_panel(false, &empty));
10569
10570        // A running tool call is a live panel: its elapsed readout is computed
10571        // at render time, so it must force a transcript rebuild every tick.
10572        let mut tools = HashMap::new();
10573        let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
10574        tool.set_running();
10575        tools.insert("tc1".to_string(), tool);
10576        assert!(transcript_has_live_panel(false, &tools));
10577
10578        // A finished tool no longer needs the repaint, but a running bash
10579        // command still does.
10580        tools.get("tc1").unwrap().set_result("done", false);
10581        assert!(!transcript_has_live_panel(false, &tools));
10582        assert!(transcript_has_live_panel(true, &tools));
10583    }
10584
10585    #[test]
10586    fn dequeue_merge_preserves_a_typed_draft() {
10587        assert_eq!(merge_queued_into_draft("queued", ""), "queued");
10588        assert_eq!(merge_queued_into_draft("queued", "   "), "queued");
10589        assert_eq!(
10590            merge_queued_into_draft("queued", "typed"),
10591            "queued\n\ntyped"
10592        );
10593    }
10594
10595    #[test]
10596    fn settings_menu_covers_the_wired_settings_surface() {
10597        let state = test_tui_state();
10598        let settings = crate::settings::Settings::default();
10599        let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
10600        let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
10601        assert_eq!(
10602            keys,
10603            [
10604                "theme",
10605                "model",
10606                "thinking",
10607                "scoped-models",
10608                "hide-thinking",
10609                "show-images",
10610                "cache-miss-notices",
10611                "quiet-startup",
10612                "terminal-progress",
10613                "fullscreen-copy-on-select",
10614                "double-escape-action",
10615                "editor-padding",
10616                "autocomplete-max-items",
10617                "http-idle-timeout",
10618            ]
10619        );
10620        // Submenu rows must not cycle their displayed value.
10621        for item in items.iter().filter(|item| item.has_submenu) {
10622            assert!(
10623                item.values.is_empty(),
10624                "{} should be submenu-only",
10625                item.key
10626            );
10627        }
10628        // Value rows advertise a cycle that contains the current value.
10629        for item in items.iter().filter(|item| !item.has_submenu) {
10630            assert!(!item.values.is_empty(), "{} has no values", item.key);
10631            assert!(
10632                item.values.iter().any(|value| value == &item.value),
10633                "{} current value {} not in {:?}",
10634                item.key,
10635                item.value,
10636                item.values
10637            );
10638        }
10639        // The default model row falls back to the running lane's model.
10640        assert_eq!(items[1].value, "claude-sonnet-5");
10641        // Native defaults: cache-miss notices off, terminal progress on.
10642        assert_eq!(items[6].value, "false");
10643        assert_eq!(items[8].value, "true");
10644    }
10645
10646    #[test]
10647    fn apply_setting_change_updates_settings_and_live_state() {
10648        let state = test_tui_state();
10649        let mut settings = crate::settings::Settings::default();
10650
10651        // Live: hide-thinking flips both the runtime flag and the persisted one.
10652        let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
10653        assert!(state.hide_thinking());
10654        assert_eq!(settings.hide_thinking_block, Some(true));
10655        assert!(note.expect("user-visible note").contains("hidden"));
10656
10657        // Live: show-images writes the runtime mutex.
10658        apply_setting_change(&state, "show-images", "false", &mut settings);
10659        assert!(!*state.show_images.lock().unwrap());
10660        assert_eq!(settings.show_images, Some(false));
10661
10662        // Live: cache-miss notices.
10663        apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
10664        assert!(*state.cache_miss_notices.lock().unwrap());
10665        assert_eq!(settings.show_cache_miss_notices, Some(true));
10666
10667        // Persist-only rows change settings without a transcript note.
10668        assert_eq!(
10669            apply_setting_change(&state, "editor-padding", "3", &mut settings),
10670            None
10671        );
10672        assert_eq!(settings.editor_padding_x, Some(3));
10673        apply_setting_change(&state, "double-escape-action", "none", &mut settings);
10674        assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
10675        apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
10676        assert_eq!(settings.autocomplete_max_visible, Some(20));
10677
10678        // HTTP idle timeout maps labels onto the numeric/`disabled` forms.
10679        apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
10680        assert_eq!(settings.http_idle_timeout_ms(), Some(0));
10681        apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
10682        assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
10683
10684        // Unknown keys are ignored rather than panicking.
10685        assert_eq!(
10686            apply_setting_change(&state, "nope", "x", &mut settings),
10687            None
10688        );
10689    }
10690
10691    #[test]
10692    fn add_user_message_renders_a_visible_user_bubble() {
10693        // The submit handler renders directly-sent prompts through this helper
10694        // (the harness emits no user `message_start` for them), so a silent
10695        // failure here would make every prompt vanish from the transcript.
10696        let chat = Arc::new(Container::new());
10697        add_user_message(&chat, "hello from the user");
10698        let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
10699        let plain = strip_ansi(&rendered.join("\n"));
10700        assert!(
10701            plain.contains("hello from the user"),
10702            "user bubble must show the prompt text; got: {plain:?}"
10703        );
10704    }
10705
10706    #[test]
10707    fn verbose_overrides_quiet_startup_listing() {
10708        assert!(should_show_startup_listing(false, false));
10709        assert!(should_show_startup_listing(true, false));
10710        assert!(should_show_startup_listing(true, true));
10711        assert!(!should_show_startup_listing(false, true));
10712    }
10713
10714    #[test]
10715    fn resume_command_omits_the_default_session_dir() {
10716        let cwd = std::path::Path::new("/proj/demo");
10717        assert_eq!(format_resume_command("", cwd, None), None);
10718        assert_eq!(
10719            format_resume_command("abc-123", cwd, None).as_deref(),
10720            Some("rpi --session abc-123")
10721        );
10722        // The default dir carries no flag (mirrors native pi).
10723        let default = crate::session::default_session_dir(cwd);
10724        assert_eq!(
10725            format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
10726            Some("rpi --session abc-123")
10727        );
10728        // A custom dir is echoed, quoting when needed.
10729        let custom = std::path::Path::new("/tmp/my sessions");
10730        assert_eq!(
10731            format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
10732            Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
10733        );
10734    }
10735
10736    #[test]
10737    fn quote_shell_arg_quotes_only_when_needed() {
10738        assert_eq!(quote_shell_arg("plain"), "plain");
10739        assert_eq!(quote_shell_arg("with space"), "\"with space\"");
10740        assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
10741    }
10742
10743    #[test]
10744    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
10745        let tmp = tempfile::tempdir().unwrap();
10746        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
10747        let local_root = tmp.path().join("local-package");
10748        std::fs::create_dir_all(git_root.join(".git")).unwrap();
10749        std::fs::create_dir_all(&local_root).unwrap();
10750        std::fs::write(
10751            git_root.join("package.json"),
10752            r#"{"name":"git-demo","version":"1.0.0"}"#,
10753        )
10754        .unwrap();
10755        std::fs::write(
10756            local_root.join("package.json"),
10757            r#"{"name":"local-demo","version":"1.0.0"}"#,
10758        )
10759        .unwrap();
10760        let resources = crate::packages::discover(
10761            tmp.path(),
10762            &[
10763                "git:github.com/example/repo".to_string(),
10764                local_root.to_string_lossy().into_owned(),
10765            ],
10766        );
10767        assert_eq!(resources.packages.len(), 2);
10768
10769        let filtered = git_only_update_resources(resources);
10770
10771        assert_eq!(filtered.packages.len(), 1);
10772        assert!(matches!(
10773            filtered.packages[0].source,
10774            crate::packages::PackageSource::Git
10775        ));
10776    }
10777
10778    #[test]
10779    fn trust_command_uses_the_session_cwd_after_process_chdir() {
10780        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
10781        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
10782        const TEST_NAME: &str =
10783            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
10784
10785        if std::env::var_os(CHILD_ENV).is_some() {
10786            let session_cwd = std::path::PathBuf::from(
10787                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
10788            );
10789            let changed_cwd = std::env::current_dir().unwrap();
10790
10791            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
10792
10793            assert_eq!(
10794                crate::config::project_trust_decision(&session_cwd).unwrap(),
10795                Some(true)
10796            );
10797            assert_eq!(
10798                crate::config::project_trust_decision(&changed_cwd).unwrap(),
10799                None
10800            );
10801            return;
10802        }
10803
10804        let tmp = tempfile::tempdir().unwrap();
10805        let session_cwd = tmp.path().join("session-project");
10806        let changed_cwd = tmp.path().join("extension-cwd");
10807        let agent_dir = tmp.path().join("agent");
10808        std::fs::create_dir_all(&session_cwd).unwrap();
10809        std::fs::create_dir_all(&changed_cwd).unwrap();
10810        std::fs::create_dir_all(&agent_dir).unwrap();
10811
10812        let status = std::process::Command::new(std::env::current_exe().unwrap())
10813            .arg("--exact")
10814            .arg(TEST_NAME)
10815            .arg("--nocapture")
10816            .env(CHILD_ENV, "1")
10817            .env(SESSION_CWD_ENV, &session_cwd)
10818            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
10819            .current_dir(&changed_cwd)
10820            .status()
10821            .unwrap();
10822
10823        assert!(status.success(), "child test process failed: {status}");
10824    }
10825
10826    #[test]
10827    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
10828        let global = crate::settings::Settings {
10829            editor_padding_x: Some(3),
10830            autocomplete_max_visible: Some(6),
10831            hide_thinking_block: Some(false),
10832            quiet_startup: Some(true),
10833            show_terminal_progress: Some(true),
10834            ..Default::default()
10835        };
10836        let rpi_project = crate::settings::Settings {
10837            editor_padding_x: Some(0),
10838            hide_thinking_block: Some(true),
10839            quiet_startup: Some(false),
10840            show_terminal_progress: Some(false),
10841            ..Default::default()
10842        };
10843        let pi_project = crate::settings::Settings {
10844            editor_padding_x: Some(9),
10845            autocomplete_max_visible: Some(12),
10846            quiet_startup: Some(true),
10847            ..Default::default()
10848        };
10849
10850        assert_eq!(
10851            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
10852            TuiStartupSettings {
10853                editor_padding_x: 0,
10854                autocomplete_max_visible: 12,
10855                hide_thinking: true,
10856                quiet_startup: false,
10857                show_terminal_progress: false,
10858                show_images: true,
10859                cache_miss_notices: false,
10860            }
10861        );
10862    }
10863
10864    #[test]
10865    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
10866        let global = crate::settings::Settings {
10867            quiet_startup: Some(false),
10868            ..Default::default()
10869        };
10870        let rpi_project = crate::settings::Settings::default();
10871        let pi_project = crate::settings::Settings {
10872            quiet_startup: Some(true),
10873            ..Default::default()
10874        };
10875
10876        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
10877
10878        assert!(resolved.quiet_startup);
10879    }
10880
10881    #[test]
10882    fn tui_startup_settings_use_global_values_without_project_fields() {
10883        let global = crate::settings::Settings {
10884            editor_padding_x: Some(7),
10885            autocomplete_max_visible: Some(8),
10886            hide_thinking_block: Some(true),
10887            quiet_startup: Some(true),
10888            show_terminal_progress: Some(false),
10889            show_images: Some(false),
10890            show_cache_miss_notices: Some(true),
10891            ..Default::default()
10892        };
10893
10894        assert_eq!(
10895            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
10896            TuiStartupSettings {
10897                editor_padding_x: 7,
10898                autocomplete_max_visible: 8,
10899                hide_thinking: true,
10900                quiet_startup: true,
10901                show_terminal_progress: false,
10902                show_images: false,
10903                cache_miss_notices: true,
10904            }
10905        );
10906    }
10907
10908    #[test]
10909    fn tui_startup_settings_read_native_nested_terminal_block() {
10910        // Native pi nests these under `terminal`; rpi's flat keys stay
10911        // compatible and the nested block is the fallback.
10912        let global = crate::settings::Settings {
10913            terminal: Some(crate::settings::TerminalSettings {
10914                show_images: Some(false),
10915                show_terminal_progress: Some(false),
10916                clear_on_shrink: None,
10917            }),
10918            ..Default::default()
10919        };
10920        let resolved =
10921            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10922        assert!(!resolved.show_images);
10923        assert!(!resolved.show_terminal_progress);
10924    }
10925
10926    #[test]
10927    fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
10928        let global = crate::settings::Settings {
10929            show_images: Some(true),
10930            terminal: Some(crate::settings::TerminalSettings {
10931                show_images: Some(false),
10932                show_terminal_progress: None,
10933                clear_on_shrink: None,
10934            }),
10935            ..Default::default()
10936        };
10937        let resolved =
10938            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10939        assert!(resolved.show_images);
10940    }
10941
10942    #[test]
10943    fn tui_startup_settings_ignore_untrusted_project_values() {
10944        let global = crate::settings::Settings {
10945            quiet_startup: Some(false),
10946            show_terminal_progress: Some(true),
10947            ..Default::default()
10948        };
10949        let project = crate::settings::Settings {
10950            quiet_startup: Some(true),
10951            show_terminal_progress: Some(false),
10952            ..Default::default()
10953        };
10954
10955        let resolved = resolve_tui_startup_settings(&global, &[project], false);
10956
10957        assert!(!resolved.quiet_startup);
10958        assert!(resolved.show_terminal_progress);
10959    }
10960
10961    #[test]
10962    fn transcript_page_uses_viewport_with_overlap() {
10963        assert_eq!(transcript_page_size(24), 20);
10964        assert_eq!(transcript_page_size(4), 1);
10965        assert_eq!(transcript_page_size(0), 1);
10966    }
10967
10968    #[test]
10969    fn parse_user_bash_distinguishes_single_and_double_bang() {
10970        // `!cmd` runs and stays in context.
10971        assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
10972        // `!!cmd` runs but is excluded from context.
10973        assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
10974        // Surrounding whitespace on the command is trimmed, the `!` prefix is not.
10975        assert_eq!(parse_user_bash("!  echo hi  "), Some(("echo hi", false)));
10976        assert_eq!(parse_user_bash("!!  echo hi"), Some(("echo hi", true)));
10977    }
10978
10979    #[test]
10980    fn parse_user_bash_ignores_bare_bang_and_plain_text() {
10981        // A bare `!` / `!!` has no command: fall through to the prompt path
10982        // instead of executing an empty shell line.
10983        assert_eq!(parse_user_bash("!"), None);
10984        assert_eq!(parse_user_bash("!!"), None);
10985        assert_eq!(parse_user_bash("!   "), None);
10986        assert_eq!(parse_user_bash("hello"), None);
10987        assert_eq!(parse_user_bash(""), None);
10988        // `/` keeps routing to the slash-command registry.
10989        assert_eq!(parse_user_bash("/model"), None);
10990    }
10991
10992    #[test]
10993    fn user_bash_run_slot_guards_and_cancels() {
10994        let state = test_tui_state();
10995        assert!(!state.user_bash_running());
10996        let cancel = state.begin_user_bash();
10997        assert!(state.user_bash_running());
10998        // Esc cancels without freeing the slot; the spawned task clears it when
10999        // the capture resolves (native pi's `isBashRunning`).
11000        assert!(state.cancel_user_bash());
11001        assert!(state.user_bash_running());
11002        assert!(cancel.is_cancelled());
11003        state.finish_user_bash();
11004        assert!(!state.user_bash_running());
11005        // Nothing to cancel once idle.
11006        assert!(!state.cancel_user_bash());
11007    }
11008
11009    #[test]
11010    fn key_repeat_is_dispatched_but_release_is_not() {
11011        assert!(should_dispatch_key(KeyEventKind::Press));
11012        assert!(should_dispatch_key(KeyEventKind::Repeat));
11013        assert!(!should_dispatch_key(KeyEventKind::Release));
11014    }
11015
11016    #[test]
11017    fn key_event_encoding_matches_pi_keybinding_protocol() {
11018        let key = |code, modifiers| KeyEvent::new(code, modifiers);
11019        assert_eq!(
11020            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
11021            "\r"
11022        );
11023        assert_eq!(
11024            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
11025            "\x1b[13;2u"
11026        );
11027        assert_eq!(
11028            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
11029            "\x1b[9;2u"
11030        );
11031        assert_eq!(
11032            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
11033            "\x1b[Z"
11034        );
11035        assert_eq!(
11036            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
11037            "\x1b[Z"
11038        );
11039        assert_eq!(
11040            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
11041            "\x03"
11042        );
11043        assert_eq!(
11044            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
11045            "\x0f"
11046        );
11047        assert_eq!(
11048            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
11049            "!"
11050        );
11051        assert_eq!(
11052            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
11053            "1"
11054        );
11055    }
11056
11057    #[test]
11058    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
11059        let bridge = JsDialogBridge::default();
11060        let (sender, receiver) = std_mpsc::channel();
11061        bridge.pending.lock().unwrap().push_back(JsDialogPending {
11062            request: JsDialogRequest {
11063                id: "dialog-1".into(),
11064                method: "input".into(),
11065                title: String::new(),
11066                message: String::new(),
11067                options: Vec::new(),
11068                placeholder: None,
11069                prefill: None,
11070            },
11071            result: sender,
11072        });
11073
11074        // Model the cancellation arriving after the queue entry has been
11075        // removed but before the TUI has installed the native widget.
11076        bridge.cancel("dialog-1");
11077        assert!(bridge.take_pending().is_none());
11078        assert_eq!(
11079            receiver.recv().unwrap(),
11080            serde_json::json!({ "cancelled": true })
11081        );
11082        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
11083    }
11084
11085    #[test]
11086    fn test_layout_renders_welcome_message() {
11087        let chat = Arc::new(Container::new());
11088        add_welcome_message(&chat);
11089
11090        let scroll = Arc::new(ScrollView::new(
11091            chat.clone(),
11092            ScrollViewOptions {
11093                follow: FollowMode::End,
11094                primary: true,
11095                ..Default::default()
11096            },
11097        ));
11098
11099        let editor = Arc::new(Editor::new(
11100            EditorOptions {
11101                padding_x: 1,
11102                ..Default::default()
11103            },
11104            EditorStyle::default(),
11105            Arc::new(rpi_tui::Keybindings::new()),
11106        ));
11107        let dock = Arc::new(Container::new());
11108        dock.add_child(editor);
11109
11110        let footer = Arc::new(FooterComponent::new());
11111
11112        let root = VStack::from_children(vec![
11113            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
11114            StackChild::Entry(StackEntry::new(dock)),
11115            StackChild::Entry(StackEntry::new(footer)),
11116        ]);
11117
11118        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
11119
11120        let all: String = frame.lines.join("\n");
11121        assert!(
11122            all.contains("rpi"),
11123            "Welcome message not found. Rendered: {}",
11124            all
11125        );
11126        assert!(
11127            all.contains("Type your message"),
11128            "Help text not found. Rendered: {}",
11129            all
11130        );
11131    }
11132
11133    #[test]
11134    fn test_chat_container_has_welcome_content() {
11135        let chat = Arc::new(Container::new());
11136        add_welcome_message_with_capabilities(
11137            &chat,
11138            &["read".into(), "bash".into(), "web_fetch".into()],
11139            &["rust-review".into(), "release".into()],
11140        );
11141
11142        let lines = chat.render(80);
11143        let all: String = lines.join("\n");
11144        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
11145        // joined by an ANSI reset — strip ANSI before checking the substring.
11146        let plain = strip_ansi(&all);
11147        assert!(
11148            plain.contains("rpi"),
11149            "Welcome message not in chat container: {:?}",
11150            lines
11151        );
11152        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
11153        assert!(
11154            plain.contains("read · bash · web_fetch"),
11155            "Tool names missing: {plain}"
11156        );
11157        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
11158        assert!(
11159            plain.contains("rust-review · release"),
11160            "Skill names missing: {plain}"
11161        );
11162    }
11163
11164    #[test]
11165    fn welcome_header_renders_the_brand_mark() {
11166        let chat = Arc::new(Container::new());
11167        add_welcome_message(&chat);
11168        let lines: Vec<String> = chat.render(80).iter().map(|l| strip_ansi(l)).collect();
11169        let all = lines.join("\n");
11170
11171        // The three-bar mark: the bottom row carries all three bars, and the
11172        // wordmark sits on its baseline next to the middle bar.
11173        assert!(
11174            lines.iter().any(|l| l.contains("██ ██ ██")),
11175            "brand mark missing: {all}"
11176        );
11177        assert!(
11178            all.contains("rpi"),
11179            "the mark must carry the product name: {all}"
11180        );
11181
11182        // Regression guard for the layout bug this replaced: `π` is East-Asian
11183        // Ambiguous — width 1 normally, width 2 under a CJK locale — which
11184        // walked the old box borders out of alignment depending on the user's
11185        // system. The crab is deliberately wide but unambiguous, and `crate::brand`
11186        // tests pin its width so the padding cannot silently drift.
11187        assert!(
11188            !all.contains('π'),
11189            "the header must not use east-asian-ambiguous glyphs: {all}"
11190        );
11191    }
11192
11193    #[test]
11194    fn update_notices_render_inside_the_transcript() {
11195        let chat = Arc::new(Container::new());
11196        let report = crate::updates::UpdateReport {
11197            notices: vec![
11198                crate::updates::UpdateNotice {
11199                    name: "rpi".into(),
11200                    current: "0.1.10".into(),
11201                    latest: "0.1.11".into(),
11202                    command: "rpi update".into(),
11203                },
11204                crate::updates::UpdateNotice {
11205                    name: "rpi-search".into(),
11206                    current: "0.1.0".into(),
11207                    latest: "0.1.1".into(),
11208                    command: "rpi pi-package update".into(),
11209                },
11210            ],
11211            warnings: vec![crate::updates::UpdateWarning {
11212                message: "The previously scheduled rpi update failed: access denied".into(),
11213                command: "rpi update".into(),
11214            }],
11215        };
11216
11217        add_update_notices(&chat, &report);
11218
11219        assert_eq!(chat.child_count(), 1);
11220        let plain = strip_ansi(&chat.render(80).join("\n"));
11221        assert!(plain.contains("Update Failed"), "{plain}");
11222        assert!(
11223            plain.contains("rpi update failed: access denied"),
11224            "{plain}"
11225        );
11226        assert!(plain.contains("Update Available"), "{plain}");
11227        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
11228        assert!(plain.contains("rpi update"), "{plain}");
11229        assert!(plain.contains("Package Updates Available"), "{plain}");
11230        assert!(plain.contains("rpi pi-package update"), "{plain}");
11231        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
11232    }
11233
11234    #[test]
11235    fn empty_update_report_does_not_add_transcript_content() {
11236        let chat = Arc::new(Container::new());
11237
11238        add_update_notices(&chat, &crate::updates::UpdateReport::default());
11239
11240        assert_eq!(chat.child_count(), 0);
11241    }
11242
11243    #[test]
11244    fn skill_reads_are_detected_by_path() {
11245        let name = skill_tool_name(
11246            "read",
11247            &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
11248        );
11249        assert_eq!(name.as_deref(), Some("release"));
11250
11251        let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
11252        assert!(name.is_none());
11253
11254        // Only `read` (not other tools) triggers the skill box.
11255        assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
11256    }
11257
11258    #[test]
11259    fn welcome_capabilities_show_empty_state() {
11260        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
11261        assert_eq!(plain, "Skills (0) none");
11262    }
11263
11264    #[test]
11265    fn empty_tool_progress_is_deferred_until_start() {
11266        let empty = rpi_agent::AgentToolResult::default();
11267        assert!(!tool_update_has_payload("", &empty));
11268
11269        let text = rpi_agent::AgentToolResult::text("partial output");
11270        assert!(tool_update_has_payload("partial output", &text));
11271
11272        let details = rpi_agent::AgentToolResult {
11273            details: serde_json::json!({"path": "src/lib.rs"}),
11274            ..Default::default()
11275        };
11276        assert!(tool_update_has_payload("", &details));
11277    }
11278
11279    #[test]
11280    fn completed_stream_reconciles_the_final_tail_before_detaching() {
11281        let component = Arc::new(AssistantMessageComponent::new(
11282            AssistantMessageOptions::default(),
11283        ));
11284        component.set_streaming(true);
11285        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
11286
11287        let current = Mutex::new(Some(component.clone()));
11288        let cached = Mutex::new("partial response".to_string());
11289        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
11290        final_message.content = vec![Content::text(
11291            "partial response with the previously missing final tail",
11292        )];
11293        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
11294
11295        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
11296
11297        assert!(current.lock().unwrap().is_none());
11298        assert_eq!(
11299            cached.lock().unwrap().as_str(),
11300            "partial response with the previously missing final tail"
11301        );
11302        let rendered = strip_ansi(&component.render(100).join("\n"));
11303        assert!(
11304            rendered.contains("previously missing final tail"),
11305            "{rendered}"
11306        );
11307    }
11308
11309    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
11310    /// replaces the editor text, the NEXT rendered frame must show the
11311    /// completed text (" /model " with the caret after it), not the old
11312    /// prefix. Mirrors the real dock layout (autocomplete_container above the
11313    /// bordered editor) and drives the same accept path the Tab handler uses.
11314    #[test]
11315    fn tab_accept_suggestion_reflects_in_next_render() {
11316        use rpi_tui::render_layout_frame;
11317
11318        let editor = Arc::new(Editor::new(
11319            EditorOptions {
11320                padding_x: 1,
11321                ..Default::default()
11322            },
11323            EditorStyle::default(),
11324            Arc::new(rpi_tui::Keybindings::new()),
11325        ));
11326        editor.set_focused(true);
11327        let editor_container = Arc::new(Container::new());
11328        editor_container.add_child(editor.clone());
11329        let autocomplete_container = Arc::new(Container::new());
11330        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
11331        let dock = Arc::new(VStack::from_children(vec![
11332            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
11333            StackChild::Entry(
11334                StackEntry::new(editor_container.clone())
11335                    .shrink(0)
11336                    .min_size(3),
11337            ),
11338            StackChild::Entry(StackEntry::new(footer)),
11339        ]));
11340
11341        // Simulate the user typing "/mo" (the popup shows suggestions).
11342        let manager = AutocompleteManager::new();
11343        let mut combined = CombinedAutocompleteProvider::new();
11344        combined.add_provider(Arc::new(
11345            SlashCommandAutocompleteProvider::with_default_commands(),
11346        ));
11347        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
11348        manager.set_provider(Arc::new(combined));
11349        // Simulate typing "/mo" via the real insert path (advances the caret
11350        // by char length, like `handle_key` does).
11351        editor.insert("/mo");
11352        assert_eq!(editor.cursor_position(), (0, 3));
11353
11354        let frame_before = render_layout_frame(dock.clone(), 80, 10);
11355        assert!(
11356            frame_before.lines.iter().any(|l| l.contains("/mo")),
11357            "precondition: editor shows the typed prefix. Frame rows:\n{}",
11358            frame_before
11359                .lines
11360                .iter()
11361                .map(|l| format!("  [{l}]"))
11362                .collect::<Vec<_>>()
11363                .join("\n")
11364        );
11365
11366        // Tab: accept the top suggestion (the same code path as the key loop).
11367        let text = editor.get_text();
11368        let cursor = editor_cursor_offset(&editor, &text);
11369        let sugg = manager
11370            .get_suggestions(&text, cursor)
11371            .expect("slash suggestions for /mo");
11372        let top = sugg.items.first().expect("at least one suggestion");
11373        let start = sugg.start.min(text.len());
11374        let end = sugg.end.min(text.len());
11375        let mut replaced = String::new();
11376        replaced.push_str(&text[..start]);
11377        replaced.push_str(&top.text);
11378        replaced.push_str(&text[end..]);
11379        if top.insert_space && !replaced.ends_with('/') {
11380            replaced.push(' ');
11381        }
11382        let new_cursor = start + top.text.len();
11383        editor.set_text(&replaced);
11384        set_editor_cursor_offset(&editor, &replaced, new_cursor);
11385        autocomplete_container.clear();
11386        assert_eq!(editor.get_text(), "/model");
11387
11388        // The next render MUST display the completed text.
11389        let frame_after = render_layout_frame(dock, 80, 10);
11390        let all: String = frame_after.lines.join("\n");
11391        assert!(
11392            all.contains("/model"),
11393            "completed text missing from next render. Got:\n{all}"
11394        );
11395        // The caret must sit AFTER the completed command (the snap_boundary
11396        // regression put it one char early: "/mode|l" with the final char
11397        // dangling past the caret).
11398        let editor_line = frame_after
11399            .lines
11400            .iter()
11401            .find(|l| l.contains("/model"))
11402            .expect("editor row with completed text");
11403        assert!(
11404            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
11405            "caret must follow the full completed text. Got: {editor_line:?}"
11406        );
11407    }
11408
11409    #[test]
11410    fn multiline_autocomplete_preserves_row_and_column() {
11411        let editor = Arc::new(Editor::simple());
11412        editor.set_text("first\n/mo");
11413        editor.set_cursor(1, 3);
11414
11415        let text = editor.get_text();
11416        assert_eq!(editor_cursor_offset(&editor, &text), 9);
11417
11418        let replaced = "first\n/model";
11419        editor.set_text(replaced);
11420        set_editor_cursor_offset(&editor, replaced, 12);
11421        assert_eq!(editor.cursor_position(), (1, 6));
11422    }
11423
11424    #[test]
11425    fn test_slash_command_dispatch() {
11426        // The registry is the single source of truth for dispatch: `find(token)`
11427        // returns the command (by name or alias) whose `name()` is the canonical
11428        // form, or `None` for an unknown token. This replaces the old enum-based
11429        // `handle_slash_command` assertions with equivalent registry lookups.
11430        let registry = build_builtin_registry();
11431
11432        // Helper: a token resolves to the command with this canonical name.
11433        let resolves_to = |token: &str, canonical: &str| {
11434            let found = registry.find(token).expect("{token} should resolve");
11435            assert_eq!(
11436                found.name(),
11437                canonical,
11438                "{token} resolved to {} (expected {canonical})",
11439                found.name()
11440            );
11441        };
11442
11443        resolves_to("/help", "/help");
11444        resolves_to("/?", "/help"); // alias → canonical
11445        resolves_to("/clear", "/clear");
11446        resolves_to("/new", "/clear"); // alias
11447        resolves_to("/q", "/exit"); // alias
11448        resolves_to("/quit", "/exit"); // alias
11449        resolves_to("/version", "/version");
11450        resolves_to("/v", "/version"); // alias
11451        resolves_to("/changelog", "/changelog");
11452        resolves_to("/hotkeys", "/hotkeys");
11453        resolves_to("/model", "/model");
11454        resolves_to("/m", "/model"); // alias
11455        resolves_to("/theme", "/theme");
11456        resolves_to("/session", "/session");
11457        resolves_to("/resume", "/session"); // alias
11458        resolves_to("/compact", "/compact");
11459        resolves_to("/copy", "/copy");
11460        resolves_to("/thinking", "/thinking");
11461        resolves_to("/think", "/thinking"); // alias
11462        resolves_to("/tools", "/tools");
11463        resolves_to("/images", "/images");
11464        resolves_to("/armin", "/armin");
11465        resolves_to("/earendil", "/earendil");
11466        resolves_to("/context", "/context");
11467        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
11468        resolves_to("/settings", "/settings");
11469        resolves_to("/name", "/name");
11470        resolves_to("/export", "/export");
11471
11472        // Unknown token → not found.
11473        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
11474    }
11475
11476    #[test]
11477
11478    fn test_registry_visible_entries_cover_dispatch() {
11479        // The autocomplete list is derived from the registry, so every visible
11480        // command the dispatcher recognizes must appear in it — by construction,
11481        // but this guards against a future command being registered with
11482        // `visible()` / a non-empty description that the builder drops.
11483        let registry = build_builtin_registry();
11484        let names: Vec<String> = registry
11485            .visible_entries()
11486            .iter()
11487            .map(|c| c.name.clone())
11488            .collect();
11489        for recognized in [
11490            "/help",
11491            "/clear",
11492            "/new",
11493            "/exit",
11494            "/quit",
11495            "/version",
11496            "/changelog",
11497            "/model",
11498            "/session",
11499            "/theme",
11500            "/compact",
11501            "/copy",
11502            "/hotkeys",
11503            "/tools",
11504            "/images",
11505            "/thinking",
11506            "/usage",
11507            "/armin",
11508            "/earendil",
11509        ] {
11510            assert!(
11511                names.contains(&recognized.to_string()),
11512                "{recognized} missing from autocomplete list"
11513            );
11514        }
11515        // Hidden commands stay off the list.
11516        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
11517            assert!(
11518                !names.contains(&hidden.to_string()),
11519                "{hidden} should be hidden from autocomplete"
11520            );
11521        }
11522    }
11523
11524    #[test]
11525    fn test_agent_event_mapping_creates_assistant_and_tool() {
11526        // Synthetic AgentEvent sequence → UI mutations, exercised against the
11527        // real drain handler with a no-op TUI stand-in.
11528        use rpi_ai::types::{
11529            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
11530            ToolCall, ToolCallType, Usage,
11531        };
11532
11533        let state = Arc::new(TuiState {
11534            current_assistant: std::sync::Mutex::new(None),
11535            tool_components: std::sync::Mutex::new(HashMap::new()),
11536            bash_components: std::sync::Mutex::new(HashMap::new()),
11537            themes_enabled: true,
11538            hide_thinking: std::sync::Mutex::new(false),
11539            tool_outputs_expanded: std::sync::Mutex::new(false),
11540            show_terminal_progress: true,
11541            status: std::sync::Mutex::new(RunStatus::Idle),
11542            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11543            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11544            js_preparation_cancel: std::sync::Mutex::new(None),
11545            user_bash_cancel: std::sync::Mutex::new(None),
11546            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11547            footer: Arc::new(FooterComponent::new()),
11548            status_container: Arc::new(Container::new()),
11549            chat_container: Arc::new(Container::new()),
11550            loader: Arc::new(Loader::new()),
11551            editor: Arc::new(Editor::simple()),
11552            last_assistant_text: std::sync::Mutex::new(String::new()),
11553            active_selector: std::sync::Mutex::new(None),
11554            active_extension_editor: std::sync::Mutex::new(None),
11555            active_extension_input: std::sync::Mutex::new(None),
11556            active_extension_cancel: std::sync::Mutex::new(None),
11557            autocomplete: AutocompleteManager::new(),
11558            autocomplete_container: Arc::new(Container::new()),
11559            autocomplete_max_visible: 5,
11560            pending_images: std::sync::Mutex::new(Vec::new()),
11561            theme_manager: Arc::new(ThemeManager::new()),
11562            tui: None,
11563            current_model_id: std::sync::Mutex::new(String::new()),
11564            show_images: std::sync::Mutex::new(true),
11565            cache_miss_notices: std::sync::Mutex::new(false),
11566            history: std::sync::Mutex::new(Vec::new()),
11567            history_index: std::sync::Mutex::new(-1),
11568            history_draft: std::sync::Mutex::new(None),
11569            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11570            scoped_edit: std::sync::Mutex::new(None),
11571            markdown_transformer: std::sync::Mutex::new(None),
11572            extension_session: Arc::new(std::sync::Mutex::new(
11573                rpi_extensions::ExtensionSession::none(),
11574            )),
11575            search: Arc::new(AltScreenSearch::new()),
11576            search_bar: Arc::new(SearchBar::new()),
11577            pending_container: Arc::new(Container::new()),
11578            dequeue_hint: String::new(),
11579            pending_snapshot: std::sync::Mutex::new(
11580                rpi_harness::agent_harness::QueuedMessages::default(),
11581            ),
11582            selection_start: std::sync::Mutex::new(None),
11583            selection_end: std::sync::Mutex::new(None),
11584        });
11585
11586        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
11587        // terminal; instead, exercise the *mutation* half directly against a
11588        // captured chat container via a synthetic message-start event's data.
11589        let assistant = AssistantMessage {
11590            role: rpi_ai::types::AssistantRole,
11591            content: vec![
11592                Content::Thinking(ThinkingContent {
11593                    kind: ThinkingContentType,
11594                    thinking: "Reasoning about the reply.".into(),
11595                    thinking_signature: None,
11596                    redacted: false,
11597                }),
11598                Content::Text(TextContent {
11599                    kind: TextContentType,
11600                    text: "Hello.".into(),
11601                    text_signature: None,
11602                }),
11603                Content::ToolCall(ToolCall {
11604                    kind: ToolCallType,
11605                    id: "tc1".into(),
11606                    name: "bash".into(),
11607                    arguments: serde_json::json!({"command": "echo hi"}),
11608                    thought_signature: None,
11609                    namespace: None,
11610                }),
11611            ],
11612            api: rpi_ai::Api::AnthropicMessages,
11613            provider: "anthropic".into(),
11614            model: "claude-sonnet-5".into(),
11615            response_model: None,
11616            response_id: None,
11617            usage: Usage::zero(),
11618            stop_reason: StopReason::Stop,
11619            deferred: None,
11620            error_message: None,
11621            raw_stop_reason: None,
11622            end_turn: None,
11623            timestamp: 0,
11624        };
11625
11626        // Manually apply the MessageStart assistant branch logic (mirrors the
11627        // drain handler, without needing a TuiAltScreen).
11628        let comp = Arc::new(AssistantMessageComponent::new(
11629            AssistantMessageOptions::default(),
11630        ));
11631        comp.set_streaming(true);
11632        comp.update_blocks(&assistant_blocks(&assistant));
11633        let chat = Arc::new(Container::new());
11634        chat.add_child(comp.clone());
11635        *state.current_assistant.lock().unwrap() = Some(comp);
11636
11637        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
11638        for c in &assistant.content {
11639            if let Content::ToolCall(tc) = c {
11640                let mut tools = state.tool_components.lock().unwrap();
11641                if !tools.contains_key(&tc.id) {
11642                    let tc_comp = Arc::new(ToolExecutionComponent::new(
11643                        &tc.name,
11644                        &tc.arguments.to_string(),
11645                    ));
11646                    tc_comp.set_running();
11647                    chat.add_child(tc_comp.clone());
11648                    tools.insert(tc.id.clone(), tc_comp);
11649                }
11650            }
11651        }
11652
11653        // Assert: the assistant component rendered the text + the thinking
11654        // block (the update_blocks path keeps thinking visible), and a tool
11655        // component was registered.
11656        let rendered = chat.render(80);
11657        let joined: String = rendered.join("\n");
11658        assert!(
11659            joined.contains("Hello."),
11660            "assistant text not rendered: {joined}"
11661        );
11662        assert!(
11663            joined.contains("Reasoning about the reply."),
11664            "thinking block not rendered: {joined}"
11665        );
11666        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
11667        assert!(state.current_assistant.lock().unwrap().is_some());
11668
11669        // Manually apply ToolExecutionEnd (mirrors drain).
11670        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
11671        ended.set_result("hi", false);
11672        assert!(state.tool_components.lock().unwrap().is_empty());
11673
11674        // A running bash panel owns the visible spinner. The global loader is
11675        // hidden until the last concurrent bash tool completes, then restored
11676        // while the agent remains in the Working state.
11677        assert!(state.try_start_working());
11678        assert!(
11679            !state.try_start_working(),
11680            "a second submit must be rejected"
11681        );
11682        state.set_status(RunStatus::Idle);
11683        state.set_status(RunStatus::Working);
11684        // The active loader now renders inside the editor's top border.
11685        assert!(state.editor.working().is_some());
11686        assert_eq!(state.status_container.child_count(), 0);
11687        assert!(state.footer.get_status().is_empty());
11688        state.show_retry(3, 10, 8_000);
11689        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
11690        assert!(retry_status.contains("Retrying (3/10)"));
11691        state.set_status(RunStatus::Working);
11692        {
11693            let mut bash = state.bash_components.lock().unwrap();
11694            bash.insert(
11695                "bash-1".into(),
11696                Arc::new(BashExecutionComponent::new("one")),
11697            );
11698            bash.insert(
11699                "bash-2".into(),
11700                Arc::new(BashExecutionComponent::new("two")),
11701            );
11702        }
11703        state.sync_working_loader_with_bash();
11704        assert_eq!(state.status_container.child_count(), 0);
11705        state.bash_components.lock().unwrap().remove("bash-1");
11706        state.sync_working_loader_with_bash();
11707        assert_eq!(state.status_container.child_count(), 0);
11708        state.bash_components.lock().unwrap().remove("bash-2");
11709        state.sync_working_loader_with_bash();
11710        // The editor-border indicator stays visible across bash panels, so the
11711        // status container stays empty.
11712        assert_eq!(state.status_container.child_count(), 0);
11713        assert!(state.editor.working().is_some());
11714
11715        state.set_status(RunStatus::Aborting);
11716        assert_eq!(state.status_container.child_count(), 0);
11717        assert!(!state.loader.is_running());
11718    }
11719
11720    #[test]
11721    fn fresh_launch_does_not_restore_old_history() {
11722        let fresh = Args::default();
11723        assert!(!launch_restores_history(&fresh));
11724
11725        let continued = Args {
11726            continue_session: true,
11727            ..Args::default()
11728        };
11729        assert!(launch_restores_history(&continued));
11730
11731        let selected = Args {
11732            session: Some("session-id".into()),
11733            ..Args::default()
11734        };
11735        assert!(launch_restores_history(&selected));
11736    }
11737
11738    #[test]
11739    fn context_badge_ignores_aborted_and_error_turns() {
11740        use rpi_ai::types::{Api, StopReason, Usage};
11741
11742        let footer = Arc::new(FooterComponent::new());
11743        footer.set_context_window(100_000);
11744
11745        // A real response fills in the percent (`?` -> `25.0%/100k`).
11746        let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11747        ok.stop_reason = StopReason::Stop;
11748        ok.usage = Usage {
11749            input: 25_000,
11750            ..Usage::zero()
11751        };
11752        record_context_usage(&footer, &ok);
11753        let stats = strip_ansi(&footer.render(120).join("\n"));
11754        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11755
11756        // An aborted turn must not clobber the last good value.
11757        let mut aborted =
11758            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11759        aborted.stop_reason = StopReason::Aborted;
11760        aborted.usage = Usage {
11761            input: 90_000,
11762            ..Usage::zero()
11763        };
11764        record_context_usage(&footer, &aborted);
11765        let stats = strip_ansi(&footer.render(120).join("\n"));
11766        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11767
11768        // An errored turn likewise (its usage is not a real measurement).
11769        let mut errored =
11770            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11771        errored.stop_reason = StopReason::Error;
11772        errored.usage = Usage {
11773            input: 90_000,
11774            ..Usage::zero()
11775        };
11776        record_context_usage(&footer, &errored);
11777        let stats = strip_ansi(&footer.render(120).join("\n"));
11778        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11779    }
11780
11781    #[test]
11782    fn test_short_model_name() {
11783        assert_eq!(
11784            short_model_name("anthropic:claude-sonnet-5"),
11785            "claude-sonnet-5"
11786        );
11787        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
11788    }
11789
11790    #[test]
11791    fn model_selector_items_are_deduplicated_and_provider_qualified() {
11792        use rpi_ai::{Api, Model};
11793
11794        let mut gateway = Model::new(
11795            "gpt-5.6-sol",
11796            "GPT 5.6 Sol",
11797            Api::OpenaiCompletions,
11798            "routeryo-copy",
11799            "https://gateway.example.com",
11800        );
11801        let duplicate = gateway.clone();
11802        let anthropic = Model::new(
11803            "claude-sonnet-5",
11804            "Claude Sonnet 5",
11805            Api::AnthropicMessages,
11806            "anthropic",
11807            "https://api.anthropic.com",
11808        );
11809        gateway.headers = Some(std::collections::BTreeMap::from([(
11810            "authorization".into(),
11811            "Bearer test".into(),
11812        )]));
11813
11814        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
11815        assert_eq!(items.len(), 2);
11816        assert_eq!(items[0].value, "gpt-5.6-sol");
11817        assert_eq!(items[0].label, "GPT 5.6 Sol");
11818        assert_eq!(
11819            items[0].description.as_deref(),
11820            Some("routeryo-copy/gpt-5.6-sol (current)")
11821        );
11822        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
11823    }
11824
11825    #[test]
11826    fn model_selector_search_text_covers_provider_name_and_qualified_id() {
11827        use rpi_ai::{Api, Model};
11828
11829        let anthropic = Model::new(
11830            "claude-sonnet-5",
11831            "Claude Sonnet 5",
11832            Api::AnthropicMessages,
11833            "anthropic",
11834            "https://api.anthropic.com",
11835        );
11836        let items = model_selector_items(&[anthropic], "");
11837        let search = items[0].search_key();
11838        // Mirrors native `getModelSelectorSearchText`: provider-prefixed first,
11839        // then the display name.
11840        assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
11841        assert!(search.contains("Claude Sonnet 5"), "{search}");
11842
11843        // A provider query and a display-name query both match through the
11844        // real fuzzy filter used by the searchable selector.
11845        use rpi_tui::fuzzy_filter;
11846        for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
11847            let matched = fuzzy_filter(&items, query, |item| item.search_key());
11848            assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
11849        }
11850    }
11851
11852    #[test]
11853    fn model_selector_match_accepts_bare_and_qualified_ids() {
11854        use rpi_ai::{Api, Model};
11855
11856        let gateway = Model::new(
11857            "gpt-5.6-sol",
11858            "GPT 5.6 Sol",
11859            Api::OpenaiCompletions,
11860            "routeryo-copy",
11861            "https://gateway.example.com",
11862        );
11863        let anthropic = Model::new(
11864            "claude-sonnet-5",
11865            "Claude Sonnet 5",
11866            Api::AnthropicMessages,
11867            "anthropic",
11868            "https://api.anthropic.com",
11869        );
11870        let catalog = [gateway, anthropic];
11871        assert_eq!(
11872            find_model_selector_match(&catalog, "gpt-5.6-sol")
11873                .unwrap()
11874                .provider,
11875            "routeryo-copy"
11876        );
11877        assert_eq!(
11878            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
11879                .unwrap()
11880                .id,
11881            "gpt-5.6-sol"
11882        );
11883        assert_eq!(
11884            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
11885                .unwrap()
11886                .id,
11887            "claude-sonnet-5"
11888        );
11889        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
11890    }
11891
11892    #[test]
11893    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
11894        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
11895
11896        let failed = AssistantMessage {
11897            role: AssistantRole,
11898            content: Vec::new(),
11899            api: rpi_ai::Api::AnthropicMessages,
11900            provider: "anthropic".into(),
11901            model: "claude-sonnet-5".into(),
11902            response_model: None,
11903            response_id: None,
11904            usage: Usage::zero(),
11905            stop_reason: StopReason::Error,
11906            deferred: None,
11907            error_message: Some("upstream returned 401".into()),
11908            raw_stop_reason: None,
11909            end_turn: None,
11910            timestamp: 0,
11911        };
11912        assert_eq!(
11913            assistant_error_text(&failed).as_deref(),
11914            Some("upstream returned 401")
11915        );
11916
11917        let mut no_detail = failed;
11918        no_detail.error_message = Some("  ".into());
11919        assert_eq!(
11920            assistant_error_text(&no_detail).as_deref(),
11921            Some("Provider request failed.")
11922        );
11923
11924        let mut aborted = no_detail;
11925        aborted.stop_reason = StopReason::Aborted;
11926        aborted.error_message = Some("abort error: Request aborted".into());
11927        assert_eq!(
11928            assistant_error_text(&aborted).as_deref(),
11929            Some("abort error: Request aborted")
11930        );
11931
11932        aborted.error_message = None;
11933        assert_eq!(
11934            assistant_error_text(&aborted).as_deref(),
11935            Some("Request aborted.")
11936        );
11937    }
11938
11939    #[test]
11940    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
11941        assert_eq!(
11942            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
11943            "405\nMethod Not Allowed"
11944        );
11945        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
11946        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
11947        let long = "x".repeat(20_000);
11948        let cleaned = sanitize_error_message(&long);
11949        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
11950        assert!(cleaned.ends_with('…'));
11951    }
11952
11953    #[test]
11954    fn test_cycle_next_model_wraps_around() {
11955        use rpi_ai::{Api, Model};
11956        let mk = |id: &str| {
11957            Model::new(
11958                id,
11959                id,
11960                Api::AnthropicMessages,
11961                "anthropic",
11962                "https://api.anthropic.com",
11963            )
11964        };
11965        let catalog = [mk("a"), mk("b"), mk("c")];
11966        // Next after "a" is "b"; after "c" wraps to "a".
11967        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
11968        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
11969        // An unknown current id falls back to the first model.
11970        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
11971        // Empty catalog yields None.
11972        let empty: Vec<Model> = vec![];
11973        assert!(cycle_next_model(&empty, "a").is_none());
11974    }
11975
11976    #[test]
11977    fn test_autocomplete_slash_suggestions_render() {
11978        // The autocomplete container should render at least one suggestion
11979        // line when the editor holds a `/` prefix, and clear when it doesn't.
11980        let state = Arc::new(TuiState {
11981            current_assistant: std::sync::Mutex::new(None),
11982            tool_components: std::sync::Mutex::new(HashMap::new()),
11983            bash_components: std::sync::Mutex::new(HashMap::new()),
11984            themes_enabled: true,
11985            hide_thinking: std::sync::Mutex::new(false),
11986            tool_outputs_expanded: std::sync::Mutex::new(false),
11987            show_terminal_progress: true,
11988            status: std::sync::Mutex::new(RunStatus::Idle),
11989            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11990            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11991            js_preparation_cancel: std::sync::Mutex::new(None),
11992            user_bash_cancel: std::sync::Mutex::new(None),
11993            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11994            footer: Arc::new(FooterComponent::new()),
11995            status_container: Arc::new(Container::new()),
11996            chat_container: Arc::new(Container::new()),
11997            loader: Arc::new(Loader::new()),
11998            editor: Arc::new(Editor::simple()),
11999            last_assistant_text: std::sync::Mutex::new(String::new()),
12000            active_selector: std::sync::Mutex::new(None),
12001            active_extension_editor: std::sync::Mutex::new(None),
12002            active_extension_input: std::sync::Mutex::new(None),
12003            active_extension_cancel: std::sync::Mutex::new(None),
12004            autocomplete: AutocompleteManager::new(),
12005            autocomplete_container: Arc::new(Container::new()),
12006            autocomplete_max_visible: 5,
12007            pending_images: std::sync::Mutex::new(Vec::new()),
12008            theme_manager: Arc::new(ThemeManager::new()),
12009            tui: None,
12010            current_model_id: std::sync::Mutex::new(String::new()),
12011            show_images: std::sync::Mutex::new(true),
12012            cache_miss_notices: std::sync::Mutex::new(false),
12013            history: std::sync::Mutex::new(Vec::new()),
12014            history_index: std::sync::Mutex::new(-1),
12015            history_draft: std::sync::Mutex::new(None),
12016            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12017            scoped_edit: std::sync::Mutex::new(None),
12018            markdown_transformer: std::sync::Mutex::new(None),
12019            extension_session: Arc::new(std::sync::Mutex::new(
12020                rpi_extensions::ExtensionSession::none(),
12021            )),
12022            search: Arc::new(AltScreenSearch::new()),
12023            search_bar: Arc::new(SearchBar::new()),
12024            pending_container: Arc::new(Container::new()),
12025            dequeue_hint: String::new(),
12026            pending_snapshot: std::sync::Mutex::new(
12027                rpi_harness::agent_harness::QueuedMessages::default(),
12028            ),
12029            selection_start: std::sync::Mutex::new(None),
12030            selection_end: std::sync::Mutex::new(None),
12031        });
12032        {
12033            let mut combined = CombinedAutocompleteProvider::new();
12034            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12035                build_builtin_registry().visible_entries(),
12036            )));
12037            state.autocomplete.set_provider(Arc::new(combined));
12038        }
12039
12040        let editor = Arc::new(Editor::simple());
12041        editor.set_text("/he");
12042        editor.set_cursor(0, 3);
12043        refresh_autocomplete(&state, &editor);
12044        let lines = state.autocomplete_container.render(80);
12045        let joined: String = lines.join("\n");
12046        assert!(
12047            joined.contains("/help"),
12048            "slash suggestions not rendered: {joined}"
12049        );
12050
12051        // Clear: no suggestions for plain text.
12052        editor.set_text("hello");
12053        editor.set_cursor(0, 5);
12054        refresh_autocomplete(&state, &editor);
12055        assert!(state.autocomplete_container.render(80).is_empty());
12056    }
12057
12058    #[test]
12059    fn test_select_list_swap_restores_editor() {
12060        // The editor-container swap: opening a selector replaces the editor
12061        // child; closing restores it. Verify the container child count + the
12062        // active_selector flag round-trip.
12063        let state = Arc::new(TuiState {
12064            current_assistant: std::sync::Mutex::new(None),
12065            tool_components: std::sync::Mutex::new(HashMap::new()),
12066            bash_components: std::sync::Mutex::new(HashMap::new()),
12067            themes_enabled: true,
12068            hide_thinking: std::sync::Mutex::new(false),
12069            tool_outputs_expanded: std::sync::Mutex::new(false),
12070            show_terminal_progress: true,
12071            status: std::sync::Mutex::new(RunStatus::Idle),
12072            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12073            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12074            js_preparation_cancel: std::sync::Mutex::new(None),
12075            user_bash_cancel: std::sync::Mutex::new(None),
12076            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12077            footer: Arc::new(FooterComponent::new()),
12078            status_container: Arc::new(Container::new()),
12079            chat_container: Arc::new(Container::new()),
12080            loader: Arc::new(Loader::new()),
12081            editor: Arc::new(Editor::simple()),
12082            last_assistant_text: std::sync::Mutex::new(String::new()),
12083            active_selector: std::sync::Mutex::new(None),
12084            active_extension_editor: std::sync::Mutex::new(None),
12085            active_extension_input: std::sync::Mutex::new(None),
12086            active_extension_cancel: std::sync::Mutex::new(None),
12087            autocomplete: AutocompleteManager::new(),
12088            autocomplete_container: Arc::new(Container::new()),
12089            autocomplete_max_visible: 5,
12090            pending_images: std::sync::Mutex::new(Vec::new()),
12091            theme_manager: Arc::new(ThemeManager::new()),
12092            tui: None,
12093            current_model_id: std::sync::Mutex::new(String::new()),
12094            show_images: std::sync::Mutex::new(true),
12095            cache_miss_notices: std::sync::Mutex::new(false),
12096            history: std::sync::Mutex::new(Vec::new()),
12097            history_index: std::sync::Mutex::new(-1),
12098            history_draft: std::sync::Mutex::new(None),
12099            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12100            scoped_edit: std::sync::Mutex::new(None),
12101            markdown_transformer: std::sync::Mutex::new(None),
12102            extension_session: Arc::new(std::sync::Mutex::new(
12103                rpi_extensions::ExtensionSession::none(),
12104            )),
12105            search: Arc::new(AltScreenSearch::new()),
12106            search_bar: Arc::new(SearchBar::new()),
12107            pending_container: Arc::new(Container::new()),
12108            dequeue_hint: String::new(),
12109            pending_snapshot: std::sync::Mutex::new(
12110                rpi_harness::agent_harness::QueuedMessages::default(),
12111            ),
12112            selection_start: std::sync::Mutex::new(None),
12113            selection_end: std::sync::Mutex::new(None),
12114        });
12115        let editor_container = Arc::new(Container::new());
12116        let editor = Arc::new(Editor::simple());
12117        editor_container.add_child(editor.clone());
12118        assert!(!state.selector_open());
12119
12120        let tui_terminal = Box::new(ProcessTerminal::new());
12121        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
12122        let list = Arc::new(SelectList::new(
12123            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
12124            5,
12125        ));
12126        open_selector(
12127            &state,
12128            &editor_container,
12129            &editor,
12130            &tui,
12131            list,
12132            SelectorKind::Theme,
12133        );
12134        assert!(state.selector_open());
12135        // list only (editor swapped out).
12136        assert_eq!(editor_container.child_count(), 1);
12137
12138        close_selector(&state, &editor_container, &editor, &tui);
12139        assert!(!state.selector_open());
12140        // editor restored.
12141        assert_eq!(editor_container.child_count(), 1);
12142    }
12143
12144    #[test]
12145    fn test_message_history_browse_restores_draft() {
12146        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
12147        // messages, browse older → newer → back past the newest restores the
12148        // draft the user was typing.
12149        let state = Arc::new(TuiState {
12150            current_assistant: std::sync::Mutex::new(None),
12151            tool_components: std::sync::Mutex::new(HashMap::new()),
12152            bash_components: std::sync::Mutex::new(HashMap::new()),
12153            themes_enabled: true,
12154            hide_thinking: std::sync::Mutex::new(false),
12155            tool_outputs_expanded: std::sync::Mutex::new(false),
12156            show_terminal_progress: true,
12157            status: std::sync::Mutex::new(RunStatus::Idle),
12158            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12159            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12160            js_preparation_cancel: std::sync::Mutex::new(None),
12161            user_bash_cancel: std::sync::Mutex::new(None),
12162            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12163            footer: Arc::new(FooterComponent::new()),
12164            status_container: Arc::new(Container::new()),
12165            chat_container: Arc::new(Container::new()),
12166            loader: Arc::new(Loader::new()),
12167            editor: Arc::new(Editor::simple()),
12168            last_assistant_text: std::sync::Mutex::new(String::new()),
12169            active_selector: std::sync::Mutex::new(None),
12170            active_extension_editor: std::sync::Mutex::new(None),
12171            active_extension_input: std::sync::Mutex::new(None),
12172            active_extension_cancel: std::sync::Mutex::new(None),
12173            autocomplete: AutocompleteManager::new(),
12174            autocomplete_container: Arc::new(Container::new()),
12175            autocomplete_max_visible: 5,
12176            pending_images: std::sync::Mutex::new(Vec::new()),
12177            theme_manager: Arc::new(ThemeManager::new()),
12178            tui: None,
12179            current_model_id: std::sync::Mutex::new(String::new()),
12180            show_images: std::sync::Mutex::new(true),
12181            cache_miss_notices: std::sync::Mutex::new(false),
12182            history: std::sync::Mutex::new(Vec::new()),
12183            history_index: std::sync::Mutex::new(-1),
12184            history_draft: std::sync::Mutex::new(None),
12185            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12186            scoped_edit: std::sync::Mutex::new(None),
12187            markdown_transformer: std::sync::Mutex::new(None),
12188            extension_session: Arc::new(std::sync::Mutex::new(
12189                rpi_extensions::ExtensionSession::none(),
12190            )),
12191            search: Arc::new(AltScreenSearch::new()),
12192            search_bar: Arc::new(SearchBar::new()),
12193            pending_container: Arc::new(Container::new()),
12194            dequeue_hint: String::new(),
12195            pending_snapshot: std::sync::Mutex::new(
12196                rpi_harness::agent_harness::QueuedMessages::default(),
12197            ),
12198            selection_start: std::sync::Mutex::new(None),
12199            selection_end: std::sync::Mutex::new(None),
12200        });
12201        let editor = Arc::new(Editor::simple());
12202
12203        push_history(&state, "first message");
12204        push_history(&state, "second message");
12205        // Consecutive duplicate is skipped.
12206        push_history(&state, "second message");
12207        push_history(&state, "   "); // empty → skipped
12208        assert_eq!(state.history.lock().unwrap().len(), 2);
12209        assert_eq!(state.history.lock().unwrap()[0], "second message");
12210
12211        // User starts typing a fresh prompt.
12212        editor.set_text("half-typed");
12213        editor.set_cursor(0, 11);
12214
12215        // ↑ → most recent.
12216        navigate_history(&state, &editor, -1);
12217        assert_eq!(editor.get_text(), "second message");
12218        assert_eq!(*state.history_index.lock().unwrap(), 0);
12219        // ↑ → older.
12220        navigate_history(&state, &editor, -1);
12221        assert_eq!(editor.get_text(), "first message");
12222        assert_eq!(*state.history_index.lock().unwrap(), 1);
12223        // ↑ past the oldest → stays (no wrap).
12224        navigate_history(&state, &editor, -1);
12225        assert_eq!(editor.get_text(), "first message");
12226        // ↓ → newer.
12227        navigate_history(&state, &editor, 1);
12228        assert_eq!(editor.get_text(), "second message");
12229        // ↓ past the newest → restores the draft.
12230        navigate_history(&state, &editor, 1);
12231        assert_eq!(editor.get_text(), "half-typed");
12232        assert_eq!(*state.history_index.lock().unwrap(), -1);
12233    }
12234
12235    #[test]
12236    fn test_accept_top_suggestion_replaces_prefix() {
12237        // `/he` + Tab → `/help ` (slash command provider inserts a space).
12238        let state = Arc::new(TuiState {
12239            current_assistant: std::sync::Mutex::new(None),
12240            tool_components: std::sync::Mutex::new(HashMap::new()),
12241            bash_components: std::sync::Mutex::new(HashMap::new()),
12242            themes_enabled: true,
12243            hide_thinking: std::sync::Mutex::new(false),
12244            tool_outputs_expanded: std::sync::Mutex::new(false),
12245            show_terminal_progress: true,
12246            status: std::sync::Mutex::new(RunStatus::Idle),
12247            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12248            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12249            js_preparation_cancel: std::sync::Mutex::new(None),
12250            user_bash_cancel: std::sync::Mutex::new(None),
12251            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12252            footer: Arc::new(FooterComponent::new()),
12253            status_container: Arc::new(Container::new()),
12254            chat_container: Arc::new(Container::new()),
12255            loader: Arc::new(Loader::new()),
12256            editor: Arc::new(Editor::simple()),
12257            last_assistant_text: std::sync::Mutex::new(String::new()),
12258            active_selector: std::sync::Mutex::new(None),
12259            active_extension_editor: std::sync::Mutex::new(None),
12260            active_extension_input: std::sync::Mutex::new(None),
12261            active_extension_cancel: std::sync::Mutex::new(None),
12262            autocomplete: AutocompleteManager::new(),
12263            autocomplete_container: Arc::new(Container::new()),
12264            autocomplete_max_visible: 5,
12265            pending_images: std::sync::Mutex::new(Vec::new()),
12266            theme_manager: Arc::new(ThemeManager::new()),
12267            tui: None,
12268            current_model_id: std::sync::Mutex::new(String::new()),
12269            show_images: std::sync::Mutex::new(true),
12270            cache_miss_notices: std::sync::Mutex::new(false),
12271            history: std::sync::Mutex::new(Vec::new()),
12272            history_index: std::sync::Mutex::new(-1),
12273            history_draft: std::sync::Mutex::new(None),
12274            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12275            scoped_edit: std::sync::Mutex::new(None),
12276            markdown_transformer: std::sync::Mutex::new(None),
12277            extension_session: Arc::new(std::sync::Mutex::new(
12278                rpi_extensions::ExtensionSession::none(),
12279            )),
12280            search: Arc::new(AltScreenSearch::new()),
12281            search_bar: Arc::new(SearchBar::new()),
12282            pending_container: Arc::new(Container::new()),
12283            dequeue_hint: String::new(),
12284            pending_snapshot: std::sync::Mutex::new(
12285                rpi_harness::agent_harness::QueuedMessages::default(),
12286            ),
12287            selection_start: std::sync::Mutex::new(None),
12288            selection_end: std::sync::Mutex::new(None),
12289        });
12290        {
12291            let mut combined = CombinedAutocompleteProvider::new();
12292            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12293                build_builtin_registry().visible_entries(),
12294            )));
12295            state.autocomplete.set_provider(Arc::new(combined));
12296        }
12297        let editor = Arc::new(Editor::simple());
12298        editor.set_text("/he");
12299        editor.set_cursor(0, 3);
12300        refresh_autocomplete(&state, &editor);
12301        let accepted = accept_top_suggestion(&state, &editor);
12302        assert!(accepted, "should accept the top suggestion");
12303        let text = editor.get_text();
12304        assert!(
12305            text.starts_with("/help"),
12306            "editor text should start with /help, got {text}"
12307        );
12308    }
12309
12310    #[test]
12311    fn configured_key_parser_supports_native_notation() {
12312        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
12313        assert_eq!(combo.code, KeyCode::Char('g'));
12314        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
12315        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
12316        assert_eq!(combo.code, KeyCode::BackTab);
12317    }
12318
12319    #[test]
12320    fn double_escape_trigger_has_half_second_window() {
12321        let now = std::time::Instant::now();
12322        assert!(!double_escape_trigger(None, now));
12323        assert!(double_escape_trigger(
12324            Some(now - std::time::Duration::from_millis(500)),
12325            now
12326        ));
12327        assert!(!double_escape_trigger(
12328            Some(now - std::time::Duration::from_millis(501)),
12329            now
12330        ));
12331    }
12332
12333    #[test]
12334    fn pasted_multi_line_block_becomes_one_prompt_not_many() {
12335        // End-to-end shape of the fix: replay the key stream a Windows paste
12336        // produces ("l1\nl2\nl3" + a trailing Enter) through the same pieces
12337        // the key loop uses — the burst discriminator for each Enter and the
12338        // editor for the text — and assert that *no* submit fired while the
12339        // block arrived, then exactly one submit on the user's own Enter.
12340        use std::sync::atomic::{AtomicUsize, Ordering};
12341
12342        let editor = rpi_tui::Editor::simple();
12343        let submits = Arc::new(AtomicUsize::new(0));
12344        let submits_for_cb = submits.clone();
12345        editor.on_submit(Arc::new(move |_text: &str| {
12346            submits_for_cb.fetch_add(1, Ordering::SeqCst);
12347        }));
12348
12349        // Every pasted key is delivered back-to-back, so `now` is unchanged and
12350        // the console queue is never empty until the paste is drained.
12351        let now = std::time::Instant::now();
12352        let mut last_text_key_at: Option<std::time::Instant> = None;
12353        let block = ["l1", "l2", "l3"];
12354        for (index, line) in block.iter().enumerate() {
12355            for ch in line.chars() {
12356                editor.insert(&ch.to_string());
12357                last_text_key_at = Some(now);
12358            }
12359            if index + 1 < block.len() {
12360                // The pasted newline: more input is still queued behind it.
12361                let more_queued = true;
12362                assert!(
12363                    enter_is_paste_burst(last_text_key_at, now, more_queued),
12364                    "pasted newline {index} must not submit"
12365                );
12366                editor.insert("\n");
12367                last_text_key_at = Some(now);
12368            }
12369        }
12370        assert_eq!(editor.get_text(), "l1\nl2\nl3");
12371        assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
12372
12373        // The user's own Enter: a real gap, nothing queued.
12374        let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
12375        assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
12376        editor.handle_key(crossterm::event::KeyEvent::new(
12377            KeyCode::Enter,
12378            KeyModifiers::NONE,
12379        ));
12380        assert_eq!(
12381            submits.load(Ordering::SeqCst),
12382            1,
12383            "typed Enter submits once"
12384        );
12385    }
12386
12387    #[test]
12388    fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
12389        // A Windows paste delivers CRLF; the editor must fold it to LF so the
12390        // rendered block is clean and the cursor arithmetic stays sound.
12391        let editor = rpi_tui::Editor::simple();
12392        editor.insert("alpha\r\nbeta\r\n");
12393        assert_eq!(editor.get_text(), "alpha\nbeta\n");
12394        assert!(!editor.get_text().contains('\r'));
12395    }
12396
12397    #[test]
12398    fn pasted_enter_is_not_a_submit() {
12399        let now = std::time::Instant::now();
12400
12401        // Queued input behind the Enter => paste, regardless of history.
12402        assert!(enter_is_paste_burst(None, now, true));
12403
12404        // Enter immediately after pasted characters => paste (also covers a
12405        // paste whose final line ends with a newline, where nothing is queued).
12406        assert!(enter_is_paste_burst(
12407            Some(now - std::time::Duration::from_millis(1)),
12408            now,
12409            false
12410        ));
12411        assert!(enter_is_paste_burst(
12412            Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
12413            now,
12414            false
12415        ));
12416
12417        // A typed Enter: nothing queued and a real gap since the last key.
12418        assert!(!enter_is_paste_burst(None, now, false));
12419        assert!(!enter_is_paste_burst(
12420            Some(now - PASTE_BURST_GAP),
12421            now,
12422            false
12423        ));
12424        assert!(!enter_is_paste_burst(
12425            Some(now - std::time::Duration::from_millis(120)),
12426            now,
12427            false
12428        ));
12429    }
12430
12431    #[test]
12432    fn paste_probe_ignores_the_enter_keys_own_release() {
12433        use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
12434        let release =
12435            KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
12436
12437        // Windows queues press+release together over RDP: the release behind a
12438        // lone Enter must NOT count as queued paste content.
12439        let mut queued = vec![Event::Key(release)].into_iter();
12440        let (more, stashed) = probe_paste_input(None, || queued.next());
12441        assert!(!more, "a key release alone is not queued paste input");
12442        assert!(stashed.is_none());
12443
12444        // A real follow-up key (the next pasted line) IS queued input and is
12445        // stashed for the caller instead of being lost.
12446        let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
12447        let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
12448        let (more, stashed) = probe_paste_input(None, || queued.next());
12449        assert!(more, "a queued key press is paste continuation");
12450        assert!(matches!(stashed, Some(Event::Key(_))));
12451
12452        // Nothing queued at all.
12453        let (more, stashed) = probe_paste_input(None, || None);
12454        assert!(!more);
12455        assert!(stashed.is_none());
12456
12457        // An already-stashed event counts as queued input, without reading more.
12458        let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
12459            panic!("must not read when an event is already stashed")
12460        });
12461        assert!(more);
12462        assert!(matches!(stashed, Some(Event::Key(_))));
12463    }
12464
12465    // ---- ask_user TUI bridge ----
12466
12467    #[test]
12468    fn ask_user_tool_names_are_recognized() {
12469        assert!(is_ask_user_tool("ask_user"));
12470        assert!(is_ask_user_tool("ask_user_question"));
12471        assert!(!is_ask_user_tool("bash"));
12472    }
12473
12474    #[test]
12475    fn ask_user_args_display_never_leaks_transport_json() {
12476        let text = ask_user_args_display(&serde_json::json!({
12477            "question": "你的代理端口是多少?",
12478            "context": "本机",
12479            "options": ["7890", {"title": "7897", "description": "clash"}],
12480            "suggest": "1080"
12481        }));
12482        assert!(text.contains("你的代理端口是多少?"));
12483        assert!(text.contains("本机"));
12484        assert!(text.contains("Choices: 7890, 7897"));
12485        assert!(text.contains("Suggestions: 1080"));
12486        assert!(!text.trim_start().starts_with('{'));
12487
12488        let multi = ask_user_args_display(&serde_json::json!({
12489            "questions": [
12490                {"id": "a", "question": "Q1"},
12491                {"id": "b", "header": "H", "question": "Q2"}
12492            ]
12493        }));
12494        assert!(multi.contains("Q1"));
12495        assert!(multi.contains("H: Q2"));
12496
12497        assert_eq!(
12498            ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
12499            "Confirmation requested: Deploy?"
12500        );
12501        assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
12502    }
12503
12504    #[test]
12505    fn ask_user_result_and_progress_prefer_readable_content() {
12506        let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
12507        assert!(
12508            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
12509                .contains("端口?")
12510        );
12511        let empty = rpi_agent::AgentToolResult::default();
12512        assert!(
12513            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
12514                .contains("端口?")
12515        );
12516        let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
12517        assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
12518        assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
12519    }
12520
12521    #[test]
12522    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
12523        let flat = rpi_extensions::UiDialogRequest {
12524            request_id: "r1".into(),
12525            tool_call_id: Some("c1".into()),
12526            ui: serde_json::json!({
12527                "kind": "selector",
12528                "id": "proxy_port",
12529                "header": "Proxy",
12530                "question": "端口?",
12531                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
12532                "allowMultiple": true,
12533                "allowFreeform": false,
12534                "suggest": "1080"
12535            }),
12536            raw: serde_json::json!({}),
12537        };
12538        let prompt = parse_ask_user_prompt(&flat);
12539        assert_eq!(prompt.question_id, "proxy_port");
12540        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
12541        assert_eq!(prompt.options.len(), 2);
12542        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
12543        assert!(prompt.allow_multiple);
12544        assert!(!prompt.allow_freeform);
12545        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
12546
12547        let alias = rpi_extensions::UiDialogRequest {
12548            ui: serde_json::json!({
12549                "questions": [{"id": "q", "question": "Q?", "options": []}]
12550            }),
12551            ..flat.clone()
12552        };
12553        let prompt = parse_ask_user_prompt(&alias);
12554        assert_eq!(prompt.question_id, "q");
12555        assert!(prompt.allow_freeform, "no options defaults to freeform");
12556
12557        let confirm = rpi_extensions::UiDialogRequest {
12558            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
12559            ..flat.clone()
12560        };
12561        let prompt = parse_ask_user_prompt(&confirm);
12562        assert_eq!(prompt.kind, "confirm");
12563        assert_eq!(prompt.options.len(), 2);
12564        assert_eq!(prompt.options[0].0, "Yes");
12565    }
12566
12567    #[test]
12568    fn ask_user_bridge_answers_and_cancels_by_request_id() {
12569        let mailbox = rpi_extensions::UiDialogMailbox::new();
12570        mailbox.attach();
12571        let bridge = AskUserBridge::new(mailbox.clone());
12572
12573        // Two concurrent requests must not cross answers.
12574        mailbox
12575            .handle(serde_json::json!({
12576                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
12577                "ui": {"kind": "input", "question": "Port?"}
12578            }))
12579            .unwrap();
12580        mailbox
12581            .handle(serde_json::json!({
12582                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
12583                "ui": {"kind": "input", "question": "Level?"}
12584            }))
12585            .unwrap();
12586
12587        let first = bridge.take_pending().expect("r1 visible");
12588        assert_eq!(first.request_id, "r1");
12589        assert!(
12590            bridge.take_pending().is_none(),
12591            "only one request occupies the input slot"
12592        );
12593        bridge.respond("r1", serde_json::json!({"text": "7897"}));
12594        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
12595
12596        let second = bridge.take_pending().expect("r2 visible");
12597        assert_eq!(second.request_id, "r2");
12598        bridge.cancel("r2");
12599        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
12600
12601        // cancel_all drops anything still queued.
12602        mailbox
12603            .handle(serde_json::json!({
12604                "op": "open", "requestId": "r3",
12605                "ui": {"kind": "input", "question": "X?"}
12606            }))
12607            .unwrap();
12608        bridge.cancel_all();
12609        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
12610
12611        // A detached mailbox rejects new prompts (headless contract).
12612        bridge.shutdown();
12613        assert!(mailbox
12614            .handle(serde_json::json!({
12615                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
12616            }))
12617            .is_err());
12618    }
12619}