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