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, 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;
60use crate::session_driver::{assistant_blocks, assistant_tool_calls};
61use crate::transcript_view::UiEvent;
62
63/// B5e: the markdown-transformer trait object the assistant-message render path
64/// applies to raw text BEFORE the [`Markdown`] renderer styles it. A plain
65/// `Fn(&str) -> String` (NO `rpi-extensions` types) so `rpi-tui` stays free of
66/// an `rpi-extensions` dep — `rpi-cli` (which already depends on
67/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot` and
68/// hands the trait object to `AssistantMessageComponent::set_markdown_transformer`.
69type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
70
71/// A synchronous rendezvous between the Node runtime-request thread and the
72/// blocking TUI key loop. Node's `ctx.ui.*` methods are promises, so the host
73/// request must remain pending while the user interacts with the native
74/// component. The key loop owns opening/closing components; this bridge only
75/// carries JSON results and cancellation state across the threads.
76#[derive(Clone, Default)]
77struct JsDialogBridge {
78    pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
79    active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
80    /// The one dialog currently installed in the TUI input slot. Other
81    /// requests may remain active while a command is waiting, but a cancel
82    /// notification must never close whichever dialog happens to be visible.
83    visible: Arc<Mutex<Option<String>>>,
84    cancelled_before_open: Arc<Mutex<HashSet<String>>>,
85    closed: Arc<Mutex<bool>>,
86}
87
88struct JsDialogPending {
89    request: JsDialogRequest,
90    result: std_mpsc::Sender<serde_json::Value>,
91}
92
93struct JsDialogActive {
94    result: std_mpsc::Sender<serde_json::Value>,
95    cancel_requested: bool,
96}
97
98#[derive(Clone, Debug)]
99struct JsDialogRequest {
100    id: String,
101    method: String,
102    title: String,
103    message: String,
104    options: Vec<String>,
105    placeholder: Option<String>,
106    prefill: Option<String>,
107}
108
109impl JsDialogRequest {
110    fn parse(args: &serde_json::Value) -> Result<Self, String> {
111        let id = args
112            .get("dialogId")
113            .and_then(serde_json::Value::as_str)
114            .filter(|value| !value.is_empty())
115            .ok_or("ui.dialog missing dialogId")?
116            .to_string();
117        let method = args
118            .get("method")
119            .and_then(serde_json::Value::as_str)
120            .ok_or("ui.dialog missing method")?
121            .to_string();
122        if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
123            return Err(format!("unsupported UI dialog method: {method}"));
124        }
125        let options = args
126            .get("options")
127            .and_then(serde_json::Value::as_array)
128            .map(|values| {
129                values
130                    .iter()
131                    .filter_map(serde_json::Value::as_str)
132                    .map(ToOwned::to_owned)
133                    .collect()
134            })
135            .unwrap_or_default();
136        Ok(Self {
137            id,
138            method,
139            title: args
140                .get("title")
141                .and_then(serde_json::Value::as_str)
142                .unwrap_or_default()
143                .to_string(),
144            message: args
145                .get("message")
146                .and_then(serde_json::Value::as_str)
147                .unwrap_or_default()
148                .to_string(),
149            options,
150            placeholder: args
151                .get("placeholder")
152                .and_then(serde_json::Value::as_str)
153                .map(ToOwned::to_owned),
154            prefill: args
155                .get("prefill")
156                .and_then(serde_json::Value::as_str)
157                .map(ToOwned::to_owned),
158        })
159    }
160}
161
162impl JsDialogBridge {
163    fn handle_runtime_request(
164        &self,
165        action: &str,
166        args: serde_json::Value,
167    ) -> Result<serde_json::Value, String> {
168        match action {
169            "ui.dialog" => self.wait_for_dialog(args),
170            "ui.dialog.cancel" => {
171                let id = args
172                    .get("dialogId")
173                    .and_then(serde_json::Value::as_str)
174                    .ok_or("ui.dialog.cancel missing dialogId")?;
175                self.cancel(id);
176                Ok(serde_json::json!(true))
177            }
178            _ => Err(format!("unsupported capability: {action}")),
179        }
180    }
181
182    fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
183        let request = JsDialogRequest::parse(&args)?;
184        let (sender, receiver) = std_mpsc::channel();
185        // Hold the closed flag while enqueueing. `cancel_all` takes this same
186        // lock before draining pending requests, so shutdown cannot observe
187        // an empty queue and then have this request arrive behind the drain.
188        let _closed = self
189            .closed
190            .lock()
191            .map_err(|_| "JS dialog bridge poisoned")?;
192        if *_closed {
193            return Ok(serde_json::json!({ "cancelled": true }));
194        }
195        let cancelled_before_open = self
196            .cancelled_before_open
197            .lock()
198            .map_err(|_| "JS dialog cancellation state poisoned")?
199            .remove(&request.id);
200        if cancelled_before_open {
201            return Ok(serde_json::json!({ "cancelled": true }));
202        }
203        // Do not hold the cancellation-state lock while taking `pending`:
204        // `take_pending` takes those locks in the opposite order.
205        self.pending
206            .lock()
207            .map_err(|_| "JS dialog pending state poisoned")?
208            .push_back(JsDialogPending {
209                request,
210                result: sender,
211            });
212        drop(_closed);
213        receiver
214            .recv()
215            .map_err(|_| "JS dialog closed before it received an answer".to_string())
216    }
217
218    /// Move one request to the active set. The caller invokes this only when
219    /// the TUI has no other modal occupying the editor slot.
220    fn take_pending(&self) -> Option<JsDialogRequest> {
221        loop {
222            let pending = self.pending.lock().ok()?.pop_front()?;
223            let mut active = self.active.lock().ok()?;
224            // Check cancellation while holding the active lock and insert the
225            // entry in the same critical section. `cancel()` checks `active`
226            // before recording a pre-open cancellation, so it will either see
227            // this entry or leave a marker that we consume here. Checking the
228            // marker before acquiring `active` had a small race where a cancel
229            // could land between the check and insertion and strand the dialog.
230            if self
231                .cancelled_before_open
232                .lock()
233                .ok()?
234                .remove(&pending.request.id)
235            {
236                drop(active);
237                let _ = pending
238                    .result
239                    .send(serde_json::json!({ "cancelled": true }));
240                continue;
241            }
242            active.insert(
243                pending.request.id.clone(),
244                JsDialogActive {
245                    result: pending.result,
246                    cancel_requested: false,
247                },
248            );
249            if let Ok(mut visible) = self.visible.lock() {
250                *visible = Some(pending.request.id.clone());
251            }
252            return Some(pending.request);
253        }
254    }
255
256    fn respond(&self, id: &str, result: serde_json::Value) {
257        if let Ok(mut active) = self.active.lock() {
258            if let Some(entry) = active.remove(id) {
259                let _ = entry.result.send(result);
260            }
261        }
262        if let Ok(mut visible) = self.visible.lock() {
263            if visible.as_deref() == Some(id) {
264                *visible = None;
265            }
266        }
267    }
268
269    fn cancel(&self, id: &str) {
270        if let Ok(mut pending) = self.pending.lock() {
271            if let Some(index) = pending.iter().position(|item| item.request.id == id) {
272                if let Some(item) = pending.remove(index) {
273                    let _ = item.result.send(serde_json::json!({ "cancelled": true }));
274                    return;
275                }
276            }
277        }
278        if let Ok(mut active) = self.active.lock() {
279            if let Some(entry) = active.get_mut(id) {
280                if !entry.cancel_requested {
281                    entry.cancel_requested = true;
282                    let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
283                }
284                return;
285            }
286        }
287        if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
288            cancelled.insert(id.to_string());
289        }
290    }
291
292    fn cancelled_active_ids(&self) -> Vec<String> {
293        self.active
294            .lock()
295            .map(|active| {
296                active
297                    .iter()
298                    .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
299                    .collect()
300            })
301            .unwrap_or_default()
302    }
303
304    fn is_visible(&self, id: &str) -> bool {
305        self.visible
306            .lock()
307            .map(|visible| visible.as_deref() == Some(id))
308            .unwrap_or(false)
309    }
310
311    fn finish(&self, id: &str) {
312        if let Ok(mut active) = self.active.lock() {
313            active.remove(id);
314        }
315        if let Ok(mut visible) = self.visible.lock() {
316            if visible.as_deref() == Some(id) {
317                *visible = None;
318            }
319        }
320    }
321
322    fn cancel_all(&self) {
323        // Keep the closed lock through the queue drains. `wait_for_dialog`
324        // holds it while enqueueing, making the shutdown check + enqueue an
325        // atomic operation with respect to this drain.
326        let Ok(mut closed) = self.closed.lock() else {
327            return;
328        };
329        *closed = true;
330        if let Ok(mut pending) = self.pending.lock() {
331            for item in pending.drain(..) {
332                let _ = item.result.send(serde_json::json!({ "cancelled": true }));
333            }
334        }
335        if let Ok(mut active) = self.active.lock() {
336            for (_, entry) in active.drain() {
337                let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
338            }
339        }
340        if let Ok(mut visible) = self.visible.lock() {
341            *visible = None;
342        }
343        drop(closed);
344    }
345
346    /// Cancel requests owned by one interrupted prompt preparation while
347    /// keeping the bridge available to a replacement Node host.
348    fn cancel_open_requests(&self) {
349        // Keep enqueueing closed until the old host and its preparation
350        // worker have stopped. Otherwise a late runtime request can land just
351        // after the drain and strand its handler thread.
352        let Ok(mut closed) = self.closed.lock() else {
353            return;
354        };
355        *closed = true;
356        if let Ok(mut pending) = self.pending.lock() {
357            for item in pending.drain(..) {
358                let _ = item.result.send(serde_json::json!({ "cancelled": true }));
359            }
360        }
361        if let Ok(mut active) = self.active.lock() {
362            for (_, entry) in active.drain() {
363                let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
364            }
365        }
366        if let Ok(mut visible) = self.visible.lock() {
367            *visible = None;
368        }
369        if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
370            cancelled.clear();
371        }
372        drop(closed);
373    }
374
375    fn reopen(&self) {
376        if let Ok(mut closed) = self.closed.lock() {
377            *closed = false;
378        }
379    }
380}
381
382/// B5e: build the `AssistantMessageComponent` markdown-transformer closure the
383/// render path applies to raw assistant text before styling. Wraps any plugin
384/// `register_markdown_transformer` handlers registered in `snapshot` (chained
385/// in registration order: each handler's output feeds the next). `None` when
386/// no markdown transformers are registered (the component defaults to the
387/// identity transform + this avoids a closure allocation on the hot render
388/// path).
389///
390/// The closure captures an `Arc<RegistrySnapshot>` clone so it outlives the
391/// borrow that built it (the snapshot's `active` flag guards dispatch in
392/// `emit_resources_discover`/event translation; a reloaded session's old
393/// snapshot flips false, so a stale closure no-ops rather than driving a
394/// half-swapped registry — the transformer falls back to the input unchanged
395/// on an inactive snapshot, matching the plugin's per-handler skip-on-error).
396///
397/// This is the cycle-free seam: `rpi-tui` takes a `Fn(&str) -> String` trait
398/// object (no `rpi-extensions` dep); `rpi-cli` (which already depends on
399/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot`. The
400/// calling pattern mirrors `plugin_stub_smoke.rs`'s direct `RenderFn` round-
401/// trip (input `{"markdown":…}` → `render_fn` → reclaim `out` via the plugin's
402/// `free_string` → parse `{"markdown":…}`).
403fn build_markdown_transformer(
404    snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
405) -> Option<MarkdownTransformer> {
406    let snapshot = snapshot?;
407    // Pre-check: if no markdown renderers are registered, return None so the
408    // component uses the identity path (no per-delta closure call). The
409    // renderers list is a per-call `renderers_of` clone; snapshotting it once
410    // here keeps the closure cheap on the hot path.
411    let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
412    if renderers.is_empty() {
413        return None;
414    }
415    Some(Arc::new(move |raw: &str| -> String {
416        transform_markdown_chain(&snapshot, &renderers, raw)
417    }))
418}
419
420/// Drive the markdown-transformer chain for one input string. Each registered
421/// handler receives the previous handler's output (or the raw input for the
422/// first), as a `{"markdown": <text>}` JSON envelope; its `RenderFn` returns
423/// `{"markdown": <transformed>}` (rc=0) or an error (rc!=0). On any failure —
424/// nonzero rc, a panic across the FFI (caught), a missing `markdown` field, or
425/// an inactive snapshot — the chain short-circuits to the current text
426/// unchanged (per-handler skip-on-error, mirroring pi's `runner.ts` fan-out).
427fn transform_markdown_chain(
428    snapshot: &rpi_extensions::RegistrySnapshot,
429    renderers: &[rpi_extensions::RegisteredRenderer],
430    raw: &str,
431) -> String {
432    // A stale snapshot (post-/reload) must not drive a swapped-out registry.
433    // The renderers were captured from this snapshot; if it has gone inactive,
434    // fall back to the raw input so the UI never renders stale-transformed text
435    // from a dead plugin.
436    if !snapshot.is_active() {
437        return raw.to_string();
438    }
439
440    let mut current = raw.to_string();
441    for renderer in renderers {
442        let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
443            Ok(s) => s,
444            Err(_) => return current, // serialize failure — keep current, stop chain
445        };
446        // SAFETY: `render_fn` is a plugin-provided `extern "C" fn` over a
447        // borrowed `StbStringRef` + an out-param. The plugin warrants
448        // `poll`/`render` are non-blocking + thread-safe (the same contract
449        // the tool adapter relies on). `user_data` is the plugin's opaque
450        // pointer, stable for the registry lifetime (the keepalive keeps the
451        // cdylib mapped). We reclaim `out` via the plugin's `free_string`
452        // exactly once. The whole call is `catch_unwind`-wrapped — a plugin
453        // panic must not unwind across the FFI boundary (same policy as the
454        // tool partial cb + the runtime_action trampoline).
455        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
456            let mut out = rpi_plugin_sdk::StbString::empty();
457            let rc = (renderer.render_fn)(
458                rpi_plugin_sdk::StbStringRef::from_str(&input),
459                &mut out as *mut rpi_plugin_sdk::StbString,
460                renderer.user_data,
461            );
462            let text = if rc == 0 {
463                let s = out.to_string_lossy();
464                Some(s)
465            } else {
466                None
467            };
468            // Reclaim the plugin-owned `out` regardless of rc (rc!=0 may still
469            // have written an error JSON the plugin allocated). `free_with` is
470            // idempotent on an empty `StbString`.
471            out.free_with(Some(renderer.plugin_free_string));
472            text
473        }));
474        let out_text = match outcome {
475            Ok(Some(s)) => s,
476            Ok(None) => return current, // rc != 0 — skip this handler, keep current
477            Err(_) => return current,   // panic — skip, keep current (do not abort: the
478                                         // render path is not the action trampoline; a panicking transformer
479                                         // degrades to identity rather than killing the process. Logged via
480                                         // the `tracing` crate's panic hook.)
481        };
482        // Parse `{"markdown": <text>}`; lenient — a missing/non-string field
483        // keeps the current text (skip this handler).
484        let next = serde_json::from_str::<serde_json::Value>(&out_text)
485            .ok()
486            .and_then(|v| {
487                v.get("markdown")
488                    .and_then(|m| m.as_str())
489                    .map(|s| s.to_string())
490            })
491            .unwrap_or(current);
492        current = next;
493    }
494    current
495}
496
497// ===========================================================================
498// Slash commands — trait + registry
499// ===========================================================================
500//
501// Each built-in slash command is one `impl SlashCommand`. The commands are
502// registered at startup into a [`CommandRegistry`] (one source of truth) that
503// serves both dispatch ("given this token, run the command") and autocomplete
504// ("list the visible commands"). This replaces the old two-list + sync-test
505// arrangement, where `handle_slash_command` and `v1_slash_commands()` had to be
506// kept in lock-step by hand.
507//
508// `execute` runs on the blocking key/compose thread (the editor `on_submit`
509// callback and the Ctrl+L hotkey both land there), so it MUST stay synchronous:
510//   - commands needing async (`set_model`/`set_thinking_level`/`set_active_tools`)
511//     `tokio::spawn` the work and return immediately;
512//   - commands needing the main async loop (`compact`/`copy`/`exit`/`clear`/
513//     `user-input`) signal it via `ctx.tx.send(TuiMessage::…)`;
514//   - everything else mutates the chat container + requests a render directly.
515
516/// The borrowed world a slash command runs against. All fields are `Arc` (or a
517/// cheap `String` snapshot), so one `CommandContext` clones freely into each
518/// command without per-capture ceremony — this struct is exactly the set of
519/// `*_for_cb` clones the old submit closure used to make individually.
520#[derive(Clone)]
521struct CommandContext {
522    chat: Arc<Container>,
523    tui: Arc<TuiAltScreen>,
524    tx: mpsc::UnboundedSender<TuiMessage>,
525    state: Arc<TuiState>,
526    editor: Arc<Editor>,
527    editor_container: Arc<Container>,
528    lane: Arc<dyn AgentLane>,
529    model_catalog: Arc<Vec<rpi_ai::Model>>,
530    /// Lane model id snapshot, read once via `lane.get_model().await` BEFORE the
531    /// blocking key loop starts. Selectors/key loop can't await, so they read
532    /// this owned string instead. Semantically unchanged from pre-refactor.
533    lane_model_id: String,
534    cwd: std::path::PathBuf,
535    /// Package resources resolved at startup. An empty set means Pi package
536    /// loading was not explicitly enabled and must remain disabled for all
537    /// interactive theme selectors.
538    package_resources: Arc<crate::packages::PackageResources>,
539    /// Harness resources snapshot (skills + prompt templates) for `/context`.
540    /// Captured once at TUI startup because the blocking submit thread can't
541    /// `.await get_resources()`.
542    resources: Arc<rpi_harness::types::AgentHarnessResources>,
543    /// B5d: the reload context `/reload` drives. `Arc<ReloadContext>` so the
544    /// blocking submit thread can cheaply clone it into the `ReloadCommand`
545    /// without an `.await` (the command can't drive reload directly — it signals
546    /// the main loop via `TuiMessage::ReloadExtensions`, which awaits the shared
547    /// `reload_extension_resources` routine on the async runtime).
548    reload_context: Arc<crate::session::ReloadContext>,
549    /// The product-layer session (`AgentSession`). Commands that need durable
550    /// reads, export, or usage stats go through this instead of reaching into
551    /// the harness directly, so the product surface is one object.
552    session: Arc<crate::agent_session::AgentSession>,
553}
554
555/// One slash command.
556trait SlashCommand: Send + Sync {
557    /// Canonical name, with the leading `/` (e.g. "/model").
558    fn name(&self) -> &str;
559    /// Aliases, also `/`-prefixed. Matched alongside `name()` during dispatch.
560    /// Use [`SlashCommand::alias_visible`] to also surface an alias in the
561    /// `/`-autocomplete list (most aliases stay hidden).
562    fn aliases(&self) -> &'static [&'static str] {
563        &[]
564    }
565    /// Whether the canonical name appears in the `/` autocomplete list. Hidden
566    /// commands (`/context`, `/name`, …) return `false`.
567    fn visible(&self) -> bool {
568        true
569    }
570    /// Aliases that should also appear in the `/` autocomplete list. Defaults to
571    /// none — most aliases (`/q`, `/m`, `/think`, `/resume`, `/v`) are kept off
572    /// the list to keep it short. `/new` and `/quit` override this to surface.
573    fn alias_visible(&self) -> &'static [&'static str] {
574        &[]
575    }
576    /// Description shown in autocomplete and `/help`. A non-empty description is
577    /// required to surface in autocomplete even when `visible()` is true.
578    fn description(&self) -> &'static str {
579        ""
580    }
581    fn description_owned(&self) -> String {
582        self.description().to_string()
583    }
584    /// Execute the command. Only invoked for inputs starting with `/` whose
585    /// first token matches `name()` or an alias. `args` is the whitespace-
586    /// trimmed remainder after the command token ("" when none). Must stay
587    /// synchronous (see the module-level note) — async work goes through
588    /// `ctx.tx.send(TuiMessage::…)` or `tokio::spawn`.
589    fn execute(&self, ctx: &CommandContext, args: &str);
590}
591
592/// Holds all registered slash commands; the single source of truth for both
593/// dispatch and the autocomplete list.
594struct CommandRegistry {
595    commands: Vec<Arc<dyn SlashCommand>>,
596}
597
598impl CommandRegistry {
599    fn new() -> Self {
600        Self {
601            commands: Vec::new(),
602        }
603    }
604
605    fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
606        self.commands.push(cmd);
607    }
608
609    /// Find the command whose `name()` or an alias matches `token` (e.g. "/q").
610    /// `token` is the first whitespace-delimited word of the input, `/`-prefixed.
611    fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
612        self.commands
613            .iter()
614            .find(|c| c.name() == token || c.aliases().contains(&token))
615    }
616
617    /// The autocomplete entries, derived from the registry so it can never drift
618    /// from what dispatch recognizes. Surfaces the canonical name when
619    /// `visible()` + non-empty description, plus any `alias_visible()` entries.
620    /// Order = registration order; built-ins are registered before templates,
621    /// so they win on a fuzzy tie (unchanged).
622    fn visible_entries(&self) -> Vec<SlashCommandEntry> {
623        let mut out: Vec<SlashCommandEntry> = Vec::new();
624        for c in &self.commands {
625            let description = c.description_owned();
626            if c.visible() && !description.is_empty() {
627                out.push(SlashCommandEntry {
628                    name: c.name().into(),
629                    description: description.clone(),
630                });
631            }
632            // Surfaced aliases share the command's description.
633            for alias in c.alias_visible() {
634                out.push(SlashCommandEntry {
635                    name: (*alias).into(),
636                    description: description.clone(),
637                });
638            }
639        }
640        out
641    }
642}
643
644/// Dispatch a ui_prompt_start event to extensions.
645fn dispatch_ui_prompt_event(state: &TuiState) {
646    use rpi_plugin_sdk::EventTag;
647
648    if let Ok(session) = state.extension_session.lock() {
649        if let Some(snapshot) = session.snapshot_arc() {
650            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
651        }
652    }
653}
654
655/// Dispatch a ui_prompt_end event to extensions.
656fn dispatch_ui_prompt_event_end(state: &TuiState) {
657    use rpi_plugin_sdk::EventTag;
658
659    if let Ok(session) = state.extension_session.lock() {
660        if let Some(snapshot) = session.snapshot_arc() {
661            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
662        }
663    }
664}
665
666/// Canonical key name for a key event, matching the vocabulary
667/// [`parse_configured_key`] accepts (`"space"`, `"enter"`, `"f1"`, `"a"`).
668/// `None` for keys that cannot be named (media keys, unknown codes).
669fn key_shortcut_name(code: KeyCode) -> Option<&'static str> {
670    Some(match code {
671        KeyCode::Char(' ') => "space",
672        KeyCode::Enter => "enter",
673        KeyCode::Esc => "escape",
674        KeyCode::Tab => "tab",
675        KeyCode::BackTab => "backtab",
676        KeyCode::Backspace => "backspace",
677        KeyCode::Delete => "delete",
678        KeyCode::Up => "up",
679        KeyCode::Down => "down",
680        KeyCode::Left => "left",
681        KeyCode::Right => "right",
682        KeyCode::Home => "home",
683        KeyCode::End => "end",
684        KeyCode::PageUp => "pageup",
685        KeyCode::PageDown => "pagedown",
686        _ => return None,
687    })
688}
689
690/// Lower-case name for a printable ASCII key (`a`, `1`, `,`), or `None` when the
691/// key needs a dedicated name from [`key_shortcut_name`].
692fn char_shortcut_name(ch: char) -> Option<String> {
693    if ch.is_ascii_graphic() {
694        Some(ch.to_ascii_lowercase().to_string())
695    } else {
696        None
697    }
698}
699
700/// Route one key event to extension `Input` handlers when the key is claimed as
701/// a shortcut. Returns `true` when the key was claimed (the caller must then
702/// skip normal editor handling).
703///
704/// Only keys an extension explicitly claimed via `register_shortcut` are
705/// routed, and only while the editor is empty, so a claimed key never steals
706/// ordinary typing. Press **and** Release (and Repeat) are dispatched, which is
707/// what lets a push-to-talk extension measure how long a key was held — the
708/// normal editor path deliberately drops Release.
709fn dispatch_key_event(state: &TuiState, key: &KeyEvent, editor: &Editor) -> bool {
710    use rpi_plugin_sdk::EventTag;
711
712    let name = key_shortcut_name(key.code)
713        .map(str::to_string)
714        .or_else(|| match key.code {
715            KeyCode::Char(ch) => char_shortcut_name(ch),
716            KeyCode::F(n) => Some(format!("f{n}")),
717            _ => None,
718        });
719    let Some(name) = name else {
720        return false;
721    };
722
723    let Ok(session) = state.extension_session.lock() else {
724        return false;
725    };
726    let Some(snapshot) = session.snapshot_arc() else {
727        return false;
728    };
729    // Nothing claimed this key (or nobody subscribes to Input) — not ours.
730    if !snapshot.has_shortcut(&name) || snapshot.handlers_for(EventTag::Input).is_empty() {
731        return false;
732    }
733    // Only claim the key while the editor is empty: with a draft in progress the
734    // key belongs to typing (space especially).
735    if !editor.get_text().is_empty() {
736        return false;
737    }
738
739    let kind = match key.kind {
740        KeyEventKind::Press => "press",
741        KeyEventKind::Repeat => "repeat",
742        KeyEventKind::Release => "release",
743    };
744    let payload = serde_json::json!({
745        "type": "key",
746        "key": name,
747        "kind": kind,
748        "ctrl": key.modifiers.contains(KeyModifiers::CONTROL),
749        "alt": key.modifiers.contains(KeyModifiers::ALT),
750        "shift": key.modifiers.contains(KeyModifiers::SHIFT),
751    })
752    .to_string();
753    // The handler decides whether it actually wants the key: it returns the
754    // `CLAIMED` code while its feature is active and `CONTINUE` otherwise, so a
755    // registered-but-disabled shortcut (push-to-talk toggled off) still lets the
756    // key reach the editor normally.
757    rpi_extensions::dispatch_data_event_claiming(&snapshot, EventTag::Input, &payload)
758}
759
760/// Resolve the command for a `/`-prefixed input and run it, or emit the
761/// unknown-command error if nothing matches. Non-slash text never reaches here
762/// — callers route only `/`-prefixed inputs and send plain text directly.
763fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
764    let mut parts = text.split_whitespace();
765    let token = parts.next().unwrap_or("");
766    let args = parts.collect::<Vec<_>>().join(" ");
767    match registry.find(token) {
768        Some(cmd) => cmd.execute(ctx, &args),
769        None => {
770            add_error_message(
771                &ctx.chat,
772                &format!("Unknown command: {text}. Type /help for available commands."),
773            );
774            ctx.tui.request_render(false);
775        }
776    }
777}
778
779/// Current wall-clock time in milliseconds since the Unix epoch. Used for
780/// `bashExecution` message timestamps (agent-loop messages carry real times, so
781/// the transcript record must too).
782fn now_ms() -> i64 {
783    std::time::SystemTime::now()
784        .duration_since(std::time::UNIX_EPOCH)
785        .map(|d| d.as_millis() as i64)
786        .unwrap_or(0)
787}
788
789/// Parse a submitted line into a user shell command.
790///
791/// Mirrors native pi's `text.startsWith("!")` handling
792/// (`interactive-mode.ts:3225`): `!cmd` runs and is recorded in the session
793/// context, `!!cmd` is excluded from it. Returns `None` for a bare `!` / `!!`
794/// (or whitespace only), so the line falls through to the normal prompt path.
795fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
796    let rest = text.strip_prefix('!')?;
797    let (command, exclude_from_context) = match rest.strip_prefix('!') {
798        Some(command) => (command, true),
799        None => (rest, false),
800    };
801    let command = command.trim();
802    if command.is_empty() {
803        return None;
804    }
805    Some((command, exclude_from_context))
806}
807
808/// Run a `!command` submitted from the editor.
809///
810/// The command executes out-of-band — it never reaches the model. Output
811/// streams into a [`BashExecutionComponent`] in the transcript and the
812/// terminal result is handed to the main loop, which persists it as a
813/// `bashExecution` message (skipped from context for `!!`). Mirrors
814/// `handleBashCommand` (`interactive-mode.ts:6729`).
815fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
816    let comp = Arc::new(BashExecutionComponent::new_with_context(
817        command,
818        exclude_from_context,
819    ));
820    comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
821    ctx.chat.add_child(comp.clone());
822
823    // Register the cancellation slot before spawning so an Esc arriving on the
824    // key thread can never miss the run (native pi's `_bashAbortControllers`).
825    let cancel = ctx.state.begin_user_bash();
826    ctx.tui.request_render(false);
827
828    let env: Arc<dyn rpi_tools::ExecutionEnv> =
829        Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
830    let tui = ctx.tui.clone();
831    let tx = ctx.tx.clone();
832    let cwd = ctx.cwd.clone();
833    let command_owned = command.to_string();
834
835    tokio::spawn(async move {
836        let comp_chunk = comp.clone();
837        let tui_chunk = tui.clone();
838        let on_chunk: Box<
839            dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
840        > = Box::new(move |_chunk, get_progress| {
841            // The capture layer reports the complete tail captured so far, not
842            // a delta; `append_output` replaces its snapshot accordingly.
843            comp_chunk.append_output(&get_progress().output);
844            tui_chunk.request_render(false);
845        });
846        let options = rpi_tools::shell_output::ShellCaptureOptions {
847            cwd: Some(cwd),
848            cancel: Some(&cancel),
849            on_chunk: Some(on_chunk),
850            return_execution_errors: true,
851            ..Default::default()
852        };
853        let result =
854            rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
855                .await;
856
857        // `Ok` carries the real exit code/cancellation; `Err` is a transport
858        // level failure (spawn failure, invalid env) which we surface inline.
859        let (exit_code, cancelled, output, truncated, full_output_path) = match result {
860            Ok(capture) => (
861                capture.exit_code,
862                capture.cancelled,
863                capture.output.clone(),
864                capture.truncated,
865                capture
866                    .full_output_path
867                    .as_ref()
868                    .map(|path| path.to_string_lossy().into_owned()),
869            ),
870            Err(error) => (
871                None,
872                false,
873                format!("Bash command failed: {error}"),
874                false,
875                None,
876            ),
877        };
878        comp.append_output(&output);
879        comp.set_complete(
880            exit_code,
881            cancelled,
882            BashTruncation {
883                truncated,
884                full_output_path: full_output_path.clone(),
885            },
886        );
887        tui.render_now(false);
888
889        let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
890            command: command_owned,
891            output,
892            exit_code,
893            cancelled,
894            truncated,
895            full_output_path,
896            exclude_from_context,
897        }));
898    });
899}
900
901/// Encode a crossterm key into the raw key data consumed by the Node TUI
902/// compatibility layer. Plain keys retain the usual terminal sequences;
903/// modified functional keys use Kitty CSI-u so Shift/Alt/Ctrl combinations are
904/// not collapsed into their unmodified equivalent (notably Shift+Enter).
905fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
906    use crossterm::event::{KeyCode, KeyModifiers};
907
908    let modifiers = key.modifiers;
909    let ctrl = modifiers.contains(KeyModifiers::CONTROL);
910    let shift = modifiers.contains(KeyModifiers::SHIFT);
911    let alt = modifiers.contains(KeyModifiers::ALT);
912    let super_key = modifiers.contains(KeyModifiers::SUPER);
913
914    if modifiers == KeyModifiers::NONE {
915        return match key.code {
916            KeyCode::Char(ch) => ch.to_string(),
917            KeyCode::Enter => "\r".into(),
918            KeyCode::Esc => "\x1b".into(),
919            KeyCode::Backspace => "\x7f".into(),
920            KeyCode::Tab => "\t".into(),
921            // Crossterm represents Shift+Tab as `BackTab` on both Unix
922            // (`ESC[Z`) and Windows. Preserve the canonical terminal form
923            // so the Node keybinding matcher sees `shift+tab`.
924            KeyCode::BackTab => "\x1b[Z".into(),
925            KeyCode::Up => "\x1b[A".into(),
926            KeyCode::Down => "\x1b[B".into(),
927            KeyCode::Right => "\x1b[C".into(),
928            KeyCode::Left => "\x1b[D".into(),
929            KeyCode::Home => "\x1b[H".into(),
930            KeyCode::End => "\x1b[F".into(),
931            KeyCode::PageUp => "\x1b[5~".into(),
932            KeyCode::PageDown => "\x1b[6~".into(),
933            KeyCode::Delete => "\x1b[3~".into(),
934            KeyCode::Insert => "\x1b[2~".into(),
935            KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
936            _ => String::new(),
937        };
938    }
939
940    // Legacy control bytes are what the native `matchesKey` implementation
941    // expects for the common Ctrl+letter actions (Ctrl+C, Ctrl+O, Ctrl+J...).
942    if ctrl && !shift && !alt && !super_key {
943        if let KeyCode::Char(ch) = key.code {
944            if let Some(code) = control_code(ch) {
945                return char::from(code).to_string();
946            }
947        }
948    }
949
950    // Legacy Alt+character input is unambiguous when no other modifier is
951    // present and is accepted by pi's `matchesKey` fallback parser.
952    if alt && !ctrl && !shift && !super_key {
953        if let KeyCode::Char(ch) = key.code {
954            return format!("\x1b{ch}");
955        }
956    }
957
958    // Crossterm has already resolved the keyboard layout for character events
959    // (for example, Windows reports Shift+1 as `Char('!')`). Pass that actual
960    // character through unchanged so custom components receive text instead
961    // of a CSI-u escape sequence. Functional keys and combined modifiers use
962    // CSI-u below so their modifier identity remains available to keybindings.
963    if shift && !ctrl && !alt && !super_key {
964        if let KeyCode::Char(ch) = key.code {
965            return ch.to_string();
966        }
967    }
968
969    if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
970        return sequence;
971    }
972    let Some(codepoint) = key_codepoint(key.code, ctrl) else {
973        return String::new();
974    };
975    kitty_key_sequence(codepoint, modifiers)
976}
977
978fn control_code(ch: char) -> Option<u8> {
979    let ch = ch.to_ascii_lowercase();
980    Some(match ch {
981        '@' | ' ' => 0,
982        'a'..='z' => (ch as u8) & 0x1f,
983        '[' => 0x1b,
984        '\\' => 0x1c,
985        ']' => 0x1d,
986        '^' => 0x1e,
987        '_' | '-' => 0x1f,
988        _ => return None,
989    })
990}
991
992fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
993    use crossterm::event::KeyCode;
994    Some(match code {
995        KeyCode::Char(ch) => {
996            if ctrl {
997                ch.to_ascii_lowercase() as u32
998            } else {
999                ch as u32
1000            }
1001        }
1002        KeyCode::Enter => 13,
1003        KeyCode::Esc => 27,
1004        KeyCode::Backspace => 127,
1005        KeyCode::Tab => 9,
1006        // Keep modified BackTab combinations representable through CSI-u;
1007        // the unmodified/SHIFT form is handled as the legacy `ESC[Z` above.
1008        KeyCode::BackTab => 9,
1009        _ => return None,
1010    })
1011}
1012
1013fn modified_functional_sequence(
1014    code: crossterm::event::KeyCode,
1015    modifiers: crossterm::event::KeyModifiers,
1016) -> Option<String> {
1017    use crossterm::event::{KeyCode, KeyModifiers};
1018    // `BackTab` is already a semantic Shift+Tab event. Crossterm normally
1019    // includes SHIFT in its modifier bits, but preserving the legacy sequence
1020    // for a synthetic event without that bit keeps the adapter portable.
1021    if code == KeyCode::BackTab
1022        && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
1023    {
1024        return Some("\x1b[Z".into());
1025    }
1026    let modifier = kitty_modifier(modifiers);
1027    let sequence = match code {
1028        KeyCode::Up => format!("\x1b[1;{modifier}A"),
1029        KeyCode::Down => format!("\x1b[1;{modifier}B"),
1030        KeyCode::Right => format!("\x1b[1;{modifier}C"),
1031        KeyCode::Left => format!("\x1b[1;{modifier}D"),
1032        KeyCode::Home => format!("\x1b[1;{modifier}H"),
1033        KeyCode::End => format!("\x1b[1;{modifier}F"),
1034        KeyCode::Insert => format!("\x1b[2;{modifier}~"),
1035        KeyCode::Delete => format!("\x1b[3;{modifier}~"),
1036        KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
1037        KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
1038        _ => return None,
1039    };
1040    Some(sequence)
1041}
1042
1043fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
1044    use crossterm::event::KeyModifiers;
1045    let mut modifier = 1u8;
1046    if modifiers.contains(KeyModifiers::SHIFT) {
1047        modifier += 1;
1048    }
1049    if modifiers.contains(KeyModifiers::ALT) {
1050        modifier += 2;
1051    }
1052    if modifiers.contains(KeyModifiers::CONTROL) {
1053        modifier += 4;
1054    }
1055    if modifiers.contains(KeyModifiers::SUPER) {
1056        modifier += 8;
1057    }
1058    modifier
1059}
1060
1061fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
1062    let modifier = kitty_modifier(modifiers);
1063    format!("\x1b[{codepoint};{modifier}u")
1064}
1065
1066/// A slash command registered by a native extension. The command metadata is
1067/// captured for autocomplete, while the handler is looked up from the live
1068/// session on every invocation so `/reload` takes effect without rebuilding
1069/// the editor callback.
1070struct ExtensionCommand {
1071    name: String,
1072    description: String,
1073    session: crate::session::ExtensionSessionCell,
1074}
1075
1076struct JsExtensionCommand {
1077    name: String,
1078    session: crate::js_extensions::JsExtensionSession,
1079}
1080
1081impl SlashCommand for JsExtensionCommand {
1082    fn name(&self) -> &str {
1083        &self.name
1084    }
1085    fn description(&self) -> &'static str {
1086        "JS extension command"
1087    }
1088    fn description_owned(&self) -> String {
1089        "JS extension command".to_string()
1090    }
1091    fn execute(&self, ctx: &CommandContext, args: &str) {
1092        // JS commands may own the terminal for their entire lifetime (for
1093        // example pi-btw's fullscreen side thread). Running them inline here
1094        // would block the crossterm key thread, so no input could reach the
1095        // extension while it is waiting for `ui.custom()` to complete.
1096        let session = self.session.clone();
1097        let command = self.name.trim_start_matches('/').to_string();
1098        let args = args.to_string();
1099        let ctx = ctx.clone();
1100        // The command runs off the key thread. Keep the startup snapshot so a
1101        // late editorText result cannot overwrite text typed while the command
1102        // was in flight.
1103        let initial_editor_text = ctx.editor.get_text();
1104        tokio::task::spawn_blocking(move || {
1105            match session.invoke_command_with_context(
1106                &command,
1107                &args,
1108                serde_json::json!({"editorText": initial_editor_text}),
1109            ) {
1110                Ok(value) => {
1111                    if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
1112                        if ctx.editor.get_text() == initial_editor_text
1113                            && editor_text != initial_editor_text
1114                        {
1115                            let cursor = editor_text.chars().count();
1116                            ctx.editor.set_text(editor_text);
1117                            ctx.editor.set_cursor(0, cursor);
1118                        }
1119                    }
1120                    if let Some(notifications) =
1121                        value.get("notifications").and_then(|v| v.as_array())
1122                    {
1123                        for notification in notifications {
1124                            let message = notification
1125                                .get("message")
1126                                .and_then(|v| v.as_str())
1127                                .unwrap_or_default();
1128                            if message.is_empty() {
1129                                continue;
1130                            }
1131                            match notification.get("level").and_then(|v| v.as_str()) {
1132                                Some("error") => add_error_message(&ctx.chat, message),
1133                                _ => add_note_message(&ctx.chat, message),
1134                            }
1135                        }
1136                    }
1137                    let result = value.get("result").unwrap_or(&value);
1138                    let text = result
1139                        .get("text")
1140                        .and_then(|item| item.as_str())
1141                        .map(str::to_string)
1142                        .or_else(|| result.as_str().map(str::to_string))
1143                        .filter(|text| !text.is_empty() && text != "null");
1144                    if let Some(text) = text {
1145                        add_note_message(&ctx.chat, &text);
1146                    }
1147                }
1148                Err(error) => {
1149                    add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
1150                }
1151            }
1152            ctx.tui.request_render(false);
1153        });
1154    }
1155}
1156
1157impl SlashCommand for ExtensionCommand {
1158    fn name(&self) -> &str {
1159        &self.name
1160    }
1161
1162    fn description(&self) -> &'static str {
1163        "extension command"
1164    }
1165
1166    fn description_owned(&self) -> String {
1167        self.description.clone()
1168    }
1169
1170    fn execute(&self, ctx: &CommandContext, args: &str) {
1171        let result = invoke_extension_command(&self.session, &self.name, args);
1172        handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
1173    }
1174}
1175
1176fn invoke_extension_command(
1177    session: &crate::session::ExtensionSessionCell,
1178    name: &str,
1179    args: &str,
1180) -> Option<serde_json::Value> {
1181    let command = session
1182        .lock()
1183        .ok()
1184        .and_then(|s| s.snapshot_arc())
1185        .and_then(|snap| {
1186            snap.commands()
1187                .iter()
1188                .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
1189                .cloned()
1190        })?;
1191    let input = serde_json::json!({ "args": args, "command": name });
1192    let input = serde_json::to_string(&input).ok()?;
1193    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1194        let mut out = rpi_plugin_sdk::StbString::empty();
1195        let rc = (command.handler)(
1196            rpi_plugin_sdk::StbStringRef::from_str(&input),
1197            &mut out as *mut rpi_plugin_sdk::StbString,
1198            command.user_data,
1199        );
1200        let text = if rc == 0 {
1201            Some(out.to_string_lossy())
1202        } else {
1203            None
1204        };
1205        rpi_extensions::host_free_string(out);
1206        text
1207    }))
1208    .ok()
1209    .flatten()?;
1210    serde_json::from_str(&outcome).ok()
1211}
1212
1213fn handle_extension_ui_result(
1214    result: Option<serde_json::Value>,
1215    ctx: &CommandContext,
1216    session: crate::session::ExtensionSessionCell,
1217    command_name: String,
1218) {
1219    let Some(value) = result else {
1220        add_error_message(&ctx.chat, "Extension command failed.");
1221        ctx.tui.request_render(false);
1222        return;
1223    };
1224    // A cancellation continuation may intentionally return JSON null. Native
1225    // pi resolves the pending promise with `undefined` and does not add a
1226    // visible "null" message to the transcript.
1227    if value.is_null() {
1228        ctx.tui.request_render(false);
1229        return;
1230    }
1231    match value.get("kind").and_then(|v| v.as_str()) {
1232        Some("message") | None => {
1233            let fallback = value.to_string();
1234            let text = value
1235                .get("text")
1236                .and_then(|v| v.as_str())
1237                .unwrap_or(&fallback)
1238                .to_string();
1239            if !text.is_empty() {
1240                add_note_message(&ctx.chat, &text);
1241            }
1242            ctx.tui.request_render(false);
1243        }
1244        Some("selector") => open_extension_selector(ctx, session, command_name, value),
1245        Some("editor") => open_extension_editor(ctx, session, command_name, value),
1246        // Native pi exposes `ctx.ui.input(title, placeholder)` separately
1247        // from the multiline editor. Render it as a focused single-line
1248        // dialog in the swapped input slot.
1249        Some("input") => open_extension_input(ctx, session, command_name, value),
1250        Some(other) => {
1251            add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1252            ctx.tui.request_render(false);
1253        }
1254    }
1255}
1256
1257/// Render the title used by the native pi extension dialogs.  Keeping it in
1258/// the swapped editor container makes the question stay visible while the
1259/// extension waits for the answer, instead of adding a transient chat note.
1260fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1261    let colors = current_theme().colors;
1262    let text = if bold {
1263        tui_bold(title)
1264    } else {
1265        title.to_string()
1266    };
1267    Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1268}
1269
1270fn extension_dialog_hint(label: &str) -> Arc<Text> {
1271    Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1272}
1273
1274/// Markdown-aware version of `extension_dialog_title` for extension dialogs
1275/// that may contain markdown content (tables, lists, bold, etc).
1276fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1277    let text = if bold {
1278        format!("**{}**", title)
1279    } else {
1280        title.to_string()
1281    };
1282    Arc::new(Markdown::new(text, 1, 0))
1283}
1284
1285/// Markdown-aware version of `extension_dialog_hint` for extension dialogs
1286/// that may contain markdown content.
1287fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1288    Arc::new(Markdown::new(label, 1, 0))
1289}
1290
1291/// Take and run the cancellation callback for the active extension dialog.
1292/// Taking it before invoking the callback breaks the temporary Arc cycle: the
1293/// callback owns the command context so it can process a follow-up result.
1294fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1295    let callback = state.active_extension_cancel.lock().unwrap().take();
1296    if let Some(callback) = callback {
1297        callback();
1298        true
1299    } else {
1300        false
1301    }
1302}
1303
1304fn open_extension_input(
1305    ctx: &CommandContext,
1306    session: crate::session::ExtensionSessionCell,
1307    command_name: String,
1308    value: serde_json::Value,
1309) {
1310    let title = value
1311        .get("title")
1312        .and_then(|v| v.as_str())
1313        .filter(|title| !title.is_empty())
1314        .unwrap_or("Input");
1315    let input = value
1316        .get("placeholder")
1317        .and_then(|v| v.as_str())
1318        .map(Input::with_placeholder)
1319        .unwrap_or_default();
1320    let input = Arc::new(input);
1321    if let Some(initial) = value
1322        .get("initialText")
1323        .or_else(|| value.get("text"))
1324        .and_then(|v| v.as_str())
1325    {
1326        input.set_value(initial);
1327    }
1328    input.set_focused(true);
1329
1330    let frame = Arc::new(Container::new());
1331    frame.add_child(Arc::new(DynamicBorder::new()));
1332    frame.add_child(Arc::new(Spacer::new(1)));
1333    frame.add_child(extension_dialog_title(title, false));
1334    frame.add_child(Arc::new(Spacer::new(1)));
1335    frame.add_child(input.clone());
1336    frame.add_child(Arc::new(Spacer::new(1)));
1337    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1338    frame.add_child(Arc::new(Spacer::new(1)));
1339    frame.add_child(Arc::new(DynamicBorder::new()));
1340
1341    *ctx.state.active_extension_editor.lock().unwrap() = None;
1342    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1343    ctx.editor_container.clear();
1344    ctx.editor_container.add_child(frame);
1345
1346    let state = ctx.state.clone();
1347    let ec = ctx.editor_container.clone();
1348    let original = ctx.editor.clone();
1349    let tui = ctx.tui.clone();
1350    let session_submit = session.clone();
1351    let command_submit = command_name.clone();
1352    let ctx_submit = ctx.clone();
1353    input.on_submit(Arc::new(move |text| {
1354        let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1355        let result = invoke_extension_command(
1356            &session_submit,
1357            &command_submit,
1358            &serde_json::to_string(&args).unwrap_or_default(),
1359        );
1360        close_extension_editor(&state, &ec, &original, &tui);
1361        handle_extension_ui_result(
1362            result,
1363            &ctx_submit,
1364            session_submit.clone(),
1365            command_submit.clone(),
1366        );
1367    }));
1368
1369    let state_cancel = ctx.state.clone();
1370    let ec_cancel = ctx.editor_container.clone();
1371    let original_cancel = ctx.editor.clone();
1372    let tui_cancel = ctx.tui.clone();
1373    let session_cancel = session.clone();
1374    let command_cancel = command_name.clone();
1375    let ctx_cancel = ctx.clone();
1376    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1377        let args = serde_json::json!({ "action": "cancel" });
1378        let result = invoke_extension_command(
1379            &session_cancel,
1380            &command_cancel,
1381            &serde_json::to_string(&args).unwrap_or_default(),
1382        );
1383        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1384        handle_extension_ui_result(
1385            result,
1386            &ctx_cancel,
1387            session_cancel.clone(),
1388            command_cancel.clone(),
1389        );
1390    }));
1391
1392    ctx.tui.set_focus(Some(input));
1393    ctx.tui.request_render(false);
1394}
1395
1396fn open_extension_selector(
1397    ctx: &CommandContext,
1398    session: crate::session::ExtensionSessionCell,
1399    command_name: String,
1400    value: serde_json::Value,
1401) {
1402    let items = value
1403        .get("items")
1404        .and_then(|v| v.as_array())
1405        .map(|items| {
1406            items
1407                .iter()
1408                .filter_map(|item| {
1409                    // Native pi's selector accepts `string[]`; the Rust ABI
1410                    // also permits `{value,label,description}` objects.
1411                    let value = if let Some(value) = item.as_str() {
1412                        value
1413                    } else {
1414                        item.get("value")?.as_str()?
1415                    };
1416                    let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1417                    let mut out = SelectItem::new(value, label);
1418                    if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1419                        out = out.with_description(desc);
1420                    }
1421                    Some(out)
1422                })
1423                .collect::<Vec<_>>()
1424        })
1425        .unwrap_or_default();
1426    if items.is_empty() {
1427        add_error_message(&ctx.chat, "Extension selector has no items.");
1428        ctx.tui.request_render(false);
1429        return;
1430    }
1431    let list = Arc::new(SelectList::new(items, 10));
1432    let title = value
1433        .get("title")
1434        .and_then(|v| v.as_str())
1435        .filter(|title| !title.is_empty())
1436        .unwrap_or("Select");
1437    let frame = Arc::new(Container::new());
1438    frame.add_child(Arc::new(DynamicBorder::new()));
1439    frame.add_child(Arc::new(Spacer::new(1)));
1440    frame.add_child(extension_dialog_title(title, true));
1441    frame.add_child(Arc::new(Spacer::new(1)));
1442    frame.add_child(list.clone());
1443    frame.add_child(Arc::new(Spacer::new(1)));
1444    frame.add_child(extension_dialog_hint(
1445        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1446    ));
1447    frame.add_child(Arc::new(Spacer::new(1)));
1448    frame.add_child(Arc::new(DynamicBorder::new()));
1449
1450    let state = ctx.state.clone();
1451    let ec = ctx.editor_container.clone();
1452    let editor = ctx.editor.clone();
1453    let tui = ctx.tui.clone();
1454    let session_select = session.clone();
1455    let command_select = command_name.clone();
1456    let ctx_select = ctx.clone();
1457    list.on_select(Arc::new(move |item| {
1458        let args = serde_json::json!({ "action": "select", "value": item.value });
1459        let result = invoke_extension_command(
1460            &session_select,
1461            &command_select,
1462            &serde_json::to_string(&args).unwrap_or_default(),
1463        );
1464        close_selector(&state, &ec, &editor, &tui);
1465        handle_extension_ui_result(
1466            result,
1467            &ctx_select,
1468            session_select.clone(),
1469            command_select.clone(),
1470        );
1471    }));
1472    let state_cancel = ctx.state.clone();
1473    let ec_cancel = ctx.editor_container.clone();
1474    let editor_cancel = ctx.editor.clone();
1475    let tui_cancel = ctx.tui.clone();
1476    list.on_cancel(Arc::new(move || {
1477        if !run_extension_cancel(&state_cancel) {
1478            close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1479        }
1480    }));
1481    let state_cancel = ctx.state.clone();
1482    let ec_cancel = ctx.editor_container.clone();
1483    let editor_cancel = ctx.editor.clone();
1484    let tui_cancel = ctx.tui.clone();
1485    let session_cancel = session.clone();
1486    let command_cancel = command_name.clone();
1487    let ctx_cancel = ctx.clone();
1488    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1489        let args = serde_json::json!({ "action": "cancel" });
1490        let result = invoke_extension_command(
1491            &session_cancel,
1492            &command_cancel,
1493            &serde_json::to_string(&args).unwrap_or_default(),
1494        );
1495        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1496        handle_extension_ui_result(
1497            result,
1498            &ctx_cancel,
1499            session_cancel.clone(),
1500            command_cancel.clone(),
1501        );
1502    }));
1503    open_selector_with_view(
1504        &ctx.state,
1505        &ctx.editor_container,
1506        &ctx.editor,
1507        &ctx.tui,
1508        SelectorView::List(list),
1509        frame,
1510        SelectorKind::Extension,
1511    );
1512}
1513
1514fn open_extension_editor(
1515    ctx: &CommandContext,
1516    session: crate::session::ExtensionSessionCell,
1517    command_name: String,
1518    value: serde_json::Value,
1519) {
1520    let initial = value
1521        .get("initialText")
1522        .or_else(|| value.get("text"))
1523        .and_then(|v| v.as_str())
1524        .unwrap_or_default()
1525        .to_string();
1526    let title = value
1527        .get("title")
1528        .and_then(|v| v.as_str())
1529        .filter(|title| !title.is_empty())
1530        .unwrap_or("Editor");
1531    let editor = Arc::new(Editor::new(
1532        EditorOptions {
1533            padding_x: 1,
1534            autocomplete_max_visible: 0,
1535            placeholder: value
1536                .get("placeholder")
1537                .and_then(|v| v.as_str())
1538                .map(str::to_string),
1539            initial_text: Some(initial),
1540        },
1541        EditorStyle {
1542            prompt: "> ".to_string(),
1543            placeholder: String::new(),
1544        },
1545        Arc::new(rpi_tui::Keybindings::new()),
1546    ));
1547    editor.set_focused(true);
1548    let frame = Arc::new(Container::new());
1549    frame.add_child(Arc::new(DynamicBorder::new()));
1550    frame.add_child(Arc::new(Spacer::new(1)));
1551    frame.add_child(extension_dialog_title(title, false));
1552    frame.add_child(Arc::new(Spacer::new(1)));
1553    frame.add_child(editor.clone());
1554    frame.add_child(Arc::new(Spacer::new(1)));
1555    frame.add_child(extension_dialog_hint(
1556        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1557    ));
1558    frame.add_child(Arc::new(Spacer::new(1)));
1559    frame.add_child(Arc::new(DynamicBorder::new()));
1560
1561    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1562    *ctx.state.active_extension_input.lock().unwrap() = None;
1563    ctx.editor_container.clear();
1564    ctx.editor_container.add_child(frame);
1565
1566    let state = ctx.state.clone();
1567    let ec = ctx.editor_container.clone();
1568    let original = ctx.editor.clone();
1569    let tui = ctx.tui.clone();
1570    let session_submit = session.clone();
1571    let command_submit = command_name.clone();
1572    let ctx_submit = ctx.clone();
1573    editor.on_submit(Arc::new(move |text| {
1574        let args = serde_json::json!({ "action": "edit", "text": text });
1575        let result = invoke_extension_command(
1576            &session_submit,
1577            &command_submit,
1578            &serde_json::to_string(&args).unwrap_or_default(),
1579        );
1580        close_extension_editor(&state, &ec, &original, &tui);
1581        handle_extension_ui_result(
1582            result,
1583            &ctx_submit,
1584            session_submit.clone(),
1585            command_submit.clone(),
1586        );
1587    }));
1588
1589    let state_cancel = ctx.state.clone();
1590    let ec_cancel = ctx.editor_container.clone();
1591    let original_cancel = ctx.editor.clone();
1592    let tui_cancel = ctx.tui.clone();
1593    let session_cancel = session.clone();
1594    let command_cancel = command_name.clone();
1595    let ctx_cancel = ctx.clone();
1596    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1597        let args = serde_json::json!({ "action": "cancel" });
1598        let result = invoke_extension_command(
1599            &session_cancel,
1600            &command_cancel,
1601            &serde_json::to_string(&args).unwrap_or_default(),
1602        );
1603        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1604        handle_extension_ui_result(
1605            result,
1606            &ctx_cancel,
1607            session_cancel.clone(),
1608            command_cancel.clone(),
1609        );
1610    }));
1611
1612    ctx.tui.set_focus(Some(editor));
1613    ctx.tui.request_render(false);
1614}
1615
1616fn close_extension_editor(
1617    state: &Arc<TuiState>,
1618    editor_container: &Arc<Container>,
1619    editor: &Arc<Editor>,
1620    tui: &Arc<TuiAltScreen>,
1621) {
1622    editor_container.clear();
1623    editor_container.add_child(editor.clone());
1624    state.autocomplete_container.clear();
1625    *state.active_extension_editor.lock().unwrap() = None;
1626    *state.active_extension_input.lock().unwrap() = None;
1627    *state.active_extension_cancel.lock().unwrap() = None;
1628    editor.set_focused(true);
1629    tui.set_focus(Some(editor.clone()));
1630    tui.request_render(false);
1631}
1632
1633/// Open one Node `ctx.ui.*` request in the native editor slot. The Node host
1634/// waits on the runtime response while these callbacks resolve the bridge on
1635/// Enter, selection, or cancellation.
1636fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1637    match request.method.as_str() {
1638        "select" => open_js_selector(ctx, bridge, request, false),
1639        "confirm" => open_js_selector(ctx, bridge, request, true),
1640        "input" => open_js_input(ctx, bridge, request),
1641        "editor" => open_js_editor(ctx, bridge, request),
1642        _ => {
1643            bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1644        }
1645    }
1646}
1647
1648fn open_js_selector(
1649    ctx: &CommandContext,
1650    bridge: Arc<JsDialogBridge>,
1651    request: JsDialogRequest,
1652    confirm: bool,
1653) {
1654    let values = if confirm {
1655        vec!["Yes".to_string(), "No".to_string()]
1656    } else {
1657        request.options.clone()
1658    };
1659    if values.is_empty() {
1660        bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1661        return;
1662    }
1663    let items = values
1664        .iter()
1665        .map(|value| SelectItem::new(value, value))
1666        .collect::<Vec<_>>();
1667    let list = Arc::new(SelectList::new(items, 10));
1668    let frame = Arc::new(Container::new());
1669    frame.add_child(Arc::new(DynamicBorder::new()));
1670    frame.add_child(Arc::new(Spacer::new(1)));
1671    frame.add_child(extension_dialog_title(
1672        if request.title.is_empty() {
1673            if confirm {
1674                "Confirm"
1675            } else {
1676                "Select"
1677            }
1678        } else {
1679            request.title.as_str()
1680        },
1681        true,
1682    ));
1683    if !request.message.is_empty() {
1684        frame.add_child(Arc::new(Spacer::new(1)));
1685        frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1686    }
1687    frame.add_child(Arc::new(Spacer::new(1)));
1688    frame.add_child(list.clone());
1689    frame.add_child(Arc::new(Spacer::new(1)));
1690    frame.add_child(extension_dialog_hint(
1691        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1692    ));
1693    frame.add_child(Arc::new(Spacer::new(1)));
1694    frame.add_child(Arc::new(DynamicBorder::new()));
1695
1696    let id = request.id.clone();
1697    let bridge_select = bridge.clone();
1698    let state_select = ctx.state.clone();
1699    let ec_select = ctx.editor_container.clone();
1700    let editor_select = ctx.editor.clone();
1701    let tui_select = ctx.tui.clone();
1702    list.on_select(Arc::new(move |item| {
1703        let result = if confirm {
1704            serde_json::json!({ "confirmed": item.value == "Yes" })
1705        } else {
1706            serde_json::json!({ "value": item.value })
1707        };
1708        bridge_select.respond(&id, result);
1709        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1710    }));
1711
1712    let id_cancel = request.id.clone();
1713    let bridge_cancel = bridge.clone();
1714    let state_cancel = ctx.state.clone();
1715    let ec_cancel = ctx.editor_container.clone();
1716    let editor_cancel = ctx.editor.clone();
1717    let tui_cancel = ctx.tui.clone();
1718    list.on_cancel(Arc::new(move || {
1719        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1720        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1721    }));
1722
1723    let id_abort = request.id.clone();
1724    let bridge_abort = bridge.clone();
1725    let state_abort = ctx.state.clone();
1726    let ec_abort = ctx.editor_container.clone();
1727    let editor_abort = ctx.editor.clone();
1728    let tui_abort = ctx.tui.clone();
1729    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1730        bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1731        close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1732    }));
1733
1734    open_selector_with_view(
1735        &ctx.state,
1736        &ctx.editor_container,
1737        &ctx.editor,
1738        &ctx.tui,
1739        SelectorView::List(list),
1740        frame,
1741        SelectorKind::Extension,
1742    );
1743}
1744
1745fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1746    let input = request
1747        .placeholder
1748        .as_deref()
1749        .map(Input::with_placeholder)
1750        .unwrap_or_default();
1751    let input = Arc::new(input);
1752    input.set_focused(true);
1753
1754    let frame = Arc::new(Container::new());
1755    frame.add_child(Arc::new(DynamicBorder::new()));
1756    frame.add_child(Arc::new(Spacer::new(1)));
1757    frame.add_child(extension_dialog_title(
1758        if request.title.is_empty() {
1759            "Input"
1760        } else {
1761            &request.title
1762        },
1763        false,
1764    ));
1765    frame.add_child(Arc::new(Spacer::new(1)));
1766    frame.add_child(input.clone());
1767    frame.add_child(Arc::new(Spacer::new(1)));
1768    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1769    frame.add_child(Arc::new(Spacer::new(1)));
1770    frame.add_child(Arc::new(DynamicBorder::new()));
1771
1772    *ctx.state.active_extension_editor.lock().unwrap() = None;
1773    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1774    ctx.editor_container.clear();
1775    ctx.editor_container.add_child(frame);
1776
1777    let id = request.id.clone();
1778    let bridge_submit = bridge.clone();
1779    let state_submit = ctx.state.clone();
1780    let ec_submit = ctx.editor_container.clone();
1781    let editor_submit = ctx.editor.clone();
1782    let tui_submit = ctx.tui.clone();
1783    input.on_submit(Arc::new(move |value| {
1784        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1785        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1786    }));
1787
1788    let id_cancel = request.id.clone();
1789    let bridge_cancel = bridge.clone();
1790    let state_cancel = ctx.state.clone();
1791    let ec_cancel = ctx.editor_container.clone();
1792    let editor_cancel = ctx.editor.clone();
1793    let tui_cancel = ctx.tui.clone();
1794    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1795        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1796        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1797    }));
1798    ctx.tui.set_focus(Some(input));
1799    ctx.tui.request_render(false);
1800}
1801
1802fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1803    let editor = Arc::new(Editor::new(
1804        EditorOptions {
1805            padding_x: 1,
1806            autocomplete_max_visible: 0,
1807            initial_text: request.prefill.clone(),
1808            ..Default::default()
1809        },
1810        EditorStyle {
1811            prompt: "> ".to_string(),
1812            placeholder: String::new(),
1813        },
1814        Arc::new(rpi_tui::Keybindings::new()),
1815    ));
1816    editor.set_focused(true);
1817
1818    let frame = Arc::new(Container::new());
1819    frame.add_child(Arc::new(DynamicBorder::new()));
1820    frame.add_child(Arc::new(Spacer::new(1)));
1821    frame.add_child(extension_dialog_title(
1822        if request.title.is_empty() {
1823            "Editor"
1824        } else {
1825            &request.title
1826        },
1827        false,
1828    ));
1829    frame.add_child(Arc::new(Spacer::new(1)));
1830    frame.add_child(editor.clone());
1831    frame.add_child(Arc::new(Spacer::new(1)));
1832    frame.add_child(extension_dialog_hint(
1833        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1834    ));
1835    frame.add_child(Arc::new(Spacer::new(1)));
1836    frame.add_child(Arc::new(DynamicBorder::new()));
1837
1838    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1839    *ctx.state.active_extension_input.lock().unwrap() = None;
1840    ctx.editor_container.clear();
1841    ctx.editor_container.add_child(frame);
1842
1843    let id = request.id.clone();
1844    let bridge_submit = bridge.clone();
1845    let state_submit = ctx.state.clone();
1846    let ec_submit = ctx.editor_container.clone();
1847    let editor_submit = ctx.editor.clone();
1848    let tui_submit = ctx.tui.clone();
1849    editor.on_submit(Arc::new(move |value| {
1850        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1851        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1852    }));
1853
1854    let id_cancel = request.id.clone();
1855    let bridge_cancel = bridge.clone();
1856    let state_cancel = ctx.state.clone();
1857    let ec_cancel = ctx.editor_container.clone();
1858    let editor_cancel = ctx.editor.clone();
1859    let tui_cancel = ctx.tui.clone();
1860    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1861        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1862        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1863    }));
1864    ctx.tui.set_focus(Some(editor));
1865    ctx.tui.request_render(false);
1866}
1867
1868fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1869    for id in bridge.cancelled_active_ids() {
1870        // Several commands can ask for a dialog concurrently. Only the id
1871        // currently occupying the TUI slot may close the visible component;
1872        // an older cancellation must leave a newer ask dialog untouched.
1873        if !bridge.is_visible(&id) {
1874            bridge.finish(&id);
1875            continue;
1876        }
1877        if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1878            selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1879        } else if ctx.state.extension_dialog_open() {
1880            if !run_extension_cancel(&ctx.state) {
1881                close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1882            }
1883        }
1884        // `respond` normally removes the active entry from the callback. The
1885        // fallback path above can run before a callback was installed, so
1886        // always discard the id after routing the cancellation.
1887        bridge.finish(&id);
1888    }
1889}
1890
1891// ===========================================================================
1892// ask_user — plugin UI-dialog bridge (runtime action 17)
1893// ===========================================================================
1894
1895/// Sentinel option value that switches a freeform-capable selector into the
1896/// single-line input slot (native `allowFreeform` affordance).
1897const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1898
1899/// One in-flight `ask_user` request being shown in the TUI. Unlike
1900/// [`JsDialogBridge`], the plugin side is a synchronous `poll` loop that reads
1901/// answers back through the shared [`rpi_extensions::UiDialogMailbox`], so this
1902/// bridge only tracks which request id currently occupies the input slot.
1903#[derive(Clone)]
1904struct AskUserBridge {
1905    mailbox: rpi_extensions::UiDialogMailbox,
1906    visible: Arc<Mutex<Option<String>>>,
1907}
1908
1909impl AskUserBridge {
1910    fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1911        Self {
1912            mailbox,
1913            visible: Arc::new(Mutex::new(None)),
1914        }
1915    }
1916
1917    /// Mark the interactive consumer as available. Idempotent.
1918    fn attach(&self) {
1919        self.mailbox.attach();
1920    }
1921
1922    /// Take the next pending request and mark it visible. Only the single key
1923    /// loop calls this while no other dialog is open, so the visible-id
1924    /// bookkeeping is race-free without a separate lock.
1925    fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1926        let mut visible = self.visible.lock().ok()?;
1927        if visible.is_some() {
1928            return None;
1929        }
1930        let request = self.mailbox.take_pending()?;
1931        *visible = Some(request.request_id.clone());
1932        Some(request)
1933    }
1934
1935    fn clear_visible(&self, id: &str) {
1936        if let Ok(mut visible) = self.visible.lock() {
1937            if visible.as_deref() == Some(id) {
1938                *visible = None;
1939            }
1940        }
1941    }
1942
1943    /// Record an answer for `id` and release the input slot.
1944    fn respond(&self, id: &str, answer: serde_json::Value) {
1945        let _ = self.mailbox.respond(id, answer);
1946        self.clear_visible(id);
1947    }
1948
1949    /// Cancel `id` and release the input slot.
1950    fn cancel(&self, id: &str) {
1951        let _ = self.mailbox.cancel(id);
1952        self.clear_visible(id);
1953    }
1954
1955    /// Cancel every outstanding request (run abort). Pending prompts from the
1956    /// aborted turn must not surface in a later turn.
1957    fn cancel_all(&self) {
1958        self.mailbox.cancel_all();
1959        if let Ok(mut visible) = self.visible.lock() {
1960            *visible = None;
1961        }
1962    }
1963
1964    /// Detach + cancel everything (TUI shutdown) so a parked plugin `poll`
1965    /// observes a terminal state instead of blocking on a dead session.
1966    fn shutdown(&self) {
1967        self.mailbox.detach();
1968        if let Ok(mut visible) = self.visible.lock() {
1969            *visible = None;
1970        }
1971    }
1972}
1973
1974/// Parsed presentation data for one `ask_user` question. Supports both the
1975/// flat native question contract and the earlier `questions[0]` alias, plus a
1976/// `confirm` summary.
1977#[derive(Clone)]
1978struct AskUserPrompt {
1979    question_id: String,
1980    question: String,
1981    header: Option<String>,
1982    context: Option<String>,
1983    options: Vec<(String, Option<String>)>,
1984    allow_multiple: bool,
1985    allow_freeform: bool,
1986    suggest: Option<String>,
1987    kind: String,
1988}
1989
1990fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1991    keys.iter().find_map(|key| {
1992        value
1993            .get(*key)
1994            .and_then(serde_json::Value::as_str)
1995            .map(str::trim)
1996            .filter(|text| !text.is_empty())
1997            .map(str::to_string)
1998    })
1999}
2000
2001fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
2002    keys.iter()
2003        .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
2004}
2005
2006fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
2007    let mut options = Vec::new();
2008    let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
2009        return options;
2010    };
2011    for item in items {
2012        if let Some(title) = item.as_str() {
2013            let title = title.trim();
2014            if !title.is_empty() {
2015                options.push((title.to_string(), None));
2016            }
2017            continue;
2018        }
2019        let Some(object) = item.as_object() else {
2020            continue;
2021        };
2022        let title = ["title", "label", "text", "value", "name"]
2023            .iter()
2024            .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
2025            .map(str::trim)
2026            .filter(|title| !title.is_empty());
2027        let Some(title) = title else { continue };
2028        let description = object
2029            .get("description")
2030            .and_then(serde_json::Value::as_str)
2031            .map(str::trim)
2032            .filter(|text| !text.is_empty())
2033            .map(str::to_string);
2034        options.push((title.to_string(), description));
2035    }
2036    options
2037}
2038
2039fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
2040    let ui = &request.ui;
2041    let kind = ui
2042        .get("kind")
2043        .and_then(serde_json::Value::as_str)
2044        .unwrap_or("selector")
2045        .to_string();
2046    // A request may carry a single flat question or a `questions[]` array. The
2047    // plugin emits one question per request, but accepting `questions[0]` keeps
2048    // the host compatible with hosts/plugins that batch.
2049    let nested = ui
2050        .get("questions")
2051        .and_then(serde_json::Value::as_array)
2052        .and_then(|items| items.first());
2053    let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
2054
2055    let question = ask_user_str(source, &["question", "summary", "title"])
2056        .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
2057        .unwrap_or_default();
2058    let question_id = ask_user_str(source, &["id", "questionId"])
2059        .or_else(|| ask_user_str(ui, &["id", "questionId"]))
2060        .unwrap_or_else(|| request.request_id.clone());
2061    let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
2062    let context = ask_user_str(source, &["context", "message"])
2063        .or_else(|| ask_user_str(ui, &["context", "message"]));
2064    let mut options = ask_user_options(source);
2065    if options.is_empty() {
2066        options = ask_user_options(ui);
2067    }
2068    if kind == "confirm" && options.is_empty() {
2069        options = vec![("Yes".to_string(), None), ("No".to_string(), None)];
2070    }
2071    let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
2072        .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
2073        .unwrap_or(false);
2074    // Freeform defaults on when there is nothing to pick from, matching the
2075    // "no options ⇒ free input" contract.
2076    let allow_freeform =
2077        ask_user_bool(source, &["allowFreeform", "allow_freeform", "allow_custom"])
2078            .or_else(|| ask_user_bool(ui, &["allowFreeform", "allow_freeform", "allow_custom"]))
2079            .unwrap_or(options.is_empty());
2080    let suggest = ask_user_str(source, &["suggest", "placeholder"])
2081        .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
2082
2083    AskUserPrompt {
2084        question_id,
2085        question,
2086        header,
2087        context,
2088        options,
2089        allow_multiple,
2090        allow_freeform,
2091        suggest,
2092        kind,
2093    }
2094}
2095
2096/// Build the shared header frame for an ask-user prompt.
2097/// Uses markdown-aware rendering for question and context content.
2098fn ask_user_frame(prompt: &AskUserPrompt, body: Arc<dyn Component>, hint: &str) -> Arc<Container> {
2099    let frame = Arc::new(Container::new());
2100    frame.add_child(Arc::new(DynamicBorder::new()));
2101    frame.add_child(Arc::new(Spacer::new(1)));
2102    if let Some(header) = prompt.header.as_deref() {
2103        frame.add_child(extension_dialog_title_md(header, true));
2104    }
2105    if !prompt.question.trim().is_empty() {
2106        frame.add_child(extension_dialog_title_md(
2107            &prompt.question,
2108            prompt.header.is_none(),
2109        ));
2110    }
2111    if let Some(context) = prompt.context.as_deref() {
2112        frame.add_child(extension_dialog_hint_md(context));
2113    }
2114    frame.add_child(Arc::new(Spacer::new(1)));
2115    frame.add_child(body);
2116    frame.add_child(Arc::new(Spacer::new(1)));
2117    frame.add_child(extension_dialog_hint(hint));
2118    frame.add_child(Arc::new(Spacer::new(1)));
2119    frame.add_child(Arc::new(DynamicBorder::new()));
2120    frame
2121}
2122
2123/// Open one `ask_user` request in the TUI input slot.
2124fn open_ask_user_dialog(
2125    ctx: &CommandContext,
2126    bridge: AskUserBridge,
2127    request: rpi_extensions::UiDialogRequest,
2128) {
2129    let prompt = parse_ask_user_prompt(&request);
2130    if prompt.kind == "input" || prompt.options.is_empty() {
2131        open_ask_user_input(ctx, bridge, request, prompt);
2132    } else {
2133        open_ask_user_selector(ctx, bridge, request, prompt);
2134    }
2135}
2136
2137fn open_ask_user_selector(
2138    ctx: &CommandContext,
2139    bridge: AskUserBridge,
2140    request: rpi_extensions::UiDialogRequest,
2141    prompt: AskUserPrompt,
2142) {
2143    let multi = prompt.allow_multiple && prompt.kind != "confirm";
2144    let mut items: Vec<SelectItem> = Vec::new();
2145    for (title, description) in &prompt.options {
2146        let item = SelectItem::new(title, title);
2147        let item = match description.as_deref() {
2148            Some(description) => item.with_description(description),
2149            None => item,
2150        };
2151        items.push(item);
2152    }
2153    // A freeform-capable single selector gets an extra sentinel row that opens
2154    // the input slot for a typed answer.
2155    if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
2156        items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
2157    }
2158    let list = if multi {
2159        Arc::new(SelectList::new_multi(items, 10))
2160    } else {
2161        Arc::new(SelectList::new(items, 10))
2162    };
2163
2164    let request_id = request.request_id.clone();
2165    let state = ctx.state.clone();
2166    let editor_container = ctx.editor_container.clone();
2167    let editor = ctx.editor.clone();
2168    let tui = ctx.tui.clone();
2169
2170    // Single-select: respond with the chosen title, or fall through to the
2171    // input slot for the freeform sentinel.
2172    let bridge_select = bridge.clone();
2173    let request_for_freeform = request.clone();
2174    let prompt_for_freeform = AskUserPrompt {
2175        question_id: prompt.question_id.clone(),
2176        question: prompt.question.clone(),
2177        header: prompt.header.clone(),
2178        context: prompt.context.clone(),
2179        options: Vec::new(),
2180        allow_multiple: false,
2181        allow_freeform: true,
2182        suggest: prompt.suggest.clone(),
2183        kind: "input".to_string(),
2184    };
2185    let state_select = state.clone();
2186    let ec_select = editor_container.clone();
2187    let editor_select = editor.clone();
2188    let tui_select = tui.clone();
2189    let ctx_select = ctx.clone();
2190    list.on_select(Arc::new(move |item: &SelectItem| {
2191        if item.value == ASK_USER_FREEFORM {
2192            close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2193            open_ask_user_input(
2194                &ctx_select,
2195                bridge_select.clone(),
2196                request_for_freeform.clone(),
2197                prompt_for_freeform.clone(),
2198            );
2199            return;
2200        }
2201        bridge_select.respond(
2202            &request_id,
2203            serde_json::json!({
2204                "values": [item.display_value()],
2205                "kind": "selector",
2206            }),
2207        );
2208        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2209    }));
2210
2211    if multi {
2212        let bridge_multi = bridge.clone();
2213        let request_id_multi = request.request_id.clone();
2214        let state_multi = state.clone();
2215        let ec_multi = editor_container.clone();
2216        let editor_multi = editor.clone();
2217        let tui_multi = tui.clone();
2218        list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
2219            let values: Vec<String> = selected
2220                .iter()
2221                .map(|item| item.display_value().to_string())
2222                .collect();
2223            bridge_multi.respond(
2224                &request_id_multi,
2225                serde_json::json!({"values": values, "kind": "selector"}),
2226            );
2227            close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
2228        }));
2229    }
2230
2231    let bridge_cancel = bridge.clone();
2232    let request_id_cancel = request.request_id.clone();
2233    let state_cancel = state.clone();
2234    let ec_cancel = editor_container.clone();
2235    let editor_cancel = editor.clone();
2236    let tui_cancel = tui.clone();
2237    list.on_cancel(Arc::new(move || {
2238        bridge_cancel.cancel(&request_id_cancel);
2239        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2240    }));
2241
2242    let hint = if multi {
2243        "Space toggle · Enter submit · Esc cancel"
2244    } else {
2245        "Enter select · Esc cancel"
2246    };
2247    let frame = ask_user_frame(&prompt, list.clone(), hint);
2248    open_selector_with_view(
2249        &state,
2250        &editor_container,
2251        &editor,
2252        &tui,
2253        SelectorView::List(list),
2254        frame,
2255        SelectorKind::Extension,
2256    );
2257}
2258
2259fn open_ask_user_input(
2260    ctx: &CommandContext,
2261    bridge: AskUserBridge,
2262    request: rpi_extensions::UiDialogRequest,
2263    prompt: AskUserPrompt,
2264) {
2265    let placeholder = prompt
2266        .suggest
2267        .clone()
2268        .unwrap_or_else(|| "Type your answer…".to_string());
2269    let input = Arc::new(Input::with_placeholder(&placeholder));
2270    input.set_focused(true);
2271
2272    let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2273    *ctx.state.active_extension_editor.lock().unwrap() = None;
2274    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2275    ctx.editor_container.clear();
2276    ctx.editor_container.add_child(frame);
2277    ctx.tui.set_focus(Some(input.clone()));
2278    ctx.tui.request_render(false);
2279
2280    let state = ctx.state.clone();
2281    let ec = ctx.editor_container.clone();
2282    let editor = ctx.editor.clone();
2283    let tui = ctx.tui.clone();
2284    let bridge_submit = bridge.clone();
2285    let request_id = request.request_id.clone();
2286    input.on_submit(Arc::new(move |text: &str| {
2287        bridge_submit.respond(
2288            &request_id,
2289            serde_json::json!({
2290                "values": [text],
2291                "value": text,
2292                "text": text,
2293                "kind": "input",
2294            }),
2295        );
2296        close_extension_editor(&state, &ec, &editor, &tui);
2297    }));
2298
2299    let state_cancel = ctx.state.clone();
2300    let ec_cancel = ctx.editor_container.clone();
2301    let editor_cancel = ctx.editor.clone();
2302    let tui_cancel = ctx.tui.clone();
2303    let bridge_cancel = bridge.clone();
2304    let request_id_cancel = request.request_id.clone();
2305    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2306        bridge_cancel.cancel(&request_id_cancel);
2307        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2308    }));
2309}
2310
2311// ---- Built-in command implementations ----
2312
2313struct HelpCommand;
2314impl SlashCommand for HelpCommand {
2315    fn name(&self) -> &'static str {
2316        "/help"
2317    }
2318    fn aliases(&self) -> &'static [&'static str] {
2319        &["/?"]
2320    }
2321    fn description(&self) -> &'static str {
2322        "Show available commands"
2323    }
2324    fn execute(&self, ctx: &CommandContext, _args: &str) {
2325        add_help_message(&ctx.chat);
2326        ctx.tui.request_render(false);
2327    }
2328}
2329
2330struct ClearChatCommand;
2331impl SlashCommand for ClearChatCommand {
2332    fn name(&self) -> &'static str {
2333        "/clear"
2334    }
2335    fn aliases(&self) -> &'static [&'static str] {
2336        &["/new"]
2337    }
2338    // `/new` carries its own weight as a discoverable entry, so surface it.
2339    fn alias_visible(&self) -> &'static [&'static str] {
2340        &["/new"]
2341    }
2342    fn description(&self) -> &'static str {
2343        "Clear the conversation"
2344    }
2345    fn execute(&self, ctx: &CommandContext, _args: &str) {
2346        let _ = ctx.tx.send(TuiMessage::ClearChat);
2347    }
2348}
2349
2350struct ExitCommand;
2351impl SlashCommand for ExitCommand {
2352    fn name(&self) -> &'static str {
2353        "/exit"
2354    }
2355    fn aliases(&self) -> &'static [&'static str] {
2356        &["/quit", "/q"]
2357    }
2358    // `/quit` is surfaced (matches pi's BUILTIN list); `/q` stays a hidden alias.
2359    fn alias_visible(&self) -> &'static [&'static str] {
2360        &["/quit"]
2361    }
2362    fn description(&self) -> &'static str {
2363        "Exit the application"
2364    }
2365    fn execute(&self, ctx: &CommandContext, _args: &str) {
2366        ctx.state.cancel_js_preparation();
2367        let _ = ctx.tx.send(TuiMessage::Exit);
2368    }
2369}
2370
2371struct VersionCommand;
2372impl SlashCommand for VersionCommand {
2373    fn name(&self) -> &'static str {
2374        "/version"
2375    }
2376    fn aliases(&self) -> &'static [&'static str] {
2377        &["/v"]
2378    }
2379    fn description(&self) -> &'static str {
2380        "Show version information"
2381    }
2382    fn execute(&self, ctx: &CommandContext, _args: &str) {
2383        add_version_message(&ctx.chat);
2384        ctx.tui.request_render(false);
2385    }
2386}
2387
2388struct ChangelogCommand;
2389impl SlashCommand for ChangelogCommand {
2390    fn name(&self) -> &'static str {
2391        "/changelog"
2392    }
2393    fn description(&self) -> &'static str {
2394        "Show recent release changes"
2395    }
2396    fn execute(&self, ctx: &CommandContext, _args: &str) {
2397        add_changelog_message(&ctx.chat);
2398        ctx.tui.request_render(false);
2399    }
2400}
2401
2402struct HotkeysCommand;
2403impl SlashCommand for HotkeysCommand {
2404    fn name(&self) -> &'static str {
2405        "/hotkeys"
2406    }
2407    fn description(&self) -> &'static str {
2408        "Show keyboard shortcuts"
2409    }
2410    fn execute(&self, ctx: &CommandContext, _args: &str) {
2411        add_hotkeys_message(&ctx.chat);
2412        ctx.tui.request_render(false);
2413    }
2414}
2415
2416struct ModelCommand;
2417impl SlashCommand for ModelCommand {
2418    fn name(&self) -> &'static str {
2419        "/model"
2420    }
2421    fn aliases(&self) -> &'static [&'static str] {
2422        &["/m"]
2423    }
2424    fn description(&self) -> &'static str {
2425        "Choose a model (selector)"
2426    }
2427    fn execute(&self, ctx: &CommandContext, args: &str) {
2428        let term = args.trim();
2429        if !term.is_empty() {
2430            // /model <name> — direct switch by id (pi handleModelCommand).
2431            //
2432            // Native pi only switches on an EXACT match; a term that matches
2433            // nothing falls through to the selector with the query prefilled
2434            // instead of erroring out (`showModelSelector(searchTerm)`).
2435            if let Some(model) = find_model_selector_match(&ctx.model_catalog, term) {
2436                let model_id = model.id.clone();
2437                ctx.state.set_current_model(&model);
2438                let lane = ctx.lane.clone();
2439                tokio::spawn(async move {
2440                    let _ = lane.set_model(model).await;
2441                });
2442                add_note_message(
2443                    &ctx.chat,
2444                    &format!(
2445                        "Model set to {} — applies to the next message.",
2446                        short_model_name(&model_id)
2447                    ),
2448                );
2449                ctx.tui.request_render(false);
2450                return;
2451            }
2452        }
2453        open_model_selector(
2454            &ctx.state,
2455            &ctx.editor_container,
2456            &ctx.editor,
2457            &ctx.tui,
2458            &ctx.model_catalog,
2459            &ctx.lane,
2460            &ctx.lane_model_id,
2461            &ctx.chat,
2462            (!term.is_empty()).then_some(term),
2463        );
2464    }
2465}
2466
2467struct ThinkingCommand;
2468impl SlashCommand for ThinkingCommand {
2469    fn name(&self) -> &'static str {
2470        "/thinking"
2471    }
2472    fn aliases(&self) -> &'static [&'static str] {
2473        &["/think"]
2474    }
2475    fn description(&self) -> &'static str {
2476        "Set thinking level (selector)"
2477    }
2478    fn execute(&self, ctx: &CommandContext, args: &str) {
2479        let level_name = args.trim();
2480        if !level_name.is_empty() {
2481            // /thinking <level> — direct set (pi supports the param form).
2482            let Some(level) = thinking_level_from_name(level_name) else {
2483                add_error_message(
2484                    &ctx.chat,
2485                    &format!(
2486                        "Unknown thinking level \"{level_name}\". Valid: {}",
2487                        crate::args::VALID_THINKING_LEVELS.join(", ")
2488                    ),
2489                );
2490                ctx.tui.request_render(false);
2491                return;
2492            };
2493            let lane = ctx.lane.clone();
2494            let footer = ctx.state.footer.clone();
2495            tokio::spawn(async move {
2496                let _ = lane.set_thinking_level(level).await;
2497            });
2498            footer.set_thinking_level(Some(thinking_level_name(level)));
2499            add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2500            ctx.tui.request_render(false);
2501            return;
2502        }
2503        open_thinking_selector(
2504            &ctx.state,
2505            &ctx.editor_container,
2506            &ctx.editor,
2507            &ctx.tui,
2508            &ctx.lane,
2509            &ctx.model_catalog,
2510            &ctx.lane_model_id,
2511            &ctx.chat,
2512        );
2513    }
2514}
2515
2516struct ToolsCommand;
2517impl SlashCommand for ToolsCommand {
2518    fn name(&self) -> &'static str {
2519        "/tools"
2520    }
2521    fn description(&self) -> &'static str {
2522        "Toggle tools on/off"
2523    }
2524    fn execute(&self, ctx: &CommandContext, _args: &str) {
2525        open_tools_selector(
2526            &ctx.state,
2527            &ctx.editor_container,
2528            &ctx.editor,
2529            &ctx.tui,
2530            &ctx.lane,
2531            &ctx.chat,
2532        );
2533    }
2534}
2535
2536struct ImagesCommand;
2537impl SlashCommand for ImagesCommand {
2538    fn name(&self) -> &'static str {
2539        "/images"
2540    }
2541    fn description(&self) -> &'static str {
2542        "Toggle inline images"
2543    }
2544    fn execute(&self, ctx: &CommandContext, _args: &str) {
2545        open_images_selector(
2546            &ctx.state,
2547            &ctx.editor_container,
2548            &ctx.editor,
2549            &ctx.tui,
2550            &ctx.chat,
2551        );
2552    }
2553}
2554
2555struct SessionCommand;
2556impl SlashCommand for SessionCommand {
2557    fn name(&self) -> &'static str {
2558        "/session"
2559    }
2560    fn aliases(&self) -> &'static [&'static str] {
2561        &["/resume"]
2562    }
2563    fn description(&self) -> &'static str {
2564        "List saved sessions"
2565    }
2566    fn execute(&self, ctx: &CommandContext, _args: &str) {
2567        open_session_selector(
2568            &ctx.state,
2569            &ctx.editor_container,
2570            &ctx.editor,
2571            &ctx.tui,
2572            &ctx.cwd,
2573            &ctx.tx,
2574        );
2575    }
2576}
2577
2578struct ThemeCommand;
2579impl SlashCommand for ThemeCommand {
2580    fn name(&self) -> &'static str {
2581        "/theme"
2582    }
2583    fn description(&self) -> &'static str {
2584        "Choose a theme (selector)"
2585    }
2586    fn execute(&self, ctx: &CommandContext, args: &str) {
2587        let name = args.trim().to_ascii_lowercase();
2588        if !name.is_empty() {
2589            // /theme <name> — direct apply + persist (matches /settings Theme).
2590            let preset = match name.as_str() {
2591                "light" => ThemePreset::Light,
2592                "monochrome" => ThemePreset::Monochrome,
2593                "dark" => ThemePreset::Dark,
2594                _ => {
2595                    add_error_message(
2596                        &ctx.chat,
2597                        &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2598                    );
2599                    ctx.tui.request_render(false);
2600                    return;
2601                }
2602            };
2603            apply_theme_preset(preset);
2604            let mut settings = crate::settings::load_settings().unwrap_or_default();
2605            settings.theme = Some(name.clone());
2606            let _ = crate::settings::save_settings(&settings);
2607            add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2608            ctx.tui.request_render(false);
2609            ctx.tui.render_now(true);
2610            return;
2611        }
2612        open_theme_selector(
2613            &ctx.state,
2614            &ctx.editor_container,
2615            &ctx.editor,
2616            &ctx.tui,
2617            &ctx.cwd,
2618            &ctx.package_resources,
2619        );
2620    }
2621}
2622
2623struct CompactCommand;
2624impl SlashCommand for CompactCommand {
2625    fn name(&self) -> &'static str {
2626        "/compact"
2627    }
2628    fn description(&self) -> &'static str {
2629        "Compact the conversation"
2630    }
2631    fn execute(&self, ctx: &CommandContext, _args: &str) {
2632        let _ = ctx.tx.send(TuiMessage::Compact);
2633    }
2634}
2635
2636struct CopyCommand;
2637impl SlashCommand for CopyCommand {
2638    fn name(&self) -> &'static str {
2639        "/copy"
2640    }
2641    fn description(&self) -> &'static str {
2642        "Copy last reply to clipboard"
2643    }
2644    fn execute(&self, ctx: &CommandContext, _args: &str) {
2645        let _ = ctx.tx.send(TuiMessage::Copy);
2646    }
2647}
2648
2649struct ExportCommand;
2650impl SlashCommand for ExportCommand {
2651    fn name(&self) -> &'static str {
2652        "/export"
2653    }
2654    fn description(&self) -> &'static str {
2655        "Export session (supports: md, html, jsonl)"
2656    }
2657    fn execute(&self, ctx: &CommandContext, args: &str) {
2658        let format = args.trim().to_lowercase();
2659        if format.is_empty() || format == "md" || format == "markdown" {
2660            let _ = ctx.tx.send(TuiMessage::ExportSession);
2661        } else if format == "html" {
2662            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2663                crate::export::ExportFormat::Html,
2664            ));
2665        } else if format == "jsonl" {
2666            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2667                crate::export::ExportFormat::Jsonl,
2668            ));
2669        } else {
2670            // Invalid format, show error
2671            add_error_message(
2672                &ctx.chat,
2673                &format!(
2674                    "Unknown export format: {}. Supported: md, html, jsonl",
2675                    args.trim()
2676                ),
2677            );
2678            ctx.tui.request_render(false);
2679        }
2680    }
2681}
2682
2683struct ForkCommand;
2684impl SlashCommand for ForkCommand {
2685    fn name(&self) -> &'static str {
2686        "/fork"
2687    }
2688    fn description(&self) -> &'static str {
2689        "Fork the session into a new one"
2690    }
2691    fn execute(&self, ctx: &CommandContext, _args: &str) {
2692        let _ = ctx.tx.send(TuiMessage::ForkSession);
2693    }
2694}
2695
2696/// `/clone` is the native Pi spelling for duplicating the current session.
2697/// Reuse the same durable fork path as `/fork`; both create a child session
2698/// and rebind the live harness to it.
2699struct CloneCommand;
2700impl SlashCommand for CloneCommand {
2701    fn name(&self) -> &'static str {
2702        "/clone"
2703    }
2704    fn description(&self) -> &'static str {
2705        "Duplicate the current session"
2706    }
2707    fn execute(&self, ctx: &CommandContext, _args: &str) {
2708        let _ = ctx.tx.send(TuiMessage::ForkSession);
2709    }
2710}
2711
2712struct TreeCommand;
2713impl SlashCommand for TreeCommand {
2714    fn name(&self) -> &'static str {
2715        "/tree"
2716    }
2717    fn description(&self) -> &'static str {
2718        "Navigate the current session tree"
2719    }
2720    fn execute(&self, ctx: &CommandContext, _args: &str) {
2721        let _ = ctx.tx.send(TuiMessage::OpenTree);
2722    }
2723}
2724
2725struct LoginCommand;
2726impl SlashCommand for LoginCommand {
2727    fn name(&self) -> &'static str {
2728        "/login"
2729    }
2730    fn description(&self) -> &'static str {
2731        "Save an Anthropic API key"
2732    }
2733    fn execute(&self, ctx: &CommandContext, args: &str) {
2734        let key = args.trim();
2735        if key.is_empty() {
2736            add_note_message(&ctx.chat, "Usage: /login <api-key>");
2737        } else {
2738            let result = crate::config::upsert_credential(
2739                "anthropic",
2740                crate::config::Credential::ApiKey {
2741                    key: Some(key.to_string()),
2742                    env: None,
2743                },
2744            );
2745            match result {
2746                Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2747                Err(error) => {
2748                    add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2749                }
2750            }
2751        }
2752        ctx.tui.request_render(false);
2753    }
2754}
2755
2756struct LogoutCommand;
2757impl SlashCommand for LogoutCommand {
2758    fn name(&self) -> &'static str {
2759        "/logout"
2760    }
2761    fn description(&self) -> &'static str {
2762        "Remove saved Anthropic credentials"
2763    }
2764    fn execute(&self, ctx: &CommandContext, _args: &str) {
2765        match crate::config::delete_credential("anthropic") {
2766            Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2767            Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2768            Err(error) => {
2769                add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2770            }
2771        }
2772        ctx.tui.request_render(false);
2773    }
2774}
2775
2776fn set_project_trust_for_command(
2777    cwd: &std::path::Path,
2778    value: Option<bool>,
2779) -> Result<(), crate::config::ConfigError> {
2780    crate::config::set_project_trust(cwd, value)
2781}
2782
2783struct TrustCommand;
2784impl SlashCommand for TrustCommand {
2785    fn name(&self) -> &'static str {
2786        "/trust"
2787    }
2788    fn description(&self) -> &'static str {
2789        "Trust the current project"
2790    }
2791    fn execute(&self, ctx: &CommandContext, args: &str) {
2792        let value = match args.trim().to_ascii_lowercase().as_str() {
2793            "" | "yes" | "y" | "true" => Some(true),
2794            "no" | "n" | "false" => Some(false),
2795            "clear" | "reset" | "none" => None,
2796            _ => {
2797                add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2798                ctx.tui.request_render(false);
2799                return;
2800            }
2801        };
2802        match set_project_trust_for_command(&ctx.cwd, value) {
2803            Ok(()) => {
2804                let label = match value {
2805                    Some(true) => "trusted",
2806                    Some(false) => "untrusted",
2807                    None => "trust decision cleared",
2808                };
2809                add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2810            }
2811            Err(error) => add_error_message(
2812                &ctx.chat,
2813                &format!("Could not save trust decision: {error}"),
2814            ),
2815        }
2816        ctx.tui.request_render(false);
2817    }
2818}
2819
2820struct NameCommand;
2821impl SlashCommand for NameCommand {
2822    fn name(&self) -> &'static str {
2823        "/name"
2824    }
2825    fn description(&self) -> &'static str {
2826        "Set session display name"
2827    }
2828    fn execute(&self, ctx: &CommandContext, args: &str) {
2829        let name = args.trim();
2830        if name.is_empty() {
2831            add_note_message(
2832                &ctx.chat,
2833                "Usage: /name <display name> — sets the current session's name.",
2834            );
2835            ctx.tui.request_render(false);
2836            return;
2837        }
2838        let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2839    }
2840}
2841
2842struct ImportCommand;
2843impl SlashCommand for ImportCommand {
2844    fn name(&self) -> &'static str {
2845        "/import"
2846    }
2847    fn description(&self) -> &'static str {
2848        "Import a session file (path)"
2849    }
2850    fn execute(&self, ctx: &CommandContext, args: &str) {
2851        let path = args.trim();
2852        if path.is_empty() {
2853            add_note_message(
2854                &ctx.chat,
2855                "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2856            );
2857            ctx.tui.request_render(false);
2858            return;
2859        }
2860        let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2861    }
2862}
2863
2864struct SettingsCommand;
2865impl SlashCommand for SettingsCommand {
2866    fn name(&self) -> &'static str {
2867        "/settings"
2868    }
2869    fn description(&self) -> &'static str {
2870        "Open settings menu"
2871    }
2872    fn execute(&self, ctx: &CommandContext, _args: &str) {
2873        open_settings_selector(
2874            &ctx.state,
2875            &ctx.editor_container,
2876            &ctx.editor,
2877            &ctx.tui,
2878            &ctx.lane,
2879            &ctx.model_catalog,
2880            &ctx.lane_model_id,
2881            &ctx.chat,
2882            &ctx.cwd,
2883            &ctx.package_resources,
2884        );
2885    }
2886}
2887
2888struct ScopedModelsCommand;
2889impl SlashCommand for ScopedModelsCommand {
2890    fn name(&self) -> &'static str {
2891        "/scoped-models"
2892    }
2893    fn description(&self) -> &'static str {
2894        "Choose models for Ctrl+P cycling"
2895    }
2896    fn execute(&self, ctx: &CommandContext, _args: &str) {
2897        open_scoped_models_selector(
2898            &ctx.state,
2899            &ctx.editor_container,
2900            &ctx.editor,
2901            &ctx.tui,
2902            &ctx.model_catalog,
2903            &ctx.chat,
2904        );
2905    }
2906}
2907
2908struct ShareCommand;
2909impl SlashCommand for ShareCommand {
2910    fn name(&self) -> &'static str {
2911        "/share"
2912    }
2913    fn description(&self) -> &'static str {
2914        "Share session (gist via gh, or clipboard)"
2915    }
2916    fn execute(&self, ctx: &CommandContext, _args: &str) {
2917        let _ = ctx.tx.send(TuiMessage::ShareSession);
2918    }
2919}
2920
2921struct ArminCommand;
2922impl SlashCommand for ArminCommand {
2923    fn name(&self) -> &'static str {
2924        "/armin"
2925    }
2926    fn description(&self) -> &'static str {
2927        "??? (easter egg)"
2928    }
2929    fn execute(&self, ctx: &CommandContext, _args: &str) {
2930        crate::extras::add_armin(&ctx.chat);
2931        ctx.tui.request_render(false);
2932    }
2933}
2934
2935struct EarendilCommand;
2936impl SlashCommand for EarendilCommand {
2937    fn name(&self) -> &'static str {
2938        "/earendil"
2939    }
2940    fn description(&self) -> &'static str {
2941        "Announcement"
2942    }
2943    fn execute(&self, ctx: &CommandContext, _args: &str) {
2944        crate::extras::add_earendil(&ctx.chat);
2945        ctx.tui.request_render(false);
2946    }
2947}
2948
2949/// `/context` — lists discovered context files, skills, and prompt templates.
2950/// Hidden from autocomplete (needs the resources snapshot to be meaningful as a
2951/// discovery surface; like `/name`, it's recognized-v1 but kept off the list).
2952struct ContextCommand;
2953impl SlashCommand for ContextCommand {
2954    fn name(&self) -> &'static str {
2955        "/context"
2956    }
2957    fn visible(&self) -> bool {
2958        false
2959    }
2960    fn execute(&self, ctx: &CommandContext, _args: &str) {
2961        show_context_panel(&ctx.chat, &ctx.resources);
2962        // Live session stats are async, so ask the main loop to append them.
2963        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2964        ctx.tui.request_render(false);
2965    }
2966}
2967
2968/// `/usage` — token/cost/cache totals read through the product-layer
2969/// `AgentSession`. This is the same numbers the footer shows, but expanded and
2970/// with the cache-hit breakdown that the one-line footer cannot fit.
2971struct UsageCommand;
2972impl SlashCommand for UsageCommand {
2973    fn name(&self) -> &'static str {
2974        "/usage"
2975    }
2976    fn description(&self) -> &'static str {
2977        "Show token, cost, and cache totals for this session"
2978    }
2979    fn execute(&self, ctx: &CommandContext, _args: &str) {
2980        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2981        ctx.tui.request_render(false);
2982    }
2983}
2984
2985/// `/reload` — re-run extension + resource discovery into the LIVE harness
2986/// (B5d): reload the cdylib plugins, invalidate the old `ActionBridge` +
2987/// registry snapshot, rebuild skills/prompts/context/SYSTEM.md/APPEND_SYSTEM.md
2988/// + the `TeeEmitter`, and push the rebuilt state via the B5d harness setters.
2989/// The command itself runs on the blocking submit thread, so it can't drive
2990/// the async `reload_extension_resources` routine directly — it signals the main
2991/// loop via `TuiMessage::ReloadExtensions`, which awaits it on the async runtime.
2992/// (A plugin's `runtime_action(Reload)` signals the same loop via the
2993/// `ReloadMailbox` the TUI installs — the B5d async-reload design avoids the
2994/// self-unmapping race a synchronous plugin-initiated reload would have.)
2995struct ReloadCommand;
2996impl SlashCommand for ReloadCommand {
2997    fn name(&self) -> &'static str {
2998        "/reload"
2999    }
3000    fn description(&self) -> &'static str {
3001        "Reload extensions, skills, prompts"
3002    }
3003    fn execute(&self, ctx: &CommandContext, _args: &str) {
3004        // Signal the main loop. It owns the `&AgentHarness` borrow the
3005        // `reload_extension_resources` routine needs (the blocking submit thread
3006        // only has the context's `Arc<ReloadContext>` + the `Arc<dyn AgentLane>`).
3007        add_note_message(&ctx.chat, "Reloading extensions + resources…");
3008        ctx.tui.request_render(false);
3009        let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
3010    }
3011}
3012
3013/// Build the full command registry: active built-ins first (so they win on a
3014/// fuzzy autocomplete tie), then the v1-out-of-scope stubs. Prompt-template
3015/// commands are merged in separately by the autocomplete builder (they dispatch
3016/// via template expansion, not this registry).
3017fn build_builtin_registry() -> CommandRegistry {
3018    let mut r = CommandRegistry::new();
3019    r.register(Arc::new(HelpCommand));
3020    r.register(Arc::new(ClearChatCommand));
3021    r.register(Arc::new(ExitCommand));
3022    r.register(Arc::new(VersionCommand));
3023    r.register(Arc::new(ChangelogCommand));
3024    r.register(Arc::new(ModelCommand));
3025    r.register(Arc::new(ThinkingCommand));
3026    r.register(Arc::new(ToolsCommand));
3027    r.register(Arc::new(ImagesCommand));
3028    r.register(Arc::new(SessionCommand));
3029    r.register(Arc::new(ThemeCommand));
3030    r.register(Arc::new(CompactCommand));
3031    r.register(Arc::new(CopyCommand));
3032    r.register(Arc::new(HotkeysCommand));
3033    r.register(Arc::new(ArminCommand));
3034    r.register(Arc::new(EarendilCommand));
3035    r.register(Arc::new(ContextCommand));
3036    r.register(Arc::new(UsageCommand));
3037    // Recognized but inert in v1 (one struct backs them all). The TS builtins
3038    // out of v1 scope; each carries a description so autocomplete surfaces its
3039    // existence even though running it reports "not supported".
3040    r.register(Arc::new(NameCommand));
3041    r.register(Arc::new(SettingsCommand));
3042    r.register(Arc::new(ScopedModelsCommand));
3043    r.register(Arc::new(ExportCommand));
3044    r.register(Arc::new(ImportCommand));
3045    r.register(Arc::new(ShareCommand));
3046    r.register(Arc::new(ForkCommand));
3047    r.register(Arc::new(CloneCommand));
3048    r.register(Arc::new(TreeCommand));
3049    r.register(Arc::new(TrustCommand));
3050    r.register(Arc::new(LoginCommand));
3051    r.register(Arc::new(LogoutCommand));
3052    r.register(Arc::new(ReloadCommand));
3053    r
3054}
3055
3056fn register_extension_commands(
3057    registry: &mut CommandRegistry,
3058    session: crate::session::ExtensionSessionCell,
3059) {
3060    let commands = session
3061        .lock()
3062        .ok()
3063        .and_then(|s| s.snapshot_arc())
3064        .map(|snap| snap.commands().to_vec())
3065        .unwrap_or_default();
3066    for command in commands {
3067        let name = if command.name.starts_with('/') {
3068            command.name.clone()
3069        } else {
3070            format!("/{}", command.name)
3071        };
3072        if registry.find(&name).is_some() {
3073            continue;
3074        }
3075        registry.register(Arc::new(ExtensionCommand {
3076            name,
3077            description: command.description,
3078            session: session.clone(),
3079        }));
3080    }
3081}
3082
3083fn register_js_extension_commands(
3084    registry: &mut CommandRegistry,
3085    session: Option<crate::js_extensions::JsExtensionSession>,
3086) {
3087    let Some(session) = session else {
3088        return;
3089    };
3090    for command in &session.commands {
3091        let name = if command.starts_with('/') {
3092            command.clone()
3093        } else {
3094            format!("/{command}")
3095        };
3096        if registry.find(&name).is_none() {
3097            registry.register(Arc::new(JsExtensionCommand {
3098                name,
3099                session: session.clone(),
3100            }));
3101        }
3102    }
3103}
3104
3105// ===========================================================================
3106// Channel + helpers
3107// ===========================================================================
3108
3109/// Message type for communication between the key/callback threads and the
3110/// main async loop.
3111enum TuiMessage {
3112    UserInput(String),
3113    OpenTree,
3114    NavigateTree(String),
3115    Exit,
3116    /// Clear the transcript (from `/clear`).
3117    ClearChat,
3118    /// Compact the conversation (from `/compact`).
3119    Compact,
3120    /// Copy the last assistant reply to the clipboard (from `/copy`).
3121    Copy,
3122    /// Hot-switch to another saved session (from the `/session` selector):
3123    /// the payload is the session id the selector's item value carried.
3124    SwitchSession(String),
3125    /// Export the current session to a markdown file (from `/export`).
3126    ExportSession,
3127    /// Export session with specific format
3128    ExportSessionWithFormat(crate::export::ExportFormat),
3129    /// Show live usage/context stats (from `/usage` and `/context`).
3130    ShowUsage,
3131    /// Fork the current session into a new one and switch to it (from `/fork`).
3132    ForkSession,
3133    /// Rename the current session (from `/name <name>`).
3134    SetSessionName(String),
3135    /// Import a JSONL session file into the session dir and switch to it
3136    /// (from `/import <path>`).
3137    ImportSession(String),
3138    /// Share the current session (`/share`): `gh gist create` when the gh CLI
3139    /// is available, otherwise copy the transcript to the clipboard.
3140    ShareSession,
3141    /// `/reload` — re-run extension + resource discovery into the live harness
3142    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
3143    /// mailbox) signal the main loop, which awaits
3144    /// `reload_extension_resources` on the async runtime.
3145    ReloadExtensions,
3146    /// Result returned after Ctrl+G edits a temporary file in an external
3147    /// editor. Handling it on the async loop keeps editor mutation single-
3148    /// threaded with the rest of the TUI state.
3149    ExternalEditorResult(Result<String, String>),
3150    /// A user-initiated `!command` / `!!command` shell run finished. Routed to
3151    /// the main loop so the `bashExecution` transcript record is persisted in
3152    /// order relative to agent runs (native pi's `recordBashResult` /
3153    /// `_pendingBashMessages`).
3154    UserBashFinished(UserBashReport),
3155}
3156
3157/// Terminal result of a user-initiated `!command` shell run.
3158///
3159/// Carries everything needed to build the persisted `bashExecution` message
3160/// plus the `!!` context-exclusion flag. See `start_user_bash`.
3161struct UserBashReport {
3162    command: String,
3163    output: String,
3164    exit_code: Option<i32>,
3165    cancelled: bool,
3166    truncated: bool,
3167    full_output_path: Option<String>,
3168    exclude_from_context: bool,
3169}
3170
3171/// Extract the concatenated text content from an assistant message (mirrors
3172/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
3173fn assistant_text(msg: &AssistantMessage) -> String {
3174    msg.content
3175        .iter()
3176        .filter_map(|c| match c {
3177            Content::Text(t) => Some(t.text.clone()),
3178            _ => None,
3179        })
3180        .collect()
3181}
3182
3183/// The user message's text (Text content or the text blocks of a Blocks
3184/// payload — images are skipped, consistent with the v1 text-only prompt path).
3185fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
3186    msg.content
3187        .iter()
3188        .filter_map(|c| match c {
3189            Content::Text(t) => Some(t.text.clone()),
3190            _ => None,
3191        })
3192        .collect::<Vec<_>>()
3193        .join("\n")
3194}
3195
3196fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
3197    match &msg.content {
3198        rpi_ai::types::UserContent::Text(s) => s.clone(),
3199        rpi_ai::types::UserContent::Blocks(blocks) => blocks
3200            .iter()
3201            .filter_map(|c| match c {
3202                Content::Text(t) => Some(t.text.clone()),
3203                _ => None,
3204            })
3205            .collect(),
3206    }
3207}
3208
3209/// The catalog allowed in the Ctrl+P cycle: the `/scoped-models` set from
3210/// settings.json when present, otherwise every model. The current model is
3211/// always included (fallback) so cycling can never strand the user off-scope.
3212fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
3213    let scoped = crate::settings::load_settings()
3214        .ok()
3215        .and_then(|s| s.scoped_models)
3216        .unwrap_or_default();
3217    if scoped.is_empty() {
3218        return catalog.to_vec();
3219    }
3220    let mut out: Vec<rpi_ai::Model> = catalog
3221        .iter()
3222        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
3223        .cloned()
3224        .collect();
3225    // Never strand the user: if the current model isn't in scope, keep it.
3226    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
3227        if let Some(cur) = catalog
3228            .iter()
3229            .find(|m| m.id.eq_ignore_ascii_case(current_id))
3230        {
3231            out.push(cur.clone());
3232        }
3233    }
3234    out
3235}
3236
3237/// Interactive `/settings` menu.
3238///
3239/// A [`SettingsList`] of editable settings (native pi's `SettingsSelectorComponent`):
3240/// Enter/Space cycles a row's value in place, and the theme/model/thinking/scope
3241/// rows open a sub-selector that replaces the active selector (the
3242/// `active_selector` slot is single). Value changes apply immediately where the
3243/// running TUI can honor them and are always persisted to `settings.json`, so
3244/// rows marked "applies on restart" take effect next launch.
3245fn open_settings_selector(
3246    state: &Arc<TuiState>,
3247    editor_container: &Arc<Container>,
3248    editor: &Arc<Editor>,
3249    tui: &Arc<TuiAltScreen>,
3250    lane: &Arc<dyn AgentLane>,
3251    catalog: &[rpi_ai::Model],
3252    lane_model_id: &str,
3253    chat: &Arc<Container>,
3254    cwd: &std::path::Path,
3255    package_resources: &Arc<crate::packages::PackageResources>,
3256) {
3257    let settings = crate::settings::load_settings().unwrap_or_default();
3258    let items = settings_menu_items(&settings, state, lane_model_id);
3259    let list = Arc::new(SettingsList::new(items));
3260
3261    // ---- submenu rows ----
3262    // These reuse the existing sub-selectors, which already persist their own
3263    // choices. The settings menu is replaced by the sub-selector (single
3264    // `active_selector` slot); cancelling the sub-selector returns to the editor.
3265    let state_sel = state.clone();
3266    let ec_sel = editor_container.clone();
3267    let editor_sel = editor.clone();
3268    let tui_sel = tui.clone();
3269    let lane_sel = lane.clone();
3270    let catalog_sel = catalog.to_vec();
3271    let lane_model_sel = lane_model_id.to_string();
3272    let chat_sel = chat.clone();
3273    let cwd_sel = cwd.to_path_buf();
3274    let package_resources_sel = package_resources.clone();
3275    list.on_select(Arc::new(move |item| match item.key.as_str() {
3276        "theme" => open_settings_theme_selector(
3277            &state_sel,
3278            &ec_sel,
3279            &editor_sel,
3280            &tui_sel,
3281            &chat_sel,
3282            &cwd_sel,
3283            &package_resources_sel,
3284        ),
3285        "model" => open_settings_model_selector(
3286            &state_sel,
3287            &ec_sel,
3288            &editor_sel,
3289            &tui_sel,
3290            &lane_sel,
3291            &catalog_sel,
3292            &lane_model_sel,
3293            &chat_sel,
3294        ),
3295        "thinking" => open_settings_thinking_selector(
3296            &state_sel,
3297            &ec_sel,
3298            &editor_sel,
3299            &tui_sel,
3300            &lane_sel,
3301            &catalog_sel,
3302            &lane_model_sel,
3303            &chat_sel,
3304        ),
3305        "scoped-models" => open_scoped_models_selector(
3306            &state_sel,
3307            &ec_sel,
3308            &editor_sel,
3309            &tui_sel,
3310            &catalog_sel,
3311            &chat_sel,
3312        ),
3313        _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3314    }));
3315
3316    // ---- value rows ----
3317    // Apply live where possible, then persist so the choice survives a restart.
3318    let state_change = state.clone();
3319    let chat_change = chat.clone();
3320    let tui_change = tui.clone();
3321    list.on_change(Arc::new(move |key, value| {
3322        let mut settings = crate::settings::load_settings().unwrap_or_default();
3323        let applied = apply_setting_change(&state_change, key, value, &mut settings);
3324        let saved = crate::settings::save_settings(&settings);
3325        if let Some(note) = applied {
3326            let suffix = if saved.is_ok() { "" } else { " (not saved)" };
3327            add_note_message(&chat_change, &format!("{note}{suffix}"));
3328        }
3329        tui_change.request_render(false);
3330    }));
3331
3332    let state_cancel = state.clone();
3333    let ec_cancel = editor_container.clone();
3334    let editor_cancel = editor.clone();
3335    let tui_cancel = tui.clone();
3336    list.on_cancel(Arc::new(move || {
3337        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3338    }));
3339
3340    open_selector_with_view(
3341        state,
3342        editor_container,
3343        editor,
3344        tui,
3345        SelectorView::Settings(list.clone()),
3346        list,
3347        SelectorKind::Settings,
3348    );
3349}
3350
3351/// Build the `/settings` rows from the loaded settings plus the live TUI state.
3352///
3353/// Descriptions carry "(applies on restart)" for values resolved once at
3354/// startup, so the menu never implies an effect the running session cannot
3355/// apply.
3356fn settings_menu_items(
3357    settings: &crate::settings::Settings,
3358    state: &Arc<TuiState>,
3359    lane_model_id: &str,
3360) -> Vec<SettingItem> {
3361    let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
3362    let current_model = settings
3363        .default_model
3364        .clone()
3365        .unwrap_or_else(|| lane_model_id.to_string());
3366    let current_thinking = settings
3367        .default_thinking_level
3368        .clone()
3369        .unwrap_or_else(|| "(default)".to_string());
3370    let scoped_desc = match &settings.scoped_models {
3371        Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
3372        _ => "all models".to_string(),
3373    };
3374    let hide_thinking = state.hide_thinking();
3375    let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
3376    let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
3377    let http_idle = settings
3378        .http_idle_timeout_ms()
3379        .map(|ms| {
3380            if ms == 0 {
3381                "disabled".to_string()
3382            } else {
3383                format!("{}s", ms / 1000)
3384            }
3385        })
3386        .unwrap_or_else(|| "5m".to_string());
3387
3388    vec![
3389        SettingItem::new("theme", "Theme", &current_theme)
3390            .with_description("Color theme for the interface")
3391            .with_submenu(),
3392        SettingItem::new("model", "Default model", &current_model)
3393            .with_description("Saved default model for new sessions")
3394            .with_submenu(),
3395        SettingItem::new("thinking", "Default thinking", &current_thinking)
3396            .with_description("Saved default thinking level")
3397            .with_submenu(),
3398        SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
3399            .with_description("Models enabled for Ctrl+P cycling")
3400            .with_submenu(),
3401        SettingItem::new(
3402            "hide-thinking",
3403            "Hide thinking",
3404            bool_setting_value(hide_thinking),
3405        )
3406        .with_description("Hide thinking blocks in assistant responses")
3407        .with_values(&["true", "false"]),
3408        SettingItem::new(
3409            "show-images",
3410            "Show images",
3411            bool_setting_value(show_images),
3412        )
3413        .with_description("Render images inline in the terminal")
3414        .with_values(&["true", "false"]),
3415        SettingItem::new(
3416            "cache-miss-notices",
3417            "Cache miss notices",
3418            bool_setting_value(show_cache_miss),
3419        )
3420        .with_description("Transcript notices for prompt-cache costs")
3421        .with_values(&["true", "false"]),
3422        SettingItem::new(
3423            "quiet-startup",
3424            "Quiet startup",
3425            bool_setting_value(settings.quiet_startup.unwrap_or(false)),
3426        )
3427        .with_description("Disable the verbose startup listing (applies on restart)")
3428        .with_values(&["true", "false"]),
3429        SettingItem::new(
3430            "terminal-progress",
3431            "Terminal progress",
3432            bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
3433        )
3434        .with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
3435        .with_values(&["true", "false"]),
3436        SettingItem::new(
3437            "fullscreen-copy-on-select",
3438            "Fullscreen copy on select",
3439            bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
3440        )
3441        .with_description("Copy selected text automatically in fullscreen mode")
3442        .with_values(&["true", "false"]),
3443        SettingItem::new(
3444            "double-escape-action",
3445            "Double-escape action",
3446            settings
3447                .double_escape_action
3448                .clone()
3449                .unwrap_or_else(|| "tree".to_string())
3450                .as_str(),
3451        )
3452        .with_description("Esc Esc with an empty editor (applies on restart)")
3453        .with_values(&["tree", "fork", "none"]),
3454        SettingItem::new(
3455            "editor-padding",
3456            "Editor padding",
3457            settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
3458        )
3459        .with_description("Horizontal editor padding, 0-3 (applies on restart)")
3460        .with_values(&["0", "1", "2", "3"]),
3461        SettingItem::new(
3462            "autocomplete-max-items",
3463            "Autocomplete max items",
3464            settings
3465                .autocomplete_max_visible
3466                .unwrap_or(5)
3467                .to_string()
3468                .as_str(),
3469        )
3470        .with_description("Max autocomplete rows, 3-20 (applies on restart)")
3471        .with_values(&["3", "5", "7", "10", "15", "20"]),
3472        SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
3473            .with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
3474            .with_values(&["30s", "1m", "5m", "disabled"]),
3475    ]
3476}
3477
3478/// `"true"` / `"false"` for a settings row value.
3479fn bool_setting_value(value: bool) -> &'static str {
3480    if value {
3481        "true"
3482    } else {
3483        "false"
3484    }
3485}
3486
3487/// Apply one `/settings` value change to the running TUI and to `settings`.
3488///
3489/// Returns a note to show in the transcript when the change is user-visible;
3490/// `None` for changes that only take effect on the next launch.
3491fn apply_setting_change(
3492    state: &Arc<TuiState>,
3493    key: &str,
3494    value: &str,
3495    settings: &mut crate::settings::Settings,
3496) -> Option<String> {
3497    let as_bool = || value == "true";
3498    match key {
3499        // Live: hide/show thinking blocks in the transcript immediately.
3500        "hide-thinking" => {
3501            state.set_hide_thinking(as_bool());
3502            settings.hide_thinking_block = Some(as_bool());
3503            Some(format!(
3504                "Thinking blocks {}.",
3505                if as_bool() { "hidden" } else { "shown" }
3506            ))
3507        }
3508        // Live: the image flag is consulted when images would be rendered.
3509        "show-images" => {
3510            if let Ok(mut guard) = state.show_images.lock() {
3511                *guard = as_bool();
3512            }
3513            settings.show_images = Some(as_bool());
3514            Some(format!(
3515                "Inline images {}.",
3516                if as_bool() { "enabled" } else { "disabled" }
3517            ))
3518        }
3519        // Live: read from `state` once per cache-miss notice.
3520        "cache-miss-notices" => {
3521            if let Ok(mut guard) = state.cache_miss_notices.lock() {
3522                *guard = as_bool();
3523            }
3524            settings.show_cache_miss_notices = Some(as_bool());
3525            Some(format!(
3526                "Cache miss notices {}.",
3527                if as_bool() { "enabled" } else { "disabled" }
3528            ))
3529        }
3530        // Live: read from settings on each selection mouse-up.
3531        "fullscreen-copy-on-select" => {
3532            settings.fullscreen_copy_on_select = Some(as_bool());
3533            Some(format!(
3534                "Copy on select {}.",
3535                if as_bool() { "enabled" } else { "disabled" }
3536            ))
3537        }
3538        // Persisted only: resolved once at startup.
3539        "quiet-startup" => {
3540            settings.quiet_startup = Some(as_bool());
3541            None
3542        }
3543        "terminal-progress" => {
3544            settings.show_terminal_progress = Some(as_bool());
3545            None
3546        }
3547        "double-escape-action" => {
3548            settings.double_escape_action = Some(value.to_string());
3549            None
3550        }
3551        "editor-padding" => {
3552            settings.editor_padding_x = value.parse().ok();
3553            None
3554        }
3555        "autocomplete-max-items" => {
3556            settings.autocomplete_max_visible = value.parse().ok();
3557            None
3558        }
3559        "http-idle-timeout" => {
3560            settings.http_idle_timeout = Some(match value {
3561                "disabled" => serde_json::Value::String("disabled".to_string()),
3562                "30s" => serde_json::json!(30_000),
3563                "1m" => serde_json::json!(60_000),
3564                _ => serde_json::json!(300_000),
3565            });
3566            None
3567        }
3568        _ => None,
3569    }
3570}
3571
3572/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
3573fn open_settings_theme_selector(
3574    state: &Arc<TuiState>,
3575    editor_container: &Arc<Container>,
3576    editor: &Arc<Editor>,
3577    tui: &Arc<TuiAltScreen>,
3578    chat: &Arc<Container>,
3579    cwd: &std::path::Path,
3580    package_resources: &Arc<crate::packages::PackageResources>,
3581) {
3582    let mut items = vec![
3583        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3584        SelectItem::new("light", "Light").with_description("Light background"),
3585        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3586    ];
3587    if state.themes_enabled {
3588        for path in package_resources.theme_files() {
3589            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3590                items.push(SelectItem::new(name, name).with_description("Package theme"));
3591            }
3592        }
3593    }
3594    let list = Arc::new(SelectList::new(items, 10));
3595
3596    let state_sel = state.clone();
3597    let ec_sel = editor_container.clone();
3598    let editor_sel = editor.clone();
3599    let tui_sel = tui.clone();
3600    let chat_sel = chat.clone();
3601    let cwd_sel = cwd.to_path_buf();
3602    let package_resources_sel = package_resources.clone();
3603    list.on_select(Arc::new(move |item| {
3604        let preset = match item.value.as_str() {
3605            "light" => Some(ThemePreset::Light),
3606            "monochrome" => Some(ThemePreset::Monochrome),
3607            "dark" => Some(ThemePreset::Dark),
3608            name => {
3609                if state_sel.themes_enabled {
3610                    if let Ok(custom) = crate::packages::load_theme_with_resources(
3611                        &cwd_sel,
3612                        name,
3613                        &package_resources_sel,
3614                    ) {
3615                        rpi_tui::global_theme_manager().set(custom.clone());
3616                        state_sel.theme_manager.set(custom);
3617                    }
3618                }
3619                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3620                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3621                tui_sel.render_now(true);
3622                return;
3623            }
3624        };
3625        let Some(preset) = preset else { return };
3626        apply_theme_preset(preset);
3627        state_sel.theme_manager.apply_preset(preset);
3628        let mut settings = crate::settings::load_settings().unwrap_or_default();
3629        settings.theme = Some(item.value.clone());
3630        let saved = crate::settings::save_settings(&settings);
3631        add_note_message(
3632            &chat_sel,
3633            &format!(
3634                "Theme set to {} (saved{})",
3635                item.label,
3636                if saved.is_ok() { "" } else { ", not saved" },
3637            ),
3638        );
3639        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3640        tui_sel.render_now(true);
3641    }));
3642    let state_cancel = state.clone();
3643    let ec_cancel = editor_container.clone();
3644    let editor_cancel = editor.clone();
3645    let tui_cancel = tui.clone();
3646    list.on_cancel(Arc::new(move || {
3647        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3648    }));
3649
3650    open_selector(
3651        state,
3652        editor_container,
3653        editor,
3654        tui,
3655        list,
3656        SelectorKind::Settings,
3657    );
3658}
3659
3660/// Choose the default model AND persist it (`/settings` → Default model):
3661/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
3662/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
3663fn open_settings_model_selector(
3664    state: &Arc<TuiState>,
3665    editor_container: &Arc<Container>,
3666    editor: &Arc<Editor>,
3667    tui: &Arc<TuiAltScreen>,
3668    lane: &Arc<dyn AgentLane>,
3669    catalog: &[rpi_ai::Model],
3670    lane_model_id: &str,
3671    chat: &Arc<Container>,
3672) {
3673    let items = model_selector_items(catalog, lane_model_id);
3674    if items.is_empty() {
3675        add_note_message(chat, "No models in the catalog.");
3676        tui.request_render(false);
3677        return;
3678    }
3679    let list = Arc::new(SelectList::new(items, 10));
3680
3681    let catalog_arc = catalog.to_vec();
3682    let state_sel = state.clone();
3683    let ec_sel = editor_container.clone();
3684    let editor_sel = editor.clone();
3685    let tui_sel = tui.clone();
3686    let chat_sel = chat.clone();
3687    let lane_sel = lane.clone();
3688    list.on_select(Arc::new(move |item| {
3689        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3690            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3691            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3692            return;
3693        };
3694        state_sel.set_current_model(&model);
3695        let lane = lane_sel.clone();
3696        tokio::spawn(async move {
3697            let _ = lane.set_model(model).await;
3698        });
3699        let mut settings = crate::settings::load_settings().unwrap_or_default();
3700        settings.default_model = Some(item.value.clone());
3701        let saved = crate::settings::save_settings(&settings);
3702        add_note_message(
3703            &chat_sel,
3704            &format!(
3705                "Default model set to {} (saved{}",
3706                short_model_name(&item.value),
3707                if saved.is_ok() { ")" } else { ", not saved)" },
3708            ),
3709        );
3710        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3711    }));
3712    let state_cancel = state.clone();
3713    let ec_cancel = editor_container.clone();
3714    let editor_cancel = editor.clone();
3715    let tui_cancel = tui.clone();
3716    list.on_cancel(Arc::new(move || {
3717        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3718    }));
3719
3720    open_searchable_selector(
3721        state,
3722        editor_container,
3723        editor,
3724        tui,
3725        Some("Default model"),
3726        Some("Type to filter by name, provider, or id"),
3727        list,
3728        SelectorKind::Settings,
3729    );
3730}
3731
3732/// Convert the authenticated runtime catalog into selector rows. Keep the
3733/// model id as the value so `/model <id>` and the selection callback share one
3734/// lookup path, while making the provider visible for OpenAI-compatible
3735/// gateways where the same model id may exist at multiple endpoints.
3736fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3737    let mut seen = std::collections::HashSet::new();
3738    catalog
3739        .iter()
3740        .filter(|m| {
3741            seen.insert((
3742                m.api.clone(),
3743                m.provider.to_ascii_lowercase(),
3744                m.id.to_ascii_lowercase(),
3745            ))
3746        })
3747        .map(|m| {
3748            let label = if m.name.is_empty() {
3749                short_model_name(&m.id)
3750            } else {
3751                m.name.clone()
3752            };
3753            let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3754                && m.provider.eq_ignore_ascii_case("anthropic")
3755            {
3756                m.id.clone()
3757            } else {
3758                format!("{}/{}", m.provider, m.id)
3759            };
3760            let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3761                " (current)"
3762            } else {
3763                ""
3764            };
3765            // Native pi ranks provider-prefixed queries first, so the bare id
3766            // is not the leading token (`getModelSelectorSearchText`).
3767            let name = if m.name.is_empty() {
3768                String::new()
3769            } else {
3770                format!(" {}", m.name)
3771            };
3772            let search_text = format!(
3773                "{} {}/{} {} {}{}",
3774                m.provider, m.provider, m.id, m.provider, m.id, name
3775            );
3776            SelectItem::new(&m.id, &label)
3777                .with_description(&format!("{identity}{marker}"))
3778                .with_search_text(&search_text)
3779        })
3780        .collect()
3781}
3782
3783/// Resolve a selector input by either bare model id or the qualified
3784/// `provider/model` identity shown for gateway models. This keeps manual
3785/// `/model ...` input consistent with the rows rendered by the selector.
3786fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3787    let (provider, id) = input
3788        .split_once('/')
3789        .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3790        .map_or((None, input), |(provider, id)| (Some(provider), id));
3791    catalog
3792        .iter()
3793        .find(|model| {
3794            model.id.eq_ignore_ascii_case(id)
3795                && provider.map_or(true, |provider| {
3796                    model.provider.eq_ignore_ascii_case(provider)
3797                        || (provider.eq_ignore_ascii_case("anthropic")
3798                            && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3799                })
3800        })
3801        .cloned()
3802}
3803
3804/// Choose the default thinking level AND persist it (`/settings` → Default
3805/// thinking): applies live via `lane.set_thinking_level` and saves
3806/// `defaultThinkingLevel` to settings.json.
3807fn open_settings_thinking_selector(
3808    state: &Arc<TuiState>,
3809    editor_container: &Arc<Container>,
3810    editor: &Arc<Editor>,
3811    tui: &Arc<TuiAltScreen>,
3812    lane: &Arc<dyn AgentLane>,
3813    catalog: &[rpi_ai::Model],
3814    lane_model_id: &str,
3815    chat: &Arc<Container>,
3816) {
3817    let model = catalog
3818        .iter()
3819        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3820    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3821        .map(|m| m.supported_thinking_levels())
3822        .unwrap_or_else(|| {
3823            use rpi_ai::types::ThinkingLevel::*;
3824            vec![Off, Minimal, Low, Medium, High]
3825        });
3826    let mut items: Vec<SelectItem> = Vec::new();
3827    for lvl in &levels {
3828        let name = thinking_level_name(*lvl);
3829        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3830    }
3831    if items.is_empty() {
3832        add_note_message(chat, "This model has no supported thinking levels.");
3833        tui.request_render(false);
3834        return;
3835    }
3836    let list = Arc::new(SelectList::new(items, 10));
3837
3838    let state_sel = state.clone();
3839    let ec_sel = editor_container.clone();
3840    let editor_sel = editor.clone();
3841    let tui_sel = tui.clone();
3842    let chat_sel = chat.clone();
3843    let lane_sel = lane.clone();
3844    list.on_select(Arc::new(move |item| {
3845        let Some(level) = thinking_level_from_name(&item.value) else {
3846            add_note_message(
3847                &chat_sel,
3848                &format!("Unknown thinking level: {}.", item.label),
3849            );
3850            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3851            return;
3852        };
3853        let lane = lane_sel.clone();
3854        let footer_sel = state_sel.footer.clone();
3855        tokio::spawn(async move {
3856            let _ = lane.set_thinking_level(level).await;
3857        });
3858        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3859        let mut settings = crate::settings::load_settings().unwrap_or_default();
3860        settings.default_thinking_level = Some(item.value.clone());
3861        let saved = crate::settings::save_settings(&settings);
3862        add_note_message(
3863            &chat_sel,
3864            &format!(
3865                "Default thinking set to {} (saved{}",
3866                item.label,
3867                if saved.is_ok() { ")" } else { ", not saved)" },
3868            ),
3869        );
3870        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3871    }));
3872    let state_cancel = state.clone();
3873    let ec_cancel = editor_container.clone();
3874    let editor_cancel = editor.clone();
3875    let tui_cancel = tui.clone();
3876    list.on_cancel(Arc::new(move || {
3877        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3878    }));
3879
3880    open_selector(
3881        state,
3882        editor_container,
3883        editor,
3884        tui,
3885        list,
3886        SelectorKind::Settings,
3887    );
3888}
3889
3890/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
3891/// item toggles it in the in-progress set (the selector stays open); Esc saves
3892/// the set to settings.json and closes. The active scoped set is echoed after
3893/// each toggle so the user sees the current selection.
3894fn open_scoped_models_selector(
3895    state: &Arc<TuiState>,
3896    editor_container: &Arc<Container>,
3897    editor: &Arc<Editor>,
3898    tui: &Arc<TuiAltScreen>,
3899    catalog: &[rpi_ai::Model],
3900    chat: &Arc<Container>,
3901) {
3902    if catalog.is_empty() {
3903        add_note_message(chat, "No models in the catalog.");
3904        tui.request_render(false);
3905        return;
3906    }
3907    // Seed the edit set from the saved scoped models.
3908    let seed: Vec<String> = crate::settings::load_settings()
3909        .ok()
3910        .and_then(|s| s.scoped_models)
3911        .unwrap_or_default();
3912    *state.scoped_edit.lock().unwrap() = Some(seed);
3913
3914    let mut items: Vec<SelectItem> = Vec::new();
3915    for m in catalog {
3916        items.push(SelectItem::new(&m.id, &m.id));
3917    }
3918    let list = Arc::new(SelectList::new(items, 10));
3919
3920    let state_sel = state.clone();
3921    let chat_sel = chat.clone();
3922    let tui_sel = tui.clone();
3923    list.on_select(Arc::new(move |item| {
3924        // Toggle the model in the in-progress set; the selector stays open.
3925        let mut set = state_sel.scoped_edit.lock().unwrap();
3926        let set = set.get_or_insert_with(Vec::new);
3927        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3928            set.remove(pos);
3929            add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3930        } else {
3931            set.push(item.value.clone());
3932            add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3933        }
3934        tui_sel.request_render(false);
3935    }));
3936    let state_cancel = state.clone();
3937    let ec_cancel = editor_container.clone();
3938    let editor_cancel = editor.clone();
3939    let tui_cancel = tui.clone();
3940    let chat_cancel = chat.clone();
3941    list.on_cancel(Arc::new(move || {
3942        // Save the edited set to settings.json and close.
3943        let set = state_cancel
3944            .scoped_edit
3945            .lock()
3946            .unwrap()
3947            .take()
3948            .unwrap_or_default();
3949        let mut settings = crate::settings::load_settings().unwrap_or_default();
3950        settings.scoped_models = if set.is_empty() {
3951            None
3952        } else {
3953            Some(set.clone())
3954        };
3955        match crate::settings::save_settings(&settings) {
3956            Ok(()) => {
3957                if set.is_empty() {
3958                    add_note_message(&chat_cancel, "Ctrl+P cycles all models (scope cleared).");
3959                } else {
3960                    add_note_message(
3961                        &chat_cancel,
3962                        &format!("Ctrl+P cycle scope: {}", set.join(", ")),
3963                    );
3964                }
3965            }
3966            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3967        }
3968        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3969    }));
3970
3971    open_selector(
3972        state,
3973        editor_container,
3974        editor,
3975        tui,
3976        list,
3977        SelectorKind::ScopedModels,
3978    );
3979}
3980
3981/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
3982/// PATH, create a gist of the exported markdown; otherwise fall back to the
3983/// clipboard (best-effort) and note the local path.
3984async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3985    use std::process::Stdio;
3986
3987    // Reuse the export builder for the transcript text.
3988    let tree = harness.session().view("main");
3989    let entries = match tree
3990        .find_entries(&EntryQuery {
3991            entry_type: None,
3992            custom_type: None,
3993            // Exports append entries top-to-bottom, so use chronological order
3994            // instead of the session query default (newest-first).
3995            order: Some(EntryOrder::OldestFirst),
3996            limit: None,
3997            cursor: None,
3998        })
3999        .await
4000    {
4001        Ok(e) => e,
4002        Err(e) => {
4003            add_error_message(chat, &format!("Could not read session: {e}"));
4004            return;
4005        }
4006    };
4007    let mut md = String::from("# Session\n\n");
4008    for e in entries {
4009        let Entry::Message(me) = e else { continue };
4010        match &me.message {
4011            AgentMessage::User(u) => {
4012                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
4013            }
4014            AgentMessage::Assistant(a) => {
4015                let text = assistant_text(a);
4016                if !text.is_empty() {
4017                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
4018                }
4019            }
4020            _ => {}
4021        }
4022    }
4023
4024    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
4025    let gh = std::process::Command::new("gh")
4026        .arg("gist")
4027        .arg("create")
4028        .arg("--filename")
4029        .arg("session.md")
4030        .arg("-")
4031        .stdin(Stdio::piped())
4032        .stdout(Stdio::piped())
4033        .stderr(Stdio::null())
4034        .spawn();
4035    if let Ok(mut child) = gh {
4036        use std::io::Write;
4037        if let Some(mut stdin) = child.stdin.take() {
4038            let _ = stdin.write_all(md.as_bytes());
4039            let _ = stdin.flush();
4040        }
4041        let out = child.wait_with_output().ok();
4042        if let Some(out) = out {
4043            if out.status.success() {
4044                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
4045                add_note_message(chat, &format!("Shared session: {url}"));
4046                return;
4047            }
4048        }
4049        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
4050    } else {
4051        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
4052    }
4053    // Clipboard fallback (or transcript echo when the clipboard feature is off).
4054    if copy_to_clipboard(&md) {
4055        add_note_message(chat, "Session transcript copied to the clipboard.");
4056    } else {
4057        add_note_message(
4058            chat,
4059            "Clipboard unavailable — use /export to write the transcript to a file.",
4060        );
4061    }
4062}
4063
4064// Session export is driven through the product-layer `AgentSession`
4065// (`crate::agent_session`), which owns format selection + default filenames —
4066// the TUI loop calls `AgentSession::export*` directly, so there is no separate
4067// helper here.
4068
4069/// Fork the current session into a new JSONL session and switch to it (TS
4070/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
4071/// session the user continues in). Uses the repo's `fork_typed`, then swaps
4072/// the harness backing and renders the (empty-ish) fork transcript.
4073/// Hot-switch the harness to another saved session: abort any in-flight run,
4074/// open the target session file, swap the durable backing, and re-render the
4075/// transcript from the new history (mirrors pi's `/session` resume-in-place).
4076/// Shared by the `/session` selector, `/import`, and `/fork`. The current
4077/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
4078async fn switch_to_session(
4079    harness: &AgentHarness,
4080    lane: &Arc<dyn AgentLane>,
4081    id: &str,
4082    cwd: &std::path::Path,
4083    chat: &Arc<Container>,
4084    state: &Arc<TuiState>,
4085) -> bool {
4086    if *state.status.lock().unwrap() == RunStatus::Working {
4087        state.set_status(RunStatus::Aborting);
4088        let _ = lane.abort().await;
4089    }
4090    let cwd_str = cwd.to_string_lossy().to_string();
4091    match crate::session::open_session_by_id(id, &cwd_str).await {
4092        Ok(new_session) => {
4093            let _ = harness.set_session(new_session).await;
4094            chat.clear();
4095            add_welcome_message(chat);
4096            render_session_history(
4097                harness,
4098                chat,
4099                state.markdown_transformer(),
4100                Some(state.extension_session.clone()),
4101            )
4102            .await;
4103            state.set_status(RunStatus::Idle);
4104            add_note_message(chat, &format!("Switched to session {id}."));
4105            true
4106        }
4107        Err(e) => {
4108            state.set_status(RunStatus::Idle);
4109            add_error_message(chat, &format!("Could not open session {id}: {e}"));
4110            false
4111        }
4112    }
4113}
4114
4115/// `/import <path>`: copy a JSONL session file into the default session dir,
4116/// then hot-switch to it (the file name becomes its id — matching the
4117/// selector/`open_session_by_id` containment rules).
4118async fn import_session(
4119    harness: &AgentHarness,
4120    lane: &Arc<dyn AgentLane>,
4121    path: &str,
4122    cwd: &std::path::Path,
4123    chat: &Arc<Container>,
4124    state: &Arc<TuiState>,
4125) {
4126    use std::path::Path as FsPath;
4127
4128    let src = FsPath::new(path);
4129    if !src.is_file() {
4130        add_error_message(chat, &format!("Import source not found: {path}"));
4131        return;
4132    }
4133    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
4134        add_error_message(chat, "Import source has no file name.");
4135        return;
4136    };
4137    if !fname.ends_with(".jsonl") {
4138        add_error_message(chat, "Import source must be a .jsonl session file.");
4139        return;
4140    }
4141    let dir = crate::session::default_session_dir(cwd);
4142    if let Err(e) = std::fs::create_dir_all(&dir) {
4143        add_error_message(chat, &format!("Could not create session dir: {e}"));
4144        return;
4145    }
4146    let dest = dir.join(fname);
4147    match std::fs::copy(src, &dest) {
4148        Ok(_) => {
4149            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
4150            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
4151                add_note_message(chat, &format!("Imported session from {path}"));
4152            }
4153        }
4154        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
4155    }
4156}
4157
4158async fn fork_session(
4159    harness: &AgentHarness,
4160    cwd: &std::path::Path,
4161    chat: &Arc<Container>,
4162    state: &Arc<TuiState>,
4163) {
4164    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
4165    use rpi_tools::FileSystem;
4166
4167    let cwd_str = cwd.to_string_lossy().to_string();
4168    let dir = crate::session::default_session_dir(cwd);
4169    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
4170    let fs: Arc<dyn FileSystem> = env.clone();
4171    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
4172        fs,
4173        sessions_root: dir.to_string_lossy().into_owned(),
4174        clock: Arc::new(rpi_harness::session::memory::SystemClock),
4175        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
4176    });
4177    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
4178    // it from the session list by the current session's id.
4179    let id = harness.session().storage().metadata().id.clone();
4180    let metas = match crate::session::list_session_metadata(&cwd_str).await {
4181        Ok(m) => m,
4182        Err(e) => {
4183            add_error_message(chat, &format!("Could not list sessions: {e}"));
4184            return;
4185        }
4186    };
4187    let Some(source) = metas.iter().find(|m| m.id == id) else {
4188        add_error_message(chat, &format!("Current session {id} not found on disk."));
4189        return;
4190    };
4191    let fork_storage = match repo
4192        .fork_typed(
4193            source,
4194            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
4195                id: None,
4196                parent_session_id: Some(source.id.clone()),
4197                cwd: cwd_str.clone(),
4198                metadata: None,
4199            },
4200            &rpi_harness::session::types::ForkOptions::default(),
4201        )
4202        .await
4203    {
4204        Ok(s) => s,
4205        Err(e) => {
4206            add_error_message(chat, &format!("Could not fork session: {e}"));
4207            return;
4208        }
4209    };
4210    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
4211    let _ = harness.set_session(new_session).await;
4212    chat.clear();
4213    add_welcome_message(chat);
4214    render_session_history(
4215        harness,
4216        chat,
4217        state.markdown_transformer(),
4218        Some(state.extension_session.clone()),
4219    )
4220    .await;
4221    state.set_status(RunStatus::Idle);
4222    add_note_message(chat, "Forked into a new session.");
4223}
4224
4225/// Render the restored session's prior transcript (user + assistant messages)
4226/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
4227/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
4228/// any session read failure just starts with an empty transcript.
4229///
4230/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
4231/// the identity path. Each restored assistant component installs it so replayed
4232/// history renders through the same `register_markdown_transformer` handlers
4233/// the live stream does.
4234async fn render_session_history(
4235    harness: &AgentHarness,
4236    chat: &Arc<Container>,
4237    transformer: Option<MarkdownTransformer>,
4238    extension_session: Option<crate::session::ExtensionSessionCell>,
4239) {
4240    let tree = harness.session().view("main");
4241    let entries = match tree
4242        .find_entries(&EntryQuery {
4243            entry_type: None,
4244            custom_type: None,
4245            // Session queries default to newest-first for selectors and
4246            // pagination. The transcript appends children top-to-bottom, so
4247            // restored history must explicitly be chronological.
4248            order: Some(EntryOrder::OldestFirst),
4249            limit: None,
4250            cursor: None,
4251        })
4252        .await
4253    {
4254        Ok(e) => e,
4255        Err(_) => return,
4256    };
4257    let mut rendered_any = false;
4258    for e in entries {
4259        match e {
4260            Entry::Message(me) => match &me.message {
4261                AgentMessage::User(u) => {
4262                    add_user_message(chat, &user_message_text(u));
4263                    rendered_any = true;
4264                }
4265                AgentMessage::Assistant(a) => {
4266                    let comp = Arc::new(AssistantMessageComponent::new(
4267                        AssistantMessageOptions::default(),
4268                    ));
4269                    if let Some(t) = &transformer {
4270                        comp.set_markdown_transformer(Some(t.clone()));
4271                    }
4272                    comp.update_blocks(&assistant_blocks(a));
4273                    chat.add_child(comp);
4274                    // Single trailing spacer: the next transcript entry (user or
4275                    // assistant) follows one blank line below.
4276                    chat.add_child(Arc::new(Spacer::new(1)));
4277                    if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
4278                        add_note_message(chat, &text);
4279                    }
4280                    if let Some(error) = assistant_error_text(a) {
4281                        add_error_message(chat, &error);
4282                    }
4283                    rendered_any = true;
4284                }
4285                AgentMessage::ToolResult(result) => {
4286                    // Tool results are persisted as separate message entries,
4287                    // not as part of the assistant text. Restore them as
4288                    // completed tool panels so resumed sessions show the
4289                    // command output as well as the user's prompts.
4290                    let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
4291                    comp.set_result(&tool_result_message_text(result), result.is_error);
4292                    if tool_result_requests_markdown(result.details.as_ref()) {
4293                        comp.set_result_markdown(true);
4294                    }
4295                    chat.add_child(comp);
4296                    chat.add_child(Arc::new(Spacer::new(1)));
4297                    rendered_any = true;
4298                }
4299                AgentMessage::Custom(custom) => {
4300                    if let Some(session) = &extension_session {
4301                        if let Some(component) = extension_message_component(
4302                            session,
4303                            &custom.role,
4304                            &serde_json::json!({
4305                                "customType": custom.role,
4306                                "content": custom.content,
4307                                "details": custom.data,
4308                            }),
4309                            transformer.clone(),
4310                        ) {
4311                            chat.add_child(component);
4312                            chat.add_child(Arc::new(Spacer::new(1)));
4313                            rendered_any = true;
4314                            continue;
4315                        }
4316                    }
4317                    add_note_message(chat, &custom_message_fallback(&custom));
4318                    rendered_any = true;
4319                }
4320            },
4321            Entry::Compaction(compaction) => {
4322                add_note_message(
4323                    chat,
4324                    &format!(
4325                        "Compacted {} tokens: {}",
4326                        compaction.tokens_before, compaction.summary
4327                    ),
4328                );
4329                rendered_any = true;
4330            }
4331            Entry::BranchSummary(summary) => {
4332                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
4333                rendered_any = true;
4334            }
4335            Entry::Custom(custom) => {
4336                let rendered = extension_session.as_ref().and_then(|session| {
4337                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
4338                });
4339                if let Some(component) = rendered {
4340                    chat.add_child(component);
4341                    chat.add_child(Arc::new(Spacer::new(1)));
4342                    rendered_any = true;
4343                } else if let Some(text) =
4344                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
4345                {
4346                    add_note_message(chat, &text);
4347                    rendered_any = true;
4348                }
4349            }
4350            Entry::ModelChange(change) => {
4351                add_note_message(
4352                    chat,
4353                    &format!("Model changed to {}:{}", change.provider, change.model_id),
4354                );
4355                rendered_any = true;
4356            }
4357            Entry::ThinkingLevel(change) => {
4358                add_note_message(
4359                    chat,
4360                    &format!("Thinking level: {:?}", change.thinking_level),
4361                );
4362                rendered_any = true;
4363            }
4364            Entry::ActiveTools(change) => {
4365                add_note_message(
4366                    chat,
4367                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
4368                );
4369                rendered_any = true;
4370            }
4371        }
4372    }
4373    if rendered_any {
4374        // No trailing spacer here — each entry already adds its own trailing
4375        // Spacer(1), so an extra would double the bottom gap.
4376    }
4377}
4378
4379fn invoke_extension_renderer(
4380    session: &crate::session::ExtensionSessionCell,
4381    kind: rpi_extensions::RegisteredRendererKind,
4382    payload: &serde_json::Value,
4383) -> Option<serde_json::Value> {
4384    let snapshot = session.lock().ok()?.snapshot_arc()?;
4385    let input = serde_json::to_string(payload).ok()?;
4386    for renderer in snapshot.renderers_of(kind) {
4387        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4388            let mut out = rpi_plugin_sdk::StbString::empty();
4389            let rc = (renderer.render_fn)(
4390                rpi_plugin_sdk::StbStringRef::from_str(&input),
4391                &mut out as *mut rpi_plugin_sdk::StbString,
4392                renderer.user_data,
4393            );
4394            let text = if rc == 0 {
4395                Some(out.to_string_lossy())
4396            } else {
4397                None
4398            };
4399            out.free_with(Some(renderer.plugin_free_string));
4400            text
4401        }))
4402        .ok()
4403        .flatten();
4404        let Some(text) = outcome else { continue };
4405        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
4406            return Some(value);
4407        }
4408    }
4409    None
4410}
4411
4412fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
4413    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
4414        let text = lines
4415            .iter()
4416            .filter_map(|line| line.as_str())
4417            .collect::<Vec<_>>()
4418            .join("\n");
4419        return Some(Arc::new(Text::new(text, 0, 0)));
4420    }
4421    let text = value.get("text").and_then(|v| v.as_str())?;
4422    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4423        let component = Arc::new(AssistantMessageComponent::new(
4424            AssistantMessageOptions::default(),
4425        ));
4426        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4427        Some(component)
4428    } else {
4429        Some(Arc::new(Text::new(text, 0, 0)))
4430    }
4431}
4432
4433fn extension_message_component(
4434    session: &crate::session::ExtensionSessionCell,
4435    custom_type: &str,
4436    payload: &serde_json::Value,
4437    transformer: Option<MarkdownTransformer>,
4438) -> Option<Arc<dyn rpi_tui::Component>> {
4439    let value = invoke_extension_renderer(
4440        session,
4441        rpi_extensions::RegisteredRendererKind::Message,
4442        payload,
4443    )?;
4444    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4445        let text = value.get("text").and_then(|v| v.as_str())?;
4446        let component = Arc::new(AssistantMessageComponent::new(
4447            AssistantMessageOptions::default(),
4448        ));
4449        if let Some(transformer) = transformer {
4450            component.set_markdown_transformer(Some(transformer));
4451        }
4452        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4453        return Some(component);
4454    }
4455    extension_text_component(&value)
4456        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4457}
4458
4459/// Render usage from a completed assistant message through the registered
4460/// message renderers. Hosts without a token-usage renderer return `None`.
4461fn extension_usage_text(
4462    session: Option<&crate::session::ExtensionSessionCell>,
4463    usage: &rpi_ai::types::Usage,
4464) -> Option<String> {
4465    let session = session?;
4466    let payload = serde_json::json!({
4467        "customType": "token-usage",
4468        "usage": usage,
4469    });
4470    let value = invoke_extension_renderer(
4471        session,
4472        rpi_extensions::RegisteredRendererKind::Message,
4473        &payload,
4474    )?;
4475    value
4476        .get("text")
4477        .and_then(|value| value.as_str())
4478        .filter(|text| !text.trim().is_empty())
4479        .map(ToOwned::to_owned)
4480}
4481
4482fn extension_entry_component(
4483    session: &crate::session::ExtensionSessionCell,
4484    custom_type: &str,
4485    data: Option<serde_json::Value>,
4486) -> Option<Arc<dyn rpi_tui::Component>> {
4487    let payload = serde_json::json!({
4488        "customType": custom_type,
4489        "data": data,
4490    });
4491    let value = invoke_extension_renderer(
4492        session,
4493        rpi_extensions::RegisteredRendererKind::Entry,
4494        &payload,
4495    )?;
4496    extension_text_component(&value)
4497}
4498
4499/// Project an assistant message's content into the provider-free
4500/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
4501/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
4502/// are rendered by their own components in the transcript.
4503/// Whether startup intentionally opened a session that already has history.
4504fn launch_restores_history(args: &Args) -> bool {
4505    args.continue_session
4506        || args.resume
4507        || args.session.is_some()
4508        || args.session_id.is_some()
4509        || args.fork.is_some()
4510}
4511
4512fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4513    let content = custom
4514        .content
4515        .iter()
4516        .filter_map(|item| match item {
4517            Content::Text(text) => Some(text.text.as_str()),
4518            _ => None,
4519        })
4520        .collect::<Vec<_>>()
4521        .join("\n");
4522    if content.is_empty() {
4523        format!("{}: {}", custom.role, custom.data)
4524    } else {
4525        format!("{}: {}", custom.role, content)
4526    }
4527}
4528
4529/// Path to the enclosing repository's `.git/HEAD`, if any.
4530fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
4531    let mut dir = Some(cwd);
4532    while let Some(current) = dir {
4533        let head = current.join(".git").join("HEAD");
4534        if head.exists() {
4535            return Some(head);
4536        }
4537        dir = current.parent();
4538    }
4539    None
4540}
4541
4542/// Current git branch for `cwd`, or `None` outside a repository / on error.
4543/// Reads `.git/HEAD` directly (no `git` subprocess) so the footer can call it
4544/// cheaply at startup.
4545fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
4546    let mut dir = Some(cwd);
4547    while let Some(current) = dir {
4548        let head = current.join(".git").join("HEAD");
4549        if let Ok(contents) = std::fs::read_to_string(&head) {
4550            let trimmed = contents.trim();
4551            if let Some(reference) = trimmed.strip_prefix("ref: ") {
4552                let branch = reference
4553                    .rsplit('/')
4554                    .next()
4555                    .unwrap_or(reference)
4556                    .trim()
4557                    .to_string();
4558                if !branch.is_empty() {
4559                    return Some(branch);
4560                }
4561            }
4562            if !trimmed.is_empty() {
4563                return Some(trimmed.chars().take(7).collect());
4564            }
4565        }
4566        dir = current.parent();
4567    }
4568    None
4569}
4570
4571/// The name displayed for a model id (last path segment / after the final
4572/// `:`), to keep the footer compact.
4573fn short_model_name(id: &str) -> String {
4574    id.rsplit([':', '/'])
4575        .next()
4576        .filter(|s| !s.is_empty())
4577        .unwrap_or(id)
4578        .to_string()
4579}
4580
4581// ===========================================================================
4582// Streaming run status
4583// ===========================================================================
4584
4585/// The live status of the agent run, fed to the footer + status slot.
4586#[derive(Clone, Copy, PartialEq, Eq)]
4587enum RunStatus {
4588    Idle,
4589    Working,
4590    Aborting,
4591}
4592
4593/// Which selector overlay (if any) is currently swapped into the editor slot.
4594#[derive(Clone, Copy, PartialEq, Eq)]
4595enum SelectorKind {
4596    /// `/model` — available models (live switch via `lane.set_model`).
4597    Model,
4598    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
4599    Thinking,
4600    /// `/tools` — toggle builtin tools on/off.
4601    Tools,
4602    /// `/images` — toggle inline image rendering.
4603    Images,
4604    /// `/session` — browse and switch saved JSONL sessions.
4605    Session,
4606    /// `/theme` — dark / light / monochrome presets applied live.
4607    Theme,
4608    /// `/scoped-models` — multi-toggle Ctrl+P cycle scope.
4609    ScopedModels,
4610    /// `/settings` — interactive settings menu (and its sub-selectors).
4611    Settings,
4612    /// `/tree` — navigate to an existing entry in the current session.
4613    Tree,
4614    /// Extension-provided selector; uses the same keyboard contract.
4615    Extension,
4616}
4617
4618/// The component receiving keys while a selector is open.
4619///
4620/// Plain selectors route straight to the [`SelectList`]; searchable ones route
4621/// through [`SearchableSelectList`] so typing filters instead of being ignored.
4622/// Both variants expose the underlying list for callback registration and
4623/// selection inspection.
4624#[derive(Clone)]
4625enum SelectorView {
4626    /// A bare `SelectList` (no search box).
4627    List(Arc<SelectList>),
4628    /// A `SelectList` wrapped with a fuzzy-filter input.
4629    Searchable(Arc<SearchableSelectList>),
4630    /// The `/settings` menu: value rows cycle in place, submenu rows open a
4631    /// nested selector.
4632    Settings(Arc<SettingsList>),
4633}
4634
4635impl SelectorView {
4636    /// Route a key to the appropriate child.
4637    fn handle_key(&self, key: KeyEvent) {
4638        match self {
4639            Self::List(list) => list.handle_key(key),
4640            Self::Searchable(searchable) => searchable.handle_key(key),
4641            Self::Settings(settings) => settings.handle_key(key),
4642        }
4643    }
4644}
4645
4646/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
4647/// and the render-tick task.
4648/// A draft injected by an extension via `SetEditorText` with `autoSendMs`:
4649/// the TUI places it in the prompt editor and submits it once `deadline`
4650/// passes, *unless the user touches the draft first*.
4651///
4652/// "Touching" is either a keystroke (the key loop clears this — the pressed
4653/// key is still dispatched to the editor, so the user simply keeps typing) or
4654/// any difference between the live editor text and `text` (the render tick's
4655/// safety net for programmatic/`Paste` changes that bypass the key path).
4656#[derive(Debug, Clone)]
4657struct AutoSendPending {
4658    /// When an untouched draft is submitted.
4659    deadline: std::time::Instant,
4660    /// The text as injected; a mismatch means the user edited it.
4661    text: String,
4662}
4663
4664/// What one tick of the auto-send countdown should do. Split out of
4665/// [`poll_auto_send`] so the submit/cancel rules are unit-testable without a
4666/// live TUI.
4667#[derive(Debug, PartialEq, Eq)]
4668enum AutoSendStep {
4669    /// No countdown is running.
4670    Idle,
4671    /// Abandon the countdown and leave the draft alone: the user edited it, or
4672    /// a run started underneath us.
4673    Cancel,
4674    /// The countdown elapsed untouched — submit the draft.
4675    Submit,
4676    /// Still counting down; the caller renders the remaining time.
4677    Wait(std::time::Duration),
4678}
4679
4680/// Pure decision for one tick of the auto-send countdown.
4681fn auto_send_step(
4682    pending: Option<&AutoSendPending>,
4683    current_text: &str,
4684    idle: bool,
4685    now: std::time::Instant,
4686) -> AutoSendStep {
4687    let Some(pending) = pending else {
4688        return AutoSendStep::Idle;
4689    };
4690    // Any divergence from the injected text is the user's own edit; an emptied
4691    // editor (a slash command ran, Esc cleared it) has nothing left to send.
4692    if current_text != pending.text || current_text.trim().is_empty() {
4693        return AutoSendStep::Cancel;
4694    }
4695    if !idle {
4696        return AutoSendStep::Cancel;
4697    }
4698    let remaining = pending.deadline.saturating_duration_since(now);
4699    if remaining.is_zero() {
4700        AutoSendStep::Submit
4701    } else {
4702        AutoSendStep::Wait(remaining)
4703    }
4704}
4705
4706struct TuiState {
4707    /// The in-flight streaming assistant message (cleared on finalize).
4708    /// `Arc`-shared with the [`TranscriptView`] the event drain renders through,
4709    /// so the render-tick / reload / toggle paths keep direct access.
4710    current_assistant: Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
4711    /// Tool-execution components keyed by `tool_call_id` (shared with the view).
4712    tool_components: Arc<std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
4713    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
4714    /// generic tool map so bash output streams into a `BashExecutionComponent`
4715    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
4716    bash_components: Arc<std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
4717    /// Whether package/custom themes may be selected in this session.
4718    themes_enabled: bool,
4719    /// Persisted display preference toggled by Ctrl+T (shared with the view).
4720    hide_thinking: Arc<std::sync::Mutex<bool>>,
4721    /// Global tool-output expansion preference toggled by Ctrl+O (shared with
4722    /// the view).
4723    tool_outputs_expanded: Arc<std::sync::Mutex<bool>>,
4724    /// Whether the native-style terminal progress indicator is enabled.
4725    show_terminal_progress: bool,
4726    /// Run status for the status indicator + interrupt routing.
4727    status: std::sync::Mutex<RunStatus>,
4728    /// Extension status registry (`SetStatus` runtime action). The render tick
4729    /// repaints the footer only when `ext_status_revision` moved.
4730    ext_status: rpi_extensions::ExtensionStatusMailbox,
4731    /// Revision of `ext_status` as of the last footer write.
4732    ext_status_revision: std::sync::atomic::AtomicU64,
4733    /// Session editor-text queue (`SetEditorText`, runtime action 19). The
4734    /// render tick drains it into the prompt editor; a voice plugin drops a
4735    /// transcription in as an editable draft rather than sending it.
4736    editor_text: rpi_extensions::EditorTextMailbox,
4737    /// Pending auto-send for an injected draft (see [`AutoSendPending`]).
4738    /// `None` when no countdown is running.
4739    auto_send: std::sync::Mutex<Option<AutoSendPending>>,
4740    /// The editor text as of the last tick, used to detect `EditorChange`
4741    /// events (see [`TuiState::sync_editor_change`]).
4742    last_editor_text: std::sync::Mutex<String>,
4743    /// Set by the TUI itself right before it writes the editor (an extension
4744    /// draft injection, the auto-send clear). The next `EditorChange` is then
4745    /// attributed to `extension` rather than to the user — without this a
4746    /// plugin could not tell its own text from a human typing.
4747    programmatic_editor_write: std::sync::atomic::AtomicBool,
4748    /// Cancellation signal for the short phase that starts the persistent JS
4749    /// host and runs `before_agent_start`. The key thread can trigger this
4750    /// directly while the async message loop is awaiting the blocking worker.
4751    js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4752    /// Cancellation signal for a user-initiated `!command` / `!!command` shell
4753    /// run. `Some` while the command executes; Esc/Ctrl+C cancels it. `None`
4754    /// when no user bash is running.
4755    user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
4756    /// `bashExecution` messages produced by `!command` while an agent run was
4757    /// in flight. Flushed to the lane on `AgentEnd` so transcript order matches
4758    /// native pi's `_pendingBashMessages` (never spliced mid-turn).
4759    pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
4760    /// The footer, updated live by the drain task.
4761    footer: Arc<FooterComponent>,
4762    /// The status-container (status slot in the dock) — cleared/filled with a
4763    /// loader while a run is active.
4764    status_container: Arc<Container>,
4765    /// The chat transcript container.
4766    chat_container: Arc<Container>,
4767    /// The active loader shown while `Working`.
4768    loader: Arc<Loader>,
4769    /// The editor, so status changes can drive its top-border working
4770    /// indicator (the spinner + elapsed time shown inside the input box border).
4771    editor: Arc<Editor>,
4772    /// The last finalized assistant text (for `/copy`). Updated by the drain
4773    /// task on `MessageEnd` / `AgentEnd`.
4774    last_assistant_text: std::sync::Mutex<String>,
4775    /// The active selector overlay, swapped into the editor slot. `Some` while
4776    /// a selector is open; the key loop routes to it first and restores the
4777    /// editor on done/cancel.
4778    active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
4779    /// Extension-provided editor currently occupying the input slot.
4780    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4781    /// Single-line input currently occupying the input slot for an extension.
4782    active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4783    /// Callback used to resolve an extension dialog with a cancellation action.
4784    active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4785    /// The autocomplete manager (slash + @file providers) consulted on every
4786    /// editor keystroke.
4787    autocomplete: AutocompleteManager,
4788    /// The container rendered above the editor holding the live autocomplete
4789    /// suggestion list (cleared when there are no suggestions).
4790    autocomplete_container: Arc<Container>,
4791    /// Maximum number of autocomplete rows rendered above the editor.
4792    autocomplete_max_visible: usize,
4793    /// Images queued from clipboard paste and attached to the next prompt.
4794    pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4795    /// The owned theme manager — `/theme` applies presets here. The global
4796    /// `theme()` is read-only after OnceLock init, so per-instance state is the
4797    /// only way to apply a preset at runtime.
4798    theme_manager: Arc<ThemeManager>,
4799    /// The alt-screen handle, held so `set_status` can reflect run state in the
4800    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
4801    /// that never call `set_status` with a title.
4802    tui: Option<Arc<TuiAltScreen>>,
4803    /// The model id currently shown in the footer + used as the Ctrl+P
4804    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+P switch) so
4805    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
4806    current_model_id: std::sync::Mutex<String>,
4807    /// Whether inline image rendering is enabled (`/images` toggle). Stored
4808    /// even though image wiring is minimal this pass — the flag is consulted
4809    /// where images would be shown and echoed back by `/images`.
4810    show_images: std::sync::Mutex<bool>,
4811    /// Transcript notices for prompt-cache costs and provider recovery
4812    /// diagnostics (native pi `showCacheMissNotices`, default `false`).
4813    cache_miss_notices: std::sync::Mutex<bool>,
4814    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
4815    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
4816    history: std::sync::Mutex<Vec<String>>,
4817    /// Browse index while recalling history: -1 = not browsing, 0 = most
4818    /// recent, 1 = older, … Reset to -1 on every submit.
4819    history_index: std::sync::Mutex<isize>,
4820    /// The editor text captured when entering browse mode, restored when the
4821    /// user navigates back past the newest entry (TS `historyDraft`).
4822    history_draft: std::sync::Mutex<Option<String>>,
4823    /// Full cache-waste tracker (pi `detectCacheMiss`/`cache-stats.ts`):
4824    /// counts and prices prompt-cache misses across turns using the same
4825    /// noise floor, idle-gap, and model-change logic as the batch scan.
4826    cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
4827    /// The in-progress scoped-models selection while the `/scoped-models`
4828    /// selector is open (toggle per item, Esc saves). `None` when not editing.
4829    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4830    /// B5e: the live assistant-markdown transformer, built from the current
4831    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
4832    /// when no markdown transformers are registered (identity render path).
4833    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
4834    /// closure no-ops once its snapshot's `active` flag flips false) and
4835    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
4836    /// transform takes effect on the visible streaming message immediately.
4837    /// New assistant components pick up whatever closure is current at
4838    /// construction time via [`install_markdown_transformer`].
4839    markdown_transformer: Arc<std::sync::Mutex<Option<MarkdownTransformer>>>,
4840    /// Live extension registry used by message/entry renderer dispatch.
4841    extension_session: crate::session::ExtensionSessionCell,
4842    /// Transcript search handler (Ctrl+Shift+F).
4843    search: Arc<AltScreenSearch>,
4844    /// Search bar component shown when search is active.
4845    search_bar: Arc<SearchBar>,
4846    /// Dock slot listing queued steering/follow-up messages while a run is
4847    /// active (native pi's `pendingMessagesContainer`). Empty when nothing is
4848    /// queued, so it renders zero rows and never affects the layout.
4849    pending_container: Arc<Container>,
4850    /// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`),
4851    /// shown in the pending-messages hint row.
4852    dequeue_hint: String,
4853    /// Last queue snapshot rendered into `pending_container`. Lets
4854    /// `set_pending_queue` skip redundant rebuilds + frame requests when the
4855    /// polled queue is unchanged.
4856    pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
4857    /// Fullscreen selection tracking for auto-copy.
4858    selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4859    selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4860}
4861
4862/// How many submitted messages are kept for ↑ recall (mirrors the TS
4863/// editor's 100-entry cap).
4864const HISTORY_LIMIT: usize = 100;
4865
4866/// Footer-status prefix used while a voice/extension draft counts down to an
4867/// auto-send. Doubles as the marker that lets [`TuiState::clear_auto_send`]
4868/// know the footer is showing *our* text before blanking it.
4869const AUTO_SEND_PREFIX: &str = "Auto-send in ";
4870
4871/// Keep a few rows of overlap so page scrolling preserves visual context,
4872/// matching the upstream fullscreen viewport behavior.
4873const PAGE_SCROLL_OVERLAP: usize = 4;
4874
4875/// Native pi scrolls a small chunk for each wheel notch rather than moving the
4876/// transcript one physical row at a time. Three lines stays precise while
4877/// avoiding the sluggish feel of the previous implementation.
4878const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4879
4880/// Parse the compact key notation used by native Pi settings (for example
4881/// `ctrl+g`, `shift+tab`, or `escape`) into crossterm's representation.
4882fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4883    let mut modifiers = KeyModifiers::NONE;
4884    let mut key = None;
4885    for part in value.trim().to_ascii_lowercase().split('+') {
4886        match part {
4887            "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4888            "shift" => modifiers |= KeyModifiers::SHIFT,
4889            "alt" | "option" => modifiers |= KeyModifiers::ALT,
4890            "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4891            part if !part.is_empty() => key = Some(part.to_string()),
4892            _ => {}
4893        }
4894    }
4895    let key = key?;
4896    let code = match key.as_str() {
4897        "esc" | "escape" => KeyCode::Esc,
4898        "enter" | "return" => KeyCode::Enter,
4899        "tab" => {
4900            if modifiers.contains(KeyModifiers::SHIFT) {
4901                return Some(rpi_tui::KeyCombo::new(
4902                    KeyCode::BackTab,
4903                    modifiers & !KeyModifiers::SHIFT,
4904                ));
4905            }
4906            KeyCode::Tab
4907        }
4908        "backspace" | "back" => KeyCode::Backspace,
4909        "delete" | "del" => KeyCode::Delete,
4910        "up" | "arrowup" => KeyCode::Up,
4911        "down" | "arrowdown" => KeyCode::Down,
4912        "left" | "arrowleft" => KeyCode::Left,
4913        "right" | "arrowright" => KeyCode::Right,
4914        "home" => KeyCode::Home,
4915        "end" => KeyCode::End,
4916        "pageup" | "page-up" => KeyCode::PageUp,
4917        "pagedown" | "page-down" => KeyCode::PageDown,
4918        "space" => KeyCode::Char(' '),
4919        "f1" => KeyCode::F(1),
4920        "f2" => KeyCode::F(2),
4921        "f3" => KeyCode::F(3),
4922        "f4" => KeyCode::F(4),
4923        "f5" => KeyCode::F(5),
4924        "f6" => KeyCode::F(6),
4925        "f7" => KeyCode::F(7),
4926        "f8" => KeyCode::F(8),
4927        "f9" => KeyCode::F(9),
4928        "f10" => KeyCode::F(10),
4929        "f11" => KeyCode::F(11),
4930        "f12" => KeyCode::F(12),
4931        value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4932        _ => return None,
4933    };
4934    Some(rpi_tui::KeyCombo::new(code, modifiers))
4935}
4936
4937fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4938    let mut bindings = rpi_tui::Keybindings::new();
4939    let settings = crate::settings::load_settings().unwrap_or_default();
4940    let Some(overrides) = settings.keybindings else {
4941        rpi_tui::set_keybindings(bindings.clone());
4942        return Arc::new(bindings);
4943    };
4944    let known: &[(&str, rpi_tui::KeybindingId)] = &[
4945        ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4946        ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4947        ("app.exit", rpi_tui::keybindings::keys::EXIT),
4948        ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4949        (
4950            "app.model.cycleForward",
4951            rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4952        ),
4953        ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4954        (
4955            "app.thinking.toggle",
4956            rpi_tui::keybindings::keys::THINKING_TOGGLE,
4957        ),
4958        (
4959            "app.editor.external",
4960            rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4961        ),
4962        (
4963            "app.thinking.cycle",
4964            rpi_tui::keybindings::keys::THINKING_CYCLE,
4965        ),
4966        (
4967            "app.clipboard.pasteImage",
4968            rpi_tui::keybindings::keys::PASTE_IMAGE,
4969        ),
4970        ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4971    ];
4972    for (name, id) in known {
4973        let Some(value) = overrides.get(*name) else {
4974            continue;
4975        };
4976        let values: Vec<String> = match value {
4977            serde_json::Value::String(value) => vec![value.clone()],
4978            serde_json::Value::Array(values) => values
4979                .iter()
4980                .filter_map(|v| v.as_str().map(str::to_string))
4981                .collect(),
4982            serde_json::Value::Null => Vec::new(),
4983            _ => continue,
4984        };
4985        let combos: Vec<_> = values
4986            .iter()
4987            .filter_map(|value| parse_configured_key(value))
4988            .collect();
4989        if values.is_empty() || !combos.is_empty() {
4990            bindings.set(id, combos);
4991        }
4992    }
4993    rpi_tui::set_keybindings(bindings.clone());
4994    Arc::new(bindings)
4995}
4996
4997fn keybinding_matches(
4998    bindings: &rpi_tui::Keybindings,
4999    event: &crossterm::event::KeyEvent,
5000    id: rpi_tui::KeybindingId,
5001) -> bool {
5002    if bindings.matches(event, id) {
5003        return true;
5004    }
5005    // crossterm reports Shift+Tab as BackTab on some terminals and as Tab
5006    // plus Shift on others. Treat both forms as the same configured action.
5007    if event.code == KeyCode::BackTab {
5008        let normalized =
5009            crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
5010        bindings.matches(&normalized, id)
5011    } else {
5012        false
5013    }
5014}
5015
5016/// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`), used in
5017/// the pending-messages hint row. Mirrors native pi's `getAppKeyDisplay`;
5018/// falls back to native pi's platform default when nothing is configured.
5019fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
5020    let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
5021    if keys.is_empty() {
5022        return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
5023    }
5024    keys.iter()
5025        .map(|combo| combo.display())
5026        .collect::<Vec<_>>()
5027        .join("/")
5028}
5029
5030/// Whether `last`/`now` fall inside the same 500ms double-press window. Native
5031/// pi uses one window for both Esc's double-escape action and Ctrl+C's "press
5032/// twice to exit", so this helper serves both.
5033fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
5034    last.is_some_and(|previous| {
5035        now.duration_since(previous) <= std::time::Duration::from_millis(500)
5036    })
5037}
5038
5039/// What one Ctrl+C press should do, per native pi's `handleCtrlC`.
5040#[derive(Debug, PartialEq, Eq)]
5041enum CtrlCAction {
5042    /// First press: clear the editor and arm the double-press window.
5043    Clear,
5044    /// The window was already open → quit.
5045    Exit,
5046}
5047
5048/// Native pi's `handleCtrlC`: a press inside the 500ms window quits, otherwise
5049/// it clears. It never aborts a run — cancelling is `app.interrupt` (Esc)'s job.
5050fn ctrl_c_action(last_sigint: Option<std::time::Instant>, now: std::time::Instant) -> CtrlCAction {
5051    if double_escape_trigger(last_sigint, now) {
5052        CtrlCAction::Exit
5053    } else {
5054        CtrlCAction::Clear
5055    }
5056}
5057
5058/// How long to wait for queued console input before treating a bare Enter as a
5059/// real submit. Pasted input is already in the queue, so this only needs to
5060/// cover scheduler latency — small enough that a human Enter feels instant.
5061const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
5062
5063/// Maximum gap between a pasted character and the next pasted key. A human
5064/// cannot deliver two keypresses this fast, so anything tighter is paste.
5065const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
5066
5067/// Whether a bare Enter is pasted content (insert a newline) rather than a
5068/// submit.
5069///
5070/// `crossterm` 0.27 only implements bracketed paste on Unix; on Windows the
5071/// console event source never produces `Event::Paste`, so a pasted block
5072/// arrives as plain key events with a bare Enter per line. Two timing signals
5073/// separate a pasted Enter from a typed one:
5074///
5075/// - `more_queued`: real key input is already sitting in the console input
5076///   queue. Callers must feed this through [`probe_paste_input`], which drops
5077///   the key's own Release events — Windows queues press+release together over
5078///   RDP, and a bare poll cannot tell that Release apart from paste content.
5079/// - `last_text_key_at`: the Enter lands within [`PASTE_BURST_GAP`] of the
5080///   preceding pasted character, which also catches a paste that *ends* with a
5081///   newline (nothing queued behind it, but it was not typed by hand).
5082///
5083/// NOTE: the caller gates this on `cfg!(windows)`. On Unix, bracketed paste
5084/// delivers real pastes as `Event::Paste`, and the timing heuristic would
5085/// misclassify a remote/mobile client's coalesced text+Enter burst as paste
5086/// ("回车变成了换行").
5087fn enter_is_paste_burst(
5088    last_text_key_at: Option<std::time::Instant>,
5089    now: std::time::Instant,
5090    more_queued: bool,
5091) -> bool {
5092    if more_queued {
5093        return true;
5094    }
5095    last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
5096}
5097
5098/// Decide whether more *real* input is queued behind the current key, for the
5099/// Windows paste-burst check.
5100///
5101/// Windows emits a `KeyEventKind::Release` for every press, usually queued
5102/// right behind the `Press`. A bare non-blocking poll therefore reports "more
5103/// input" for a lone Enter — which made a remote/RDP Enter look like a paste
5104/// burst. This drains and discards release events and returns the first
5105/// *meaningful* event it read (stashed so the caller's loop still processes it)
5106/// plus whether any real input was queued. `next_event` returns `None` when
5107/// nothing is available within the probe window.
5108fn probe_paste_input(
5109    pending: Option<Event>,
5110    mut next_event: impl FnMut() -> Option<Event>,
5111) -> (bool, Option<Event>) {
5112    let mut pending = pending;
5113    let mut more_queued = pending.is_some();
5114    while !more_queued {
5115        match next_event() {
5116            Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
5117            Some(ev) => {
5118                pending = Some(ev);
5119                more_queued = true;
5120            }
5121            None => break,
5122        }
5123    }
5124    (more_queued, pending)
5125}
5126
5127/// Milliseconds since the last text key, for the [`enter_is_paste_burst`] trace
5128/// line (`-` when there was no preceding text key).
5129fn gap_ms(last_text_key_at: Option<std::time::Instant>) -> String {
5130    match last_text_key_at {
5131        Some(previous) => std::time::Instant::now()
5132            .duration_since(previous)
5133            .as_millis()
5134            .to_string(),
5135        None => "-".to_string(),
5136    }
5137}
5138
5139fn transcript_page_size(viewport_height: usize) -> i32 {
5140    viewport_height
5141        .saturating_sub(PAGE_SCROLL_OVERLAP)
5142        .max(1)
5143        .min(i32::MAX as usize) as i32
5144}
5145
5146fn should_dispatch_key(kind: KeyEventKind) -> bool {
5147    kind != KeyEventKind::Release
5148}
5149
5150/// Last-resort quit, run on the key worker thread.
5151///
5152/// The async loop owns `run_prompt_streaming(..).await` for the whole run, so a
5153/// queued [`TuiMessage::Exit`] is not read while a run is in flight. When the
5154/// user asks to quit a second time — the first Ctrl+C only reached `Aborting` —
5155/// treat the run as wedged, restore the terminal, and exit.
5156///
5157/// `130` is the conventional `128 + SIGINT` status, so a wrapper script can tell
5158/// a forced quit from a clean one.
5159fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
5160    // Best effort: leave the alternate buffer and restore cooked mode so the
5161    // user's shell is usable afterwards. `stop` also joins the render
5162    // scheduler, so no in-flight frame can repaint over the restored screen.
5163    tui.stop(Default::default());
5164    eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
5165    std::process::exit(130);
5166}
5167
5168/// Native pi's `handleCtrlZ` (`app.suspend`): hand the terminal back to the
5169/// shell and stop the process. `raise(SIGTSTP)` returns once the user resumes
5170/// us with `fg`/`bg`, at which point we re-enter the alternate screen.
5171#[cfg(unix)]
5172fn handle_suspend(tui: &Arc<TuiAltScreen>) {
5173    tui.suspend();
5174    // A `Result` here would mean the signal could not be raised; there is no
5175    // useful recovery, so resume either way and let the user retry.
5176    let _ = nix::sys::signal::raise(nix::sys::signal::Signal::SIGTSTP);
5177    tui.resume();
5178}
5179
5180/// Native pi binds no suspend key on Windows (`app.suspend`'s `defaultKeys` is
5181/// empty there), so [`keys::SUSPEND`] never matches and this is unreachable.
5182#[cfg(not(unix))]
5183fn handle_suspend(_tui: &Arc<TuiAltScreen>) {}
5184
5185/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
5186fn format_tokens(n: i64) -> String {
5187    if n >= 1_000_000 {
5188        format!("{:.1}M", n as f64 / 1_000_000.0)
5189    } else if n >= 1_000 {
5190        format!("{:.1}K", n as f64 / 1_000.0)
5191    } else {
5192        n.to_string()
5193    }
5194}
5195
5196/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
5197/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
5198/// resets the browse state so a fresh prompt never resumes mid-history.
5199fn push_history(state: &Arc<TuiState>, text: &str) {
5200    let trimmed = text.trim().to_string();
5201    if trimmed.is_empty() {
5202        return;
5203    }
5204    let mut history = state.history.lock().unwrap();
5205    if history.first() == Some(&trimmed) {
5206        return;
5207    }
5208    history.insert(0, trimmed);
5209    history.truncate(HISTORY_LIMIT);
5210    *state.history_index.lock().unwrap() = -1;
5211    *state.history_draft.lock().unwrap() = None;
5212}
5213
5214/// Submit a draft as a user prompt through the *same* path the Enter key takes:
5215/// render the user bubble, record history, then hand the text to the main loop
5216/// as [`TuiMessage::UserInput`]. Using the editor path (rather than a direct
5217/// `SendUserMessage`) is what makes a voice transcription show up as a user
5218/// message — the harness emits no user `message_start` for a directly-sent
5219/// prompt.
5220fn submit_draft(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>, text: &str) {
5221    if text.trim().is_empty() || !state.try_start_working() {
5222        return;
5223    }
5224    add_user_message(&state.chat_container, text);
5225    if let Some(tui) = &state.tui {
5226        if let Some(scroll) = tui.get_primary_scroll_view() {
5227            scroll.scroll_to_end();
5228        }
5229        tui.request_render(false);
5230    }
5231    push_history(state, text);
5232    let _ = tx.send(TuiMessage::UserInput(text.to_string()));
5233}
5234
5235/// Advance a pending auto-send countdown. Returns `true` when the caller needs
5236/// to repaint.
5237///
5238/// The draft is abandoned — leaving the user's text in place and clearing the
5239/// footer countdown — when the user edits it (the editor no longer matches the
5240/// injected text), when a run has started, or when the editor was cleared.
5241/// Otherwise the remaining time is written to the footer; once it reaches zero
5242/// the draft is submitted.
5243fn poll_auto_send(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>) -> bool {
5244    // Clone out of the lock before touching the editor/footer so no other
5245    // thread can block behind this tick.
5246    let pending = state.auto_send.lock().unwrap().clone();
5247    let step = auto_send_step(
5248        pending.as_ref(),
5249        &state.editor.get_text(),
5250        *state.status.lock().unwrap() == RunStatus::Idle,
5251        std::time::Instant::now(),
5252    );
5253
5254    match step {
5255        AutoSendStep::Idle => false,
5256        AutoSendStep::Cancel => {
5257            state.clear_auto_send();
5258            true
5259        }
5260        AutoSendStep::Submit => {
5261            let text = state.editor.get_text();
5262            state.clear_auto_send();
5263            state.clear_editor_programmatically();
5264            submit_draft(state, tx, &text);
5265            true
5266        }
5267        AutoSendStep::Wait(remaining) => {
5268            let secs = remaining.as_millis() as f64 / 1000.0;
5269            let label = format!("{AUTO_SEND_PREFIX}{secs:.1}s · any key to edit");
5270            if state.footer.get_status() != label {
5271                state.footer.set_status(&label);
5272                true
5273            } else {
5274                false
5275            }
5276        }
5277    }
5278}
5279
5280/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
5281/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
5282/// current editor text as the draft; navigating back past the newest entry
5283/// restores that draft.
5284fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
5285    let history = state.history.lock().unwrap();
5286    if history.is_empty() {
5287        return;
5288    }
5289    let mut index = state.history_index.lock().unwrap();
5290    let new_index = *index - direction as isize;
5291    if new_index < -1 || new_index >= history.len() as isize {
5292        return;
5293    }
5294    if *index == -1 && new_index >= 0 {
5295        // Entering browse mode: stash the current input.
5296        *state.history_draft.lock().unwrap() = Some(editor.get_text());
5297    }
5298    *index = new_index;
5299    if new_index == -1 {
5300        // Exited browse mode: restore the draft (or clear if there was none).
5301        let draft = state.history_draft.lock().unwrap().take();
5302        match draft {
5303            Some(d) => {
5304                let len = d.len();
5305                editor.set_text(&d);
5306                editor.set_cursor(0, len);
5307            }
5308            None => editor.set_text(""),
5309        }
5310    } else {
5311        let text = history[new_index as usize].clone();
5312        let len = text.len();
5313        editor.set_text(&text);
5314        editor.set_cursor(0, len);
5315    }
5316}
5317
5318impl TuiState {
5319    fn begin_js_preparation(&self) -> CancellationToken {
5320        let cancellation = CancellationToken::new();
5321        if let Some(previous) = self
5322            .js_preparation_cancel
5323            .lock()
5324            .unwrap()
5325            .replace(cancellation.clone())
5326        {
5327            previous.cancel();
5328        }
5329        cancellation
5330    }
5331
5332    fn finish_js_preparation(&self) {
5333        self.js_preparation_cancel.lock().unwrap().take();
5334    }
5335
5336    fn cancel_js_preparation(&self) -> bool {
5337        let cancellation = self.js_preparation_cancel.lock().unwrap().take();
5338        if let Some(cancellation) = cancellation {
5339            cancellation.cancel();
5340            true
5341        } else {
5342            false
5343        }
5344    }
5345
5346    /// Whether a user-initiated `!command` shell run is currently active.
5347    fn user_bash_running(&self) -> bool {
5348        self.user_bash_cancel.lock().unwrap().is_some()
5349    }
5350
5351    /// Register a cancellation token for a new user bash run, cancelling any
5352    /// previous one (mirrors `begin_js_preparation`).
5353    fn begin_user_bash(&self) -> CancellationToken {
5354        let cancellation = CancellationToken::new();
5355        if let Some(previous) = self
5356            .user_bash_cancel
5357            .lock()
5358            .unwrap()
5359            .replace(cancellation.clone())
5360        {
5361            previous.cancel();
5362        }
5363        cancellation
5364    }
5365
5366    /// Clear the user-bash slot. Called when the command finishes so the
5367    /// running guard and the Esc router both see idle state.
5368    fn finish_user_bash(&self) {
5369        self.user_bash_cancel.lock().unwrap().take();
5370    }
5371
5372    /// Cancel a running user bash command. Returns `true` when one was active
5373    /// (mirrors `cancel_js_preparation`). The spawned task clears the slot
5374    /// itself once `execute_shell_with_capture` returns.
5375    fn cancel_user_bash(&self) -> bool {
5376        let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
5377        if let Some(cancellation) = cancellation {
5378            cancellation.cancel();
5379            true
5380        } else {
5381            false
5382        }
5383    }
5384
5385    fn set_status(&self, status: RunStatus) {
5386        *self.status.lock().unwrap() = status;
5387        self.apply_status(status);
5388    }
5389
5390    /// The dock container listing queued messages (native pi's
5391    /// `pendingMessagesContainer`).
5392    fn pending_container(&self) -> &Arc<Container> {
5393        &self.pending_container
5394    }
5395
5396    /// Render the current queue into the pending dock slot. Mirrors native
5397    /// pi's `updatePendingMessagesDisplay`: `Steering: <text>` /
5398    /// `Follow-up: <text>` rows (dim) plus a `↳ <key> to edit all queued
5399    /// messages` hint. A no-op when the snapshot is unchanged, so the poll
5400    /// caller can invoke it freely without forcing frames.
5401    fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
5402        {
5403            let mut current = self.pending_snapshot.lock().unwrap();
5404            if *current == messages {
5405                return;
5406            }
5407            *current = messages.clone();
5408        }
5409        let container = &self.pending_container;
5410        container.clear();
5411        if messages.is_empty() {
5412            return;
5413        }
5414        let colors = current_theme().colors;
5415        container.add_child(Arc::new(Spacer::new(1)));
5416        for text in &messages.steering {
5417            container.add_child(Arc::new(Text::new(
5418                colors.muted.fg(&format!("Steering: {text}")),
5419                1,
5420                0,
5421            )));
5422        }
5423        for text in &messages.follow_up {
5424            container.add_child(Arc::new(Text::new(
5425                colors.muted.fg(&format!("Follow-up: {text}")),
5426                1,
5427                0,
5428            )));
5429        }
5430        container.add_child(Arc::new(Text::new(
5431            colors.muted.fg(&format!(
5432                "↳ {} to edit all queued messages",
5433                self.dequeue_hint
5434            )),
5435            1,
5436            0,
5437        )));
5438    }
5439
5440    /// Atomically reserve the single interactive run slot. The editor callback
5441    /// runs on a different thread from the async prompt loop, so checking and
5442    /// setting in separate steps would allow rapid Enter presses to queue more
5443    /// than one operation.
5444    fn try_start_working(&self) -> bool {
5445        let mut status = self.status.lock().unwrap();
5446        if *status != RunStatus::Idle {
5447            return false;
5448        }
5449        *status = RunStatus::Working;
5450        drop(status);
5451        self.apply_status(RunStatus::Working);
5452        true
5453    }
5454
5455    fn apply_status(&self, status: RunStatus) {
5456        match status {
5457            RunStatus::Working => {
5458                // The status dock already shows the active loader. Keep the
5459                // footer focused on the model and shortcuts instead of
5460                // repeating a second `Working…` at the bottom of the TUI.
5461                self.footer.set_status("");
5462                // Reflect the in-flight turn in the terminal window/tab title
5463                // (OSC 2). No-op when `tui` is absent (unit tests).
5464                if let Some(tui) = &self.tui {
5465                    tui.set_title(&crate::brand::window_title("⟳"));
5466                }
5467                self.status_container.clear();
5468                // The working indicator renders inside the editor's top border.
5469                // `show_terminal_progress` disables it entirely.
5470                if self.show_terminal_progress {
5471                    self.editor.set_working(Some(WorkingState {
5472                        frame: 0,
5473                        started_at: std::time::Instant::now(),
5474                        message: "Working…".to_string(),
5475                    }));
5476                } else {
5477                    self.editor.set_working(None);
5478                }
5479            }
5480            RunStatus::Aborting => {
5481                self.footer.set_status("Aborting…");
5482                // Do not leave a frozen "Working" spinner on screen after the
5483                // render tick intentionally stops advancing in this state.
5484                self.loader.stop();
5485                self.status_container.clear();
5486                self.editor.set_working(None);
5487            }
5488            RunStatus::Idle => {
5489                self.footer.set_status("");
5490                if let Some(tui) = &self.tui {
5491                    tui.set_title(&crate::brand::window_title(""));
5492                }
5493                self.loader.stop();
5494                self.status_container.clear();
5495                self.editor.set_working(None);
5496            }
5497        }
5498    }
5499
5500    /// Push the extension status registry into the footer when it changed.
5501    /// Returns `true` when the footer was rewritten (the caller then requests a
5502    /// repaint — an idle session would otherwise never redraw).
5503    fn sync_extension_status(&self) -> bool {
5504        let revision = self.ext_status.revision();
5505        if self
5506            .ext_status_revision
5507            .load(std::sync::atomic::Ordering::SeqCst)
5508            == revision
5509        {
5510            return false;
5511        }
5512        self.ext_status_revision
5513            .store(revision, std::sync::atomic::Ordering::SeqCst);
5514        self.footer.set_extension_status(&self.ext_status.text());
5515        true
5516    }
5517
5518    /// Drain the extension editor-text queue (`SetEditorText`) into the prompt
5519    /// editor. Returns `true` when at least one edit was applied (the caller
5520    /// then repaints).
5521    ///
5522    /// An edit with an `autoSendMs` arms [`AutoSendPending`] so an *untouched*
5523    /// draft is submitted without a keystroke; the user can still steal it back
5524    /// by typing (see the key loop) or by any edit that diverges from the
5525    /// injected text (see [`poll_auto_send`]). The injected text is the draft,
5526    /// so submitting it goes through the normal prompt path and renders a user
5527    /// bubble — unlike `SendUserMessage`, which emits no user `message_start`.
5528    fn drain_editor_text(&self) -> bool {
5529        let mut applied = false;
5530        while let Some(edit) = self.editor_text.take_pending() {
5531            let current = self.editor.get_text();
5532            let text = match edit.mode {
5533                rpi_extensions::EditorTextMode::Replace => edit.text,
5534                rpi_extensions::EditorTextMode::Append => {
5535                    if current.is_empty() {
5536                        edit.text
5537                    } else {
5538                        // A blank separator keeps two dictations readable; an
5539                        // existing trailing space is not doubled.
5540                        let mut joined = current.trim_end().to_string();
5541                        joined.push(' ');
5542                        joined.push_str(&edit.text);
5543                        joined
5544                    }
5545                }
5546            };
5547            set_editor_text_caret_at_end(&self.editor, &text);
5548            // The `EditorChange` this produces is ours, not the user's.
5549            self.programmatic_editor_write
5550                .store(true, std::sync::atomic::Ordering::SeqCst);
5551            let pending = edit
5552                .auto_send_ms
5553                .filter(|ms| *ms > 0)
5554                .map(|ms| AutoSendPending {
5555                    deadline: std::time::Instant::now() + std::time::Duration::from_millis(ms),
5556                    text: text.clone(),
5557                });
5558            *self.auto_send.lock().unwrap() = pending;
5559            applied = true;
5560        }
5561        applied
5562    }
5563
5564    /// Clear the editor as **host housekeeping**, not as a user edit.
5565    ///
5566    /// The clear that follows every submit is the important one: it is the host
5567    /// tidying up, and reporting it as `source:"user"` told a hands-free voice
5568    /// extension "the human just took the keyboard" — so `/voice auto` switched
5569    /// itself off the instant it was enabled (the command's own editor clear
5570    /// arrives on the next tick, ~80ms after the mode turned on) and the
5571    /// microphone never stayed open. A real user clear (Esc, select-all +
5572    /// delete) goes through the editor itself and is still `user`.
5573    fn clear_editor_programmatically(&self) {
5574        self.programmatic_editor_write
5575            .store(true, std::sync::atomic::Ordering::SeqCst);
5576        self.editor.clear();
5577    }
5578
5579    /// Drop a pending auto-send, clearing the footer countdown when it still
5580    /// shows ours (so an unrelated run status is never clobbered).
5581    fn clear_auto_send(&self) {
5582        if self.auto_send.lock().unwrap().take().is_some()
5583            && self.footer.get_status().starts_with(AUTO_SEND_PREFIX)
5584        {
5585            self.footer.set_status("");
5586        }
5587    }
5588
5589    /// Cancel a pending auto-send because the user pressed a key. Returns
5590    /// `true` when a countdown was running. The key is *still* dispatched
5591    /// normally, so it lands in the draft as the user's first correction.
5592    fn cancel_auto_send_on_key(&self) -> bool {
5593        let had = self.auto_send.lock().unwrap().is_some();
5594        if had {
5595            self.clear_auto_send();
5596        }
5597        had
5598    }
5599
5600    /// Notify extensions when the prompt draft changed since the last tick.
5601    ///
5602    /// This is how a voice extension learns the user is composing again, so it
5603    /// can stop reading the previous answer aloud (barge-in) — the draft text
5604    /// itself is not shipped, only `chars`/`empty`/`source`.
5605    ///
5606    /// `source` separates a human edit from one the TUI made on a plugin's
5607    /// behalf (`SetEditorText`, or clearing the draft after an auto-send). A
5608    /// hands-free extension needs that distinction: its own injected
5609    /// transcription must not be mistaken for the user taking over the
5610    /// keyboard, which would make it stop listening on every single turn.
5611    ///
5612    /// Polled from the render tick rather than pushed from [`Editor::on_change`]:
5613    /// that callback slot already has an owner (the bash-mode border colour) and
5614    /// runs while the editor's callback lock is held, where re-entering
5615    /// extension code is needlessly risky.
5616    fn sync_editor_change(&self) {
5617        use rpi_plugin_sdk::EventTag;
5618
5619        let Some(payload) = self.take_editor_change() else {
5620            return;
5621        };
5622        // Cheap pre-check so the common (no subscriber) case never touches a
5623        // plugin: session lock + snapshot read only.
5624        let Ok(session) = self.extension_session.lock() else {
5625            return;
5626        };
5627        let Some(snapshot) = session.snapshot_arc() else {
5628            return;
5629        };
5630        if snapshot.handlers_for(EventTag::EditorChange).is_empty() {
5631            return;
5632        }
5633        rpi_extensions::dispatch_data_event(
5634            &snapshot,
5635            EventTag::EditorChange,
5636            &payload.to_string(),
5637        );
5638    }
5639
5640    /// Build the `EditorChange` payload when the draft changed since the last
5641    /// tick, or `None` when it did not. Split out of
5642    /// [`Self::sync_editor_change`] so the attribution rule is unit-testable
5643    /// without a live extension registry.
5644    ///
5645    /// The attribution is consumed here — even when the caller then finds no
5646    /// subscriber — so a stale "programmatic" mark can never mislabel a later
5647    /// real keystroke.
5648    fn take_editor_change(&self) -> Option<serde_json::Value> {
5649        let text = self.editor.get_text();
5650        {
5651            let mut last = self.last_editor_text.lock().unwrap();
5652            if *last == text {
5653                return None;
5654            }
5655            *last = text.clone();
5656        }
5657        let programmatic = self
5658            .programmatic_editor_write
5659            .swap(false, std::sync::atomic::Ordering::SeqCst);
5660        Some(serde_json::json!({
5661            "chars": text.chars().count(),
5662            "empty": text.is_empty(),
5663            "source": if programmatic {
5664                // The TUI wrote it (an injected draft / the auto-send clear).
5665                "extension"
5666            } else {
5667                "user"
5668            },
5669        }))
5670    }
5671
5672    /// The bash panel has its own `Running...` spinner. Keep the global
5673    /// `Working...` loader out of the status slot while any bash tool is active
5674    /// so the same operation is not presented as two simultaneous loaders.
5675    fn sync_working_loader_with_bash(&self) {
5676        if *self.status.lock().unwrap() != RunStatus::Working {
5677            return;
5678        }
5679
5680        // The working indicator now lives inside the editor's top border and
5681        // stays visible for the whole Working state, so no status-slot swap is
5682        // needed when bash panels (which render their own spinner in the
5683        // transcript) appear or disappear.
5684        self.status_container.clear();
5685    }
5686
5687    fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
5688        *self.status.lock().unwrap() = RunStatus::Working;
5689        self.footer.set_status("");
5690        if let Some(tui) = &self.tui {
5691            tui.set_title(&crate::brand::window_title("↻"));
5692        }
5693        self.loader.stop();
5694        self.status_container.clear();
5695        self.status_container
5696            .add_child(Arc::new(StatusIndicator::retry(
5697                attempt,
5698                max_retries,
5699                std::time::Duration::from_millis(delay_ms),
5700            )));
5701    }
5702
5703    /// Whether a selector overlay is currently open (routes keys to it first).
5704    fn selector_open(&self) -> bool {
5705        self.active_selector.lock().unwrap().is_some()
5706    }
5707
5708    fn extension_editor_open(&self) -> bool {
5709        self.active_extension_editor.lock().unwrap().is_some()
5710    }
5711
5712    fn extension_input_open(&self) -> bool {
5713        self.active_extension_input.lock().unwrap().is_some()
5714    }
5715
5716    fn extension_dialog_open(&self) -> bool {
5717        self.extension_editor_open() || self.extension_input_open()
5718    }
5719
5720    fn set_hide_thinking(&self, hide: bool) {
5721        *self.hide_thinking.lock().unwrap() = hide;
5722        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5723            comp.set_hide_thinking(hide);
5724        }
5725    }
5726
5727    fn hide_thinking(&self) -> bool {
5728        *self.hide_thinking.lock().unwrap()
5729    }
5730
5731    fn toggle_thinking(&self) -> bool {
5732        let next = !self.hide_thinking();
5733        self.set_hide_thinking(next);
5734        next
5735    }
5736
5737    fn toggle_tool_outputs(&self) -> bool {
5738        let next = !*self.tool_outputs_expanded.lock().unwrap();
5739        *self.tool_outputs_expanded.lock().unwrap() = next;
5740        for comp in self.tool_components.lock().unwrap().values() {
5741            comp.set_expanded(next);
5742        }
5743        for comp in self.bash_components.lock().unwrap().values() {
5744            comp.set_expanded(next);
5745        }
5746        next
5747    }
5748
5749    /// The model id currently tracked as active (footer + Ctrl+P anchor).
5750    fn current_model_id(&self) -> String {
5751        self.current_model_id.lock().unwrap().clone()
5752    }
5753
5754    /// Update the tracked model id + footer label after a switch (live or
5755    /// cycle). Called from the `/model` on_select and the Ctrl+P handler.
5756    fn set_current_model(&self, model: &rpi_ai::Model) {
5757        *self.current_model_id.lock().unwrap() = model.id.clone();
5758        self.footer.set_model(&short_model_name(&model.id));
5759        // A model switch changes the context window, so the `%/{window}` badge
5760        // must rescale against the same last-reported token count.
5761        self.footer.set_context_window(model.context_window as i64);
5762    }
5763
5764    /// B5e: read a clone of the current assistant-markdown transformer (if any).
5765    /// New assistant components call this at construction so they render with
5766    /// whatever plugin `register_markdown_transformer` handlers are live.
5767    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
5768        self.markdown_transformer.lock().unwrap().clone()
5769    }
5770
5771    /// Build a [`TranscriptView`] over the shared component maps. Cheap (clones
5772    /// a handful of `Arc`s) and lets the event drain render the transcript
5773    /// through the same code path as the remote client.
5774    ///
5775    /// [`TranscriptView`]: crate::transcript_view::TranscriptView
5776    fn transcript_view(&self) -> crate::transcript_view::TranscriptView {
5777        crate::transcript_view::TranscriptView::with_maps(
5778            self.chat_container.clone(),
5779            self.current_assistant.clone(),
5780            self.tool_components.clone(),
5781            self.bash_components.clone(),
5782            self.hide_thinking.clone(),
5783            self.tool_outputs_expanded.clone(),
5784            self.markdown_transformer.clone(),
5785        )
5786    }
5787
5788    /// B5e: swap the live transformer. Used at startup (install the first
5789    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
5790    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
5791    /// transform should take effect on the VISIBLE streaming message too, so
5792    /// this re-installs on the in-flight `current_assistant` component — its
5793    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
5794    /// `None` clears the transform (identity), e.g. a reload that unregisters
5795    /// every markdown transformer.
5796    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
5797        *self.markdown_transformer.lock().unwrap() = transformer.clone();
5798        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5799            comp.set_markdown_transformer(transformer);
5800        }
5801    }
5802
5803    fn queue_image(&self, image: rpi_ai::types::ImageContent) {
5804        self.pending_images.lock().unwrap().push(image);
5805    }
5806
5807    fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
5808        std::mem::take(&mut *self.pending_images.lock().unwrap())
5809    }
5810}
5811
5812// ===========================================================================
5813// interactive_tui — the entry point
5814// ===========================================================================
5815
5816#[derive(Debug, PartialEq)]
5817struct TuiStartupSettings {
5818    editor_padding_x: usize,
5819    autocomplete_max_visible: usize,
5820    hide_thinking: bool,
5821    quiet_startup: bool,
5822    show_terminal_progress: bool,
5823    /// Render images inline (`terminal.showImages` / flat `showImages`).
5824    show_images: bool,
5825    /// Transcript notices for prompt-cache costs (`showCacheMissNotices`).
5826    cache_miss_notices: bool,
5827}
5828
5829fn preferred_project_setting<T>(
5830    project_settings: &[crate::settings::Settings],
5831    field: impl Fn(&crate::settings::Settings) -> Option<T>,
5832) -> Option<T> {
5833    project_settings.iter().find_map(field)
5834}
5835
5836fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
5837    verbose || !quiet_startup
5838}
5839
5840fn git_only_update_resources(
5841    mut resources: crate::packages::PackageResources,
5842) -> crate::packages::PackageResources {
5843    resources
5844        .packages
5845        .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
5846    resources
5847}
5848
5849/// Resolve TUI-only settings with native Pi's project-over-global precedence.
5850/// `project_settings` must be ordered `.rpi` then `.pi`; an explicit `Some`
5851/// wins even when the value is `false` or zero.
5852fn resolve_tui_startup_settings(
5853    global: &crate::settings::Settings,
5854    project_settings: &[crate::settings::Settings],
5855    project_trusted: bool,
5856) -> TuiStartupSettings {
5857    let project_settings = if project_trusted {
5858        project_settings
5859    } else {
5860        &[]
5861    };
5862    let editor_padding_x =
5863        preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
5864            .or(global.editor_padding_x)
5865            .unwrap_or(1)
5866            .min(16);
5867    let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
5868        settings.autocomplete_max_visible
5869    })
5870    .or(global.autocomplete_max_visible)
5871    .unwrap_or(5)
5872    .clamp(1, 20);
5873    let hide_thinking =
5874        preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
5875            .or(global.hide_thinking_block)
5876            .unwrap_or(false);
5877    let quiet_startup =
5878        preferred_project_setting(project_settings, |settings| settings.quiet_startup)
5879            .or(global.quiet_startup)
5880            .unwrap_or(false);
5881    let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
5882        settings.show_terminal_progress()
5883    })
5884    .or_else(|| global.show_terminal_progress())
5885    .unwrap_or(true);
5886    let show_images =
5887        preferred_project_setting(project_settings, |settings| settings.show_images())
5888            .or_else(|| global.show_images())
5889            .unwrap_or(true);
5890    let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
5891        settings.show_cache_miss_notices
5892    })
5893    .or(global.show_cache_miss_notices)
5894    .unwrap_or(false);
5895
5896    TuiStartupSettings {
5897        editor_padding_x,
5898        autocomplete_max_visible,
5899        hide_thinking,
5900        quiet_startup,
5901        show_terminal_progress,
5902        show_images,
5903        cache_miss_notices,
5904    }
5905}
5906
5907/// TUI-based interactive mode.
5908///
5909/// `event_rx` carries the live `AgentEvent` stream (installed by
5910/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
5911/// fn), it falls back to a blocking, await-final-text path.
5912///
5913/// `model_catalog` is the read-only catalog the `/model` selector displays.
5914///
5915/// This implementation mirrors the TypeScript `InteractiveMode` class:
5916/// build the layout root once, drain `AgentEvent`s into UI mutations that
5917/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
5918/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
5919/// autocomplete are layered on via the editor-container swap pattern.
5920pub async fn interactive_tui(
5921    harness: &AgentHarness,
5922    event_rx: Option<broadcast::Receiver<AgentEvent>>,
5923    args: &Args,
5924    model_catalog: Vec<rpi_ai::Model>,
5925    initial: Option<String>,
5926    extra_messages: &[String],
5927    initial_images: Vec<rpi_ai::types::ImageContent>,
5928    theme: Option<&str>,
5929    no_themes: bool,
5930    reload_context: &crate::session::ReloadContext,
5931) -> i32 {
5932    let lane: Arc<dyn AgentLane> = harness.lane("main");
5933    // Reuse the startup snapshot captured before provider/session setup. An
5934    // extension may mutate the process cwd while loading; that must not change
5935    // which project settings or package update roots this TUI observes.
5936    let cwd = reload_context.cwd.clone();
5937    let saved_settings = crate::settings::load_settings().unwrap_or_default();
5938    let project_trusted = reload_context.project_trusted;
5939    let project_settings = if project_trusted {
5940        crate::settings::load_project_settings(&cwd)
5941    } else {
5942        Vec::new()
5943    };
5944    let tui_settings =
5945        resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5946    let editor_padding_x = tui_settings.editor_padding_x;
5947    let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5948    let mut hide_thinking = tui_settings.hide_thinking;
5949    let show_terminal_progress = tui_settings.show_terminal_progress;
5950    let show_images = tui_settings.show_images;
5951    let cache_miss_notices = tui_settings.cache_miss_notices;
5952    let quiet_startup = tui_settings.quiet_startup;
5953    let update_checks_enabled =
5954        !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
5955
5956    // Session display preferences (durable, latest-wins) override the
5957    // settings-file defaults. These are per-session facts stored as custom
5958    // entries (see `rpi_harness::session::values`), so resuming a session with
5959    // `-c`/`-r` restores the thinking/tool-output display the user last chose
5960    // there — independent of global settings. Absent keys leave the settings
5961    // value untouched.
5962    let session_values = rpi_harness::session::SessionValues::load(harness.session())
5963        .await
5964        .unwrap_or_default();
5965    if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5966        hide_thinking = persisted;
5967    }
5968
5969    // Resolve the active model once, up front. The full id feeds the TuiState
5970    // tracking field + the selectors/key loop (which run on a blocking thread
5971    // and can't await `lane.get_model()`); the short name feeds the footer.
5972    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5973    let model_name = short_model_name(&lane_model_id);
5974
5975    // Snapshot startup capabilities for the welcome screen. Both accessors
5976    // return defensive clones, so rendering this summary does not retain a
5977    // harness lock or trigger a second resource scan.
5978    let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5979    // AgentHarness uses an empty active-name list as the default "all tools"
5980    // state. Do not expose that implementation sentinel as `Tools (0) none`
5981    // in the welcome banner (it is especially visible on the first prompt).
5982    if active_tool_names.is_empty() {
5983        active_tool_names = harness
5984            .get_tools()
5985            .await
5986            .unwrap_or_default()
5987            .into_iter()
5988            .map(|tool| tool.tool.schema().name.clone())
5989            .collect();
5990    }
5991    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5992    let skill_names: Vec<String> = resources_snapshot
5993        .skills
5994        .as_deref()
5995        .unwrap_or(&[])
5996        .iter()
5997        .map(|skill| skill.name.clone())
5998        .collect();
5999
6000    // Resolve package resources for @file autocomplete + session discovery.
6001    let package_resources = reload_context.package_resources.clone();
6002
6003    // Channel between the key/callback threads and the main async loop.
6004    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
6005
6006    // Apply the saved theme before constructing transcript components. Some
6007    // components keep styled text, so doing this after the welcome banner left
6008    // the first screen in the dark palette until it was rebuilt.
6009    let theme_manager = Arc::new(ThemeManager::new());
6010    if let Some(preset) = match theme {
6011        Some("light") => Some(ThemePreset::Light),
6012        Some("monochrome") => Some(ThemePreset::Monochrome),
6013        Some("dark") => Some(ThemePreset::Dark),
6014        _ => None,
6015    } {
6016        apply_theme_preset(preset);
6017        theme_manager.apply_preset(preset);
6018    } else if !no_themes {
6019        if let Some(name) = theme {
6020            match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
6021                Ok(custom) => {
6022                    rpi_tui::global_theme_manager().set(custom.clone());
6023                    theme_manager.set(custom);
6024                }
6025                Err(error) => {
6026                    eprintln!("warning: could not load package theme `{name}`: {error}");
6027                }
6028            }
6029        }
6030    }
6031
6032    // ---- TUI + containers ----
6033    let terminal = Box::new(ProcessTerminal::new());
6034    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
6035    let js_dialog_bridge = Arc::new(JsDialogBridge::default());
6036    // Plugin `ask_user` prompts (runtime action 17). Attaching marks this
6037    // session as interactive so a detached/headless host fails loudly instead
6038    // of returning a fabricated answer. The mailbox is session-scoped (held in
6039    // `ReloadContext`) so in-flight prompts survive a `/reload`.
6040    let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
6041    ask_user_bridge.attach();
6042    tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
6043
6044    if let Some(js) = &reload_context.js_extension_session {
6045        if let Err(error) = js.install_ui_runtime(tui.clone()) {
6046            eprintln!("warning: could not enable JS custom UI bridge: {error}");
6047        } else if let Some(js_active) = js.active_tools() {
6048            // Apply the discovery-time JS subset while preserving Rust
6049            // built-ins already active in the harness lane. The real TUI
6050            // lifecycle reconciliation runs after the key worker starts below.
6051            let js_names = js.tool_names();
6052            let mut active = lane.get_active_tools().await.unwrap_or_default();
6053            active.retain(|name| {
6054                crate::session::tool_name_allowed(name, args)
6055                    && !js_names.iter().any(|js_name| js_name == name)
6056            });
6057            active.extend(js_active.into_iter().filter(|name| {
6058                js_names.iter().any(|js_name| js_name == name)
6059                    && crate::session::tool_name_allowed(name, args)
6060            }));
6061            active = crate::session::filter_active_tool_names(active, args);
6062            let _ = lane.set_active_tools(active).await;
6063            active_tool_names = lane.get_active_tools().await.unwrap_or_default();
6064        }
6065    }
6066
6067    let chat_container = Arc::new(Container::new());
6068    if let Some(js) = &reload_context.js_extension_session {
6069        // Tool contexts do not have a command-result envelope. Route
6070        // `ctx.ui.notify()` through the live transcript so notifications from
6071        // tools such as ask_user_question are visible immediately.
6072        let chat_notify = chat_container.clone();
6073        let tui_notify = tui.clone();
6074        if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
6075            if action != "ui.notify" {
6076                return Err(format!("unsupported capability: {action}"));
6077            }
6078            let message = args
6079                .get("message")
6080                .and_then(serde_json::Value::as_str)
6081                .unwrap_or_default();
6082            if !message.is_empty() {
6083                if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
6084                    add_error_message(&chat_notify, message);
6085                } else {
6086                    add_note_message(&chat_notify, message);
6087                }
6088                tui_notify.request_render(false);
6089            }
6090            Ok(serde_json::json!(true))
6091        })) {
6092            if args.verbose {
6093                eprintln!("warning: could not enable JS notification bridge: {error}");
6094            }
6095        }
6096    }
6097    if should_show_startup_listing(args.verbose, quiet_startup) {
6098        add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
6099    }
6100
6101    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
6102    // banner + the earendil announcement once, then write the sentinel. The TS
6103    // original is a multi-step dialog (theme picker + analytics opt-in); this
6104    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
6105    // analytics deferred — no telemetry wiring). See `extras.rs`.
6106    crate::extras::maybe_first_time_setup(&chat_container);
6107
6108    // A --continue/--resume/--session launch opens on an existing JSONL
6109    // session — render its prior user/assistant transcript so the user sees
6110    // where they left off (tool executions are skipped: their live display
6111    // belongs to the current run, and replaying old results would be noise).
6112    let initial_transformer = build_markdown_transformer(
6113        reload_context
6114            .extension_session
6115            .lock()
6116            .unwrap()
6117            .snapshot_arc(),
6118    );
6119    // A normal launch creates a fresh session and must not replay records from
6120    // another/project harness. Only explicit restore/fork modes render prior
6121    // conversation history. This fixes stale prompts appearing every startup.
6122    if launch_restores_history(args) {
6123        render_session_history(
6124            &harness,
6125            &chat_container,
6126            initial_transformer.clone(),
6127            Some(reload_context.extension_session.clone()),
6128        )
6129        .await;
6130    }
6131
6132    // `document_container` wraps the welcome header + chat so the scrollview
6133    // follows the whole transcript (mirrors TS `documentContainer`).
6134    let document_container = Arc::new(Container::new());
6135    document_container.add_child(chat_container.clone());
6136    // Add bottom padding to the output area for visual breathing room.
6137    document_container.add_child(Arc::new(Spacer::new(1)));
6138
6139    let scroll_view = Arc::new(ScrollView::new(
6140        document_container.clone(),
6141        ScrollViewOptions {
6142            follow: FollowMode::End,
6143            primary: true,
6144            overscroll: OverscrollMode::Chain,
6145            // Native pi keeps transcript chrome out of the way. Our Auto mode
6146            // has no hide timer yet and therefore became effectively permanent
6147            // after the first wheel event, unlike the upstream experience.
6148            scrollbar: ScrollbarMode::Hidden,
6149            ..Default::default()
6150        },
6151    ));
6152
6153    // ---- Editor ----
6154    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
6155    // editor renders full-width `─` top/bottom borders with padding-only lines
6156    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
6157    let keybindings = configured_keybindings();
6158    let editor = Arc::new(Editor::new(
6159        EditorOptions {
6160            padding_x: editor_padding_x,
6161            autocomplete_max_visible,
6162            ..Default::default()
6163        },
6164        EditorStyle::default(),
6165        keybindings.clone(),
6166    ));
6167
6168    // Bash-mode border: a `!`-prefixed draft recolors the editor border to the
6169    // bash accent (native pi's `updateEditorBorderColor` / `isBashMode`). The
6170    // editor fires `on_change` for keystrokes and for programmatic
6171    // `clear`/`set_text`, so the color also resets after a `!command` submits.
6172    {
6173        let editor_for_change = editor.clone();
6174        editor.on_change(Arc::new(move |text: &str| {
6175            let colors = current_theme().colors;
6176            let is_bash = text.trim_start().starts_with('!');
6177            editor_for_change.set_border_color(if is_bash {
6178                Some(colors.bash_mode)
6179            } else {
6180                None
6181            });
6182        }));
6183    }
6184
6185    // ---- Footer + status ----
6186    let footer = Arc::new(FooterComponent::new());
6187    footer.set_model(&model_name);
6188    footer.set_cwd(&cwd.to_string_lossy());
6189    footer.set_git_branch(git_branch_for(&cwd).as_deref());
6190    if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
6191        footer.set_context_window(m.context_window as i64);
6192    }
6193    footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Clear/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+P: Cycle | Ctrl+T: Expand tool | /help");
6194
6195    // Live git-branch refresh (native fs-watch on `.git/HEAD`): a checkout or
6196    // commit updates the footer's branch without a manual refresh.
6197    let _branch_watcher = find_git_head_path(&cwd).map(|head| {
6198        let footer_ref = footer.clone();
6199        let cwd_ref = cwd.clone();
6200        crate::fs_watch::watch_path(head, move || {
6201            footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
6202        })
6203    });
6204
6205    let status_container = Arc::new(Container::new());
6206    let loader = Arc::new(Loader::with_text("Working…"));
6207
6208    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
6209    // Prompt templates discovered at session build (Part A2) are surfaced as
6210    // `/`-prefixed entries alongside the built-in slash commands: typing
6211    // `/<name>` in the editor expands the template (mirrors pi
6212    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
6213    // the template's frontmatter description (or a fallback) so the autocomplete
6214    // popover shows what each template does.
6215    //
6216    // We snapshot the full resources once (skills + prompt-templates): the
6217    // autocomplete builder consumes the templates, and the `/context` command
6218    // (fired from the blocking submit handler, which can't `.await`) reads the
6219    // snapshot to render the discovered-resources panel without touching the
6220    // harness async accessor.
6221    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
6222        .prompt_templates
6223        .clone()
6224        .unwrap_or_default()
6225        .iter()
6226        .map(|t| SlashCommandEntry {
6227            name: format!("/{}", t.name),
6228            description: t
6229                .description
6230                .clone()
6231                .unwrap_or_else(|| "Expand prompt template".to_string()),
6232        })
6233        .collect();
6234    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
6235        Arc::new(resources_snapshot);
6236    // Build the built-in command registry once — the single source of truth for
6237    // both dispatch and the built-in autocomplete entries. The discovered
6238    // prompt-template commands are merged into the autocomplete list separately
6239    // (they dispatch via template expansion, not the registry); built-ins come
6240    // first so they win on a fuzzy tie.
6241    let mut command_registry = build_builtin_registry();
6242    register_extension_commands(
6243        &mut command_registry,
6244        reload_context.extension_session.clone(),
6245    );
6246    register_js_extension_commands(
6247        &mut command_registry,
6248        reload_context.js_extension_session.clone(),
6249    );
6250    let registry = Arc::new(command_registry);
6251    let mut all_slash_commands = registry.visible_entries();
6252    all_slash_commands.extend(template_slash_commands);
6253    let autocomplete = AutocompleteManager::new();
6254    {
6255        let mut combined = CombinedAutocompleteProvider::new();
6256        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
6257            all_slash_commands,
6258        )));
6259        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
6260            cwd.clone(),
6261        )));
6262        autocomplete.set_provider(Arc::new(combined));
6263    }
6264    let autocomplete_container = Arc::new(Container::new());
6265
6266    let tool_outputs_expanded = session_values
6267        .get_as::<bool>("display.tool_outputs_expanded")
6268        .unwrap_or(false);
6269    let state = Arc::new(TuiState {
6270        current_assistant: Arc::new(std::sync::Mutex::new(None)),
6271        tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
6272        bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
6273        themes_enabled: !no_themes,
6274        hide_thinking: Arc::new(std::sync::Mutex::new(hide_thinking)),
6275        tool_outputs_expanded: Arc::new(std::sync::Mutex::new(tool_outputs_expanded)),
6276        show_terminal_progress,
6277        status: std::sync::Mutex::new(RunStatus::Idle),
6278        ext_status: reload_context.ext_status.clone(),
6279        ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
6280        editor_text: reload_context.editor_text.clone(),
6281        auto_send: std::sync::Mutex::new(None),
6282        last_editor_text: std::sync::Mutex::new(String::new()),
6283        programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
6284        js_preparation_cancel: std::sync::Mutex::new(None),
6285        user_bash_cancel: std::sync::Mutex::new(None),
6286        pending_bash_messages: std::sync::Mutex::new(Vec::new()),
6287        footer: footer.clone(),
6288        status_container: status_container.clone(),
6289        chat_container: chat_container.clone(),
6290        loader: loader.clone(),
6291        editor: editor.clone(),
6292        last_assistant_text: std::sync::Mutex::new(String::new()),
6293        active_selector: std::sync::Mutex::new(None),
6294        active_extension_editor: std::sync::Mutex::new(None),
6295        active_extension_input: std::sync::Mutex::new(None),
6296        active_extension_cancel: std::sync::Mutex::new(None),
6297        autocomplete,
6298        autocomplete_container: autocomplete_container.clone(),
6299        autocomplete_max_visible,
6300        pending_images: std::sync::Mutex::new(Vec::new()),
6301        theme_manager,
6302        tui: Some(tui.clone()),
6303        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
6304        show_images: std::sync::Mutex::new(show_images),
6305        cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
6306        history: std::sync::Mutex::new(Vec::new()),
6307        history_index: std::sync::Mutex::new(-1),
6308        history_draft: std::sync::Mutex::new(None),
6309        cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
6310        scoped_edit: std::sync::Mutex::new(None),
6311        markdown_transformer: Arc::new(std::sync::Mutex::new(initial_transformer)),
6312        extension_session: reload_context.extension_session.clone(),
6313        search: Arc::new(AltScreenSearch::new()),
6314        search_bar: Arc::new(SearchBar::new()),
6315        pending_container: Arc::new(Container::new()),
6316        dequeue_hint: pending_dequeue_hint(&keybindings),
6317        pending_snapshot: std::sync::Mutex::new(
6318            rpi_harness::agent_harness::QueuedMessages::default(),
6319        ),
6320        selection_start: std::sync::Mutex::new(None),
6321        selection_end: std::sync::Mutex::new(None),
6322    });
6323
6324    // Capture the model catalog + cwd for the selector builders + the key loop
6325    // (the callbacks fire on blocking threads and need owned data).
6326    let model_catalog_arc = Arc::new(model_catalog.clone());
6327    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
6328
6329    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
6330    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
6331    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
6332    //
6333    // The scrollview gets `basis(0)` so the constrained stack allocator starts
6334    // it at zero height and grows it to fill the space the dock does not need
6335    // — this keeps the dock (editor borders + footer) pinned to the bottom and
6336    // never shrinks it below the editor's 3 rows (top + content + bottom). The
6337    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
6338    // never clip the input panel below its minimum.
6339    let editor_container = Arc::new(Container::new());
6340    editor_container.add_child(editor.clone());
6341
6342    let dock = Arc::new(VStack::from_children(vec![
6343        StackChild::Entry(StackEntry::new(state.pending_container().clone())),
6344        StackChild::Entry(StackEntry::new(status_container.clone())),
6345        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
6346        // NOTE: no Spacer here. The scroll content already ends with a blank line
6347        // (`document_container`), and a second one in the dock stacked into a
6348        // two-row gap between the last message and the input box. One row of
6349        // breathing room is the whole safe area now; the transcript keeps its
6350        // own padding so a long message never touches the editor border.
6351        StackChild::Entry(
6352            StackEntry::new(editor_container.clone())
6353                .shrink(0)
6354                .min_size(3),
6355        ),
6356        StackChild::Entry(StackEntry::new(state.search_bar.clone())),
6357        StackChild::Entry(StackEntry::new(footer.clone())),
6358    ]));
6359
6360    let root = VStack::from_children(vec![
6361        StackChild::Entry(
6362            StackEntry::new(scroll_view.clone())
6363                .basis(0)
6364                .grow(1)
6365                .shrink(1)
6366                .min_size(1),
6367        ),
6368        StackChild::Entry(StackEntry::new(dock).shrink(1)),
6369    ]);
6370
6371    tui.set_layout_root(Some(Arc::new(root)));
6372    tui.set_focus(Some(editor.clone()));
6373    editor.set_focused(true);
6374
6375    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
6376    //
6377    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
6378    // the old version made individually) + the registry, then routes `/`-text
6379    // through `dispatch_slash` and sends plain text directly. Each command's
6380    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
6381    // chat mutation) — the handler itself stays a thin router.
6382    //
6383    // One `CommandContext` is built and cloned for both the submit handler and
6384    // the key loop (Ctrl+L routes `/model` through the same registry); all
6385    // fields are `Arc`/cheap, so the clones are free.
6386    //
6387    // The product-layer `AgentSession` wraps a clone of the harness (the
6388    // harness handle is cheap and shares the session/bus), so `/usage`,
6389    // `/export`, and future product commands go through one surface.
6390    let scoped_models = args
6391        .model
6392        .as_deref()
6393        .map(crate::agent_session::ScopedModel::parse)
6394        .into_iter()
6395        .collect();
6396    let agent_session = Arc::new(crate::agent_session::AgentSession::new(
6397        harness.clone(),
6398        model_catalog.clone(),
6399        scoped_models,
6400        cwd.clone(),
6401    ));
6402    let ctx = CommandContext {
6403        chat: chat_container.clone(),
6404        tui: tui.clone(),
6405        tx: tx.clone(),
6406        state: state.clone(),
6407        editor: editor.clone(),
6408        editor_container: editor_container.clone(),
6409        lane: lane.clone(),
6410        model_catalog: model_catalog_arc.clone(),
6411        lane_model_id: lane_model_id.clone(),
6412        cwd: cwd.clone(),
6413        package_resources: package_resources.clone(),
6414        resources: resources_arc.clone(),
6415        reload_context: Arc::new(reload_context.clone()),
6416        session: agent_session.clone(),
6417    };
6418
6419    if let Some(js) = &reload_context.js_extension_session {
6420        let bridge = js_dialog_bridge.clone();
6421        if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
6422            bridge.handle_runtime_request(action, args)
6423        })) {
6424            eprintln!("warning: could not enable JS dialog UI bridge: {error}");
6425        }
6426    }
6427
6428    let ctx_for_cb = ctx.clone();
6429    let registry_for_cb = registry.clone();
6430    editor.on_submit(Arc::new(move |text: &str| {
6431        let text = text.trim();
6432        if text.is_empty() {
6433            return;
6434        }
6435
6436        if text.starts_with('/') {
6437            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
6438            return;
6439        }
6440
6441        // User-initiated shell mode: `!command` runs in the background and is
6442        // recorded in the session context; `!!command` is excluded from it.
6443        // Handled before the agent prompt path so it never reaches the model.
6444        // Mirrors `interactive-mode.ts:3225`.
6445        if let Some((command, exclude_from_context)) = parse_user_bash(text) {
6446            if ctx_for_cb.state.user_bash_running() {
6447                add_error_message(
6448                    &ctx_for_cb.chat,
6449                    "A bash command is already running. Press Esc to cancel it first.",
6450                );
6451                ctx_for_cb.tui.request_render(false);
6452                return;
6453            }
6454            start_user_bash(&ctx_for_cb, command, exclude_from_context);
6455            return;
6456        }
6457
6458        let run_status = *ctx_for_cb.state.status.lock().unwrap();
6459        if run_status != RunStatus::Idle {
6460            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
6461            let lane = ctx_for_cb.lane.clone();
6462            let chat = ctx_for_cb.chat.clone();
6463            let tui = ctx_for_cb.tui.clone();
6464            let state = ctx_for_cb.state.clone();
6465            tokio::spawn(async move {
6466                // Queue immediately while the agent loop is still running.
6467                // Routing this through the TUI's main channel delayed it until
6468                // `prompt_text()` returned, after the loop's drain points had
6469                // passed, so the queued message appeared to disappear.
6470                //
6471                // Native pi's `steer()` enqueues unconditionally, even after
6472                // the run ends, and the message stays in the queue for the
6473                // next run. This matches the TS design and avoids the race
6474                // between `activeRun` clearing and the status check.
6475                if let Err(error) = lane.steer(message).await {
6476                    add_error_message(&chat, &format!("Could not queue message: {error}"));
6477                    tui.render_now(false);
6478                    return;
6479                }
6480                // Surface the queued entry in the pending dock immediately;
6481                // the drain-time refresh only fires on the next agent event.
6482                refresh_pending_messages(&state, &lane).await;
6483                tui.request_render(false);
6484            });
6485            // A queued prompt is echoed only once the loop actually consumes
6486            // it: the run's `AgentEvent::MessageStart` renders the user bubble
6487            // (native pi renders `message_start` for user messages). Until
6488            // then it is surfaced by the pending-messages display, so the
6489            // transcript never shows a message that is still waiting.
6490            ctx_for_cb.tui.request_render(false);
6491            return;
6492        }
6493
6494        if !ctx_for_cb.state.try_start_working() {
6495            return;
6496        }
6497
6498        // Render the user bubble here, NOT from `AgentEvent::MessageStart`.
6499        //
6500        // The harness persists the prompt itself and then drives
6501        // `run_agent_loop` with an EMPTY prompts vec (so the prompt is not
6502        // double-counted in the provider context), which means the loop emits
6503        // no `message_start` for it — see
6504        // `directly_sent_prompt_emits_no_user_message_start` in
6505        // `crates/pi-harness/tests/harness_run_e2e.rs`. Queued steering /
6506        // follow-up prompts DO get a `message_start` (the loop drains those
6507        // itself), so those are deliberately rendered from the event instead.
6508        add_user_message(&ctx_for_cb.chat, text);
6509        // A new prompt starts a fresh interaction at the tail even when the
6510        // user had scrolled up to inspect older output.
6511        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
6512            scroll.scroll_to_end();
6513        }
6514        ctx_for_cb.tui.request_render(false);
6515        // Remember the message for ↑ recall (slash commands are not part of
6516        // the replayable message history).
6517        push_history(&ctx_for_cb.state, text);
6518        if ctx_for_cb
6519            .tx
6520            .send(TuiMessage::UserInput(text.to_string()))
6521            .is_err()
6522        {
6523            ctx_for_cb.state.set_status(RunStatus::Idle);
6524        }
6525    }));
6526
6527    // Turn on the native-pi frame throttle before the first frame: from here on
6528    // `request_render(false)` parks a frame and the scheduler thread paints at
6529    // most one per `MIN_RENDER_INTERVAL_MS` (16 ms). A provider that streams one
6530    // `MessageUpdate` per delta would otherwise force a full transcript
6531    // re-render + full-viewport terminal write per token, which is what made
6532    // long output saturate the terminal (and its GPU compositor). Immediate
6533    // renders (selectors, the first frame, `render_now(..)`) are unaffected.
6534    tui.start_render_scheduler();
6535    tui.start_readerless();
6536
6537    // Consume local self-update results even when network checks are disabled.
6538    // Keep package discovery separate so package managers and Git remotes
6539    // cannot delay an rpi update notice or a prior helper failure.
6540    let mut update_check_handles = Vec::new();
6541    let rpi_chat = chat_container.clone();
6542    let rpi_tui = tui.clone();
6543    update_check_handles.push(tokio::spawn(async move {
6544        let report = crate::updates::check_rpi_startup().await;
6545        if !report.is_empty() {
6546            add_update_notices(&rpi_chat, &report);
6547        }
6548        rpi_tui.request_render(false);
6549    }));
6550
6551    if update_checks_enabled {
6552        let update_args = args.clone();
6553        let update_cwd = cwd.clone();
6554        let update_project_trusted = project_trusted;
6555        let chat = chat_container.clone();
6556        let tui = tui.clone();
6557        update_check_handles.push(tokio::spawn(async move {
6558            let resources = crate::session::package_resources_for_update_check(
6559                &update_args,
6560                &update_cwd,
6561                update_project_trusted,
6562            );
6563            let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
6564            {
6565                Ok(npm_command) => {
6566                    crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
6567                        Some(&resources),
6568                        &npm_command,
6569                        update_project_trusted.then_some(update_cwd.as_path()),
6570                    )
6571                    .await
6572                }
6573                Err(error) => {
6574                    add_error_message(
6575                        &chat,
6576                        &format!("npm package update checks disabled: {error}"),
6577                    );
6578                    let git_resources = git_only_update_resources(resources);
6579                    crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
6580                }
6581            };
6582            if !report.is_empty() {
6583                add_update_notices(&chat, &report);
6584            }
6585            tui.request_render(false);
6586        }));
6587    }
6588
6589    // ---- Streaming drain task ----
6590    let drain_handle = if let Some(rx) = event_rx {
6591        let tui_drain = tui.clone();
6592        let state_drain = state.clone();
6593        let chat_drain = chat_container.clone();
6594        let lane_drain = lane.clone();
6595        Some(tokio::spawn(async move {
6596            drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
6597        }))
6598    } else {
6599        None
6600    };
6601
6602    // ---- B5d: plugin→TUI reload bridge ----
6603    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
6604    // (its cdylib would be unmapped while the call frame is still on the stack).
6605    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
6606    // (an `UnboundedSender<()>`); this task drains those signals and forwards
6607    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
6608    // `reload_extension_resources` routine asynchronously. The mailbox is the
6609    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
6610    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
6611    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
6612    reload_context.mailbox.install(reload_sig_tx);
6613    let reload_tx = tx.clone();
6614    let reload_bridge_handle = tokio::spawn(async move {
6615        while reload_sig_rx.recv().await.is_some() {
6616            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
6617                break; // main loop gone — stop forwarding
6618            }
6619        }
6620    });
6621
6622    // ---- Render-tick task (advances the loader spinner while Working) ----
6623    //
6624    // The `Loader` only advances its frame on render; without a periodic
6625    // `request_render` the spinner visibly freezes between events.
6626    let tui_tick = tui.clone();
6627    let state_tick = state.clone();
6628    // The tick, not the key loop, drives the voice-draft auto-send: it must
6629    // fire even when the user types nothing.
6630    let tx_tick = tx.clone();
6631    let tick_handle = tokio::spawn(async move {
6632        // 80ms per frame — native pi's `DEFAULT_INTERVAL_MS`, i.e. a full
6633        // 10-frame cycle every 800ms. The tick only *requests a repaint*; the
6634        // frame is advanced by `Editor::render` (mirroring `Loader::render`),
6635        // so streaming output — which schedules a frame per token — spins it
6636        // faster, and this interval is the floor when the model is silent.
6637        //
6638        // Advancing here as well would double-step every tick and make the idle
6639        // spinner run at 40ms/frame, twice native pi's rate.
6640        let mut interval = tokio::time::interval(std::time::Duration::from_millis(
6641            rpi_tui::loader::SPINNER_FRAME_MS,
6642        ));
6643        interval.tick().await; // discard immediate
6644        loop {
6645            interval.tick().await;
6646            // Extension status (langfuse ✓ …, …) — cheap revision check, and the
6647            // only reason an idle session repaints its footer.
6648            if state_tick.sync_extension_status() {
6649                tui_tick.request_render(false);
6650            }
6651            // Extension editor-text injection (`SetEditorText`): a voice plugin
6652            // drops a transcription in as an editable draft.
6653            if state_tick.drain_editor_text() {
6654                tui_tick.request_render(false);
6655            }
6656            // Tell extensions the draft changed (voice barge-in). Runs after the
6657            // drain so an injected transcription also counts as a change.
6658            state_tick.sync_editor_change();
6659            // Advance a draft's auto-send countdown (submit / cancel / repaint).
6660            if poll_auto_send(&state_tick, &tx_tick) {
6661                tui_tick.request_render(false);
6662            }
6663            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
6664            if working {
6665                // A live transcript panel — a running bash command *or* a
6666                // running tool call — computes its elapsed readout from
6667                // `Instant::now()` at render time. A frame that reuses the
6668                // cached scroll content never rebuilds it, so the readout
6669                // froze at the second of the last full rebuild (reported: a
6670                // `search` call stuck at "2.0s" with no way to tell it was
6671                // still running). Rebuild while any such panel is live.
6672                let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
6673                let has_live_panel = {
6674                    let tools = state_tick.tool_components.lock().unwrap();
6675                    transcript_has_live_panel(bash_present, &tools)
6676                };
6677                if has_live_panel {
6678                    tui_tick.request_render(false);
6679                } else {
6680                    // Only the dock loader animates. Keep the already-rendered
6681                    // transcript instead of rebuilding a long history at 12.5
6682                    // frames per second.
6683                    tui_tick.request_render_reusing_scroll_content();
6684                }
6685            }
6686        }
6687    });
6688
6689    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
6690    let running = Arc::new(std::sync::Mutex::new(true));
6691    let running_key = running.clone();
6692    let tx_for_key = tx.clone();
6693    let tui_for_key = tui.clone();
6694    let editor_for_key = editor.clone();
6695    let scroll_for_key = scroll_view.clone();
6696    let lane_for_key = lane.clone();
6697    let state_for_key = state.clone();
6698    let model_catalog_for_key = model_catalog_arc.clone();
6699    let js_for_key = reload_context.js_extension_session.clone();
6700    let js_dialog_for_key = js_dialog_bridge.clone();
6701    let ask_user_for_key = ask_user_bridge.clone();
6702    // Ctrl+L routes through the same registry as `/model` (one path, not two),
6703    // so the key loop needs the same `CommandContext` + registry the submit
6704    // handler uses. All fields are `Arc`/cheap, so this clone is free.
6705    let ctx_for_key = ctx.clone();
6706    let registry_for_key = registry.clone();
6707    let keybindings_for_key = keybindings.clone();
6708    let double_escape_action = crate::settings::load_settings()
6709        .ok()
6710        .and_then(|settings| settings.double_escape_action)
6711        .unwrap_or_else(|| "tree".to_string())
6712        .to_ascii_lowercase();
6713
6714    let key_handle = tokio::task::spawn_blocking(move || {
6715        let mut last_escape_time = None;
6716        // Native pi's `handleCtrlC` "press twice to exit" window: the instant of
6717        // the last lone Ctrl+C. Shares the 500ms with Esc's double-escape.
6718        let mut last_sigint_time: Option<std::time::Instant> = None;
6719        // Paste-burst tracking (see the bare-Enter guard below): the instant of
6720        // the most recent key event that could have been pasted text.
6721        let mut last_text_key_at: Option<std::time::Instant> = None;
6722        // One event peeked at by the paste-burst probe (which must discard a
6723        // key's Release but never lose a meaningful event it read). Restored
6724        // at the top of the next loop iteration.
6725        let mut pending_event: Option<Event> = None;
6726        if crate::key_trace::enabled() {
6727            crate::key_trace::note(&format!(
6728                "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
6729                std::process::id(),
6730                std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
6731                crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
6732            ));
6733        }
6734        loop {
6735            if !*running_key.lock().unwrap() {
6736                break;
6737            }
6738            if !state_for_key.selector_open()
6739                && !state_for_key.extension_dialog_open()
6740                && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
6741            {
6742                if let Some(request) = js_dialog_for_key.take_pending() {
6743                    open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
6744                } else if let Some(request) = ask_user_for_key.take_pending() {
6745                    open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
6746                }
6747            }
6748            cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
6749            let ev = if let Some(ev) = pending_event.take() {
6750                ev
6751            } else {
6752                // `event::read()` blocks indefinitely. Poll first so shutdown can
6753                // stop and join this worker even when no further key arrives.
6754                match crossterm::event::poll(std::time::Duration::from_millis(50)) {
6755                    Ok(true) => {}
6756                    Ok(false) => continue,
6757                    Err(_) => {
6758                        state_for_key.cancel_js_preparation();
6759                        let _ = tx_for_key.send(TuiMessage::Exit);
6760                        break;
6761                    }
6762                }
6763                let Ok(ev) = crossterm::event::read() else {
6764                    state_for_key.cancel_js_preparation();
6765                    let _ = tx_for_key.send(TuiMessage::Exit);
6766                    break;
6767                };
6768                ev
6769            };
6770            // `Event::Resize` is delivered as its own event (not a Key). With
6771            // `start_readerless` there is no competing terminal-reader thread to
6772            // handle it, so refresh the cached terminal size here and force a
6773            // full redraw so the constrained layout re-fits the new dimensions.
6774            if let Event::Resize(_cols, _rows) = ev {
6775                tui_for_key.refresh_size();
6776                if let Some(js) = &js_for_key {
6777                    if js.custom_active() {
6778                        let _ = js.send_custom_resize(_cols as usize, _rows as usize);
6779                        tui_for_key.request_render(false);
6780                    }
6781                }
6782                continue;
6783            }
6784            // Mouse wheel scrolls the transcript (pi supports wheel
6785            // scrolling). Previously every non-Key event was dropped, so a
6786            // wheel had zero effect — "滚动还是不行".
6787            if let Event::Mouse(m) = ev {
6788                use crossterm::event::MouseEventKind;
6789                match m.kind {
6790                    MouseEventKind::ScrollUp => {
6791                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
6792                        if scroll_for_key.scroll_by(delta) != delta {
6793                            tui_for_key.request_render_reusing_scroll_content();
6794                        }
6795                    }
6796                    MouseEventKind::ScrollDown => {
6797                        let delta = MOUSE_WHEEL_SCROLL_LINES;
6798                        if scroll_for_key.scroll_by(delta) != delta {
6799                            tui_for_key.request_render_reusing_scroll_content();
6800                        }
6801                    }
6802                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
6803                        // Start selection tracking. `selection_end` doubles as the
6804                        // "a real drag happened" marker: a plain tap never sets
6805                        // it, so it can never overwrite the clipboard.
6806                        *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
6807                        *state_for_key.selection_end.lock().unwrap() = None;
6808                    }
6809                    MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
6810                        // Motion with the button held = a genuine selection drag.
6811                        // (Terminals only report this with button-event mouse
6812                        // tracking, which `ProcessTerminal` enables.)
6813                        if state_for_key.selection_start.lock().unwrap().is_some() {
6814                            *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
6815                        }
6816                    }
6817                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
6818                        // End selection and auto-copy if enabled
6819                        if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
6820                            let end = (m.column, m.row);
6821
6822                            // Only auto-copy when the pointer actually MOVED with
6823                            // the button held (`Drag` set `selection_end`). A plain
6824                            // click/tap — even one whose Down/Up cells differ by a
6825                            // pixel over RDP — must not touch the clipboard: it used
6826                            // to fire `extract_selected_text`, which copies whatever
6827                            // rendered TUI line sat under the pointer, silently
6828                            // clobbering the user's real clipboard so the next paste
6829                            // returned that stale text.
6830                            let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
6831                            *state_for_key.selection_end.lock().unwrap() = Some(end);
6832
6833                            // Check if auto-copy is enabled
6834                            let auto_copy = crate::settings::load_settings()
6835                                .ok()
6836                                .and_then(|s| s.fullscreen_copy_on_select)
6837                                .unwrap_or(true);
6838
6839                            if auto_copy && is_drag {
6840                                // Try to extract selected text from chat container
6841                                if let Some(selected_text) =
6842                                    extract_selected_text(&state_for_key.chat_container, start, end)
6843                                {
6844                                    if !selected_text.trim().is_empty() {
6845                                        let _ = copy_to_clipboard(&selected_text);
6846                                    }
6847                                }
6848                            }
6849                        }
6850                        *state_for_key.selection_start.lock().unwrap() = None;
6851                        *state_for_key.selection_end.lock().unwrap() = None;
6852                    }
6853                    _ => {}
6854                }
6855                continue;
6856            }
6857            let Event::Key(key) = ev else {
6858                if let Event::Paste(text) = ev {
6859                    crate::key_trace::note(&format!(
6860                        "paste event ({} bytes, bracketed paste supported)",
6861                        text.len()
6862                    ));
6863                    let candidate = text.trim().trim_matches(['\"', '\'']);
6864                    let path = std::path::PathBuf::from(candidate);
6865                    if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
6866                        if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
6867                            add_image_preview(&state_for_key.chat_container, &image);
6868                            state_for_key.queue_image(image);
6869                            add_note_message(
6870                                &state_for_key.chat_container,
6871                                "Dropped image attached to the next prompt.",
6872                            );
6873                            tui_for_key.request_render(false);
6874                            continue;
6875                        }
6876                    }
6877                    editor_for_key.handle_paste(&text);
6878                    refresh_autocomplete(&state_for_key, &editor_for_key);
6879                    tui_for_key.request_render_reusing_scroll_content();
6880                }
6881                continue;
6882            };
6883            // Diagnostic trace (off unless RPI_DEBUG_KEYS is set): records what
6884            // actually arrived before any routing decision, so a client that
6885            // sends LF for Enter is visible as `Char('j') mods=CONTROL`.
6886            crate::key_trace::key(&key, "");
6887            // A key press during a voice-draft auto-send countdown cancels the
6888            // countdown — the user wants to edit, not send. The key still falls
6889            // through to the editor, so it doubles as their first correction.
6890            if key.kind != KeyEventKind::Release && state_for_key.cancel_auto_send_on_key() {
6891                tui_for_key.request_render(false);
6892            }
6893            // Extension shortcuts see Press *and* Release (this is what lets a
6894            // push-to-talk extension time a hold). Checked before the
6895            // release-drop below; a claimed key with an empty editor is routed
6896            // to the plugin's `Input` handler instead of the editor.
6897            if dispatch_key_event(&state_for_key, &key, &editor_for_key) {
6898                continue;
6899            }
6900            // Drop releases but preserve Repeat so holding arrows, Backspace,
6901            // PageUp, etc. behaves naturally. Windows emits Press + Release
6902            // for a tap; terminals with keyboard enhancement may additionally
6903            // emit Repeat while a key is held.
6904            if !should_dispatch_key(key.kind) {
6905                continue;
6906            }
6907
6908            // Prompt preparation runs on a blocking worker before the agent
6909            // lane owns the turn. Cancel it directly: an abort queued only to
6910            // the lane cannot wake a JS factory or lifecycle hook that never
6911            // resolves. This check precedes custom/dialog routing because
6912            // those components may themselves have been opened by the hook.
6913            let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
6914                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
6915                || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
6916            if prompt_abort && state_for_key.cancel_js_preparation() {
6917                state_for_key.set_status(RunStatus::Aborting);
6918                if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
6919                    close_extension_editor(
6920                        &state_for_key,
6921                        &ctx_for_key.editor_container,
6922                        &editor_for_key,
6923                        &tui_for_key,
6924                    );
6925                }
6926                js_dialog_for_key.cancel_open_requests();
6927                tui_for_key.set_render_suspended(false);
6928                tui_for_key.request_render(false);
6929                continue;
6930            }
6931
6932            if let Some(js) = &js_for_key {
6933                if js.custom_active() {
6934                    let visible = js.custom_accepts_input();
6935                    let data = key_event_to_input(key);
6936                    if !data.is_empty() {
6937                        // A visible custom owns the whole key stream, so its
6938                        // acknowledgement is unnecessary and would add a
6939                        // synchronous round-trip to every keystroke. Hidden
6940                        // overlays need the consume result to decide whether the
6941                        // outer editor should see the key.
6942                        if visible {
6943                            let _ = js.send_custom_input(&data);
6944                            continue;
6945                        }
6946                        let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
6947                        // A hidden component only keeps raw listeners alive (for
6948                        // example ask_user_question's reopen shortcut); an
6949                        // unconsumed key continues through the outer editor.
6950                        if consumed {
6951                            tui_for_key.request_render_reusing_scroll_content();
6952                            continue;
6953                        }
6954                    }
6955                }
6956            }
6957
6958            // Ctrl+C cancels an open selector before it reaches the global
6959            // abort/exit handler. Route through Esc so selector callbacks run.
6960            if key.modifiers == KeyModifiers::CONTROL
6961                && key.code == KeyCode::Char('c')
6962                && state_for_key.selector_open()
6963            {
6964                let selector = state_for_key
6965                    .active_selector
6966                    .lock()
6967                    .unwrap()
6968                    .clone()
6969                    .expect("selector_open guaranteed Some")
6970                    .0;
6971                selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6972                tui_for_key.request_render_reusing_scroll_content();
6973                continue;
6974            }
6975
6976            // Extension dialogs own the input slot while awaiting a result.
6977            // Esc and Ctrl+C both resolve the pending command with cancel;
6978            // all other keys go to the active native editor/input widget.
6979            if state_for_key.extension_dialog_open() {
6980                let cancel = key.code == KeyCode::Esc
6981                    || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
6982                if cancel {
6983                    if !run_extension_cancel(&state_for_key) {
6984                        close_extension_editor(
6985                            &state_for_key,
6986                            &ctx_for_key.editor_container,
6987                            &editor_for_key,
6988                            &tui_for_key,
6989                        );
6990                    }
6991                } else if let Some(extension_editor) = state_for_key
6992                    .active_extension_editor
6993                    .lock()
6994                    .unwrap()
6995                    .clone()
6996                {
6997                    extension_editor.handle_key(key);
6998                } else if let Some(extension_input) =
6999                    state_for_key.active_extension_input.lock().unwrap().clone()
7000                {
7001                    extension_input.handle_key(key);
7002                }
7003                tui_for_key.request_render_reusing_scroll_content();
7004                continue;
7005            }
7006
7007            // 0. Ctrl+C: native pi's `handleCtrlC`. A second press inside the
7008            //    double-press window quits; any other press only clears the
7009            //    editor (text *and* selection — native `clearEditor`). It never
7010            //    aborts — `app.interrupt` (Esc) owns cancelling a run — so a
7011            //    stray Ctrl+C while streaming can no longer kill the turn.
7012            //    Open selectors and extension dialogs are handled above so
7013            //    their cancellation callbacks get first chance.
7014            if keybinding_matches(
7015                &keybindings_for_key,
7016                &key,
7017                rpi_tui::keybindings::keys::CLEAR,
7018            ) {
7019                // A held key auto-repeats: only a fresh Press may arm or
7020                // consume the window, so holding Ctrl+C cannot turn one tap
7021                // into the quit.
7022                if key.kind != KeyEventKind::Repeat {
7023                    let now = std::time::Instant::now();
7024                    match ctrl_c_action(last_sigint_time, now) {
7025                        CtrlCAction::Exit => {
7026                            last_sigint_time = None;
7027                            // The async loop parks inside
7028                            // `run_prompt_streaming(..).await` for the whole run,
7029                            // so a queued `Exit` would not be read until the run
7030                            // returns — force the quit while one is active. Either
7031                            // way the TUI stays quittable.
7032                            if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
7033                                let _ = tx_for_key.send(TuiMessage::Exit);
7034                            } else {
7035                                emergency_exit(&tui_for_key);
7036                            }
7037                        }
7038                        CtrlCAction::Clear => {
7039                            editor_for_key.clear();
7040                            refresh_autocomplete(&state_for_key, &editor_for_key);
7041                            last_sigint_time = Some(now);
7042                            tui_for_key.request_render(false);
7043                        }
7044                    }
7045                }
7046                continue;
7047            }
7048
7049            // 0b. Ctrl+X: native pi's `app.message.copy` — copy the editor
7050            //     selection, else the last assistant reply. Native's Ctrl+C no
7051            //     longer copies, so selection-copy lives here now.
7052            if !state_for_key.selector_open()
7053                && keybinding_matches(
7054                    &keybindings_for_key,
7055                    &key,
7056                    rpi_tui::keybindings::keys::MESSAGE_COPY,
7057                )
7058            {
7059                if editor_for_key.has_selection() && editor_for_key.copy_selection() {
7060                    add_note_message(&state_for_key.chat_container, "Copied!");
7061                } else {
7062                    copy_last_assistant(&state_for_key, &state_for_key.chat_container);
7063                }
7064                tui_for_key.request_render(false);
7065                continue;
7066            }
7067
7068            // 0c. Ctrl+Z (Unix only): native pi's `app.suspend` — hand the
7069            //     terminal back to the shell and stop ourselves; `SIGTSTP`
7070            //     returns when the user resumes with `fg`. Windows binds no key
7071            //     here (native's `defaultKeys` is empty), so this never fires.
7072            if keybinding_matches(
7073                &keybindings_for_key,
7074                &key,
7075                rpi_tui::keybindings::keys::SUSPEND,
7076            ) {
7077                handle_suspend(&tui_for_key);
7078                continue;
7079            }
7080
7081            // 1. A selector overlay is open → route to it first. Only Esc
7082            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
7083            //    escape to the selector; on done/cancel the selector callbacks
7084            //    restore the editor and clear `active_selector`.
7085            if state_for_key.selector_open() {
7086                // Esc always cancels the selector (even with modifiers off).
7087                // Route through `SelectList::handle_key(Esc)` so the list's
7088                // `on_cancel` fires (the `/scoped-models` toggle selector saves
7089                // its edits there) — the old shortcut called `close_selector`
7090                // directly and skipped the callback.
7091                if key.code == KeyCode::Esc {
7092                    let (selector, _kind) = state_for_key
7093                        .active_selector
7094                        .lock()
7095                        .unwrap()
7096                        .clone()
7097                        .expect("selector_open guaranteed Some");
7098                    selector.handle_key(key);
7099                    continue;
7100                }
7101                let (selector, _kind) = state_for_key
7102                    .active_selector
7103                    .lock()
7104                    .unwrap()
7105                    .clone()
7106                    .expect("selector_open guaranteed Some");
7107                selector.handle_key(key);
7108                tui_for_key.request_render_reusing_scroll_content();
7109                continue;
7110            }
7111
7112            // 2a. Ctrl+D: native pi's `app.exit`. `handleCtrlD` only fires when
7113            //     the editor is empty; with text present the key falls through to
7114            //     the editor's deleteCharForward (`tui.editor.deleteCharForward`).
7115            //     It never aborts — that is `app.interrupt` (Esc)'s job.
7116            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
7117                if !editor_for_key.get_text().is_empty() {
7118                    // Editor holds text — delete the char forward (pi parity).
7119                    editor_for_key.handle_key(key);
7120                    refresh_autocomplete(&state_for_key, &editor_for_key);
7121                    tui_for_key.request_render_reusing_scroll_content();
7122                    continue;
7123                }
7124                // Empty editor → quit. A parked run needs the forced path: a
7125                // queued `Exit` is unread until the run returns.
7126                if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
7127                    let _ = tx_for_key.send(TuiMessage::Exit);
7128                } else {
7129                    emergency_exit(&tui_for_key);
7130                }
7131                continue;
7132            }
7133
7134            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
7135            //     selector is open Esc already cancelled it above; when idle,
7136            //     Esc falls through to the editor (no-op-ish). Only fire while
7137            //     Working so an idle Esc doesn't abort a non-existent run.
7138            if keybinding_matches(
7139                &keybindings_for_key,
7140                &key,
7141                rpi_tui::keybindings::keys::INTERRUPT,
7142            ) {
7143                let status = *state_for_key.status.lock().unwrap();
7144                if status == RunStatus::Working {
7145                    state_for_key.set_status(RunStatus::Aborting);
7146                    ask_user_for_key.cancel_all();
7147                    if !run_extension_cancel(&state_for_key)
7148                        && state_for_key.extension_dialog_open()
7149                    {
7150                        close_extension_editor(
7151                            &state_for_key,
7152                            &ctx_for_key.editor_container,
7153                            &editor_for_key,
7154                            &tui_for_key,
7155                        );
7156                    }
7157                    // Native pi's `onEscape` restores the queue before it
7158                    // aborts, so an interrupt hands the staged messages back.
7159                    abort_run_restoring_queue(
7160                        lane_for_key.clone(),
7161                        editor_for_key.clone(),
7162                        state_for_key.clone(),
7163                        tui_for_key.clone(),
7164                    );
7165                    continue;
7166                }
7167                // A running `!command` takes Esc next (native pi's `onEscape`
7168                // precedence: streaming → bash → bash-mode editor →
7169                // double-escape). The run slot stays occupied until the capture
7170                // resolves, so a second Esc is a no-op rather than a
7171                // double-escape trigger.
7172                if state_for_key.user_bash_running() {
7173                    state_for_key.cancel_user_bash();
7174                    tui_for_key.request_render(false);
7175                    continue;
7176                }
7177                if status == RunStatus::Idle
7178                    && editor_for_key.get_text().trim().is_empty()
7179                    && double_escape_action != "none"
7180                {
7181                    let now = std::time::Instant::now();
7182                    if double_escape_trigger(last_escape_time, now) {
7183                        last_escape_time = None;
7184                        match double_escape_action.as_str() {
7185                            "tree" => {
7186                                let _ = tx_for_key.send(TuiMessage::OpenTree);
7187                            }
7188                            "fork" => {
7189                                let _ = tx_for_key.send(TuiMessage::ForkSession);
7190                            }
7191                            _ => {}
7192                        }
7193                    } else {
7194                        last_escape_time = Some(now);
7195                    }
7196                }
7197                continue;
7198            }
7199
7200            // 2c. Ctrl+G: edit the current draft in the user's external
7201            // editor, matching native Pi's VISUAL/EDITOR fallback chain.
7202            if keybinding_matches(
7203                &keybindings_for_key,
7204                &key,
7205                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
7206            ) {
7207                launch_external_editor(editor_for_key.get_expanded_text(), tx_for_key.clone());
7208                continue;
7209            }
7210
7211            // 2d. Ctrl+O: toggle all tool output panels between compact and
7212            // expanded rendering (native Pi's global output toggle).
7213            if keybinding_matches(
7214                &keybindings_for_key,
7215                &key,
7216                rpi_tui::keybindings::keys::TOOLS_EXPAND,
7217            ) {
7218                let expanded = state_for_key.toggle_tool_outputs();
7219                // Persist per-session so a later `-c`/`-r` restores it.
7220                let session = ctx_for_key.session.clone();
7221                tokio::spawn(async move {
7222                    let _ = session
7223                        .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
7224                        .await;
7225                });
7226                tui_for_key.request_render(false);
7227                continue;
7228            }
7229
7230            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
7231            if keybinding_matches(
7232                &keybindings_for_key,
7233                &key,
7234                rpi_tui::keybindings::keys::THINKING_TOGGLE,
7235            ) {
7236                let hide = state_for_key.toggle_thinking();
7237                // Persist per-session so a later `-c`/`-r` restores it.
7238                let session = ctx_for_key.session.clone();
7239                tokio::spawn(async move {
7240                    let _ = session
7241                        .set_value("display.hide_thinking", serde_json::json!(hide))
7242                        .await;
7243                });
7244                tui_for_key.request_render(false);
7245                continue;
7246            }
7247
7248            // 2f. Ctrl+P: cycle to the next model in the catalog after the one
7249            //     currently tracked in `current_model_id`, apply it live via
7250            //     `lane.set_model` (takes effect on the next user message — the
7251            //     in-flight run's config is already snapshotted), and update the
7252            //     footer. `set_model` is async so it runs on a spawned task.
7253            if keybinding_matches(
7254                &keybindings_for_key,
7255                &key,
7256                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
7257            ) {
7258                let current = state_for_key.current_model_id();
7259                // Cycle within the `/scoped-models` set (settings.json) when
7260                // configured; otherwise the full catalog.
7261                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
7262                if let Some(next) = cycle_next_model(&scope, &current) {
7263                    state_for_key.set_current_model(&next);
7264                    let lane = lane_for_key.clone();
7265                    tokio::spawn(async move {
7266                        let _ = lane.set_model(next).await;
7267                    });
7268                    tui_for_key.request_render_reusing_scroll_content();
7269                }
7270                continue;
7271            }
7272
7273            // 2f-bis. Shift+Ctrl+P (Alt+P on Windows): cycle to the *previous*
7274            //     model, the mirror of the Ctrl+P hotkey above.
7275            if keybinding_matches(
7276                &keybindings_for_key,
7277                &key,
7278                rpi_tui::keybindings::keys::MODEL_CYCLE_BACKWARD,
7279            ) {
7280                let current = state_for_key.current_model_id();
7281                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
7282                if let Some(prev) = cycle_prev_model(&scope, &current) {
7283                    state_for_key.set_current_model(&prev);
7284                    let lane = lane_for_key.clone();
7285                    tokio::spawn(async move {
7286                        let _ = lane.set_model(prev).await;
7287                    });
7288                    tui_for_key.request_render_reusing_scroll_content();
7289                }
7290                continue;
7291            }
7292
7293            // 2f. Shift+Tab / BackTab: cycle the current model's supported
7294            // thinking levels, matching native Pi's thinking-level shortcut.
7295            if keybinding_matches(
7296                &keybindings_for_key,
7297                &key,
7298                rpi_tui::keybindings::keys::THINKING_CYCLE,
7299            ) {
7300                let lane = lane_for_key.clone();
7301                let catalog = model_catalog_for_key.clone();
7302                let state = state_for_key.clone();
7303                tokio::spawn(async move {
7304                    let Ok(current_model) = lane.get_model().await else {
7305                        return;
7306                    };
7307                    let levels = catalog
7308                        .iter()
7309                        .find(|model| {
7310                            model.provider == current_model.provider && model.id == current_model.id
7311                        })
7312                        .map(|model| model.supported_thinking_levels())
7313                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
7314                    if levels.is_empty() {
7315                        return;
7316                    }
7317                    let current = lane
7318                        .get_thinking_level()
7319                        .await
7320                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
7321                    let next = levels
7322                        .iter()
7323                        .position(|level| *level == current)
7324                        .map(|index| levels[(index + 1) % levels.len()])
7325                        .unwrap_or(levels[0]);
7326                    if lane.set_thinking_level(next).await.is_ok() {
7327                        state
7328                            .footer
7329                            .set_thinking_level(Some(thinking_level_name(next)));
7330                        state.tui.as_ref().map(|tui| tui.request_render(false));
7331                    }
7332                });
7333                continue;
7334            }
7335
7336            // Ctrl+V (or a configured paste-image key): if the system
7337            // clipboard holds an image, queue it for the next prompt;
7338            // otherwise paste the clipboard TEXT into the editor. Previously
7339            // a text-only clipboard fell through to the editor's Ctrl+V,
7340            // which yanks from the internal KILL RING — not the system
7341            // clipboard. On platforms without bracketed-paste `Event::Paste`
7342            // (Windows console, some remote clients) that meant Ctrl+V pasted
7343            // stale/other content instead of the user's real clipboard
7344            // ("不是系统的粘贴的内容").
7345            if keybinding_matches(
7346                &keybindings_for_key,
7347                &key,
7348                rpi_tui::keybindings::keys::PASTE_IMAGE,
7349            ) {
7350                match read_clipboard_image() {
7351                    Ok(Some(image)) => {
7352                        add_image_preview(&state_for_key.chat_container, &image);
7353                        state_for_key.queue_image(image);
7354                        add_note_message(
7355                            &state_for_key.chat_container,
7356                            "Clipboard image attached to the next prompt.",
7357                        );
7358                        tui_for_key.request_render(false);
7359                        continue;
7360                    }
7361                    Ok(None) | Err(_) => {}
7362                }
7363                // The arboard text fallback is Windows-only. On Unix a real
7364                // paste arrives as `Event::Paste` (bracketed paste is enabled
7365                // by `ProcessTerminal::enter_raw_mode`), so reading the
7366                // SERVER's clipboard here would paste stale TUI content on a
7367                // remote/mobile client — copy-on-select can have written the
7368                // welcome "Skills (…)" line into the server clipboard, and
7369                // Ctrl+V on the client returns that instead of the user's own
7370                // clipboard ("不是系统的粘贴的内容"). On Windows the console
7371                // never emits `Event::Paste`, so the arboard read is the only
7372                // paste source and must stay.
7373                if cfg!(windows) {
7374                    if let Some(text) = read_clipboard_text() {
7375                        if !text.is_empty() {
7376                            editor_for_key.handle_paste(&text);
7377                            refresh_autocomplete(&state_for_key, &editor_for_key);
7378                            tui_for_key.request_render_reusing_scroll_content();
7379                            continue;
7380                        }
7381                    }
7382                }
7383            }
7384
7385            // 3. Ctrl+Shift+F: open transcript search
7386            if key
7387                .modifiers
7388                .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
7389                && key.code == KeyCode::Char('F')
7390            {
7391                state_for_key.search.activate();
7392                state_for_key.search_bar.set_visible(true);
7393                state_for_key.search_bar.set_query("");
7394                state_for_key.search_bar.set_match_info(0, 0);
7395                tui_for_key.request_render(false);
7396                continue;
7397            }
7398
7399            // Handle search mode input
7400            if state_for_key.search.is_active() {
7401                let search_bar = state_for_key.search_bar.clone();
7402                match key.code {
7403                    KeyCode::Esc => {
7404                        state_for_key.search.deactivate();
7405                        search_bar.set_visible(false);
7406                        tui_for_key.request_render(false);
7407                        continue;
7408                    }
7409                    KeyCode::Enter => {
7410                        if key.modifiers.contains(KeyModifiers::SHIFT) {
7411                            state_for_key.search.previous_match();
7412                        } else {
7413                            state_for_key.search.next_match();
7414                        }
7415                        // Update search bar with current match info
7416                        let match_index = state_for_key.search.get_match_index();
7417                        let match_count = state_for_key.search.get_match_count();
7418                        search_bar.set_match_info(match_index, match_count);
7419                        tui_for_key.request_render(false);
7420                        continue;
7421                    }
7422                    KeyCode::Backspace => {
7423                        state_for_key.search.backspace();
7424                        // Re-search with updated query
7425                        let lines = collect_transcript_lines(&state_for_key.chat_container);
7426                        state_for_key.search.find_matches(&lines);
7427                        // Update search bar
7428                        let query = state_for_key.search.get_query();
7429                        let match_count = state_for_key.search.get_match_count();
7430                        search_bar.set_query(&query);
7431                        search_bar.set_match_info(0, match_count);
7432                        tui_for_key.request_render(false);
7433                        continue;
7434                    }
7435                    KeyCode::Char(c) => {
7436                        state_for_key.search.append_char(c);
7437                        // Re-search with updated query
7438                        let lines = collect_transcript_lines(&state_for_key.chat_container);
7439                        state_for_key.search.find_matches(&lines);
7440                        // Update search bar
7441                        let query = state_for_key.search.get_query();
7442                        let match_count = state_for_key.search.get_match_count();
7443                        search_bar.set_query(&query);
7444                        search_bar.set_match_info(0, match_count);
7445                        tui_for_key.request_render(false);
7446                        continue;
7447                    }
7448                    _ => continue,
7449                }
7450            }
7451
7452            // 3. Ctrl+L: open the model selector. Routed through the `/model`
7453            //    command so the hotkey and the slash command share one path
7454            //    (TS binds Ctrl+L to model-select).
7455            if keybinding_matches(
7456                &keybindings_for_key,
7457                &key,
7458                rpi_tui::keybindings::keys::MODEL_SELECT,
7459            ) {
7460                if let Some(cmd) = registry_for_key.find("/model") {
7461                    cmd.execute(&ctx_for_key, "");
7462                }
7463                continue;
7464            }
7465
7466            // 4. Tab: accept the top autocomplete suggestion (if any).
7467            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
7468                if accept_top_suggestion(&state_for_key, &editor_for_key) {
7469                    tui_for_key.request_render_reusing_scroll_content();
7470                }
7471                continue;
7472            }
7473
7474            // 5. Global transcript scroll. PageUp/PageDown use the actual
7475            // viewport height with four rows of overlap (upstream behavior),
7476            // while Home/End jump to the transcript boundaries.
7477            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
7478                let delta = -transcript_page_size(scroll_for_key.viewport_height());
7479                if scroll_for_key.scroll_by(delta) != delta {
7480                    tui_for_key.request_render_reusing_scroll_content();
7481                }
7482                continue;
7483            }
7484            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
7485                let delta = transcript_page_size(scroll_for_key.viewport_height());
7486                if scroll_for_key.scroll_by(delta) != delta {
7487                    tui_for_key.request_render_reusing_scroll_content();
7488                }
7489                continue;
7490            }
7491            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
7492                scroll_for_key.scroll_to_start();
7493                tui_for_key.request_render_reusing_scroll_content();
7494                continue;
7495            }
7496            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
7497                scroll_for_key.scroll_to_end();
7498                tui_for_key.request_render_reusing_scroll_content();
7499                continue;
7500            }
7501
7502            // 5b. ↑/↓ browse submitted-message history when the editor is
7503            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
7504            //     without the surprise of replacing typed text. When the
7505            //     editor holds content, ↑/↓ fall through to cursor movement
7506            //     (typing "hello", pressing ↑ at the start, must never swap
7507            //     the draft for a history entry — reported as "text
7508            //     disappeared"). Once browsing, ↓ walks back and restores the
7509            //     draft.
7510            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
7511                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7512                if editor_for_key.get_text().is_empty() || browsing {
7513                    navigate_history(&state_for_key, &editor_for_key, -1);
7514                    tui_for_key.request_render_reusing_scroll_content();
7515                    continue;
7516                }
7517            }
7518            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
7519                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7520                if editor_for_key.get_text().is_empty() || browsing {
7521                    navigate_history(&state_for_key, &editor_for_key, 1);
7522                    tui_for_key.request_render_reusing_scroll_content();
7523                    continue;
7524                }
7525            }
7526
7527            // `app.message.dequeue` (Alt+Q on Windows, Alt+Up elsewhere):
7528            // restore every queued steering/follow-up message into the editor
7529            // so it can be edited before resending. Mirrors native pi's
7530            // `handleDequeue`; the pending-messages display shows the hint.
7531            if keybinding_matches(
7532                &keybindings_for_key,
7533                &key,
7534                rpi_tui::keybindings::keys::DEQUEUE,
7535            ) {
7536                // Handled here, on the key worker — NOT via the mailbox. The
7537                // async main loop parks inside `run_prompt_streaming(..).await`
7538                // for the whole run, and a queue only exists *while* a run is
7539                // in flight, so a `TuiMessage::Dequeue` sat unread until the
7540                // run finished (after the lane had already consumed the queue).
7541                // Alt+Q therefore looked like a no-op while it was most needed.
7542                // Spawn the drain so it lands while the user is still looking at
7543                // the queue, exactly like the Alt+Enter `follow_up` path above.
7544                // Routed through `handle_dequeue` (not the raw restore) so the
7545                // `Restored N queued message(s)` status note fires, mirroring
7546                // native pi's `handleDequeue`.
7547                tokio::spawn(handle_dequeue(
7548                    lane_for_key.clone(),
7549                    editor_for_key.clone(),
7550                    state_for_key.clone(),
7551                    tui_for_key.clone(),
7552                ));
7553                continue;
7554            }
7555
7556            // `app.message.followUp` (Alt+Enter; Ctrl+Q and Alt+Enter on
7557            // Windows) queues a follow-up while a run is active. It is handled
7558            // here because Editor treats only a bare Enter as submit; when idle
7559            // the same key keeps the normal prompt behavior.
7560            if keybinding_matches(
7561                &keybindings_for_key,
7562                &key,
7563                rpi_tui::keybindings::keys::MESSAGE_FOLLOW_UP,
7564            ) {
7565                let prompt = editor_for_key.get_expanded_text().trim().to_string();
7566                if prompt.is_empty() {
7567                    continue;
7568                }
7569                editor_for_key.clear();
7570                let status = *state_for_key.status.lock().unwrap();
7571                if status == RunStatus::Idle {
7572                    if state_for_key.try_start_working() {
7573                        // Direct send: the harness emits no user `message_start`
7574                        // for it, so render the bubble here (see the submit
7575                        // handler in `interactive_tui`).
7576                        add_user_message(&state_for_key.chat_container, &prompt);
7577                        push_history(&state_for_key, &prompt);
7578                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
7579                    }
7580                } else {
7581                    // Follow-ups are echoed by the loop's
7582                    // `AgentEvent::MessageStart` once they are consumed; while
7583                    // they wait, the pending-messages display shows them.
7584                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
7585                    let lane = lane_for_key.clone();
7586                    let chat = state_for_key.chat_container.clone();
7587                    let tui = tui_for_key.clone();
7588                    let state = state_for_key.clone();
7589                    tokio::spawn(async move {
7590                        if let Err(error) = lane.follow_up(message).await {
7591                            add_error_message(&chat, &format!("Could not queue message: {error}"));
7592                            tui.request_render(false);
7593                            return;
7594                        }
7595                        refresh_pending_messages(&state, &lane).await;
7596                        tui.request_render(false);
7597                    });
7598                }
7599                tui_for_key.request_render(false);
7600                continue;
7601            }
7602
7603            // ---- Paste-burst coalescing (Windows only) ----------------------
7604            // `crossterm` 0.27 implements bracketed paste ONLY on Unix: its
7605            // Windows console event source (`ReadConsoleInputW`) never emits
7606            // `Event::Paste`, so `?2004h` buys nothing there and a pasted block
7607            // arrives as ordinary key events with a bare Enter per line. The
7608            // editor treats a bare Enter as "submit", so one paste became N
7609            // messages. See [`enter_is_paste_burst`] for the discriminator.
7610            //
7611            // On Unix the timing heuristic is both unnecessary AND harmful: a
7612            // remote/mobile client (SSH, soft keyboard) coalesces typed text +
7613            // Enter into one network burst, so the Enter lands within
7614            // [`PASTE_BURST_GAP`] of the last character and the heuristic
7615            // misclassifies it as pasted content — "回车变成了换行". Real pastes
7616            // on Unix arrive as `Event::Paste` (bracketed paste is enabled by
7617            // `ProcessTerminal::enter_raw_mode`), so gating to Windows restores
7618            // remote Enter without losing the Windows paste fix.
7619            if cfg!(windows)
7620                && key.modifiers.is_empty()
7621                && matches!(
7622                    key.code,
7623                    KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
7624                )
7625            {
7626                // Probe for a *real* queued key. Windows queues each key's
7627                // Release event directly behind its Press, and a bare
7628                // non-blocking poll cannot tell the two apart — treating that
7629                // Release as "more input" made every Enter on a remote/RDP
7630                // console look like a paste burst ("回车变成了换行"). See
7631                // [`probe_paste_input`].
7632                let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
7633                    if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
7634                        crossterm::event::read().ok()
7635                    } else {
7636                        None
7637                    }
7638                });
7639                pending_event = stashed;
7640                if enter_is_paste_burst(last_text_key_at, std::time::Instant::now(), more_queued) {
7641                    crate::key_trace::note(&format!(
7642                        "enter-decision -> newline (paste burst) gap_ms={} more_queued={more_queued}",
7643                        gap_ms(last_text_key_at),
7644                    ));
7645                    // Pasted newline: insert it and keep the remaining queued
7646                    // events flowing through this same path.
7647                    editor_for_key.insert("\n");
7648                    last_text_key_at = Some(std::time::Instant::now());
7649                    refresh_autocomplete(&state_for_key, &editor_for_key);
7650                    tui_for_key.request_render_reusing_scroll_content();
7651                    continue;
7652                }
7653                crate::key_trace::note(&format!(
7654                    "enter-decision -> submit gap_ms={} more_queued={more_queued}",
7655                    gap_ms(last_text_key_at),
7656                ));
7657                // A real submit ends the burst so a follow-up Enter is not
7658                // mistaken for paste continuation.
7659                last_text_key_at = None;
7660            } else if matches!(
7661                key.code,
7662                KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
7663            ) && !key
7664                .modifiers
7665                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
7666            {
7667                // Unmodified text keys extend the burst. Ctrl/Alt chords
7668                // (Ctrl+C, Alt+Enter, …) are commands, not paste content.
7669                last_text_key_at = Some(std::time::Instant::now());
7670            } else {
7671                // Arrows, Esc, Ctrl/Alt chords, PageUp/Down, … — not paste
7672                // content; break the burst.
7673                last_text_key_at = None;
7674            }
7675
7676            // 6. Otherwise forward to the editor + refresh autocomplete. The
7677            //    editor maps unmodified Enter and the encodings a remote client
7678            //    may send for it (`Char('\n')`/`Char('\r')`, and the raw LF
7679            //    crossterm reports as `Ctrl+J`) to submit, so a remote Enter
7680            //    sends instead of inserting a newline.
7681            editor_for_key.handle_key(key);
7682            refresh_autocomplete(&state_for_key, &editor_for_key);
7683            tui_for_key.request_render_reusing_scroll_content();
7684        }
7685    });
7686
7687    // ---- Session lifecycle (P1): SessionStart ----
7688    // The TUI + key worker are up and the session is ready to accept input.
7689    // Notify subscribed extensions now so they can initialize session-scoped
7690    // state. A veto here is **advisory** (the session is already up) — warn and
7691    // continue. No-op when no extension subscribes.
7692    if let Some(reason) = crate::session::dispatch_session_event_async(
7693        reload_context,
7694        rpi_plugin_sdk::EventTag::SessionStart,
7695    )
7696    .await
7697    {
7698        tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
7699    }
7700
7701    // ---- Initial prompts (run before reading from the channel) ----
7702    let mut prompts: Vec<String> = Vec::new();
7703    if let Some(init) = initial {
7704        prompts.push(init);
7705    }
7706    for m in extra_messages {
7707        prompts.push(m.clone());
7708    }
7709    // ---- Continue a run that recovery repaired, the way native pi does ----
7710    // This happens before any user input, and only when nothing was passed on the
7711    // command line: an explicit `-p` means the user has already said what they
7712    // want next. Driven here rather than inside the harness so the run's output
7713    // goes through the same streaming/render path as any other run, and so
7714    // startup is never blocked by a run the user cannot see.
7715    if prompts.is_empty() && lane.has_pending_resume().await {
7716        run_prompt_streaming(
7717            &lane,
7718            "",
7719            true,
7720            &tui,
7721            &state,
7722            drain_handle.is_some(),
7723            reload_context.js_extension_session.as_ref(),
7724            &js_dialog_bridge,
7725            args,
7726            Vec::new(),
7727        )
7728        .await;
7729    }
7730
7731    let mut images = initial_images;
7732    for prompt in prompts {
7733        if !*running.lock().unwrap() {
7734            break;
7735        }
7736        // The harness emits no user `message_start` for a directly-sent prompt
7737        // (it drives the loop with an empty prompts vec), so the initial
7738        // `-p` / `--prompt` bubbles are rendered here.
7739        add_user_message(&chat_container, &prompt);
7740        tui.request_render(false);
7741        run_prompt_streaming(
7742            &lane,
7743            &prompt,
7744            false,
7745            &tui,
7746            &state,
7747            drain_handle.is_some(),
7748            reload_context.js_extension_session.as_ref(),
7749            &js_dialog_bridge,
7750            args,
7751            std::mem::take(&mut images),
7752        )
7753        .await;
7754    }
7755
7756    // ---- Main loop: process submitted input + lifecycle messages ----
7757    loop {
7758        if !*running.lock().unwrap() {
7759            break;
7760        }
7761        match rx.recv().await {
7762            Some(TuiMessage::UserInput(prompt)) => {
7763                // Clear the editor so the next prompt starts fresh (the submit
7764                // handler runs on the blocking key thread and can't mutate the
7765                // editor state safely there; clearing here, on the async loop,
7766                // keeps it on one thread). Marked programmatic: this is the host
7767                // tidying up after a submit, not the user typing — reporting it
7768                // as a user edit made a hands-free voice session switch itself
7769                // off the moment it was started.
7770                state.clear_editor_programmatically();
7771                let prompt_images = state.take_pending_images();
7772                if !prompt_images.is_empty() {
7773                    add_note_message(
7774                        &chat_container,
7775                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
7776                    );
7777                }
7778                run_prompt_streaming(
7779                    &lane,
7780                    &prompt,
7781                    false,
7782                    &tui,
7783                    &state,
7784                    drain_handle.is_some(),
7785                    reload_context.js_extension_session.as_ref(),
7786                    &js_dialog_bridge,
7787                    args,
7788                    prompt_images,
7789                )
7790                .await;
7791            }
7792            Some(TuiMessage::ExternalEditorResult(result)) => {
7793                match result {
7794                    Ok(text) => {
7795                        let cursor = text.chars().count();
7796                        editor.set_text(&text);
7797                        editor.set_cursor(0, cursor);
7798                        add_note_message(&chat_container, "Draft updated from external editor.");
7799                    }
7800                    Err(error) => add_error_message(&chat_container, &error),
7801                }
7802                tui.request_render(false);
7803            }
7804            Some(TuiMessage::UserBashFinished(report)) => {
7805                // Free the run slot first: the guard and the Esc router both
7806                // read it, so a finished command must never block the next one.
7807                state.finish_user_bash();
7808                let message = rpi_harness::messages::bash_execution_message(
7809                    report.command,
7810                    report.output,
7811                    report.exit_code,
7812                    report.cancelled,
7813                    report.truncated,
7814                    report.full_output_path,
7815                    Some(report.exclude_from_context),
7816                    now_ms(),
7817                );
7818                // While a run is in flight the message is deferred so it is not
7819                // spliced into the middle of a turn's tool sequence; it is
7820                // flushed at `AgentEnd` (native pi `_pendingBashMessages`).
7821                if *state.status.lock().unwrap() == RunStatus::Idle {
7822                    if let Err(error) = lane.append_message(message).await {
7823                        add_error_message(
7824                            &chat_container,
7825                            &format!("Could not record bash result: {error}"),
7826                        );
7827                        tui.request_render(false);
7828                    }
7829                } else {
7830                    state.pending_bash_messages.lock().unwrap().push(message);
7831                }
7832            }
7833            Some(TuiMessage::OpenTree) => {
7834                if *state.status.lock().unwrap() != RunStatus::Idle {
7835                    add_note_message(
7836                        &chat_container,
7837                        "Wait for the current run to finish before opening the tree.",
7838                    );
7839                    tui.request_render(false);
7840                } else {
7841                    open_tree_selector(
7842                        &harness,
7843                        &state,
7844                        &editor_container,
7845                        &editor,
7846                        &tui,
7847                        &chat_container,
7848                        &tx,
7849                    )
7850                    .await;
7851                }
7852            }
7853            Some(TuiMessage::NavigateTree(entry_id)) => {
7854                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
7855                    Ok(result) => match result.outcome {
7856                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
7857                            chat_container.clear();
7858                            add_welcome_message(&chat_container);
7859                            render_session_history(
7860                                &harness,
7861                                &chat_container,
7862                                state.markdown_transformer(),
7863                                Some(state.extension_session.clone()),
7864                            )
7865                            .await;
7866                            add_note_message(
7867                                &chat_container,
7868                                "Moved to the selected session entry.",
7869                            );
7870                        }
7871                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
7872                            add_error_message(&chat_container, &error.message);
7873                        }
7874                        _ => add_note_message(
7875                            &chat_container,
7876                            "The selected entry could not be opened.",
7877                        ),
7878                    },
7879                    Err(error) => add_error_message(
7880                        &chat_container,
7881                        &format!("Could not navigate session tree: {error}"),
7882                    ),
7883                }
7884                tui.request_render(false);
7885            }
7886            Some(TuiMessage::ClearChat) => {
7887                chat_container.clear();
7888                add_welcome_message(&chat_container);
7889                tui.request_render(false);
7890            }
7891            Some(TuiMessage::Compact) => {
7892                run_compact(&lane, &tui, &state).await;
7893            }
7894            Some(TuiMessage::Copy) => {
7895                copy_last_assistant(&state, &chat_container);
7896                tui.request_render(false);
7897            }
7898            Some(TuiMessage::Exit) => {
7899                *running.lock().unwrap() = false;
7900                break;
7901            }
7902            Some(TuiMessage::SwitchSession(id)) => {
7903                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
7904                tui.request_render(false);
7905            }
7906            Some(TuiMessage::ImportSession(path)) => {
7907                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
7908                tui.request_render(false);
7909            }
7910            Some(TuiMessage::ShareSession) => {
7911                share_session(&harness, &chat_container).await;
7912                tui.request_render(false);
7913            }
7914            Some(TuiMessage::SetSessionName(name)) => {
7915                let outcome = harness.session().set_name(Some(&name)).await;
7916                match outcome {
7917                    Ok(_) => add_note_message(
7918                        &chat_container,
7919                        &format!("Session renamed to \"{name}\"."),
7920                    ),
7921                    Err(e) => add_error_message(
7922                        &chat_container,
7923                        &format!("Could not rename session: {e}"),
7924                    ),
7925                }
7926                tui.request_render(false);
7927            }
7928            Some(TuiMessage::ExportSession) => {
7929                let file_name = agent_session
7930                    .default_export_file_name(crate::export::ExportFormat::Markdown)
7931                    .await;
7932                let path = cwd.join(&file_name);
7933                match agent_session.export_to_markdown(&path).await {
7934                    Ok(_) => add_note_message(
7935                        &chat_container,
7936                        &format!("Exported session to {}", path.display()),
7937                    ),
7938                    Err(e) => {
7939                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
7940                    }
7941                }
7942                tui.request_render(false);
7943            }
7944            Some(TuiMessage::ExportSessionWithFormat(format)) => {
7945                let file_name = agent_session.default_export_file_name(format.clone()).await;
7946                let path = cwd.join(&file_name);
7947                match agent_session.export(format, &path).await {
7948                    Ok(_) => add_note_message(
7949                        &chat_container,
7950                        &format!("Exported session to {}", path.display()),
7951                    ),
7952                    Err(e) => {
7953                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
7954                    }
7955                }
7956                tui.request_render(false);
7957            }
7958            Some(TuiMessage::ShowUsage) => {
7959                let stats = agent_session.usage_stats().await.ok();
7960                let total = agent_session.total_usage().await.ok();
7961                let snapshot = agent_session.snapshot().await.ok();
7962                match stats {
7963                    Some(stats) => {
7964                        let mut lines = vec![
7965                            "## Usage".to_string(),
7966                            String::new(),
7967                            format!("- messages: {}", stats.message_count),
7968                            format!("- total tokens: {}", stats.total_tokens),
7969                            format!("- cached tokens: {}", stats.cached_tokens),
7970                            format!("- uncached tokens: {}", stats.uncached_tokens),
7971                            format!("- cost: ${:.4}", stats.cost_total),
7972                        ];
7973                        match stats.cache_hit_ratio() {
7974                            Some(ratio) => {
7975                                lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
7976                            }
7977                            None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
7978                        }
7979                        if let Some(total) = total {
7980                            lines.push(String::new());
7981                            lines.push(format!(
7982                                "- last cumulative input/output: {} / {}",
7983                                total.input, total.output
7984                            ));
7985                        }
7986                        if let Some(snapshot) = snapshot {
7987                            lines.push(String::new());
7988                            lines.push(format!("- lane: `{}`", snapshot.lane));
7989                            lines.push(format!(
7990                                "- leaf: `{}`",
7991                                snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
7992                            ));
7993                            if let Some(active) = &snapshot.active {
7994                                lines.push(format!(
7995                                    "- active operation: {} (`{}`)",
7996                                    active.kind.as_str(),
7997                                    active.run_id
7998                                ));
7999                            }
8000                        }
8001                        add_note_message(&chat_container, &lines.join("\n"));
8002                    }
8003                    None => {
8004                        add_error_message(&chat_container, "Could not read session usage stats.")
8005                    }
8006                }
8007                tui.request_render(false);
8008            }
8009            Some(TuiMessage::ForkSession) => {
8010                fork_session(&harness, &cwd, &chat_container, &state).await;
8011                tui.request_render(false);
8012            }
8013            Some(TuiMessage::ReloadExtensions) => {
8014                // B5d: drive the shared reload routine on the async runtime,
8015                // then surface the outcome. `reload_context` was passed into
8016                // `interactive_tui` and is the same `Arc<ReloadContext>` the
8017                // `ReloadCommand` + the plugin mailbox both route through —
8018                // clone the `Arc` out so the borrow of `harness` (the main
8019                // loop's `&AgentHarness`) lives across the await.
8020                let reload_ctx = ctx.reload_context.clone();
8021                add_note_message(&chat_container, "Reloading extensions + resources…");
8022                tui.request_render(false);
8023                let outcome =
8024                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
8025                // B5e: the reload swapped a fresh `ExtensionSession` into the
8026                // context's cell. Rebuild the markdown transformer from that
8027                // fresh snapshot and install it on the in-flight streaming
8028                // component (so a reloaded plugin's transformer takes effect on
8029                // the visible message immediately) + future components (they
8030                // read `state.markdown_transformer()` at construction). The old
8031                // closure no-ops once its snapshot's `active` flag flips false
8032                // (reload already did that before the swap).
8033                let fresh_transformer = build_markdown_transformer(
8034                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
8035                );
8036                state.set_markdown_transformer_with_reinstall(fresh_transformer);
8037                if outcome.had_errors {
8038                    // Hard failure: the summary itself carries the error detail
8039                    // (e.g. "Settings reload failed: …"), so render it as an
8040                    // error without an extra stderr pointer.
8041                    add_error_message(&chat_container, &outcome.summary);
8042                } else if outcome.had_warnings {
8043                    // Reload succeeded but produced soft diagnostics (skill
8044                    // shadowing, name validation, …). The details are printed
8045                    // to stderr; keep this as an informational note so a
8046                    // successful reload isn't shown as a red ✗ error.
8047                    add_note_message(
8048                        &chat_container,
8049                        &format!(
8050                            "{} (with warnings — see stderr for details).",
8051                            outcome.summary
8052                        ),
8053                    );
8054                } else {
8055                    add_note_message(&chat_container, &outcome.summary);
8056                }
8057                tui.request_render(false);
8058            }
8059            None => break,
8060        }
8061    }
8062
8063    // ---- Shutdown ----
8064    // Wake any Node `ctx.ui.*` request that is still waiting on the dialog
8065    // bridge before joining the key worker and restoring the terminal.
8066    js_dialog_bridge.cancel_all();
8067    ask_user_bridge.shutdown();
8068    *running.lock().unwrap() = false;
8069    for handle in update_check_handles {
8070        handle.abort();
8071        let _ = handle.await;
8072    }
8073    // A hidden custom input listener can leave the key worker blocked on a
8074    // synchronous Node response. Stop the host first so transport shutdown
8075    // wakes that request before we join the worker.
8076    if let Some(js) = &reload_context.js_extension_session {
8077        js.shutdown();
8078    }
8079    // The input worker checks `running` at least every 50ms. Join it before
8080    // restoring cooked mode so no late event read races terminal cleanup.
8081    let _ = key_handle.await;
8082    tick_handle.abort();
8083    if let Some(handle) = drain_handle {
8084        handle.abort();
8085    }
8086    // Drop the reload bridge: clearing the mailbox closes the signal channel,
8087    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
8088    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
8089    reload_context.mailbox.clear();
8090    reload_bridge_handle.abort();
8091    tui.stop(Default::default());
8092
8093    // Native pi prints how to resume the session after an interactive exit so
8094    // a terminal that scrolled away can be recovered. Mirrors
8095    // `formatResumeCommand` (id + `--session-dir` only when non-default).
8096    let session_id = harness.session().storage().metadata().id;
8097    if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
8098        println!("\x1b[2mTo resume this session:\x1b[0m {command}");
8099    }
8100    println!("\nGoodbye!");
8101    let _ = args;
8102
8103    0
8104}
8105
8106// ===========================================================================
8107// Run a single prompt (streaming or blocking)
8108// ===========================================================================
8109
8110/// Prepare the session's persistent Node host immediately before a real prompt
8111/// enters the agent loop. The first call starts the lazy host; later calls run
8112/// `before_agent_start` again on that host so each prompt sees current state.
8113/// Keeping startup here leaves an idle TUI free of a Node child while still
8114/// giving the lifecycle hook the fully installed UI bridge.
8115async fn ensure_js_runtime_before_prompt(
8116    js: Option<&crate::js_extensions::JsExtensionSession>,
8117    lane: &Arc<dyn AgentLane>,
8118    state: &Arc<TuiState>,
8119    dialog_bridge: &JsDialogBridge,
8120    args: &Args,
8121) -> bool {
8122    let Some(js) = js else {
8123        return true;
8124    };
8125    let cancellation = state.begin_js_preparation();
8126    let worker_cancellation = cancellation.clone();
8127    let js_for_start = js.clone();
8128    let mut worker = tokio::task::spawn_blocking(move || {
8129        js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
8130    });
8131    let result = tokio::select! {
8132        result = &mut worker => result,
8133        _ = cancellation.cancelled() => {
8134            dialog_bridge.cancel_open_requests();
8135            let js_for_cancel = js.clone();
8136            let _ = tokio::task::spawn_blocking(move || {
8137                js_for_cancel.cancel_prompt_preparation();
8138            }).await;
8139            worker.await
8140        }
8141    };
8142    let was_cancelled = cancellation.is_cancelled();
8143    if was_cancelled {
8144        dialog_bridge.cancel_open_requests();
8145        let js_for_cancel = js.clone();
8146        let _ = tokio::task::spawn_blocking(move || {
8147            js_for_cancel.cancel_prompt_preparation();
8148        })
8149        .await;
8150        state.finish_js_preparation();
8151        dialog_bridge.reopen();
8152        return false;
8153    }
8154    state.finish_js_preparation();
8155    match result {
8156        Ok(Ok(())) => {
8157            // The lifecycle hook can change the JS-only active tool set once
8158            // it sees the real TUI context. Merge that subset with the Rust
8159            // built-ins while applying the command-line tool policy.
8160            if let Some(js_active) = js.active_tools() {
8161                let js_names = js.tool_names();
8162                let mut active = lane.get_active_tools().await.unwrap_or_default();
8163                active.retain(|name| {
8164                    crate::session::tool_name_allowed(name, args)
8165                        && !js_names.iter().any(|js_name| js_name == name)
8166                });
8167                active.extend(js_active.into_iter().filter(|name| {
8168                    js_names.iter().any(|js_name| js_name == name)
8169                        && crate::session::tool_name_allowed(name, args)
8170                }));
8171                active = crate::session::filter_active_tool_names(active, args);
8172                let _ = lane.set_active_tools(active).await;
8173            }
8174        }
8175        Ok(Err(error)) => {
8176            if args.verbose {
8177                eprintln!("warning: could not start JS extension runtime: {error}");
8178            }
8179        }
8180        Err(error) => {
8181            if args.verbose {
8182                eprintln!("warning: JS extension runtime worker failed: {error}");
8183            }
8184        }
8185    }
8186    true
8187}
8188
8189fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
8190    std::thread::spawn(move || {
8191        let file = match tempfile::Builder::new()
8192            .prefix("rpi-draft-")
8193            .suffix(".md")
8194            .tempfile()
8195        {
8196            Ok(file) => file,
8197            Err(error) => {
8198                let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
8199                    "Could not create editor file: {error}"
8200                ))));
8201                return;
8202            }
8203        };
8204        if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
8205            let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
8206                "Could not write editor file: {error}"
8207            ))));
8208            return;
8209        }
8210        let editor = std::env::var("RPI_EXTERNAL_EDITOR")
8211            .ok()
8212            .filter(|value| !value.trim().is_empty())
8213            .or_else(|| std::env::var("VISUAL").ok())
8214            .or_else(|| std::env::var("EDITOR").ok())
8215            .unwrap_or_else(|| {
8216                if cfg!(windows) {
8217                    "notepad".to_string()
8218                } else {
8219                    "nano".to_string()
8220                }
8221            });
8222        let status = std::process::Command::new(&editor)
8223            .arg(file.path())
8224            .status();
8225        let result = match status {
8226            Ok(status) if status.success() => std::fs::read_to_string(file.path())
8227                .map_err(|error| format!("Could not read editor file: {error}")),
8228            Ok(status) => Err(format!("External editor exited with {status}")),
8229            Err(error) => Err(format!(
8230                "Could not launch external editor `{editor}`: {error}"
8231            )),
8232        };
8233        let _ = tx.send(TuiMessage::ExternalEditorResult(result));
8234    });
8235}
8236
8237/// Apply the authoritative run result when it wins the race with the async
8238/// event drain, then detach the live component from further partial updates.
8239fn reconcile_streamed_assistant_completion(
8240    current_assistant: &Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
8241    last_assistant_text: &Mutex<String>,
8242    final_message: Option<&AssistantMessage>,
8243) {
8244    let final_blocks = final_message.map(assistant_blocks);
8245    let final_text = final_message.map(assistant_text);
8246    let component = current_assistant.lock().unwrap().take();
8247
8248    if let Some(component) = component {
8249        if let Some(blocks) = final_blocks.as_deref() {
8250            component.update_blocks(blocks);
8251        }
8252        component.set_streaming(false);
8253    }
8254
8255    if let Some(text) = final_text.filter(|text| !text.is_empty()) {
8256        *last_assistant_text.lock().unwrap() = text;
8257    }
8258}
8259
8260/// Drive a single prompt through the lane. When `streaming` is true, the
8261/// `AgentEvent` drain task renders the response live and the completed outcome
8262/// reconciles its final snapshot. When false (no `event_rx`), this falls back
8263/// to the blocking await-final-text path.
8264async fn run_prompt_streaming(
8265    lane: &Arc<dyn AgentLane>,
8266    prompt: &str,
8267    resume: bool,
8268    tui: &Arc<TuiAltScreen>,
8269    state: &Arc<TuiState>,
8270    streaming: bool,
8271    js: Option<&crate::js_extensions::JsExtensionSession>,
8272    dialog_bridge: &JsDialogBridge,
8273    args: &Args,
8274    images: Vec<rpi_ai::types::ImageContent>,
8275) {
8276    // The persistent Node host is intentionally started at the first real
8277    // prompt. By this point the TUI key worker and all UI/runtime handlers are
8278    // live, so a `before_agent_start` hook may safely open a native dialog. A
8279    // session with no prompt never starts Node merely to render its welcome
8280    // screen; JS commands/tools still trigger the same lazy ensure path.
8281    // Preparation is part of the active turn. Mark it working before Node can
8282    // block so Ctrl+D and Esc retain their documented abort semantics for
8283    // initial argv prompts as well as editor submissions.
8284    state.set_status(RunStatus::Working);
8285    tui.request_render(false);
8286    if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
8287        state.set_status(RunStatus::Idle);
8288        tui.request_render(false);
8289        return;
8290    }
8291
8292    let outcome = if resume {
8293        // Continuing an interrupted run: no new user turn, just the provider call
8294        // the interrupted run never reached. Native pi behaves the same way.
8295        match lane.resume_pending().await {
8296            Ok(Some(result)) => Ok(result),
8297            // Nothing to resume after all: leave the status alone and let the
8298            // normal prompt path take over.
8299            Ok(None) => {
8300                state.set_status(RunStatus::Idle);
8301                tui.request_render(false);
8302                return;
8303            }
8304            Err(error) => Err(error),
8305        }
8306    } else {
8307        lane.prompt_text(prompt, images).await
8308    };
8309
8310    if streaming {
8311        // Broadcast delivery is asynchronous: the harness result can resolve
8312        // before the drain task processes MessageEnd. Reconcile from the
8313        // authoritative outcome before detaching the component so the final
8314        // streamed tail cannot be left at an earlier partial snapshot.
8315        let final_message = match &outcome {
8316            Ok(result) => match &result.outcome {
8317                HarnessRunOutcome::Completed { final_message, .. }
8318                | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
8319                HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
8320                HarnessRunOutcome::Suspended { .. } => None,
8321            },
8322            Err(_) => None,
8323        };
8324        reconcile_streamed_assistant_completion(
8325            &state.current_assistant,
8326            &state.last_assistant_text,
8327            final_message,
8328        );
8329    }
8330
8331    state.set_status(RunStatus::Idle);
8332    // Guarantee the pending-messages display reconciles at the end of every
8333    // run/prompt — covers aborts and the non-streaming path, where no
8334    // `AgentEnd` event necessarily reaches the drain task.
8335    refresh_pending_messages(state, lane).await;
8336
8337    // Authoritative context-badge fallback. The streaming drain normally
8338    // updates it from `MessageEnd`, but a `Lagged` broadcast or the blocking
8339    // path can skip that event; reconcile from the run outcome so the badge
8340    // always reflects the newest real response.
8341    if let Ok(result) = &outcome {
8342        let final_message: Option<&AssistantMessage> = match &result.outcome {
8343            HarnessRunOutcome::Completed { final_message, .. }
8344            | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
8345            HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
8346            HarnessRunOutcome::Suspended { .. } => None,
8347        };
8348        if let Some(a) = final_message {
8349            record_context_usage(&state.footer, a);
8350        }
8351    }
8352
8353    match outcome {
8354        Ok(result) => match &result.outcome {
8355            HarnessRunOutcome::Failed {
8356                error,
8357                final_message,
8358                ..
8359            } => {
8360                // Only add an error line if the stream did NOT already render
8361                // an assistant message for it (drain task leaves
8362                // current_assistant Some only on an abrupt end).
8363                // A final assistant error is emitted by the event drain only
8364                // in streaming mode. In regular mode there is no drain task,
8365                // so suppressing this branch merely hides 405/auth/network
8366                // diagnostics from the user.
8367                let already_rendered = streaming && final_message.is_some();
8368                if !already_rendered {
8369                    let msg = final_message
8370                        .as_ref()
8371                        .and_then(|m| m.error_message.clone())
8372                        .unwrap_or_else(|| format!("{error:?}"));
8373                    add_error_message(&state.chat_container, &msg);
8374                }
8375            }
8376            HarnessRunOutcome::Suspended { .. } => {
8377                add_error_message(
8378                    &state.chat_container,
8379                    "Run suspended (deferred) — resume is not supported in v1.",
8380                );
8381            }
8382            HarnessRunOutcome::Aborted { final_message, .. } => {
8383                // Aborted runs render their own partial/final message via the
8384                // stream; only add a note on the blocking fallback path.
8385                if !streaming {
8386                    add_error_message(&state.chat_container, "Request aborted.");
8387                    let _ = final_message; // (rendered by the stream in streaming mode)
8388                }
8389            }
8390            HarnessRunOutcome::Completed { final_message, .. } => {
8391                if !streaming {
8392                    let text = assistant_text(final_message);
8393                    if !text.is_empty() {
8394                        add_assistant_message_blocking(
8395                            &state.chat_container,
8396                            &text,
8397                            state.markdown_transformer(),
8398                        );
8399                        *state.last_assistant_text.lock().unwrap() = text;
8400                    }
8401                }
8402            }
8403        },
8404        Err(e) => {
8405            add_error_message(&state.chat_container, &e.to_string());
8406        }
8407    }
8408
8409    tui.request_render(false);
8410}
8411
8412/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
8413/// Reports the outcome as a transcript note; v1's compaction summarizes the
8414/// session in place, so no streaming display is wired (compaction emits no
8415/// `AgentEvent`s — only the harness bus `RunEnd`).
8416async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
8417    state.set_status(RunStatus::Working);
8418    tui.request_render(false);
8419    match lane.compact(None).await {
8420        Ok(_) => {
8421            add_note_message(&state.chat_container, "Conversation compacted.");
8422            // The last assistant usage describes the pre-compaction context;
8423            // reset the badge to `?` until the next response reports the new
8424            // (much smaller) prompt size — mirrors pi's `percent: null`.
8425            state.footer.set_context_tokens(None);
8426        }
8427        Err(e) => {
8428            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
8429        }
8430    }
8431    state.set_status(RunStatus::Idle);
8432    tui.request_render(false);
8433}
8434
8435/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
8436/// when no clipboard is available (or the `clipboard` feature is off), prints a
8437/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
8438fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
8439    let text = state.last_assistant_text.lock().unwrap().clone();
8440    if text.is_empty() {
8441        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
8442        return;
8443    }
8444    if copy_to_clipboard(&text) {
8445        add_note_message(chat, "Copied last reply to the clipboard.");
8446    } else {
8447        // Clipboard unavailable — print the text to the transcript so the user
8448        // can select/copy it manually (degrades gracefully in headless envs).
8449        let preview: String = text.chars().take(200).collect();
8450        add_note_message(
8451            chat,
8452            &format!(
8453                "Clipboard unavailable. Last reply: {preview}{}",
8454                if text.chars().count() > 200 {
8455                    "…"
8456                } else {
8457                    ""
8458                }
8459            ),
8460        );
8461    }
8462}
8463
8464/// Extract text from the chat container within the given screen coordinates.
8465/// Returns None if the selection is invalid or empty.
8466fn extract_selected_text(
8467    chat_container: &Arc<Container>,
8468    start: (u16, u16),
8469    end: (u16, u16),
8470) -> Option<String> {
8471    use rpi_tui::Component;
8472
8473    // Get the rendered lines from the chat container
8474    let lines = chat_container.render(80); // Use a reasonable width
8475    if lines.is_empty() {
8476        return None;
8477    }
8478
8479    // Normalize coordinates (ensure start is before end)
8480    let (start_row, end_row) = if start.1 <= end.1 {
8481        (start.1 as usize, end.1 as usize)
8482    } else {
8483        (end.1 as usize, start.1 as usize)
8484    };
8485
8486    // Clamp to valid range
8487    let start_row = start_row.min(lines.len().saturating_sub(1));
8488    let end_row = end_row.min(lines.len().saturating_sub(1));
8489
8490    if start_row > end_row {
8491        return None;
8492    }
8493
8494    // Extract the selected lines
8495    let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
8496
8497    if selected_lines.is_empty() {
8498        return None;
8499    }
8500
8501    Some(selected_lines.join("\n"))
8502}
8503
8504/// Best-effort clipboard write. Enabled only with the `clipboard` feature
8505/// (`arboard`) on non-Android platforms; otherwise returns `false` so the
8506/// caller degrades to a hint.
8507#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8508fn copy_to_clipboard(text: &str) -> bool {
8509    match arboard::Clipboard::new() {
8510        Ok(mut cb) => cb.set_text(text).is_ok(),
8511        Err(_) => false,
8512    }
8513}
8514
8515#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8516fn copy_to_clipboard(_text: &str) -> bool {
8517    false
8518}
8519
8520/// Best-effort clipboard text read for Ctrl+V paste. The optional `clipboard`
8521/// feature keeps headless builds free of platform clipboard dependencies;
8522/// on Android the OS owns clipboard access so this degrades to `None`.
8523#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8524fn read_clipboard_text() -> Option<String> {
8525    let mut clipboard = arboard::Clipboard::new().ok()?;
8526    clipboard.get_text().ok()
8527}
8528
8529#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8530fn read_clipboard_text() -> Option<String> {
8531    None
8532}
8533
8534/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
8535/// `ImageContent` contract. The optional clipboard feature keeps headless
8536/// builds free of platform clipboard dependencies.
8537#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8538fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8539    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
8540    let image = match clipboard.get_image() {
8541        Ok(image) => image,
8542        Err(_) => return Ok(None),
8543    };
8544    let width =
8545        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
8546    let height =
8547        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
8548    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
8549        return Err("clipboard image dimensions are outside the supported range".into());
8550    }
8551    let mut bytes = Vec::new();
8552    {
8553        let mut encoder = png::Encoder::new(&mut bytes, width, height);
8554        encoder.set_color(png::ColorType::Rgba);
8555        encoder.set_depth(png::BitDepth::Eight);
8556        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
8557        writer
8558            .write_image_data(&image.bytes)
8559            .map_err(|e| e.to_string())?;
8560    }
8561    Ok(Some(rpi_ai::types::ImageContent {
8562        kind: rpi_ai::types::ImageContentType,
8563        data: base64::engine::general_purpose::STANDARD.encode(bytes),
8564        mime_type: "image/png".into(),
8565    }))
8566}
8567
8568fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
8569    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
8570        let mut options = ImageOptions::default();
8571        options.width = Some(48);
8572        options.alt_text = Some("Attached image".into());
8573        chat.add_child(Arc::new(Image::from_data(bytes, options)));
8574        chat.add_child(Arc::new(Spacer::new(1)));
8575    }
8576}
8577
8578#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8579fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8580    Ok(None)
8581}
8582
8583/// Blocking fallback (no `event_rx`): render the final assistant text as a
8584/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
8585/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
8586/// the identity path. The blocking path only fires when `event_rx` is absent,
8587/// so it shares the same transformer the streaming path installs on its
8588/// components.
8589fn add_assistant_message_blocking(
8590    container: &Arc<Container>,
8591    text: &str,
8592    transformer: Option<MarkdownTransformer>,
8593) {
8594    if text.is_empty() {
8595        return;
8596    }
8597    let msg = Arc::new(AssistantMessageComponent::new(
8598        AssistantMessageOptions::default(),
8599    ));
8600    if let Some(t) = &transformer {
8601        msg.set_markdown_transformer(Some(t.clone()));
8602    }
8603    msg.update_text(text);
8604    container.add_child(msg);
8605    container.add_child(Arc::new(Spacer::new(1)));
8606}
8607
8608// ===========================================================================
8609// AgentEvent drain task — the streaming core
8610// ===========================================================================
8611
8612/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
8613/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
8614/// lifetime of the TUI.
8615/// Re-read the lane's queue and update the pending-messages dock slot.
8616/// Cheap (two short mutex locks) and a no-op when the snapshot is unchanged,
8617/// so it is safe to call on every relevant event.
8618async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
8619    if let Ok(queued) = lane.queued_messages().await {
8620        state.set_pending_queue(queued);
8621    }
8622}
8623
8624/// Whether the transcript holds a panel that repaints its elapsed readout from
8625/// the clock on every frame: a running bash command, or a tool call still in
8626/// [`ToolStatus::Running`]. Such a panel needs a real transcript rebuild each
8627/// render tick — a frame that reuses the cached scroll content would leave its
8628/// timer frozen at the second of the last full rebuild.
8629fn transcript_has_live_panel(
8630    bash_components_present: bool,
8631    tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
8632) -> bool {
8633    bash_components_present
8634        || tool_components
8635            .values()
8636            .any(|component| component.status() == ToolStatus::Running)
8637}
8638
8639/// Prepend restored queued messages to the draft the user may already have
8640/// typed (native pi's `restoreQueuedMessagesToEditor` places them first).
8641fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
8642    if current.trim().is_empty() {
8643        queued_text.to_string()
8644    } else {
8645        format!("{queued_text}\n\n{current}")
8646    }
8647}
8648
8649/// Place the caret at the END of `text` after `set_text`. `set_text` resets the
8650/// caret to `(0, 0)`, and the last line's **byte** length is the right column:
8651/// a char count was wrong for multibyte drafts, and row 0 was wrong for a
8652/// multi-line one (native pi's `setTextInternal(text, "end")`).
8653fn set_editor_text_caret_at_end(editor: &Arc<Editor>, text: &str) {
8654    editor.set_text(text);
8655    let last_row = text.split('\n').count().saturating_sub(1);
8656    let last_len = text.split('\n').next_back().map(str::len).unwrap_or(0);
8657    editor.set_cursor(last_row, last_len);
8658}
8659
8660/// Drain every queued steering/follow-up message out of the lane and restore it
8661/// to the editor (native pi's `restoreQueuedMessagesToEditor`). Returns how many
8662/// were restored; the queue is consumed either way, so the dock drops its rows.
8663///
8664/// Runs on a spawned task rather than through the [`TuiMessage`] mailbox: the
8665/// async main loop owns `run_prompt_streaming(..).await` for the whole run, and
8666/// a queue only exists *while* a run is in flight — so a `TuiMessage::Dequeue`
8667/// was never serviced until the run ended, after the lane had already consumed
8668/// the queue. That made Alt+Q a no-op while it was most needed.
8669async fn restore_queued_messages_to_editor(
8670    lane: Arc<dyn AgentLane>,
8671    editor: Arc<Editor>,
8672    state: Arc<TuiState>,
8673    tui: Arc<TuiAltScreen>,
8674) -> usize {
8675    match lane.clear_queue().await {
8676        Ok(queued) => {
8677            let all: Vec<String> = queued
8678                .steering
8679                .iter()
8680                .chain(queued.follow_up.iter())
8681                .cloned()
8682                .collect();
8683            if !all.is_empty() {
8684                let queued_text = all.join("\n\n");
8685                // Read the EXPANDED draft: `set_text` below clears the folded
8686                // paste store, so any `[paste #id …]` marker still in the
8687                // editor must be materialized first or it would be left behind
8688                // as literal text.
8689                let current = editor.get_expanded_text();
8690                let combined = merge_queued_into_draft(&queued_text, &current);
8691                set_editor_text_caret_at_end(&editor, &combined);
8692            }
8693            // The lane drained the queue; refresh so the dock drops the rows.
8694            refresh_pending_messages(&state, &lane).await;
8695            tui.request_render(false);
8696            all.len()
8697        }
8698        Err(error) => {
8699            add_error_message(
8700                &state.chat_container,
8701                &format!("Could not restore queued messages: {error}"),
8702            );
8703            tui.request_render(false);
8704            0
8705        }
8706    }
8707}
8708
8709/// `app.message.dequeue`: restore the queued messages and report the count, like
8710/// native pi's `handleDequeue` (`Restored N queued message(s)` / `No queued
8711/// messages to restore`). The status is a dim transcript note (rpi's analog of
8712/// native `showStatus`).
8713async fn handle_dequeue(
8714    lane: Arc<dyn AgentLane>,
8715    editor: Arc<Editor>,
8716    state: Arc<TuiState>,
8717    tui: Arc<TuiAltScreen>,
8718) {
8719    let restored =
8720        restore_queued_messages_to_editor(lane, editor, state.clone(), tui.clone()).await;
8721    if restored == 0 {
8722        add_note_message(&state.chat_container, "No queued messages to restore");
8723    } else {
8724        let plural = if restored > 1 { "s" } else { "" };
8725        add_note_message(
8726            &state.chat_container,
8727            &format!("Restored {restored} queued message{plural} to editor"),
8728        );
8729    }
8730    tui.request_render(false);
8731}
8732
8733/// Cancel the active run the native-pi way: pull every queued steering /
8734/// follow-up message back into the editor FIRST, then abort. Native pi's
8735/// `onEscape` does exactly this during streaming
8736/// (`restoreQueuedMessagesToEditor({abort: true})`), so a cancel never silently
8737/// swallows messages the user staged while the run was in flight — they land in
8738/// the editor to be re-edited or resent.
8739fn abort_run_restoring_queue(
8740    lane: Arc<dyn AgentLane>,
8741    editor: Arc<Editor>,
8742    state: Arc<TuiState>,
8743    tui: Arc<TuiAltScreen>,
8744) {
8745    tokio::spawn(async move {
8746        let _ = restore_queued_messages_to_editor(lane.clone(), editor, state, tui.clone()).await;
8747        let _ = lane.abort().await;
8748        tui.request_render(false);
8749    });
8750}
8751
8752async fn drain_agent_events(
8753    mut rx: broadcast::Receiver<AgentEvent>,
8754    tui: Arc<TuiAltScreen>,
8755    state: Arc<TuiState>,
8756    chat: Arc<Container>,
8757    lane: Arc<dyn AgentLane>,
8758) {
8759    loop {
8760        match rx.recv().await {
8761            Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
8762            Err(broadcast::error::RecvError::Lagged(_)) => {
8763                // We dropped some intermediate deltas; the next MessageUpdate/
8764                // MessageEnd carries a full partial snapshot so the UI re-syncs.
8765                continue;
8766            }
8767            Err(broadcast::error::RecvError::Closed) => break,
8768        }
8769    }
8770}
8771
8772/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
8773/// (`interactive-mode.ts:3068-3396`).
8774async fn handle_agent_event(
8775    event: AgentEvent,
8776    tui: &Arc<TuiAltScreen>,
8777    state: &Arc<TuiState>,
8778    chat: &Arc<Container>,
8779    lane: &Arc<dyn AgentLane>,
8780) {
8781    match event {
8782        AgentEvent::AgentStart => {
8783            state.set_status(RunStatus::Working);
8784            tui.request_render(false);
8785        }
8786
8787        AgentEvent::AgentEnd { .. } => {
8788            // Finalize any still-streaming assistant message.
8789            state.transcript_view().finalize_assistant();
8790            state.set_status(RunStatus::Idle);
8791            // Flush any `!command` results that completed mid-run: the run's
8792            // tool sequence is closed, so appending now keeps transcript order
8793            // intact (native pi's `flushPendingBashMessages`).
8794            let pending: Vec<AgentMessage> =
8795                std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
8796            for message in pending {
8797                if let Err(error) = lane.append_message(message).await {
8798                    add_error_message(chat, &format!("Could not record bash result: {error}"));
8799                }
8800            }
8801            // The run drained the queue; refresh so consumed entries drop out
8802            // of the pending-messages display.
8803            refresh_pending_messages(state, lane).await;
8804            tui.request_render(false);
8805        }
8806
8807        AgentEvent::RetryScheduled {
8808            attempt,
8809            max_retries,
8810            delay_ms,
8811            ..
8812        } => {
8813            state.show_retry(attempt, max_retries, delay_ms);
8814            tui.request_render(false);
8815        }
8816
8817        AgentEvent::TurnStart => {
8818            // A new turn: reset the streaming-assistant guard so the next
8819            // MessageStart creates a fresh component.
8820            state.transcript_view().finalize_assistant();
8821        }
8822
8823        AgentEvent::TurnEnd {
8824            message,
8825            tool_results,
8826        } => {
8827            // Finalize the assistant message for this turn.
8828            let view = state.transcript_view();
8829            if let AgentMessage::Assistant(a) = &message {
8830                view.finalize_assistant_blocks(&assistant_blocks(a));
8831            } else {
8832                view.finalize_assistant();
8833            }
8834            // Tool results whose components were never ended by a
8835            // ToolExecutionEnd are handled by the normal path; there is nothing
8836            // extra to finalize here.
8837            let _ = tool_results;
8838            tui.request_render(false);
8839        }
8840
8841        AgentEvent::MessageStart { message } => match message {
8842            AgentMessage::Assistant(a) => {
8843                // The shared transcript view renders this through the same
8844                // component path as the remote client. It installs the live
8845                // markdown transformer + hide-thinking preference itself.
8846                state.transcript_view().apply(
8847                    &UiEvent::AssistantStart {
8848                        blocks: assistant_blocks(&a),
8849                    },
8850                    tui.width(),
8851                );
8852                tui.request_render(false);
8853            }
8854            AgentMessage::Custom(custom) => {
8855                let payload = serde_json::json!({
8856                    "customType": custom.role,
8857                    "content": custom.content,
8858                    "details": custom.data,
8859                    "expanded": false,
8860                    "outputPad": 1,
8861                });
8862                if let Some(component) = extension_message_component(
8863                    &state.extension_session,
8864                    &custom.role,
8865                    &payload,
8866                    state.markdown_transformer(),
8867                ) {
8868                    chat.add_child(component);
8869                    chat.add_child(Arc::new(Spacer::new(1)));
8870                    tui.request_render(false);
8871                } else {
8872                    add_note_message(chat, &custom_message_fallback(&custom));
8873                    tui.request_render(false);
8874                }
8875            }
8876            // Queued (steering / follow-up) user messages are rendered here,
8877            // not at queue time, so a message that is still waiting never looks
8878            // delivered. The loop drains those itself and emits
8879            // `message_start` for them (`agent_loop.rs` pending-message drain).
8880            //
8881            // A DIRECTLY-sent prompt does NOT reach this arm: the harness
8882            // persists it up front and runs the loop with an empty prompts vec,
8883            // so no `message_start` is emitted for it — the submit handler
8884            // renders that bubble instead.
8885            AgentMessage::User(user) => {
8886                state.transcript_view().add_user(&user_message_text(&user));
8887                // A consumed entry must drop out of the pending display.
8888                refresh_pending_messages(state, lane).await;
8889                tui.request_render(false);
8890            }
8891            // ToolResult / other starts are echoed via the tool-execution
8892            // components; ignore the dupes.
8893            _ => {}
8894        },
8895
8896        AgentEvent::MessageUpdate {
8897            message,
8898            assistant_message_event,
8899        } => {
8900            let _ = assistant_message_event; // the snapshot is authoritative
8901            let a: &rpi_ai::types::AssistantMessage = &message;
8902            let text = assistant_text(a);
8903            // The shared view creates/updates tool + bash panels from the
8904            // snapshot's finalized tool calls and streams the block list, so a
8905            // thinking block renders live exactly as the remote client does.
8906            state.transcript_view().apply(
8907                &UiEvent::AssistantUpdate {
8908                    blocks: assistant_blocks(a),
8909                    tool_calls: assistant_tool_calls(a),
8910                },
8911                tui.width(),
8912            );
8913            // A freshly-created bash panel hides the global `Working…` loader.
8914            state.sync_working_loader_with_bash();
8915            *state.last_assistant_text.lock().unwrap() = text;
8916            tui.request_render(false);
8917        }
8918
8919        AgentEvent::MessageEnd { message } => {
8920            if let AgentMessage::Assistant(a) = &message {
8921                let text = assistant_text(a);
8922                state
8923                    .transcript_view()
8924                    .finalize_assistant_blocks(&assistant_blocks(a));
8925                // Cache the finalized text for `/copy`.
8926                if !text.is_empty() {
8927                    *state.last_assistant_text.lock().unwrap() = text;
8928                }
8929                // Cache-miss notice (`maybeShowCacheMissNotice`): the previous
8930                // turn's input established a cacheable prefix; a prompt that
8931                // re-reads less than it should means the prefix was re-billed.
8932                // Uses the shared cache-stats logic (noise floor, idle gap,
8933                // model change) and prices the miss when known.
8934                let usage = &a.usage;
8935                // Footer usage totals + cache hit rate (pi footer.ts).
8936                state.footer.add_usage(
8937                    usage.input,
8938                    usage.output,
8939                    usage.cache_read,
8940                    usage.cache_write,
8941                    usage.cost.total,
8942                );
8943                let denom = usage.input + usage.cache_read + usage.cache_write;
8944                if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
8945                    state
8946                        .footer
8947                        .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
8948                }
8949                // Context-window badge (`?/512k` → `63.2%/512k`): the provider
8950                // reports the prompt token count for this request, which is the
8951                // context size the footer displays.
8952                record_context_usage(&state.footer, a);
8953                let miss = state
8954                    .cache_tracker
8955                    .lock()
8956                    .unwrap()
8957                    .observe(a, &rpi_harness::cache_stats::NoPrices);
8958                // Native pi gates these behind `showCacheMissNotices`
8959                // (default `false`); the tracker still runs so the footer's
8960                // cache-hit rate stays accurate either way.
8961                let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
8962                if let Some(miss) = miss.filter(|_| cache_miss_notices) {
8963                    let cost = if miss.missed_cost > 0.0 {
8964                        format!(" (~${:.4})", miss.missed_cost)
8965                    } else {
8966                        String::new()
8967                    };
8968                    add_note_message(
8969                        &state.chat_container,
8970                        &format!(
8971                            "Cache miss: {} tokens re-billed{}",
8972                            format_tokens(miss.missed_tokens),
8973                            cost
8974                        ),
8975                    );
8976                }
8977                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
8978                    add_note_message(chat, &text);
8979                }
8980                // Error assistant messages carry the provider diagnostic in
8981                // `error_message`, not in text content. The assistant
8982                // component is empty for these messages, so surface the
8983                // diagnostic as a visible error row in the transcript.
8984                if let Some(error) = assistant_error_text(a) {
8985                    add_error_message(chat, &error);
8986                }
8987            }
8988            tui.request_render(false);
8989        }
8990
8991        AgentEvent::ToolExecutionStart {
8992            tool_call_id,
8993            tool_name,
8994            args,
8995        } => {
8996            state.transcript_view().apply(
8997                &UiEvent::ToolStart {
8998                    id: tool_call_id,
8999                    name: tool_name,
9000                    args,
9001                },
9002                tui.width(),
9003            );
9004            state.sync_working_loader_with_bash();
9005            tui.request_render(false);
9006        }
9007
9008        AgentEvent::ToolExecutionUpdate {
9009            tool_call_id,
9010            tool_name,
9011            args,
9012            partial_result,
9013        } => {
9014            state.transcript_view().apply(
9015                &UiEvent::ToolUpdate {
9016                    id: tool_call_id,
9017                    name: tool_name,
9018                    args,
9019                    result: crate::remote::protocol::tool_result_json(&partial_result),
9020                },
9021                tui.width(),
9022            );
9023            state.sync_working_loader_with_bash();
9024            tui.request_render(false);
9025        }
9026
9027        AgentEvent::ToolExecutionEnd {
9028            tool_call_id,
9029            tool_name,
9030            result,
9031            is_error,
9032        } => {
9033            state.transcript_view().apply(
9034                &UiEvent::ToolEnd {
9035                    id: tool_call_id,
9036                    name: tool_name,
9037                    result: crate::remote::protocol::tool_result_json(&result),
9038                    is_error,
9039                },
9040                tui.width(),
9041            );
9042            state.sync_working_loader_with_bash();
9043            // Bash tool completion is user-visible: immediate render ensures the
9044            // result is displayed even when the scheduler thread is busy or the
9045            // throttle window has not elapsed.
9046            tui.render_now(false);
9047        }
9048    }
9049}
9050
9051/// Update the footer's context-window badge from one assistant response.
9052///
9053/// `Usage::context_tokens()` is the prompt size the provider reported for that
9054/// request — the value pi's footer renders as `{percent}%/{window}`. Aborted
9055/// and errored turns are skipped (their usage is not a real context
9056/// measurement, mirroring pi's `lastAssistantUsageInfo`) so the last valid
9057/// count stays on screen instead of flickering to `?`.
9058fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
9059    if message.stop_reason == rpi_ai::StopReason::Aborted
9060        || message.stop_reason == rpi_ai::StopReason::Error
9061    {
9062        return;
9063    }
9064    let tokens = message.usage.context_tokens();
9065    if tokens > 0 {
9066        footer.set_context_tokens(Some(tokens));
9067    }
9068}
9069
9070/// Return the diagnostic carried by a failed or aborted provider request.
9071/// Providers may omit `error_message`; keep a stable fallback so a terminal
9072/// request failure can never render as an empty transcript turn.
9073fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
9074    let fallback = match message.stop_reason {
9075        rpi_ai::StopReason::Error => "Provider request failed.",
9076        rpi_ai::StopReason::Aborted => "Request aborted.",
9077        _ => return None,
9078    };
9079    Some(
9080        message
9081            .error_message
9082            .as_deref()
9083            .filter(|text| !text.trim().is_empty())
9084            .unwrap_or(fallback)
9085            .to_string(),
9086    )
9087}
9088
9089/// Whether a tool result opts into markdown rendering of its body.
9090///
9091/// A tool signals this by setting `details.markdown = true` on its result
9092/// (the `rpi-todo` extension does this for its progress bar + table payload).
9093/// The host then hands the body to the markdown renderer instead of printing
9094/// it as plain text — see `ToolExecutionComponent::set_result_markdown`.
9095/// Absent or non-`true` details keep the existing plain-text rendering, so
9096/// every other tool is unaffected.
9097fn tool_result_requests_markdown(details: Option<&serde_json::Value>) -> bool {
9098    details
9099        .and_then(|details| details.get("markdown"))
9100        .and_then(|value| value.as_bool())
9101        .unwrap_or(false)
9102}
9103
9104// ===========================================================================
9105// Selectors — editor-container swap (TS showSelector pattern)
9106// ===========================================================================
9107
9108/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
9109/// hiding the editor while the selector is open. Records the selector in
9110/// `state.active_selector` so the key loop routes to it.
9111fn open_selector(
9112    state: &Arc<TuiState>,
9113    editor_container: &Arc<Container>,
9114    editor: &Arc<Editor>,
9115    tui: &Arc<TuiAltScreen>,
9116    list: Arc<SelectList>,
9117    kind: SelectorKind,
9118) {
9119    open_selector_with_view(
9120        state,
9121        editor_container,
9122        editor,
9123        tui,
9124        SelectorView::List(list.clone()),
9125        list,
9126        kind,
9127    );
9128}
9129
9130/// Open a searchable selector: the list plus a fuzzy-filter search box.
9131///
9132/// Returns the wrapper so callers can prefill the query (native pi's
9133/// `initialSearchInput`). Mirrors `SelectSubmenu` with `searchable: true`.
9134fn open_searchable_selector(
9135    state: &Arc<TuiState>,
9136    editor_container: &Arc<Container>,
9137    editor: &Arc<Editor>,
9138    tui: &Arc<TuiAltScreen>,
9139    title: Option<&str>,
9140    description: Option<&str>,
9141    list: Arc<SelectList>,
9142    kind: SelectorKind,
9143) -> Arc<SearchableSelectList> {
9144    let searchable = Arc::new(SearchableSelectList::new(title, description, list));
9145    open_selector_with_view(
9146        state,
9147        editor_container,
9148        editor,
9149        tui,
9150        SelectorView::Searchable(searchable.clone()),
9151        searchable.clone(),
9152        kind,
9153    );
9154    // `TuiAltScreen::set_focus` only records the focused component; components
9155    // manage their own focus flag. Without this the search caret never renders
9156    // (and IME candidate positioning stays on the editor's old caret).
9157    searchable.set_focused(true);
9158    searchable
9159}
9160
9161/// Open a selector with an optional framed view. Native extension selectors
9162/// wrap the list with a title and hint while built-in selectors keep the list
9163/// as the complete view.
9164///
9165/// `view_router` receives keys (a bare list, or a searchable wrapper) while
9166/// `view` is what renders — the two are the same object for every built-in
9167/// selector, but extension selectors frame a plain list.
9168fn open_selector_with_view<V: Component + 'static>(
9169    state: &Arc<TuiState>,
9170    editor_container: &Arc<Container>,
9171    editor: &Arc<Editor>,
9172    tui: &Arc<TuiAltScreen>,
9173    view_router: SelectorView,
9174    view: Arc<V>,
9175    kind: SelectorKind,
9176) {
9177    // Dispatch ui_prompt_start event
9178    dispatch_ui_prompt_event(state);
9179
9180    // Unfocus the editor so its cursor marker doesn't render behind the list.
9181    editor.set_focused(false);
9182    // A selector replaces the editor slot. Drop stale slash/@file
9183    // suggestions so they cannot reappear after the selector closes.
9184    state.autocomplete_container.clear();
9185    // Swap: clear the container and add the selector view.
9186    editor_container.clear();
9187    editor_container.add_child(view.clone());
9188    *state.active_selector.lock().unwrap() = Some((view_router, kind));
9189    let focused: Arc<dyn Component> = view;
9190    tui.set_focus(Some(focused));
9191    tui.request_render(false);
9192}
9193
9194/// Restore the editor into the `editor_container` and clear the active
9195/// selector. Called by selector `on_cancel` and the Esc handler.
9196fn close_selector(
9197    state: &Arc<TuiState>,
9198    editor_container: &Arc<Container>,
9199    editor: &Arc<Editor>,
9200    tui: &Arc<TuiAltScreen>,
9201) {
9202    // Dispatch ui_prompt_end event
9203    dispatch_ui_prompt_event_end(state);
9204
9205    editor_container.clear();
9206    editor_container.add_child(editor.clone());
9207    state.autocomplete_container.clear();
9208    editor.set_focused(true);
9209    *state.active_selector.lock().unwrap() = None;
9210    *state.active_extension_cancel.lock().unwrap() = None;
9211    tui.set_focus(Some(editor.clone()));
9212    tui.request_render(false);
9213}
9214
9215/// Build + open the `/model` selector. Items are the resolved catalog (display
9216/// label = model name; description = id), with the current model marked.
9217/// Selecting applies the model **live** via `lane.set_model` (takes effect on
9218/// the next user message — the in-flight run's config is already snapshotted),
9219/// updates the footer, and notes the next-prompt effect.
9220fn open_model_selector(
9221    state: &Arc<TuiState>,
9222    editor_container: &Arc<Container>,
9223    editor: &Arc<Editor>,
9224    tui: &Arc<TuiAltScreen>,
9225    catalog: &[rpi_ai::Model],
9226    lane: &Arc<dyn AgentLane>,
9227    lane_model_id: &str,
9228    chat: &Arc<Container>,
9229    initial_search: Option<&str>,
9230) -> Option<Arc<SearchableSelectList>> {
9231    let items = model_selector_items(catalog, lane_model_id);
9232    if items.is_empty() {
9233        add_note_message(
9234            chat,
9235            "No models in the catalog. Use --model at startup to select one.",
9236        );
9237        tui.request_render(false);
9238        return None;
9239    }
9240    let list = Arc::new(SelectList::new(items, 10));
9241
9242    // Capture the catalog + lane so the on_select closure can resolve the
9243    // chosen Model and apply it. `on_select` fires on the blocking key thread,
9244    // so the async `set_model` runs on a spawned task (matches Ctrl+P).
9245    let catalog_arc = catalog.to_vec();
9246    let state_sel = state.clone();
9247    let ec_sel = editor_container.clone();
9248    let editor_sel = editor.clone();
9249    let tui_sel = tui.clone();
9250    let chat_sel = chat.clone();
9251    let lane_sel = lane.clone();
9252    list.on_select(Arc::new(move |item| {
9253        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
9254            add_note_message(
9255                &chat_sel,
9256                &format!("Model {} not found in catalog.", item.label),
9257            );
9258            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9259            return;
9260        };
9261        state_sel.set_current_model(&model);
9262        let lane = lane_sel.clone();
9263        tokio::spawn(async move {
9264            let _ = lane.set_model(model).await;
9265        });
9266        add_note_message(
9267            &chat_sel,
9268            &format!(
9269                "Model set to {} — applies to the next message.",
9270                short_model_name(&item.value)
9271            ),
9272        );
9273        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9274    }));
9275    let state_cancel = state.clone();
9276    let ec_cancel = editor_container.clone();
9277    let editor_cancel = editor.clone();
9278    let tui_cancel = tui.clone();
9279    list.on_cancel(Arc::new(move || {
9280        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9281    }));
9282
9283    let searchable = open_searchable_selector(
9284        state,
9285        editor_container,
9286        editor,
9287        tui,
9288        Some("Select model"),
9289        Some("Type to filter by name, provider, or id"),
9290        list,
9291        SelectorKind::Model,
9292    );
9293    if let Some(term) = initial_search {
9294        searchable.set_search_text(term);
9295    }
9296    Some(searchable)
9297}
9298
9299/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
9300/// Returns `None` only when the catalog is empty or the current id isn't
9301/// found (in which case the first entry is returned — a no-op if it IS the
9302/// current). Used by the Ctrl+P model-cycle hotkey.
9303fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9304    if catalog.is_empty() {
9305        return None;
9306    }
9307    let idx = catalog
9308        .iter()
9309        .position(|m| m.id.eq_ignore_ascii_case(current_id));
9310    match idx {
9311        Some(i) => {
9312            let next = (i + 1) % catalog.len();
9313            Some(catalog[next].clone())
9314        }
9315        None => Some(catalog[0].clone()),
9316    }
9317}
9318
9319/// Cycle to the catalog entry *before* `current_id`, wrapping to the last —
9320/// the mirror of [`cycle_next_model`] for the previous-model hotkey.
9321fn cycle_prev_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9322    if catalog.is_empty() {
9323        return None;
9324    }
9325    let idx = catalog
9326        .iter()
9327        .position(|m| m.id.eq_ignore_ascii_case(current_id));
9328    match idx {
9329        Some(i) => {
9330            let prev = (i + catalog.len() - 1) % catalog.len();
9331            Some(catalog[prev].clone())
9332        }
9333        None => Some(catalog[0].clone()),
9334    }
9335}
9336
9337/// Build + open the `/session` selector. Lists JSONL session files under the
9338/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
9339/// fallback), newest-first, labelled with the session name or first prompt so
9340/// two sessions are distinguishable. Header-only sessions are skipped: there is
9341/// nothing to switch to.
9342fn open_session_selector(
9343    state: &Arc<TuiState>,
9344    editor_container: &Arc<Container>,
9345    editor: &Arc<Editor>,
9346    tui: &Arc<TuiAltScreen>,
9347    cwd: &std::path::Path,
9348    tx: &mpsc::UnboundedSender<TuiMessage>,
9349) {
9350    let dir = crate::session::default_session_dir(cwd);
9351    let mut items: Vec<SelectItem> = Vec::new();
9352    for path in crate::session::list_session_files_sync(&dir) {
9353        let summary = crate::session::summarize_session_file(&path);
9354        if summary.empty {
9355            continue;
9356        }
9357        let stem = path
9358            .file_stem()
9359            .and_then(|s| s.to_str())
9360            .unwrap_or("(unnamed)")
9361            .to_string();
9362        let label = crate::session::session_display_label(&summary);
9363        // The file stem is `<timestamp>_<uuid>`; the id tail disambiguates rows
9364        // that share a timestamp.
9365        let id_tail = stem.rsplit('_').next().unwrap_or(&stem);
9366        let short_id = crate::session::short_session_id(id_tail);
9367        let description = format!(
9368            "{} · {}",
9369            crate::session::format_session_bytes(summary.bytes),
9370            short_id,
9371        );
9372        items.push(
9373            SelectItem::new(&stem, &label)
9374                .with_description(&description)
9375                .with_search_text(&format!("{label} {short_id} {stem}")),
9376        );
9377    }
9378    if items.is_empty() {
9379        add_note_message(
9380            &state.chat_container,
9381            "No saved sessions found. Sessions are created automatically in interactive mode.",
9382        );
9383        tui.request_render(false);
9384        return;
9385    }
9386    let mut list = SelectList::new(items, 10);
9387    list.set_layout(rpi_tui::SelectListLayoutOptions {
9388        min_primary_column_width: Some(56),
9389        max_primary_column_width: Some(72),
9390        truncate_primary: None,
9391    });
9392    let list = Arc::new(list);
9393
9394    let state_sel = state.clone();
9395    let ec_sel = editor_container.clone();
9396    let editor_sel = editor.clone();
9397    let tui_sel = tui.clone();
9398    let tx_sel = tx.clone();
9399    list.on_select(Arc::new(move |item| {
9400        // Close the selector first, then ask the async loop to hot-switch:
9401        // opening the session file + swapping the harness backing is async
9402        // (repo list/open) and must not run on the blocking key thread.
9403        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9404        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
9405    }));
9406    let state_cancel = state.clone();
9407    let ec_cancel = editor_container.clone();
9408    let editor_cancel = editor.clone();
9409    let tui_cancel = tui.clone();
9410    list.on_cancel(Arc::new(move || {
9411        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9412    }));
9413
9414    open_searchable_selector(
9415        state,
9416        editor_container,
9417        editor,
9418        tui,
9419        Some("Resume session"),
9420        Some("Type to filter sessions"),
9421        list,
9422        SelectorKind::Session,
9423    );
9424}
9425
9426fn custom_entry_display_text(
9427    custom_type: &str,
9428    data: Option<&serde_json::Value>,
9429) -> Option<String> {
9430    let data = data?;
9431    let text = data
9432        .get("summary")
9433        .or_else(|| data.get("text"))
9434        .or_else(|| data.get("output"))
9435        .and_then(|value| value.as_str())
9436        .filter(|value| !value.trim().is_empty())?;
9437    let label = match custom_type {
9438        "compactionSummary" => "Compaction summary",
9439        "branchSummary" => "Branch summary",
9440        "bashExecution" => "Command output",
9441        other => other,
9442    };
9443    Some(format!("{label}: {text}"))
9444}
9445
9446/// Open a selector for the current session's persisted entry tree. Selecting a
9447/// message moves the main lane leaf to that entry, then the caller reloads the
9448/// visible branch from durable storage.
9449async fn open_tree_selector(
9450    harness: &AgentHarness,
9451    state: &Arc<TuiState>,
9452    editor_container: &Arc<Container>,
9453    editor: &Arc<Editor>,
9454    tui: &Arc<TuiAltScreen>,
9455    chat: &Arc<Container>,
9456    tx: &mpsc::UnboundedSender<TuiMessage>,
9457) {
9458    let entries = match harness
9459        .session()
9460        .view("main")
9461        .find_entries(&EntryQuery {
9462            order: Some(EntryOrder::OldestFirst),
9463            ..Default::default()
9464        })
9465        .await
9466    {
9467        Ok(entries) => entries,
9468        Err(error) => {
9469            add_error_message(chat, &format!("Could not read session tree: {error}"));
9470            tui.request_render(false);
9471            return;
9472        }
9473    };
9474    let current = harness.session().get_leaf_id().await.ok().flatten();
9475    let items: Vec<SelectItem> = entries
9476        .iter()
9477        .map(|entry| {
9478            let label = if current.as_deref() == Some(entry.id()) {
9479                format!("{} #{} (current)", entry.entry_type(), entry.seq())
9480            } else {
9481                format!("{} #{}", entry.entry_type(), entry.seq())
9482            };
9483            let short_id = &entry.id()[..entry.id().len().min(12)];
9484            // Search on the entry type, sequence, and id so `/tree` stays
9485            // usable in long sessions.
9486            let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
9487            SelectItem::new(entry.id(), &label)
9488                .with_description(short_id)
9489                .with_search_text(&search_text)
9490        })
9491        .collect();
9492    if items.is_empty() {
9493        add_note_message(chat, "The current session has no entries to navigate.");
9494        tui.request_render(false);
9495        return;
9496    }
9497    let list = Arc::new(SelectList::new(items, 12));
9498    let state_sel = state.clone();
9499    let ec_sel = editor_container.clone();
9500    let editor_sel = editor.clone();
9501    let tui_sel = tui.clone();
9502    let tx_sel = tx.clone();
9503    list.on_select(Arc::new(move |item| {
9504        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
9505        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9506    }));
9507    let state_cancel = state.clone();
9508    let ec_cancel = editor_container.clone();
9509    let editor_cancel = editor.clone();
9510    let tui_cancel = tui.clone();
9511    list.on_cancel(Arc::new(move || {
9512        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9513    }));
9514    open_searchable_selector(
9515        state,
9516        editor_container,
9517        editor,
9518        tui,
9519        Some("Session tree"),
9520        Some("Type to filter by entry type, sequence, or id"),
9521        list,
9522        SelectorKind::Tree,
9523    );
9524}
9525
9526/// Build + open the `/theme` selector. Built-in presets and enabled package
9527/// themes are shown; selecting applies the theme live and re-renders.
9528fn open_theme_selector(
9529    state: &Arc<TuiState>,
9530    editor_container: &Arc<Container>,
9531    editor: &Arc<Editor>,
9532    tui: &Arc<TuiAltScreen>,
9533    cwd: &std::path::Path,
9534    package_resources: &Arc<crate::packages::PackageResources>,
9535) {
9536    let mut items = vec![
9537        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
9538        SelectItem::new("light", "Light").with_description("Light background"),
9539        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
9540    ];
9541    if state.themes_enabled {
9542        for path in package_resources.theme_files() {
9543            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
9544                items.push(SelectItem::new(name, name).with_description("Package theme"));
9545            }
9546        }
9547    }
9548    let list = Arc::new(SelectList::new(items, 10));
9549
9550    let state_sel = state.clone();
9551    let ec_sel = editor_container.clone();
9552    let editor_sel = editor.clone();
9553    let tui_sel = tui.clone();
9554    let chat_sel = state.chat_container.clone();
9555    let cwd_sel = cwd.to_path_buf();
9556    let package_resources_sel = package_resources.clone();
9557    list.on_select(Arc::new(move |item| {
9558        let preset = match item.value.as_str() {
9559            "light" => Some(ThemePreset::Light),
9560            "monochrome" => Some(ThemePreset::Monochrome),
9561            "dark" => Some(ThemePreset::Dark),
9562            name => {
9563                if state_sel.themes_enabled {
9564                    if let Ok(custom) = crate::packages::load_theme_with_resources(
9565                        &cwd_sel,
9566                        name,
9567                        &package_resources_sel,
9568                    ) {
9569                        rpi_tui::global_theme_manager().set(custom.clone());
9570                        state_sel.theme_manager.set(custom);
9571                    }
9572                }
9573                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9574                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9575                tui_sel.render_now(true);
9576                return;
9577            }
9578        };
9579        let Some(preset) = preset else { return };
9580        apply_theme_preset(preset);
9581        state_sel.theme_manager.apply_preset(preset);
9582        // A quick accent note so the user sees the change registered even if
9583        // the terminal's own colors mask the preset difference.
9584        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9585        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9586        tui_sel.render_now(true);
9587    }));
9588    let state_cancel = state.clone();
9589    let ec_cancel = editor_container.clone();
9590    let editor_cancel = editor.clone();
9591    let tui_cancel = tui.clone();
9592    list.on_cancel(Arc::new(move || {
9593        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9594    }));
9595
9596    open_selector(
9597        state,
9598        editor_container,
9599        editor,
9600        tui,
9601        list,
9602        SelectorKind::Theme,
9603    );
9604}
9605
9606// ===========================================================================
9607// Feasible selectors — /thinking, /tools, /images
9608// ===========================================================================
9609
9610/// One-line descriptions for each thinking level, ported from
9611/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
9612fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9613    use rpi_ai::types::ThinkingLevel::*;
9614    match level {
9615        Off => "Off — No reasoning",
9616        Minimal => "Minimal — Brief reasoning (~1k tokens)",
9617        Low => "Low — Light reasoning (~1k tokens)",
9618        Medium => "Medium — Moderate reasoning (~80% of max)",
9619        High => "High — Extensive reasoning (~95% of max)",
9620        Xhigh => "Xhigh — Near-maximal reasoning",
9621        Max => "Max — Maximum reasoning",
9622    }
9623}
9624
9625/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
9626/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
9627fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9628    use rpi_ai::types::ThinkingLevel::*;
9629    match level {
9630        Off => "off",
9631        Minimal => "minimal",
9632        Low => "low",
9633        Medium => "medium",
9634        High => "high",
9635        Xhigh => "xhigh",
9636        Max => "max",
9637    }
9638}
9639
9640/// Parse a thinking-level name back to the enum (case-insensitive). Returns
9641/// `None` for an unknown name; used by the `/thinking` selector callback.
9642fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
9643    use rpi_ai::types::ThinkingLevel::*;
9644    match name.to_ascii_lowercase().as_str() {
9645        "off" => Some(Off),
9646        "minimal" => Some(Minimal),
9647        "low" => Some(Low),
9648        "medium" => Some(Medium),
9649        "high" => Some(High),
9650        "xhigh" => Some(Xhigh),
9651        "max" => Some(Max),
9652        _ => None,
9653    }
9654}
9655
9656/// Build + open the `/thinking` selector. Items are the levels the current
9657/// model supports (`Model::supported_thinking_levels`), each with a
9658/// description; the current level (read beforehand via `lane.get_thinking_level`)
9659/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
9660///
9661/// `on_select` fires on the blocking key thread, so it can't await
9662/// `lane.get_thinking_level()` to know the current level — the opener resolves
9663/// it first (best-effort) and preselects; the toggle on_select just applies
9664/// whatever was picked.
9665fn open_thinking_selector(
9666    state: &Arc<TuiState>,
9667    editor_container: &Arc<Container>,
9668    editor: &Arc<Editor>,
9669    tui: &Arc<TuiAltScreen>,
9670    lane: &Arc<dyn AgentLane>,
9671    catalog: &[rpi_ai::Model],
9672    lane_model_id: &str,
9673    chat: &Arc<Container>,
9674) {
9675    // Find the current model in the catalog to read its supported levels. If
9676    // absent, fall back to all levels so the selector still opens.
9677    let model = catalog
9678        .iter()
9679        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
9680    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
9681        .map(|m| m.supported_thinking_levels())
9682        .unwrap_or_else(|| {
9683            use rpi_ai::types::ThinkingLevel::*;
9684            vec![Off, Minimal, Low, Medium, High]
9685        });
9686    let mut items: Vec<SelectItem> = Vec::new();
9687    for lvl in &levels {
9688        let name = thinking_level_name(*lvl);
9689        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
9690    }
9691    if items.is_empty() {
9692        add_note_message(chat, "This model has no supported thinking levels.");
9693        tui.request_render(false);
9694        return;
9695    }
9696    let list = Arc::new(SelectList::new(items, 10));
9697
9698    let state_sel = state.clone();
9699    let ec_sel = editor_container.clone();
9700    let editor_sel = editor.clone();
9701    let tui_sel = tui.clone();
9702    let chat_sel = chat.clone();
9703    let lane_sel = lane.clone();
9704    list.on_select(Arc::new(move |item| {
9705        let Some(level) = thinking_level_from_name(&item.value) else {
9706            add_note_message(
9707                &chat_sel,
9708                &format!("Unknown thinking level: {}.", item.label),
9709            );
9710            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9711            return;
9712        };
9713        let lane = lane_sel.clone();
9714        let footer_sel = state_sel.footer.clone();
9715        tokio::spawn(async move {
9716            let _ = lane.set_thinking_level(level).await;
9717        });
9718        // Reflect the chosen level in the footer's model suffix (pi parity:
9719        // `model • thinking off` / `model • medium`). The shown text for the
9720        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
9721        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
9722        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
9723        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9724    }));
9725    let state_cancel = state.clone();
9726    let ec_cancel = editor_container.clone();
9727    let editor_cancel = editor.clone();
9728    let tui_cancel = tui.clone();
9729    list.on_cancel(Arc::new(move || {
9730        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9731    }));
9732
9733    open_selector(
9734        state,
9735        editor_container,
9736        editor,
9737        tui,
9738        list,
9739        SelectorKind::Thinking,
9740    );
9741}
9742
9743/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
9744/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
9745/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
9746/// — the blocking key thread can't await) and selecting a tool **toggles** it
9747/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
9748fn open_tools_selector(
9749    state: &Arc<TuiState>,
9750    editor_container: &Arc<Container>,
9751    editor: &Arc<Editor>,
9752    tui: &Arc<TuiAltScreen>,
9753    lane: &Arc<dyn AgentLane>,
9754    chat: &Arc<Container>,
9755) {
9756    // Best-effort read of the current active set. The opener runs on the async
9757    // runtime (it's called from the main loop's channel dispatch or the submit
9758    // closure that lives on the blocking thread — but `handle.block_on` is safe
9759    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
9760    let mut active = match tokio::runtime::Handle::try_current() {
9761        Ok(h) => h
9762            .block_on(async { lane.get_active_tools().await })
9763            .unwrap_or_default(),
9764        Err(_) => Vec::new(),
9765    };
9766    // An empty active set is the harness sentinel for "all registered tools"
9767    // (the selector only exposes built-ins). Expand it before rendering and
9768    // toggling so the first `/tools` visit does not show every tool as off or
9769    // accidentally reduce the active set to the one item selected.
9770    if active.is_empty() {
9771        active = crate::session::BUILTIN_TOOL_NAMES
9772            .iter()
9773            .map(|name| (*name).to_string())
9774            .collect();
9775    }
9776    let mut items: Vec<SelectItem> = Vec::new();
9777    for name in crate::session::BUILTIN_TOOL_NAMES {
9778        let on = active.iter().any(|a| a == name);
9779        let label = if on {
9780            format!("{name} (on)")
9781        } else {
9782            (*name).to_string()
9783        };
9784        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
9785    }
9786    let list = Arc::new(SelectList::new(items, 10));
9787
9788    // Capture the active set so on_select can toggle without re-reading.
9789    let active_captured = active.clone();
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    let lane_sel = lane.clone();
9796    list.on_select(Arc::new(move |item| {
9797        let mut next = active_captured.clone();
9798        if let Some(pos) = next.iter().position(|a| a == &item.value) {
9799            next.remove(pos);
9800        } else {
9801            next.push(item.value.clone());
9802        }
9803        let on = next.iter().any(|a| a == &item.value);
9804        let lane = lane_sel.clone();
9805        let next_clone = next.clone();
9806        tokio::spawn(async move {
9807            let _ = lane.set_active_tools(next_clone).await;
9808        });
9809        let list_str = if next.is_empty() {
9810            "(none)".to_string()
9811        } else {
9812            next.join(", ")
9813        };
9814        add_note_message(
9815            &chat_sel,
9816            &format!(
9817                "{} {} — active tools: {}",
9818                item.value,
9819                if on { "enabled" } else { "disabled" },
9820                list_str
9821            ),
9822        );
9823        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9824    }));
9825    let state_cancel = state.clone();
9826    let ec_cancel = editor_container.clone();
9827    let editor_cancel = editor.clone();
9828    let tui_cancel = tui.clone();
9829    list.on_cancel(Arc::new(move || {
9830        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9831    }));
9832
9833    open_selector(
9834        state,
9835        editor_container,
9836        editor,
9837        tui,
9838        list,
9839        SelectorKind::Tools,
9840    );
9841}
9842
9843/// Build + open the `/images` selector (Yes/No). Stores the choice in
9844/// `state.show_images` and notes it. Image wiring is minimal this pass — the
9845/// flag is consulted where images would be shown and echoed back here.
9846fn open_images_selector(
9847    state: &Arc<TuiState>,
9848    editor_container: &Arc<Container>,
9849    editor: &Arc<Editor>,
9850    tui: &Arc<TuiAltScreen>,
9851    chat: &Arc<Container>,
9852) {
9853    let current = *state.show_images.lock().unwrap();
9854    let items = vec![
9855        SelectItem::new("yes", "Yes").with_description(if current {
9856            "Inline images (current)"
9857        } else {
9858            "Inline images"
9859        }),
9860        SelectItem::new("no", "No").with_description(if current {
9861            "Placeholder only"
9862        } else {
9863            "Placeholder only (current)"
9864        }),
9865    ];
9866    let list = Arc::new(SelectList::new(items, 5));
9867
9868    let state_sel = state.clone();
9869    let ec_sel = editor_container.clone();
9870    let editor_sel = editor.clone();
9871    let tui_sel = tui.clone();
9872    let chat_sel = chat.clone();
9873    list.on_select(Arc::new(move |item| {
9874        let on = item.value == "yes";
9875        *state_sel.show_images.lock().unwrap() = on;
9876        add_note_message(
9877            &chat_sel,
9878            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
9879        );
9880        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9881    }));
9882    let state_cancel = state.clone();
9883    let ec_cancel = editor_container.clone();
9884    let editor_cancel = editor.clone();
9885    let tui_cancel = tui.clone();
9886    list.on_cancel(Arc::new(move || {
9887        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9888    }));
9889
9890    open_selector(
9891        state,
9892        editor_container,
9893        editor,
9894        tui,
9895        list,
9896        SelectorKind::Images,
9897    );
9898}
9899
9900// ===========================================================================
9901// Autocomplete
9902// ===========================================================================
9903
9904/// Refresh the autocomplete suggestion list from the current editor text +
9905/// cursor. Renders the suggestions into `autocomplete_container` (above the
9906/// editor) or clears it when there are none.
9907fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
9908    let text = editor.get_text();
9909    let cursor = editor_cursor_offset(editor, &text);
9910    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
9911    render_autocomplete(state, suggestions);
9912}
9913
9914/// Convert the editor's logical `(row, byte-column)` caret into the absolute
9915/// byte offset expected by autocomplete providers.
9916fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
9917    let (row, col) = editor.cursor_position();
9918    let mut offset = 0;
9919    for (index, line) in text.split('\n').enumerate() {
9920        if index == row {
9921            return (offset + col.min(line.len())).min(text.len());
9922        }
9923        offset = offset.saturating_add(line.len() + 1);
9924    }
9925    text.len()
9926}
9927
9928/// Restore an editor caret from an absolute byte offset after autocomplete
9929/// replaces a span in a multi-line draft.
9930fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
9931    let offset = offset.min(text.len());
9932    let mut consumed = 0;
9933    for (row, line) in text.split('\n').enumerate() {
9934        let end = consumed + line.len();
9935        if offset <= end {
9936            editor.set_cursor(row, offset - consumed);
9937            return;
9938        }
9939        consumed = end + 1;
9940    }
9941    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
9942    editor.set_cursor(
9943        last_row,
9944        text.rsplit('\n').next().map(str::len).unwrap_or(0),
9945    );
9946}
9947
9948/// Render (or clear) the autocomplete suggestion list into the container.
9949fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
9950    state.autocomplete_container.clear();
9951    let Some(sugg) = suggestions else {
9952        return;
9953    };
9954    if sugg.items.is_empty() {
9955        return;
9956    }
9957    // Build a compact list: top item marked with `→`, rest with `  `.
9958    // Cap the list so the dock doesn't swallow the transcript.
9959    let accent = state.theme_manager.get().colors.accent;
9960    let muted = state.theme_manager.get().colors.muted;
9961    for (i, item) in sugg
9962        .items
9963        .iter()
9964        .take(state.autocomplete_max_visible)
9965        .enumerate()
9966    {
9967        let prefix = if i == 0 { "→ " } else { "  " };
9968        let label = item.display_text();
9969        let line = if i == 0 {
9970            format!(
9971                "{prefix}{} {}",
9972                accent.fg(label),
9973                muted.fg(item.description.as_deref().unwrap_or(""))
9974            )
9975        } else {
9976            format!(
9977                "{prefix}{} {}",
9978                muted.fg(label),
9979                muted.fg(item.description.as_deref().unwrap_or(""))
9980            )
9981        };
9982        state
9983            .autocomplete_container
9984            .add_child(Arc::new(Text::new(line, 1, 0)));
9985    }
9986}
9987
9988/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
9989/// suggestion text, reposition the caret, and clear the suggestion list.
9990/// Returns `true` if a suggestion was accepted.
9991fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
9992    let text = editor.get_text();
9993    let cursor = editor_cursor_offset(editor, &text);
9994    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
9995        return false;
9996    };
9997    let Some(top) = sugg.items.first() else {
9998        return false;
9999    };
10000    // Replace the [start, end) span with the suggestion text. `start`/`end`
10001    // are byte offsets emitted by the providers on char boundaries, so the
10002    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
10003    let start = sugg.start.min(text.len());
10004    let end = sugg.end.min(text.len());
10005    let mut replaced = String::with_capacity(text.len() + top.text.len());
10006    replaced.push_str(&text[..start]);
10007    replaced.push_str(&top.text);
10008    // Keep the text AFTER the replaced span (mid-line completion: replacing
10009    // `[start, end)` must not drop the rest of the line).
10010    replaced.push_str(&text[end..]);
10011    if top.insert_space && !replaced.ends_with('/') {
10012        replaced.push(' ');
10013    }
10014    // New caret position: after the inserted text (byte offset; the editor
10015    // snaps `set_cursor` to a char boundary as a safety net).
10016    let new_cursor = replaced.len().min(
10017        start
10018            + top.text.len()
10019            + if top.insert_space && !top.text.ends_with('/') {
10020                1
10021            } else {
10022                0
10023            },
10024    );
10025    editor.set_text(&replaced);
10026    set_editor_cursor_offset(editor, &replaced, new_cursor);
10027    state.autocomplete_container.clear();
10028    true
10029}
10030
10031// ===========================================================================
10032// Transcript message helpers
10033// ===========================================================================
10034
10035/// Add the welcome header to the chat container.
10036fn add_welcome_message(container: &Arc<Container>) {
10037    add_welcome_message_with_capabilities(container, &[], &[]);
10038}
10039
10040/// Add the startup welcome header and a compact snapshot of active tools and
10041/// discovered skills. The snapshot reflects the harness configuration used by
10042/// the first turn, including tools contributed by extensions.
10043fn add_welcome_message_with_capabilities(
10044    container: &Arc<Container>,
10045    active_tools: &[String],
10046    skills: &[String],
10047) {
10048    let c = current_theme().colors;
10049    // The mark, wordmark and language mark live in `crate::brand`. See that
10050    // module for why the layout is built from single-width characters only.
10051    container.add_child(Arc::new(crate::brand::BrandLockup::new()));
10052    container.add_child(Arc::new(Spacer::new(1)));
10053    // The mark carries the product name, so the line under it is the tagline
10054    // rather than a second logotype.
10055    let title = c.muted.fg("interactive TUI · library-first agent runtime");
10056    container.add_child(Arc::new(Text::new(title, 1, 0)));
10057    container.add_child(Arc::new(Spacer::new(1)));
10058    container.add_child(Arc::new(Text::new(
10059        c.dim.fg("Type your message and press Enter to send."),
10060        1,
10061        0,
10062    )));
10063    let hint = c
10064        .dim
10065        .fg("Enter send · Shift+Enter newline · Ctrl+C clear · Esc abort · /help");
10066    container.add_child(Arc::new(Text::new(hint, 1, 0)));
10067    container.add_child(Arc::new(Spacer::new(1)));
10068    container.add_child(Arc::new(Text::new(
10069        welcome_capability_line("Tools", active_tools),
10070        1,
10071        0,
10072    )));
10073    container.add_child(Arc::new(Text::new(
10074        welcome_capability_line("Skills", skills),
10075        1,
10076        0,
10077    )));
10078    container.add_child(Arc::new(DynamicBorder::new()));
10079}
10080
10081/// Append update notices inside the live transcript. Fullscreen mode clears
10082/// pre-TUI stdout/stderr, so update state must be represented by components.
10083fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
10084    let colors = current_theme().colors;
10085    let group = Arc::new(Container::new());
10086
10087    for warning in &report.warnings {
10088        let body = format!(
10089            "{}\n{} {}{}",
10090            colors.error.fg(&warning.message),
10091            colors.muted.fg("Run"),
10092            colors.accent.fg(&warning.command),
10093            colors.muted.fg(" to retry.")
10094        );
10095        add_update_panel(&group, "Update Failed", &body, colors.error);
10096    }
10097
10098    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
10099        let body = format!(
10100            "{} {}{}",
10101            colors
10102                .muted
10103                .fg(&format!("New version {} is available. Run", notice.latest)),
10104            colors.accent.fg(&notice.command),
10105            colors.muted.fg(".")
10106        );
10107        add_update_panel(&group, "Update Available", &body, colors.warning);
10108    }
10109
10110    let package_notices = report
10111        .notices
10112        .iter()
10113        .filter(|notice| notice.name != "rpi")
10114        .collect::<Vec<_>>();
10115    if !package_notices.is_empty() {
10116        let command = package_notices[0].command.as_str();
10117        let mut lines = vec![format!(
10118            "{} {}{}",
10119            colors.muted.fg("Package updates are available. Run"),
10120            colors.accent.fg(command),
10121            colors.muted.fg(".")
10122        )];
10123        lines.push(colors.muted.fg("Packages:"));
10124        lines.extend(
10125            package_notices
10126                .into_iter()
10127                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
10128        );
10129        add_update_panel(
10130            &group,
10131            "Package Updates Available",
10132            &lines.join("\n"),
10133            colors.warning,
10134        );
10135    }
10136
10137    // Other transcript producers append concurrently. Add the fully built
10138    // group in one operation so card borders and content cannot interleave
10139    // with user, assistant, tool, or extension messages.
10140    if group.child_count() > 0 {
10141        container.add_child(group);
10142    }
10143}
10144
10145fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
10146    container.add_child(Arc::new(Spacer::new(1)));
10147    container.add_child(Arc::new(DynamicBorder::with_color(color)));
10148    container.add_child(Arc::new(Text::new(
10149        format!("{}\n{body}", color.fg(&tui_bold(title))),
10150        1,
10151        0,
10152    )));
10153    container.add_child(Arc::new(DynamicBorder::with_color(color)));
10154}
10155
10156fn welcome_capability_line(label: &str, names: &[String]) -> String {
10157    let c = current_theme().colors;
10158    let value = if names.is_empty() {
10159        "none".to_string()
10160    } else {
10161        names.join(" · ")
10162    };
10163    format!(
10164        "{} {}",
10165        c.accent.fg(&format!("{label} ({})", names.len())),
10166        c.muted.fg(&value)
10167    )
10168}
10169
10170/// Add the `/help` command listing to the chat container.
10171fn add_help_message(container: &Arc<Container>) {
10172    let c = current_theme().colors;
10173    // Section header + a thin themed rule, then a two-column command table:
10174    // `cmd` in accent, `— desc` in muted. The old single-space layout made
10175    // the description column wander depending on command length.
10176    container.add_child(Arc::new(Text::new(
10177        c.md_heading.fg(&tui_bold("📚 Available Commands")),
10178        1,
10179        0,
10180    )));
10181    container.add_child(Arc::new(Spacer::new(1)));
10182
10183    let cmds: &[(&str, &str)] = &[
10184        ("/help, /?", "Show this help message"),
10185        ("/clear, /new", "Clear the conversation"),
10186        ("/exit, /quit, /q", "Exit the application"),
10187        ("/version, /v", "Show version information"),
10188        ("/changelog", "Show recent release changes"),
10189        ("/model, /m", "Choose a model (live switch)"),
10190        ("/thinking, /think", "Set reasoning depth (selector)"),
10191        ("/tools", "Toggle built-in tools on/off"),
10192        ("/images", "Toggle inline image rendering"),
10193        ("/session", "List saved sessions"),
10194        ("/theme", "Choose a theme (selector)"),
10195        ("/compact", "Compact the conversation"),
10196        ("/copy", "Copy last reply to clipboard"),
10197        ("/hotkeys", "Show keyboard shortcuts"),
10198        ("/armin", "🐾 Easter egg"),
10199        ("/earendil", "Earendil announcement"),
10200    ];
10201    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10202    for (cmd, desc) in cmds {
10203        let row = format!(
10204            "  {:<cmd_w$}  {}  {}",
10205            c.accent.fg(cmd),
10206            c.dim.fg("—"),
10207            c.muted.fg(desc)
10208        );
10209        container.add_child(Arc::new(Text::new(row, 1, 0)));
10210    }
10211    container.add_child(Arc::new(Spacer::new(1)));
10212}
10213
10214/// Add the `/version` block to the chat container.
10215fn add_version_message(container: &Arc<Container>) {
10216    let c = current_theme().colors;
10217    container.add_child(Arc::new(Text::new(
10218        c.md_heading.fg(&tui_bold("📦 Version Information")),
10219        1,
10220        0,
10221    )));
10222    container.add_child(Arc::new(Spacer::new(1)));
10223    // Use the crate version (kept in sync via `version.workspace = true`)
10224    // instead of the stale hardcoded "v0.1.2".
10225    container.add_child(Arc::new(Text::new(
10226        format!(
10227            "  {} {}",
10228            c.muted.fg("rpi-cli"),
10229            c.text.fg(&format!("v{}", crate::VERSION))
10230        ),
10231        1,
10232        0,
10233    )));
10234    container.add_child(Arc::new(Text::new(
10235        format!(
10236            "  {}",
10237            c.dim.fg("Rust implementation of pi coding agent TUI")
10238        ),
10239        1,
10240        0,
10241    )));
10242    container.add_child(Arc::new(Spacer::new(1)));
10243}
10244
10245/// Add a compact `/changelog` block to the chat container. Keep this local to
10246/// the binary so the command remains useful in installed builds without a
10247/// source checkout or a network request.
10248fn add_changelog_message(container: &Arc<Container>) {
10249    let c = current_theme().colors;
10250    container.add_child(Arc::new(Text::new(
10251        c.md_heading.fg(&tui_bold("Recent Changes")),
10252        1,
10253        0,
10254    )));
10255    container.add_child(Arc::new(Spacer::new(1)));
10256    let entries = [
10257        (
10258            "Native parity phase 1",
10259            "models, images, trust, export, and JSON events",
10260        ),
10261        (
10262            "TUI controls",
10263            "external editor, thinking levels, and tool output toggles",
10264        ),
10265        (
10266            "Provider auth",
10267            "OpenAI-compatible API key aliases and gateway headers",
10268        ),
10269    ];
10270    for (release, summary) in entries {
10271        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
10272        container.add_child(Arc::new(Text::new(row, 1, 0)));
10273    }
10274    container.add_child(Arc::new(Text::new(
10275        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
10276        1,
10277        0,
10278    )));
10279    container.add_child(Arc::new(Spacer::new(1)));
10280}
10281
10282/// Add the `/hotkeys` block to the chat container.
10283fn add_hotkeys_message(container: &Arc<Container>) {
10284    let c = current_theme().colors;
10285    container.add_child(Arc::new(Text::new(
10286        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
10287        1,
10288        0,
10289    )));
10290    container.add_child(Arc::new(Spacer::new(1)));
10291    let keys: &[(&str, &str)] = &[
10292        ("Enter", "Send message"),
10293        ("Shift+Enter", "New line"),
10294        ("Tab", "Accept autocomplete suggestion"),
10295        ("Ctrl+A / Ctrl+E", "Line start / end"),
10296        (
10297            "Ctrl+K / Ctrl+U",
10298            "Kill to end / start of line (Ctrl+Y yanks)",
10299        ),
10300        ("Ctrl+- / Ctrl+R", "Undo / redo"),
10301        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
10302        ("Alt+Backspace", "Kill previous word"),
10303        ("Ctrl+C", "Clear the editor (press twice quickly to exit)"),
10304        ("Ctrl+X", "Copy the editor selection or the last reply"),
10305        ("Esc", "Abort a running prompt"),
10306        ("Ctrl+L", "Open model selector"),
10307        ("Ctrl+P", "Cycle to the next model (live)"),
10308        ("Ctrl+O", "Expand/collapse all tool output"),
10309        ("Ctrl+T", "Show/hide reasoning blocks"),
10310        ("PageUp/Down", "Scroll transcript by one page"),
10311        ("Home / End", "Jump to transcript start / latest output"),
10312    ];
10313    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10314    for (key, desc) in keys {
10315        let row = format!(
10316            "  {:<key_w$}  {}  {}",
10317            c.accent.fg(key),
10318            c.dim.fg("—"),
10319            c.muted.fg(desc)
10320        );
10321        container.add_child(Arc::new(Text::new(row, 1, 0)));
10322    }
10323    container.add_child(Arc::new(Spacer::new(1)));
10324}
10325
10326/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
10327/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
10328/// plain `> text` echo. A trailing Spacer(1) separates it from the next
10329// transcript entry (every entry contributes one trailing spacer so
10330// consecutive turns are separated by exactly one blank line).
10331fn add_user_message(container: &Arc<Container>, text: &str) {
10332    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
10333    container.add_child(Arc::new(Spacer::new(1)));
10334}
10335
10336/// Add an error message to the chat container.
10337/// Collect all rendered lines from the chat container for search.
10338fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
10339    let width = 80; // Default width for search; actual width varies by terminal
10340    chat_container.render(width)
10341}
10342
10343/// Build the shell command that resumes `session_id` in this project, or
10344/// `None` when there is nothing to resume. Mirrors native pi's
10345/// `formatResumeCommand`: `rpi --session <id>`, prefixing
10346/// `--session-dir <dir>` only when a non-default directory was requested.
10347fn format_resume_command(
10348    session_id: &str,
10349    cwd: &std::path::Path,
10350    session_dir: Option<&std::path::Path>,
10351) -> Option<String> {
10352    if session_id.is_empty() {
10353        return None;
10354    }
10355    let mut args = vec![crate::APP_NAME.to_string()];
10356    if let Some(dir) = session_dir {
10357        let default = crate::session::default_session_dir(cwd);
10358        if dir != default.as_path() {
10359            args.push("--session-dir".to_string());
10360            args.push(quote_shell_arg(&dir.to_string_lossy()));
10361        }
10362    }
10363    args.push("--session".to_string());
10364    args.push(session_id.to_string());
10365    Some(args.join(" "))
10366}
10367
10368/// Quote a shell argument when it contains whitespace or shell metacharacters
10369/// (mirrors native pi's `quoteIfNeeded`).
10370fn quote_shell_arg(value: &str) -> String {
10371    let needs_quoting = value.is_empty()
10372        || value
10373            .chars()
10374            .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
10375    if needs_quoting {
10376        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
10377    } else {
10378        value.to_string()
10379    }
10380}
10381
10382fn add_error_message(container: &Arc<Container>, text: &str) {
10383    let c = current_theme().colors;
10384    let text = sanitize_error_message(text);
10385    container.add_child(Arc::new(Text::new(
10386        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
10387        1,
10388        0,
10389    )));
10390    container.add_child(Arc::new(Spacer::new(1)));
10391}
10392
10393/// Keep provider diagnostics printable in the transcript. HTTP error bodies
10394/// may contain carriage returns, terminal escapes, or an unexpectedly large
10395/// JSON payload; letting those bytes reach the renderer can corrupt the input
10396/// row or make the whole frame exceed terminal limits.
10397fn sanitize_error_message(text: &str) -> String {
10398    const MAX_ERROR_CHARS: usize = 16 * 1024;
10399    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
10400    let mut count = 0;
10401    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
10402    let mut escape_mode = 0u8;
10403    for ch in text.chars() {
10404        if escape_mode != 0 {
10405            match escape_mode {
10406                1 if ch == '[' => escape_mode = 2,
10407                1 if ch == ']' => escape_mode = 3,
10408                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
10409                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
10410                3 if ch == '\x07' => escape_mode = 0,
10411                _ => {}
10412            }
10413            continue;
10414        }
10415        if ch == '\x1b' {
10416            escape_mode = 1;
10417            continue;
10418        }
10419        if count >= MAX_ERROR_CHARS {
10420            result.push_str("…");
10421            break;
10422        }
10423        match ch {
10424            '\n' | '\t' => {
10425                result.push(ch);
10426                count += 1;
10427            }
10428            '\r' => {}
10429            c if c.is_control() => {}
10430            c => {
10431                result.push(c);
10432                count += 1;
10433            }
10434        }
10435    }
10436    if result.trim().is_empty() {
10437        "Provider request failed.".to_string()
10438    } else {
10439        result
10440    }
10441}
10442
10443/// Add a neutral note (e.g. unsupported-command message) to the chat container.
10444fn add_note_message(container: &Arc<Container>, text: &str) {
10445    let c = current_theme().colors;
10446    container.add_child(Arc::new(Text::new(
10447        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
10448        1,
10449        0,
10450    )));
10451    container.add_child(Arc::new(Spacer::new(1)));
10452}
10453
10454/// Render the `/context` panel: a transcript message listing the discovered
10455/// context files, skills, and prompt templates loaded for this session
10456/// (Part A resource discovery). Reads the harness resources snapshot captured
10457/// at TUI startup (the blocking submit handler can't `await get_resources()`.
10458///
10459/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
10460/// via `/reload`); here it's a transcript note rather than an overlay since the
10461/// resource set is session-static between `/reload`s (deferred).
10462fn show_context_panel(
10463    chat: &Arc<Container>,
10464    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
10465) {
10466    let skills = resources.skills.as_deref().unwrap_or(&[]);
10467    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
10468    let mut lines: Vec<String> = Vec::new();
10469    lines.push("📂 Discovered resources for this session:".into());
10470
10471    if skills.is_empty() {
10472        lines.push(
10473            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
10474        );
10475    } else {
10476        lines.push(format!("  Skills ({}):", skills.len()));
10477        for s in skills {
10478            let marker = if s.disable_model_invocation == Some(true) {
10479                " [hidden]"
10480            } else {
10481                ""
10482            };
10483            let desc: String = s.description.chars().take(72).collect();
10484            lines.push(format!("    • {}{marker} — {desc}", s.name));
10485        }
10486    }
10487
10488    if templates.is_empty() {
10489        lines.push(
10490            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
10491        );
10492    } else {
10493        lines.push(format!("  Prompt templates ({}):", templates.len()));
10494        for t in templates {
10495            let desc = t
10496                .description
10497                .as_deref()
10498                .unwrap_or("(no description)")
10499                .chars()
10500                .take(72)
10501                .collect::<String>();
10502            lines.push(format!("    • /{} — {desc}", t.name));
10503        }
10504    }
10505    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
10506    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
10507    lines.push(
10508        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
10509            .into(),
10510    );
10511    let body = lines.join("\n");
10512    container_note_block(chat, &body);
10513}
10514
10515/// Append a multi-line neutral note (header line + body) to the chat container.
10516fn container_note_block(container: &Arc<Container>, body: &str) {
10517    for line in body.lines() {
10518        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
10519    }
10520    container.add_child(Arc::new(Spacer::new(1)));
10521}
10522
10523// ===========================================================================
10524// TUI support + entry detection
10525// ===========================================================================
10526
10527/// Check if the terminal supports TUI mode.
10528pub fn is_tui_supported() -> bool {
10529    std::io::stdout().is_terminal()
10530}
10531
10532// Keep the `Color` import used (theme accent rendering in autocomplete).
10533#[allow(unused_imports)]
10534use rpi_tui::Color as _Color;
10535
10536#[cfg(test)]
10537mod tests {
10538    use super::*;
10539    use rpi_tui::Component;
10540
10541    /// A draft injected from a voice transcription is submitted only when it is
10542    /// still exactly what we put in the editor and no run has started; any
10543    /// user edit (or a cleared editor) hands it back for editing instead.
10544    #[test]
10545    fn auto_send_cancels_when_the_user_edits_the_draft() {
10546        let now = std::time::Instant::now();
10547        let pending = AutoSendPending {
10548            deadline: now + std::time::Duration::from_secs(2),
10549            text: "hello world".to_string(),
10550        };
10551        // Untouched → still counting down.
10552        assert!(matches!(
10553            auto_send_step(Some(&pending), "hello world", true, now),
10554            AutoSendStep::Wait(_)
10555        ));
10556        // One added character is the user's correction: never send a draft
10557        // they are actively editing.
10558        assert_eq!(
10559            auto_send_step(Some(&pending), "hello worlds", true, now),
10560            AutoSendStep::Cancel
10561        );
10562        // Cleared out from under us (a slash command, Esc).
10563        assert_eq!(
10564            auto_send_step(Some(&pending), "", true, now),
10565            AutoSendStep::Cancel
10566        );
10567        // Whitespace is not content.
10568        assert_eq!(
10569            auto_send_step(Some(&pending), "   ", true, now),
10570            AutoSendStep::Cancel
10571        );
10572    }
10573
10574    /// The countdown fires exactly at the deadline, and never injects a prompt
10575    /// into a run that started while it was ticking.
10576    #[test]
10577    fn auto_send_submits_at_the_deadline_only_while_idle() {
10578        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2000);
10579        let pending = AutoSendPending {
10580            deadline,
10581            text: "go".to_string(),
10582        };
10583        assert!(matches!(
10584            auto_send_step(
10585                Some(&pending),
10586                "go",
10587                true,
10588                deadline - std::time::Duration::from_millis(1)
10589            ),
10590            AutoSendStep::Wait(_)
10591        ));
10592        assert_eq!(
10593            auto_send_step(Some(&pending), "go", true, deadline),
10594            AutoSendStep::Submit
10595        );
10596        // A turn started underneath the countdown — keep the draft, do not
10597        // queue a prompt mid-turn.
10598        assert_eq!(
10599            auto_send_step(Some(&pending), "go", false, deadline),
10600            AutoSendStep::Cancel
10601        );
10602        // Nothing pending.
10603        assert_eq!(
10604            auto_send_step(None, "go", true, deadline),
10605            AutoSendStep::Idle
10606        );
10607    }
10608
10609    /// The footer text the countdown writes is prefixed so `clear_auto_send`
10610    /// can tell its own message from an unrelated run status.
10611    #[test]
10612    fn auto_send_footer_label_is_recognisable() {
10613        let label = format!("{AUTO_SEND_PREFIX}2.0s · any key to edit");
10614        assert!(label.starts_with(AUTO_SEND_PREFIX));
10615        assert!(!"Working…".starts_with(AUTO_SEND_PREFIX));
10616    }
10617
10618    #[test]
10619    fn markdown_flag_only_comes_from_an_explicit_true() {
10620        // Tools opt in by setting `details.markdown = true` (rpi-todo does).
10621        assert!(tool_result_requests_markdown(Some(
10622            &serde_json::json!({"kind": "todo", "markdown": true})
10623        )));
10624        // Absence, a false value, or a non-bool value keeps plain rendering,
10625        // so every other tool is unaffected.
10626        assert!(!tool_result_requests_markdown(None));
10627        assert!(!tool_result_requests_markdown(Some(
10628            &serde_json::json!({"kind": "todo"})
10629        )));
10630        assert!(!tool_result_requests_markdown(Some(
10631            &serde_json::json!({"markdown": false})
10632        )));
10633        assert!(!tool_result_requests_markdown(Some(
10634            &serde_json::json!({"markdown": "true"})
10635        )));
10636    }
10637
10638    /// Minimal `TuiState` for unit tests that only touch state flags. Mirrors
10639    /// the explicit literals other tests build, but keeps one copy in sync.
10640    fn test_tui_state() -> Arc<TuiState> {
10641        Arc::new(TuiState {
10642            current_assistant: Arc::new(std::sync::Mutex::new(None)),
10643            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10644            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10645            themes_enabled: true,
10646            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
10647            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
10648            show_terminal_progress: true,
10649            status: std::sync::Mutex::new(RunStatus::Idle),
10650            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10651            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10652            editor_text: rpi_extensions::EditorTextMailbox::new(),
10653            auto_send: std::sync::Mutex::new(None),
10654            last_editor_text: std::sync::Mutex::new(String::new()),
10655            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
10656            js_preparation_cancel: std::sync::Mutex::new(None),
10657            user_bash_cancel: std::sync::Mutex::new(None),
10658            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10659            footer: Arc::new(FooterComponent::new()),
10660            status_container: Arc::new(Container::new()),
10661            chat_container: Arc::new(Container::new()),
10662            loader: Arc::new(Loader::new()),
10663            editor: Arc::new(Editor::simple()),
10664            last_assistant_text: std::sync::Mutex::new(String::new()),
10665            active_selector: std::sync::Mutex::new(None),
10666            active_extension_editor: std::sync::Mutex::new(None),
10667            active_extension_input: std::sync::Mutex::new(None),
10668            active_extension_cancel: std::sync::Mutex::new(None),
10669            autocomplete: AutocompleteManager::new(),
10670            autocomplete_container: Arc::new(Container::new()),
10671            autocomplete_max_visible: 5,
10672            pending_images: std::sync::Mutex::new(Vec::new()),
10673            theme_manager: Arc::new(ThemeManager::new()),
10674            tui: None,
10675            current_model_id: std::sync::Mutex::new(String::new()),
10676            show_images: std::sync::Mutex::new(true),
10677            cache_miss_notices: std::sync::Mutex::new(false),
10678            history: std::sync::Mutex::new(Vec::new()),
10679            history_index: std::sync::Mutex::new(-1),
10680            history_draft: std::sync::Mutex::new(None),
10681            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10682            scoped_edit: std::sync::Mutex::new(None),
10683            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
10684            extension_session: Arc::new(std::sync::Mutex::new(
10685                rpi_extensions::ExtensionSession::none(),
10686            )),
10687            search: Arc::new(AltScreenSearch::new()),
10688            search_bar: Arc::new(SearchBar::new()),
10689            pending_container: Arc::new(Container::new()),
10690            dequeue_hint: String::new(),
10691            pending_snapshot: std::sync::Mutex::new(
10692                rpi_harness::agent_harness::QueuedMessages::default(),
10693            ),
10694            selection_start: std::sync::Mutex::new(None),
10695            selection_end: std::sync::Mutex::new(None),
10696        })
10697    }
10698
10699    /// A `SetEditorText` injection — and the host's own auto-send clear — must be
10700    /// attributed to `extension`, not to the user.
10701    ///
10702    /// A hands-free voice extension stands down whenever `source == "user"`
10703    /// (the human took the keyboard), so mislabelling the plugin's own injected
10704    /// transcription would cancel the whole conversation on its first turn.
10705    #[test]
10706    fn editor_change_source_separates_tui_writes_from_typing() {
10707        use std::sync::atomic::Ordering;
10708        let state = test_tui_state();
10709
10710        // Nothing changed yet → nothing to report.
10711        assert!(state.take_editor_change().is_none());
10712
10713        // The render tick applies an extension's draft…
10714        state.editor_text.push(rpi_extensions::EditorTextEdit {
10715            text: "你好".to_string(),
10716            mode: rpi_extensions::EditorTextMode::Replace,
10717            auto_send_ms: Some(2000),
10718        });
10719        assert!(state.drain_editor_text(), "the queued draft must land");
10720        assert!(
10721            state.programmatic_editor_write.load(Ordering::SeqCst),
10722            "a mailbox-applied edit must be marked programmatic"
10723        );
10724
10725        // …so its change is ours, not the user's.
10726        let payload = state
10727            .take_editor_change()
10728            .expect("draft applied ⇒ a change");
10729        assert_eq!(payload["source"], serde_json::json!("extension"));
10730        assert_eq!(payload["chars"], serde_json::json!(2));
10731        assert_eq!(payload["empty"], serde_json::json!(false));
10732
10733        // The mark was consumed: the same text twice is not re-reported, and a
10734        // genuine human edit is attributed to the user.
10735        assert!(state.take_editor_change().is_none());
10736        state.editor.set_text("你好啊");
10737        let payload = state.take_editor_change().expect("typing ⇒ a change");
10738        assert_eq!(payload["source"], serde_json::json!("user"));
10739        assert_eq!(payload["chars"], serde_json::json!(3));
10740    }
10741
10742    /// The attribution mark must not survive unrelated changes, or one injected
10743    /// draft would make the *next* keystroke look programmatic too.
10744    #[test]
10745    fn programmatic_mark_is_consumed_even_with_no_subscriber() {
10746        use std::sync::atomic::Ordering;
10747        let state = test_tui_state();
10748        assert!(
10749            state
10750                .extension_session
10751                .lock()
10752                .unwrap()
10753                .snapshot_arc()
10754                .is_none(),
10755            "this test relies on nobody subscribing"
10756        );
10757
10758        state.editor_text.push(rpi_extensions::EditorTextEdit {
10759            text: "draft".to_string(),
10760            mode: rpi_extensions::EditorTextMode::Replace,
10761            auto_send_ms: None,
10762        });
10763        state.drain_editor_text();
10764        // `sync_editor_change` bails out early (no subscriber)…
10765        state.sync_editor_change();
10766        // …but the mark is gone, so the next real edit is the user's.
10767        assert!(!state.programmatic_editor_write.load(Ordering::SeqCst));
10768        state.editor.set_text("draft +");
10769        let payload = state.take_editor_change().expect("typing ⇒ a change");
10770        assert_eq!(payload["source"], serde_json::json!("user"));
10771    }
10772
10773    /// Regression: the editor clear that follows *every* submit is host
10774    /// housekeeping, not the user typing.
10775    ///
10776    /// Reporting it as `source:"user"` switched a hands-free voice session off
10777    /// the instant it was switched on: `/voice auto` turns the mode on, then the
10778    /// render tick notices the cleared editor ~80ms later, stands the mode down
10779    /// and aborts the listen it just started — so the microphone never stayed
10780    /// open and the session looked like it "didn't hear anything".
10781    #[test]
10782    fn submit_clear_is_attributed_to_the_host_not_the_user() {
10783        let state = test_tui_state();
10784
10785        // Typing the command is a genuine user edit.
10786        state.editor.set_text("/voice auto");
10787        assert_eq!(
10788            state.take_editor_change().unwrap()["source"],
10789            serde_json::json!("user")
10790        );
10791
10792        // The submit path's clear is not.
10793        state.clear_editor_programmatically();
10794        let payload = state.take_editor_change().expect("clearing is a change");
10795        assert_eq!(payload["empty"], serde_json::json!(true));
10796        assert_eq!(
10797            payload["source"],
10798            serde_json::json!("extension"),
10799            "a submit-clear must not be reported as the user typing"
10800        );
10801    }
10802
10803    /// A user-initiated clear (Esc, select-all + delete) still goes through the
10804    /// editor itself and must stay `user` — only host writes are reattributed.
10805    #[test]
10806    fn user_initiated_clear_stays_a_user_change() {
10807        let state = test_tui_state();
10808        state.editor.set_text("draft");
10809        assert_eq!(
10810            state.take_editor_change().unwrap()["source"],
10811            serde_json::json!("user")
10812        );
10813        state.editor.clear();
10814        let payload = state.take_editor_change().expect("clearing is a change");
10815        assert_eq!(payload["source"], serde_json::json!("user"));
10816    }
10817
10818    #[test]
10819    fn live_panel_repaints_while_a_tool_or_bash_is_running() {
10820        // No panels → only the dock loader animates (reuse the cached
10821        // transcript instead of rebuilding a long history).
10822        let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
10823        assert!(!transcript_has_live_panel(false, &empty));
10824
10825        // A running tool call is a live panel: its elapsed readout is computed
10826        // at render time, so it must force a transcript rebuild every tick.
10827        let mut tools = HashMap::new();
10828        let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
10829        tool.set_running();
10830        tools.insert("tc1".to_string(), tool);
10831        assert!(transcript_has_live_panel(false, &tools));
10832
10833        // A finished tool no longer needs the repaint, but a running bash
10834        // command still does.
10835        tools.get("tc1").unwrap().set_result("done", false);
10836        assert!(!transcript_has_live_panel(false, &tools));
10837        assert!(transcript_has_live_panel(true, &tools));
10838    }
10839
10840    #[test]
10841    fn dequeue_merge_preserves_a_typed_draft() {
10842        assert_eq!(merge_queued_into_draft("queued", ""), "queued");
10843        assert_eq!(merge_queued_into_draft("queued", "   "), "queued");
10844        assert_eq!(
10845            merge_queued_into_draft("queued", "typed"),
10846            "queued\n\ntyped"
10847        );
10848    }
10849
10850    #[test]
10851    fn dequeue_places_the_caret_at_the_end_of_the_last_line_in_bytes() {
10852        // Restoring queued messages followed by the old `(0, char_count)` call
10853        // parked the caret at the end of the FIRST line, and treated a char
10854        // count as a byte offset (so any CJK draft landed mid-character).
10855        let editor = Arc::new(Editor::simple());
10856        // Multi-line + multibyte: the caret must be on the LAST row, after the
10857        // final byte of that row.
10858        let text = "排队\n第二行";
10859        set_editor_text_caret_at_end(&editor, text);
10860        assert_eq!(editor.get_text(), text);
10861        assert_eq!(
10862            editor.cursor_position(),
10863            (1, "第二行".len()),
10864            "caret must sit at the end of the last line, in bytes"
10865        );
10866    }
10867
10868    #[test]
10869    fn settings_menu_covers_the_wired_settings_surface() {
10870        let state = test_tui_state();
10871        let settings = crate::settings::Settings::default();
10872        let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
10873        let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
10874        assert_eq!(
10875            keys,
10876            [
10877                "theme",
10878                "model",
10879                "thinking",
10880                "scoped-models",
10881                "hide-thinking",
10882                "show-images",
10883                "cache-miss-notices",
10884                "quiet-startup",
10885                "terminal-progress",
10886                "fullscreen-copy-on-select",
10887                "double-escape-action",
10888                "editor-padding",
10889                "autocomplete-max-items",
10890                "http-idle-timeout",
10891            ]
10892        );
10893        // Submenu rows must not cycle their displayed value.
10894        for item in items.iter().filter(|item| item.has_submenu) {
10895            assert!(
10896                item.values.is_empty(),
10897                "{} should be submenu-only",
10898                item.key
10899            );
10900        }
10901        // Value rows advertise a cycle that contains the current value.
10902        for item in items.iter().filter(|item| !item.has_submenu) {
10903            assert!(!item.values.is_empty(), "{} has no values", item.key);
10904            assert!(
10905                item.values.iter().any(|value| value == &item.value),
10906                "{} current value {} not in {:?}",
10907                item.key,
10908                item.value,
10909                item.values
10910            );
10911        }
10912        // The default model row falls back to the running lane's model.
10913        assert_eq!(items[1].value, "claude-sonnet-5");
10914        // Native defaults: cache-miss notices off, terminal progress on.
10915        assert_eq!(items[6].value, "false");
10916        assert_eq!(items[8].value, "true");
10917    }
10918
10919    #[test]
10920    fn apply_setting_change_updates_settings_and_live_state() {
10921        let state = test_tui_state();
10922        let mut settings = crate::settings::Settings::default();
10923
10924        // Live: hide-thinking flips both the runtime flag and the persisted one.
10925        let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
10926        assert!(state.hide_thinking());
10927        assert_eq!(settings.hide_thinking_block, Some(true));
10928        assert!(note.expect("user-visible note").contains("hidden"));
10929
10930        // Live: show-images writes the runtime mutex.
10931        apply_setting_change(&state, "show-images", "false", &mut settings);
10932        assert!(!*state.show_images.lock().unwrap());
10933        assert_eq!(settings.show_images, Some(false));
10934
10935        // Live: cache-miss notices.
10936        apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
10937        assert!(*state.cache_miss_notices.lock().unwrap());
10938        assert_eq!(settings.show_cache_miss_notices, Some(true));
10939
10940        // Persist-only rows change settings without a transcript note.
10941        assert_eq!(
10942            apply_setting_change(&state, "editor-padding", "3", &mut settings),
10943            None
10944        );
10945        assert_eq!(settings.editor_padding_x, Some(3));
10946        apply_setting_change(&state, "double-escape-action", "none", &mut settings);
10947        assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
10948        apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
10949        assert_eq!(settings.autocomplete_max_visible, Some(20));
10950
10951        // HTTP idle timeout maps labels onto the numeric/`disabled` forms.
10952        apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
10953        assert_eq!(settings.http_idle_timeout_ms(), Some(0));
10954        apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
10955        assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
10956
10957        // Unknown keys are ignored rather than panicking.
10958        assert_eq!(
10959            apply_setting_change(&state, "nope", "x", &mut settings),
10960            None
10961        );
10962    }
10963
10964    #[test]
10965    fn add_user_message_renders_a_visible_user_bubble() {
10966        // The submit handler renders directly-sent prompts through this helper
10967        // (the harness emits no user `message_start` for them), so a silent
10968        // failure here would make every prompt vanish from the transcript.
10969        let chat = Arc::new(Container::new());
10970        add_user_message(&chat, "hello from the user");
10971        let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
10972        let plain = strip_ansi(&rendered.join("\n"));
10973        assert!(
10974            plain.contains("hello from the user"),
10975            "user bubble must show the prompt text; got: {plain:?}"
10976        );
10977    }
10978
10979    #[test]
10980    fn verbose_overrides_quiet_startup_listing() {
10981        assert!(should_show_startup_listing(false, false));
10982        assert!(should_show_startup_listing(true, false));
10983        assert!(should_show_startup_listing(true, true));
10984        assert!(!should_show_startup_listing(false, true));
10985    }
10986
10987    #[test]
10988    fn resume_command_omits_the_default_session_dir() {
10989        let cwd = std::path::Path::new("/proj/demo");
10990        assert_eq!(format_resume_command("", cwd, None), None);
10991        assert_eq!(
10992            format_resume_command("abc-123", cwd, None).as_deref(),
10993            Some("rpi --session abc-123")
10994        );
10995        // The default dir carries no flag (mirrors native pi).
10996        let default = crate::session::default_session_dir(cwd);
10997        assert_eq!(
10998            format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
10999            Some("rpi --session abc-123")
11000        );
11001        // A custom dir is echoed, quoting when needed.
11002        let custom = std::path::Path::new("/tmp/my sessions");
11003        assert_eq!(
11004            format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
11005            Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
11006        );
11007    }
11008
11009    #[test]
11010    fn quote_shell_arg_quotes_only_when_needed() {
11011        assert_eq!(quote_shell_arg("plain"), "plain");
11012        assert_eq!(quote_shell_arg("with space"), "\"with space\"");
11013        assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
11014    }
11015
11016    #[test]
11017    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
11018        let tmp = tempfile::tempdir().unwrap();
11019        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
11020        let local_root = tmp.path().join("local-package");
11021        std::fs::create_dir_all(git_root.join(".git")).unwrap();
11022        std::fs::create_dir_all(&local_root).unwrap();
11023        std::fs::write(
11024            git_root.join("package.json"),
11025            r#"{"name":"git-demo","version":"1.0.0"}"#,
11026        )
11027        .unwrap();
11028        std::fs::write(
11029            local_root.join("package.json"),
11030            r#"{"name":"local-demo","version":"1.0.0"}"#,
11031        )
11032        .unwrap();
11033        let resources = crate::packages::discover(
11034            tmp.path(),
11035            &[
11036                "git:github.com/example/repo".to_string(),
11037                local_root.to_string_lossy().into_owned(),
11038            ],
11039        );
11040        assert_eq!(resources.packages.len(), 2);
11041
11042        let filtered = git_only_update_resources(resources);
11043
11044        assert_eq!(filtered.packages.len(), 1);
11045        assert!(matches!(
11046            filtered.packages[0].source,
11047            crate::packages::PackageSource::Git
11048        ));
11049    }
11050
11051    #[test]
11052    fn trust_command_uses_the_session_cwd_after_process_chdir() {
11053        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
11054        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
11055        const TEST_NAME: &str =
11056            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
11057
11058        if std::env::var_os(CHILD_ENV).is_some() {
11059            let session_cwd = std::path::PathBuf::from(
11060                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
11061            );
11062            let changed_cwd = std::env::current_dir().unwrap();
11063
11064            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
11065
11066            assert_eq!(
11067                crate::config::project_trust_decision(&session_cwd).unwrap(),
11068                Some(true)
11069            );
11070            assert_eq!(
11071                crate::config::project_trust_decision(&changed_cwd).unwrap(),
11072                None
11073            );
11074            return;
11075        }
11076
11077        let tmp = tempfile::tempdir().unwrap();
11078        let session_cwd = tmp.path().join("session-project");
11079        let changed_cwd = tmp.path().join("extension-cwd");
11080        let agent_dir = tmp.path().join("agent");
11081        std::fs::create_dir_all(&session_cwd).unwrap();
11082        std::fs::create_dir_all(&changed_cwd).unwrap();
11083        std::fs::create_dir_all(&agent_dir).unwrap();
11084
11085        let status = std::process::Command::new(std::env::current_exe().unwrap())
11086            .arg("--exact")
11087            .arg(TEST_NAME)
11088            .arg("--nocapture")
11089            .env(CHILD_ENV, "1")
11090            .env(SESSION_CWD_ENV, &session_cwd)
11091            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
11092            .current_dir(&changed_cwd)
11093            .status()
11094            .unwrap();
11095
11096        assert!(status.success(), "child test process failed: {status}");
11097    }
11098
11099    #[test]
11100    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
11101        let global = crate::settings::Settings {
11102            editor_padding_x: Some(3),
11103            autocomplete_max_visible: Some(6),
11104            hide_thinking_block: Some(false),
11105            quiet_startup: Some(true),
11106            show_terminal_progress: Some(true),
11107            ..Default::default()
11108        };
11109        let rpi_project = crate::settings::Settings {
11110            editor_padding_x: Some(0),
11111            hide_thinking_block: Some(true),
11112            quiet_startup: Some(false),
11113            show_terminal_progress: Some(false),
11114            ..Default::default()
11115        };
11116        let pi_project = crate::settings::Settings {
11117            editor_padding_x: Some(9),
11118            autocomplete_max_visible: Some(12),
11119            quiet_startup: Some(true),
11120            ..Default::default()
11121        };
11122
11123        assert_eq!(
11124            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
11125            TuiStartupSettings {
11126                editor_padding_x: 0,
11127                autocomplete_max_visible: 12,
11128                hide_thinking: true,
11129                quiet_startup: false,
11130                show_terminal_progress: false,
11131                show_images: true,
11132                cache_miss_notices: false,
11133            }
11134        );
11135    }
11136
11137    #[test]
11138    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
11139        let global = crate::settings::Settings {
11140            quiet_startup: Some(false),
11141            ..Default::default()
11142        };
11143        let rpi_project = crate::settings::Settings::default();
11144        let pi_project = crate::settings::Settings {
11145            quiet_startup: Some(true),
11146            ..Default::default()
11147        };
11148
11149        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
11150
11151        assert!(resolved.quiet_startup);
11152    }
11153
11154    #[test]
11155    fn tui_startup_settings_use_global_values_without_project_fields() {
11156        let global = crate::settings::Settings {
11157            editor_padding_x: Some(7),
11158            autocomplete_max_visible: Some(8),
11159            hide_thinking_block: Some(true),
11160            quiet_startup: Some(true),
11161            show_terminal_progress: Some(false),
11162            show_images: Some(false),
11163            show_cache_miss_notices: Some(true),
11164            ..Default::default()
11165        };
11166
11167        assert_eq!(
11168            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
11169            TuiStartupSettings {
11170                editor_padding_x: 7,
11171                autocomplete_max_visible: 8,
11172                hide_thinking: true,
11173                quiet_startup: true,
11174                show_terminal_progress: false,
11175                show_images: false,
11176                cache_miss_notices: true,
11177            }
11178        );
11179    }
11180
11181    #[test]
11182    fn tui_startup_settings_read_native_nested_terminal_block() {
11183        // Native pi nests these under `terminal`; rpi's flat keys stay
11184        // compatible and the nested block is the fallback.
11185        let global = crate::settings::Settings {
11186            terminal: Some(crate::settings::TerminalSettings {
11187                show_images: Some(false),
11188                show_terminal_progress: Some(false),
11189                clear_on_shrink: None,
11190            }),
11191            ..Default::default()
11192        };
11193        let resolved =
11194            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
11195        assert!(!resolved.show_images);
11196        assert!(!resolved.show_terminal_progress);
11197    }
11198
11199    #[test]
11200    fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
11201        let global = crate::settings::Settings {
11202            show_images: Some(true),
11203            terminal: Some(crate::settings::TerminalSettings {
11204                show_images: Some(false),
11205                show_terminal_progress: None,
11206                clear_on_shrink: None,
11207            }),
11208            ..Default::default()
11209        };
11210        let resolved =
11211            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
11212        assert!(resolved.show_images);
11213    }
11214
11215    #[test]
11216    fn tui_startup_settings_ignore_untrusted_project_values() {
11217        let global = crate::settings::Settings {
11218            quiet_startup: Some(false),
11219            show_terminal_progress: Some(true),
11220            ..Default::default()
11221        };
11222        let project = crate::settings::Settings {
11223            quiet_startup: Some(true),
11224            show_terminal_progress: Some(false),
11225            ..Default::default()
11226        };
11227
11228        let resolved = resolve_tui_startup_settings(&global, &[project], false);
11229
11230        assert!(!resolved.quiet_startup);
11231        assert!(resolved.show_terminal_progress);
11232    }
11233
11234    #[test]
11235    fn transcript_page_uses_viewport_with_overlap() {
11236        assert_eq!(transcript_page_size(24), 20);
11237        assert_eq!(transcript_page_size(4), 1);
11238        assert_eq!(transcript_page_size(0), 1);
11239    }
11240
11241    #[test]
11242    fn parse_user_bash_distinguishes_single_and_double_bang() {
11243        // `!cmd` runs and stays in context.
11244        assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
11245        // `!!cmd` runs but is excluded from context.
11246        assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
11247        // Surrounding whitespace on the command is trimmed, the `!` prefix is not.
11248        assert_eq!(parse_user_bash("!  echo hi  "), Some(("echo hi", false)));
11249        assert_eq!(parse_user_bash("!!  echo hi"), Some(("echo hi", true)));
11250    }
11251
11252    #[test]
11253    fn parse_user_bash_ignores_bare_bang_and_plain_text() {
11254        // A bare `!` / `!!` has no command: fall through to the prompt path
11255        // instead of executing an empty shell line.
11256        assert_eq!(parse_user_bash("!"), None);
11257        assert_eq!(parse_user_bash("!!"), None);
11258        assert_eq!(parse_user_bash("!   "), None);
11259        assert_eq!(parse_user_bash("hello"), None);
11260        assert_eq!(parse_user_bash(""), None);
11261        // `/` keeps routing to the slash-command registry.
11262        assert_eq!(parse_user_bash("/model"), None);
11263    }
11264
11265    #[test]
11266    fn user_bash_run_slot_guards_and_cancels() {
11267        let state = test_tui_state();
11268        assert!(!state.user_bash_running());
11269        let cancel = state.begin_user_bash();
11270        assert!(state.user_bash_running());
11271        // Esc cancels without freeing the slot; the spawned task clears it when
11272        // the capture resolves (native pi's `isBashRunning`).
11273        assert!(state.cancel_user_bash());
11274        assert!(state.user_bash_running());
11275        assert!(cancel.is_cancelled());
11276        state.finish_user_bash();
11277        assert!(!state.user_bash_running());
11278        // Nothing to cancel once idle.
11279        assert!(!state.cancel_user_bash());
11280    }
11281
11282    #[test]
11283    fn key_repeat_is_dispatched_but_release_is_not() {
11284        assert!(should_dispatch_key(KeyEventKind::Press));
11285        assert!(should_dispatch_key(KeyEventKind::Repeat));
11286        assert!(!should_dispatch_key(KeyEventKind::Release));
11287    }
11288
11289    #[test]
11290    fn key_event_encoding_matches_pi_keybinding_protocol() {
11291        let key = |code, modifiers| KeyEvent::new(code, modifiers);
11292        assert_eq!(
11293            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
11294            "\r"
11295        );
11296        assert_eq!(
11297            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
11298            "\x1b[13;2u"
11299        );
11300        assert_eq!(
11301            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
11302            "\x1b[9;2u"
11303        );
11304        assert_eq!(
11305            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
11306            "\x1b[Z"
11307        );
11308        assert_eq!(
11309            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
11310            "\x1b[Z"
11311        );
11312        assert_eq!(
11313            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
11314            "\x03"
11315        );
11316        assert_eq!(
11317            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
11318            "\x0f"
11319        );
11320        assert_eq!(
11321            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
11322            "!"
11323        );
11324        assert_eq!(
11325            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
11326            "1"
11327        );
11328    }
11329
11330    #[test]
11331    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
11332        let bridge = JsDialogBridge::default();
11333        let (sender, receiver) = std_mpsc::channel();
11334        bridge.pending.lock().unwrap().push_back(JsDialogPending {
11335            request: JsDialogRequest {
11336                id: "dialog-1".into(),
11337                method: "input".into(),
11338                title: String::new(),
11339                message: String::new(),
11340                options: Vec::new(),
11341                placeholder: None,
11342                prefill: None,
11343            },
11344            result: sender,
11345        });
11346
11347        // Model the cancellation arriving after the queue entry has been
11348        // removed but before the TUI has installed the native widget.
11349        bridge.cancel("dialog-1");
11350        assert!(bridge.take_pending().is_none());
11351        assert_eq!(
11352            receiver.recv().unwrap(),
11353            serde_json::json!({ "cancelled": true })
11354        );
11355        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
11356    }
11357
11358    #[test]
11359    fn test_layout_renders_welcome_message() {
11360        let chat = Arc::new(Container::new());
11361        add_welcome_message(&chat);
11362
11363        let scroll = Arc::new(ScrollView::new(
11364            chat.clone(),
11365            ScrollViewOptions {
11366                follow: FollowMode::End,
11367                primary: true,
11368                ..Default::default()
11369            },
11370        ));
11371
11372        let editor = Arc::new(Editor::new(
11373            EditorOptions {
11374                padding_x: 1,
11375                ..Default::default()
11376            },
11377            EditorStyle::default(),
11378            Arc::new(rpi_tui::Keybindings::new()),
11379        ));
11380        let dock = Arc::new(Container::new());
11381        dock.add_child(editor);
11382
11383        let footer = Arc::new(FooterComponent::new());
11384
11385        let root = VStack::from_children(vec![
11386            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
11387            StackChild::Entry(StackEntry::new(dock)),
11388            StackChild::Entry(StackEntry::new(footer)),
11389        ]);
11390
11391        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
11392
11393        let all: String = frame.lines.join("\n");
11394        assert!(
11395            all.contains("rpi"),
11396            "Welcome message not found. Rendered: {}",
11397            all
11398        );
11399        assert!(
11400            all.contains("Type your message"),
11401            "Help text not found. Rendered: {}",
11402            all
11403        );
11404    }
11405
11406    #[test]
11407    fn test_chat_container_has_welcome_content() {
11408        let chat = Arc::new(Container::new());
11409        add_welcome_message_with_capabilities(
11410            &chat,
11411            &["read".into(), "bash".into(), "web_fetch".into()],
11412            &["rust-review".into(), "release".into()],
11413        );
11414
11415        let lines = chat.render(80);
11416        let all: String = lines.join("\n");
11417        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
11418        // joined by an ANSI reset — strip ANSI before checking the substring.
11419        let plain = strip_ansi(&all);
11420        assert!(
11421            plain.contains("rpi"),
11422            "Welcome message not in chat container: {:?}",
11423            lines
11424        );
11425        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
11426        assert!(
11427            plain.contains("read · bash · web_fetch"),
11428            "Tool names missing: {plain}"
11429        );
11430        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
11431        assert!(
11432            plain.contains("rust-review · release"),
11433            "Skill names missing: {plain}"
11434        );
11435    }
11436
11437    #[test]
11438    fn welcome_header_renders_the_brand_mark() {
11439        let chat = Arc::new(Container::new());
11440        add_welcome_message(&chat);
11441        let lines: Vec<String> = chat.render(80).iter().map(|l| strip_ansi(l)).collect();
11442        let all = lines.join("\n");
11443
11444        // The three-bar mark: the bottom row carries all three bars, and the
11445        // wordmark sits on its baseline next to the middle bar.
11446        assert!(
11447            lines.iter().any(|l| l.contains("██ ██ ██")),
11448            "brand mark missing: {all}"
11449        );
11450        assert!(
11451            all.contains("rpi"),
11452            "the mark must carry the product name: {all}"
11453        );
11454
11455        // Regression guard for the layout bug this replaced: `π` is East-Asian
11456        // Ambiguous — width 1 normally, width 2 under a CJK locale — which
11457        // walked the old box borders out of alignment depending on the user's
11458        // system. The crab is deliberately wide but unambiguous, and `crate::brand`
11459        // tests pin its width so the padding cannot silently drift.
11460        assert!(
11461            !all.contains('π'),
11462            "the header must not use east-asian-ambiguous glyphs: {all}"
11463        );
11464    }
11465
11466    #[test]
11467    fn update_notices_render_inside_the_transcript() {
11468        let chat = Arc::new(Container::new());
11469        let report = crate::updates::UpdateReport {
11470            notices: vec![
11471                crate::updates::UpdateNotice {
11472                    name: "rpi".into(),
11473                    current: "0.1.10".into(),
11474                    latest: "0.1.11".into(),
11475                    command: "rpi update".into(),
11476                },
11477                crate::updates::UpdateNotice {
11478                    name: "rpi-search".into(),
11479                    current: "0.1.0".into(),
11480                    latest: "0.1.1".into(),
11481                    command: "rpi pi-package update".into(),
11482                },
11483            ],
11484            warnings: vec![crate::updates::UpdateWarning {
11485                message: "The previously scheduled rpi update failed: access denied".into(),
11486                command: "rpi update".into(),
11487            }],
11488        };
11489
11490        add_update_notices(&chat, &report);
11491
11492        assert_eq!(chat.child_count(), 1);
11493        let plain = strip_ansi(&chat.render(80).join("\n"));
11494        assert!(plain.contains("Update Failed"), "{plain}");
11495        assert!(
11496            plain.contains("rpi update failed: access denied"),
11497            "{plain}"
11498        );
11499        assert!(plain.contains("Update Available"), "{plain}");
11500        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
11501        assert!(plain.contains("rpi update"), "{plain}");
11502        assert!(plain.contains("Package Updates Available"), "{plain}");
11503        assert!(plain.contains("rpi pi-package update"), "{plain}");
11504        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
11505    }
11506
11507    #[test]
11508    fn empty_update_report_does_not_add_transcript_content() {
11509        let chat = Arc::new(Container::new());
11510
11511        add_update_notices(&chat, &crate::updates::UpdateReport::default());
11512
11513        assert_eq!(chat.child_count(), 0);
11514    }
11515
11516    #[test]
11517    fn welcome_capabilities_show_empty_state() {
11518        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
11519        assert_eq!(plain, "Skills (0) none");
11520    }
11521
11522    #[test]
11523    fn completed_stream_reconciles_the_final_tail_before_detaching() {
11524        let component = Arc::new(AssistantMessageComponent::new(
11525            AssistantMessageOptions::default(),
11526        ));
11527        component.set_streaming(true);
11528        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
11529
11530        let current = Arc::new(Mutex::new(Some(component.clone())));
11531        let cached = Mutex::new("partial response".to_string());
11532        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
11533        final_message.content = vec![Content::text(
11534            "partial response with the previously missing final tail",
11535        )];
11536        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
11537
11538        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
11539
11540        assert!(current.lock().unwrap().is_none());
11541        assert_eq!(
11542            cached.lock().unwrap().as_str(),
11543            "partial response with the previously missing final tail"
11544        );
11545        let rendered = strip_ansi(&component.render(100).join("\n"));
11546        assert!(
11547            rendered.contains("previously missing final tail"),
11548            "{rendered}"
11549        );
11550    }
11551
11552    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
11553    /// replaces the editor text, the NEXT rendered frame must show the
11554    /// completed text (" /model " with the caret after it), not the old
11555    /// prefix. Mirrors the real dock layout (autocomplete_container above the
11556    /// bordered editor) and drives the same accept path the Tab handler uses.
11557    #[test]
11558    fn tab_accept_suggestion_reflects_in_next_render() {
11559        use rpi_tui::render_layout_frame;
11560
11561        let editor = Arc::new(Editor::new(
11562            EditorOptions {
11563                padding_x: 1,
11564                ..Default::default()
11565            },
11566            EditorStyle::default(),
11567            Arc::new(rpi_tui::Keybindings::new()),
11568        ));
11569        editor.set_focused(true);
11570        let editor_container = Arc::new(Container::new());
11571        editor_container.add_child(editor.clone());
11572        let autocomplete_container = Arc::new(Container::new());
11573        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
11574        let dock = Arc::new(VStack::from_children(vec![
11575            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
11576            StackChild::Entry(
11577                StackEntry::new(editor_container.clone())
11578                    .shrink(0)
11579                    .min_size(3),
11580            ),
11581            StackChild::Entry(StackEntry::new(footer)),
11582        ]));
11583
11584        // Simulate the user typing "/mo" (the popup shows suggestions).
11585        let manager = AutocompleteManager::new();
11586        let mut combined = CombinedAutocompleteProvider::new();
11587        combined.add_provider(Arc::new(
11588            SlashCommandAutocompleteProvider::with_default_commands(),
11589        ));
11590        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
11591        manager.set_provider(Arc::new(combined));
11592        // Simulate typing "/mo" via the real insert path (advances the caret
11593        // by char length, like `handle_key` does).
11594        editor.insert("/mo");
11595        assert_eq!(editor.cursor_position(), (0, 3));
11596
11597        let frame_before = render_layout_frame(dock.clone(), 80, 10);
11598        assert!(
11599            frame_before.lines.iter().any(|l| l.contains("/mo")),
11600            "precondition: editor shows the typed prefix. Frame rows:\n{}",
11601            frame_before
11602                .lines
11603                .iter()
11604                .map(|l| format!("  [{l}]"))
11605                .collect::<Vec<_>>()
11606                .join("\n")
11607        );
11608
11609        // Tab: accept the top suggestion (the same code path as the key loop).
11610        let text = editor.get_text();
11611        let cursor = editor_cursor_offset(&editor, &text);
11612        let sugg = manager
11613            .get_suggestions(&text, cursor)
11614            .expect("slash suggestions for /mo");
11615        let top = sugg.items.first().expect("at least one suggestion");
11616        let start = sugg.start.min(text.len());
11617        let end = sugg.end.min(text.len());
11618        let mut replaced = String::new();
11619        replaced.push_str(&text[..start]);
11620        replaced.push_str(&top.text);
11621        replaced.push_str(&text[end..]);
11622        if top.insert_space && !replaced.ends_with('/') {
11623            replaced.push(' ');
11624        }
11625        let new_cursor = start + top.text.len();
11626        editor.set_text(&replaced);
11627        set_editor_cursor_offset(&editor, &replaced, new_cursor);
11628        autocomplete_container.clear();
11629        assert_eq!(editor.get_text(), "/model");
11630
11631        // The next render MUST display the completed text.
11632        let frame_after = render_layout_frame(dock, 80, 10);
11633        let all: String = frame_after.lines.join("\n");
11634        assert!(
11635            all.contains("/model"),
11636            "completed text missing from next render. Got:\n{all}"
11637        );
11638        // The caret must sit AFTER the completed command (the snap_boundary
11639        // regression put it one char early: "/mode|l" with the final char
11640        // dangling past the caret).
11641        let editor_line = frame_after
11642            .lines
11643            .iter()
11644            .find(|l| l.contains("/model"))
11645            .expect("editor row with completed text");
11646        assert!(
11647            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
11648            "caret must follow the full completed text. Got: {editor_line:?}"
11649        );
11650    }
11651
11652    #[test]
11653    fn multiline_autocomplete_preserves_row_and_column() {
11654        let editor = Arc::new(Editor::simple());
11655        editor.set_text("first\n/mo");
11656        editor.set_cursor(1, 3);
11657
11658        let text = editor.get_text();
11659        assert_eq!(editor_cursor_offset(&editor, &text), 9);
11660
11661        let replaced = "first\n/model";
11662        editor.set_text(replaced);
11663        set_editor_cursor_offset(&editor, replaced, 12);
11664        assert_eq!(editor.cursor_position(), (1, 6));
11665    }
11666
11667    #[test]
11668    fn test_slash_command_dispatch() {
11669        // The registry is the single source of truth for dispatch: `find(token)`
11670        // returns the command (by name or alias) whose `name()` is the canonical
11671        // form, or `None` for an unknown token. This replaces the old enum-based
11672        // `handle_slash_command` assertions with equivalent registry lookups.
11673        let registry = build_builtin_registry();
11674
11675        // Helper: a token resolves to the command with this canonical name.
11676        let resolves_to = |token: &str, canonical: &str| {
11677            let found = registry.find(token).expect("{token} should resolve");
11678            assert_eq!(
11679                found.name(),
11680                canonical,
11681                "{token} resolved to {} (expected {canonical})",
11682                found.name()
11683            );
11684        };
11685
11686        resolves_to("/help", "/help");
11687        resolves_to("/?", "/help"); // alias → canonical
11688        resolves_to("/clear", "/clear");
11689        resolves_to("/new", "/clear"); // alias
11690        resolves_to("/q", "/exit"); // alias
11691        resolves_to("/quit", "/exit"); // alias
11692        resolves_to("/version", "/version");
11693        resolves_to("/v", "/version"); // alias
11694        resolves_to("/changelog", "/changelog");
11695        resolves_to("/hotkeys", "/hotkeys");
11696        resolves_to("/model", "/model");
11697        resolves_to("/m", "/model"); // alias
11698        resolves_to("/theme", "/theme");
11699        resolves_to("/session", "/session");
11700        resolves_to("/resume", "/session"); // alias
11701        resolves_to("/compact", "/compact");
11702        resolves_to("/copy", "/copy");
11703        resolves_to("/thinking", "/thinking");
11704        resolves_to("/think", "/thinking"); // alias
11705        resolves_to("/tools", "/tools");
11706        resolves_to("/images", "/images");
11707        resolves_to("/armin", "/armin");
11708        resolves_to("/earendil", "/earendil");
11709        resolves_to("/context", "/context");
11710        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
11711        resolves_to("/settings", "/settings");
11712        resolves_to("/name", "/name");
11713        resolves_to("/export", "/export");
11714
11715        // Unknown token → not found.
11716        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
11717    }
11718
11719    #[test]
11720
11721    fn test_registry_visible_entries_cover_dispatch() {
11722        // The autocomplete list is derived from the registry, so every visible
11723        // command the dispatcher recognizes must appear in it — by construction,
11724        // but this guards against a future command being registered with
11725        // `visible()` / a non-empty description that the builder drops.
11726        let registry = build_builtin_registry();
11727        let names: Vec<String> = registry
11728            .visible_entries()
11729            .iter()
11730            .map(|c| c.name.clone())
11731            .collect();
11732        for recognized in [
11733            "/help",
11734            "/clear",
11735            "/new",
11736            "/exit",
11737            "/quit",
11738            "/version",
11739            "/changelog",
11740            "/model",
11741            "/session",
11742            "/theme",
11743            "/compact",
11744            "/copy",
11745            "/hotkeys",
11746            "/tools",
11747            "/images",
11748            "/thinking",
11749            "/usage",
11750            "/armin",
11751            "/earendil",
11752        ] {
11753            assert!(
11754                names.contains(&recognized.to_string()),
11755                "{recognized} missing from autocomplete list"
11756            );
11757        }
11758        // Hidden commands stay off the list.
11759        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
11760            assert!(
11761                !names.contains(&hidden.to_string()),
11762                "{hidden} should be hidden from autocomplete"
11763            );
11764        }
11765    }
11766
11767    #[test]
11768    fn test_agent_event_mapping_creates_assistant_and_tool() {
11769        // Synthetic AgentEvent sequence → UI mutations, exercised against the
11770        // real drain handler with a no-op TUI stand-in.
11771        use rpi_ai::types::{
11772            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
11773            ToolCall, ToolCallType, Usage,
11774        };
11775
11776        let state = Arc::new(TuiState {
11777            current_assistant: Arc::new(std::sync::Mutex::new(None)),
11778            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11779            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11780            themes_enabled: true,
11781            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11782            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11783            show_terminal_progress: true,
11784            status: std::sync::Mutex::new(RunStatus::Idle),
11785            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11786            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11787            editor_text: rpi_extensions::EditorTextMailbox::new(),
11788            auto_send: std::sync::Mutex::new(None),
11789            last_editor_text: std::sync::Mutex::new(String::new()),
11790            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11791            js_preparation_cancel: std::sync::Mutex::new(None),
11792            user_bash_cancel: std::sync::Mutex::new(None),
11793            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11794            footer: Arc::new(FooterComponent::new()),
11795            status_container: Arc::new(Container::new()),
11796            chat_container: Arc::new(Container::new()),
11797            loader: Arc::new(Loader::new()),
11798            editor: Arc::new(Editor::simple()),
11799            last_assistant_text: std::sync::Mutex::new(String::new()),
11800            active_selector: std::sync::Mutex::new(None),
11801            active_extension_editor: std::sync::Mutex::new(None),
11802            active_extension_input: std::sync::Mutex::new(None),
11803            active_extension_cancel: std::sync::Mutex::new(None),
11804            autocomplete: AutocompleteManager::new(),
11805            autocomplete_container: Arc::new(Container::new()),
11806            autocomplete_max_visible: 5,
11807            pending_images: std::sync::Mutex::new(Vec::new()),
11808            theme_manager: Arc::new(ThemeManager::new()),
11809            tui: None,
11810            current_model_id: std::sync::Mutex::new(String::new()),
11811            show_images: std::sync::Mutex::new(true),
11812            cache_miss_notices: std::sync::Mutex::new(false),
11813            history: std::sync::Mutex::new(Vec::new()),
11814            history_index: std::sync::Mutex::new(-1),
11815            history_draft: std::sync::Mutex::new(None),
11816            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11817            scoped_edit: std::sync::Mutex::new(None),
11818            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11819            extension_session: Arc::new(std::sync::Mutex::new(
11820                rpi_extensions::ExtensionSession::none(),
11821            )),
11822            search: Arc::new(AltScreenSearch::new()),
11823            search_bar: Arc::new(SearchBar::new()),
11824            pending_container: Arc::new(Container::new()),
11825            dequeue_hint: String::new(),
11826            pending_snapshot: std::sync::Mutex::new(
11827                rpi_harness::agent_harness::QueuedMessages::default(),
11828            ),
11829            selection_start: std::sync::Mutex::new(None),
11830            selection_end: std::sync::Mutex::new(None),
11831        });
11832
11833        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
11834        // terminal; instead, exercise the *mutation* half directly against a
11835        // captured chat container via a synthetic message-start event's data.
11836        let assistant = AssistantMessage {
11837            role: rpi_ai::types::AssistantRole,
11838            content: vec![
11839                Content::Thinking(ThinkingContent {
11840                    kind: ThinkingContentType,
11841                    thinking: "Reasoning about the reply.".into(),
11842                    thinking_signature: None,
11843                    redacted: false,
11844                }),
11845                Content::Text(TextContent {
11846                    kind: TextContentType,
11847                    text: "Hello.".into(),
11848                    text_signature: None,
11849                }),
11850                Content::ToolCall(ToolCall {
11851                    kind: ToolCallType,
11852                    id: "tc1".into(),
11853                    name: "bash".into(),
11854                    arguments: serde_json::json!({"command": "echo hi"}),
11855                    thought_signature: None,
11856                    namespace: None,
11857                }),
11858            ],
11859            api: rpi_ai::Api::AnthropicMessages,
11860            provider: "anthropic".into(),
11861            model: "claude-sonnet-5".into(),
11862            response_model: None,
11863            response_id: None,
11864            usage: Usage::zero(),
11865            stop_reason: StopReason::Stop,
11866            deferred: None,
11867            error_message: None,
11868            raw_stop_reason: None,
11869            end_turn: None,
11870            timestamp: 0,
11871        };
11872
11873        // Manually apply the MessageStart assistant branch logic (mirrors the
11874        // drain handler, without needing a TuiAltScreen).
11875        let comp = Arc::new(AssistantMessageComponent::new(
11876            AssistantMessageOptions::default(),
11877        ));
11878        comp.set_streaming(true);
11879        comp.update_blocks(&assistant_blocks(&assistant));
11880        let chat = Arc::new(Container::new());
11881        chat.add_child(comp.clone());
11882        *state.current_assistant.lock().unwrap() = Some(comp);
11883
11884        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
11885        for c in &assistant.content {
11886            if let Content::ToolCall(tc) = c {
11887                let mut tools = state.tool_components.lock().unwrap();
11888                if !tools.contains_key(&tc.id) {
11889                    let tc_comp = Arc::new(ToolExecutionComponent::new(
11890                        &tc.name,
11891                        &tc.arguments.to_string(),
11892                    ));
11893                    tc_comp.set_running();
11894                    chat.add_child(tc_comp.clone());
11895                    tools.insert(tc.id.clone(), tc_comp);
11896                }
11897            }
11898        }
11899
11900        // Assert: the assistant component rendered the text + the thinking
11901        // block (the update_blocks path keeps thinking visible), and a tool
11902        // component was registered.
11903        let rendered = chat.render(80);
11904        let joined: String = rendered.join("\n");
11905        assert!(
11906            joined.contains("Hello."),
11907            "assistant text not rendered: {joined}"
11908        );
11909        assert!(
11910            joined.contains("Reasoning about the reply."),
11911            "thinking block not rendered: {joined}"
11912        );
11913        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
11914        assert!(state.current_assistant.lock().unwrap().is_some());
11915
11916        // Manually apply ToolExecutionEnd (mirrors drain).
11917        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
11918        ended.set_result("hi", false);
11919        assert!(state.tool_components.lock().unwrap().is_empty());
11920
11921        // A running bash panel owns the visible spinner. The global loader is
11922        // hidden until the last concurrent bash tool completes, then restored
11923        // while the agent remains in the Working state.
11924        assert!(state.try_start_working());
11925        assert!(
11926            !state.try_start_working(),
11927            "a second submit must be rejected"
11928        );
11929        state.set_status(RunStatus::Idle);
11930        state.set_status(RunStatus::Working);
11931        // The active loader now renders inside the editor's top border.
11932        assert!(state.editor.working().is_some());
11933        assert_eq!(state.status_container.child_count(), 0);
11934        assert!(state.footer.get_status().is_empty());
11935        state.show_retry(3, 10, 8_000);
11936        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
11937        assert!(retry_status.contains("Retrying (3/10)"));
11938        state.set_status(RunStatus::Working);
11939        {
11940            let mut bash = state.bash_components.lock().unwrap();
11941            bash.insert(
11942                "bash-1".into(),
11943                Arc::new(BashExecutionComponent::new("one")),
11944            );
11945            bash.insert(
11946                "bash-2".into(),
11947                Arc::new(BashExecutionComponent::new("two")),
11948            );
11949        }
11950        state.sync_working_loader_with_bash();
11951        assert_eq!(state.status_container.child_count(), 0);
11952        state.bash_components.lock().unwrap().remove("bash-1");
11953        state.sync_working_loader_with_bash();
11954        assert_eq!(state.status_container.child_count(), 0);
11955        state.bash_components.lock().unwrap().remove("bash-2");
11956        state.sync_working_loader_with_bash();
11957        // The editor-border indicator stays visible across bash panels, so the
11958        // status container stays empty.
11959        assert_eq!(state.status_container.child_count(), 0);
11960        assert!(state.editor.working().is_some());
11961
11962        state.set_status(RunStatus::Aborting);
11963        assert_eq!(state.status_container.child_count(), 0);
11964        assert!(!state.loader.is_running());
11965    }
11966
11967    #[test]
11968    fn fresh_launch_does_not_restore_old_history() {
11969        let fresh = Args::default();
11970        assert!(!launch_restores_history(&fresh));
11971
11972        let continued = Args {
11973            continue_session: true,
11974            ..Args::default()
11975        };
11976        assert!(launch_restores_history(&continued));
11977
11978        let selected = Args {
11979            session: Some("session-id".into()),
11980            ..Args::default()
11981        };
11982        assert!(launch_restores_history(&selected));
11983    }
11984
11985    #[test]
11986    fn context_badge_ignores_aborted_and_error_turns() {
11987        use rpi_ai::types::{Api, StopReason, Usage};
11988
11989        let footer = Arc::new(FooterComponent::new());
11990        footer.set_context_window(100_000);
11991
11992        // A real response fills in the percent (`?` -> `25.0%/100k`).
11993        let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11994        ok.stop_reason = StopReason::Stop;
11995        ok.usage = Usage {
11996            input: 25_000,
11997            ..Usage::zero()
11998        };
11999        record_context_usage(&footer, &ok);
12000        let stats = strip_ansi(&footer.render(120).join("\n"));
12001        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
12002
12003        // An aborted turn must not clobber the last good value.
12004        let mut aborted =
12005            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
12006        aborted.stop_reason = StopReason::Aborted;
12007        aborted.usage = Usage {
12008            input: 90_000,
12009            ..Usage::zero()
12010        };
12011        record_context_usage(&footer, &aborted);
12012        let stats = strip_ansi(&footer.render(120).join("\n"));
12013        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
12014
12015        // An errored turn likewise (its usage is not a real measurement).
12016        let mut errored =
12017            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
12018        errored.stop_reason = StopReason::Error;
12019        errored.usage = Usage {
12020            input: 90_000,
12021            ..Usage::zero()
12022        };
12023        record_context_usage(&footer, &errored);
12024        let stats = strip_ansi(&footer.render(120).join("\n"));
12025        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
12026    }
12027
12028    #[test]
12029    fn test_short_model_name() {
12030        assert_eq!(
12031            short_model_name("anthropic:claude-sonnet-5"),
12032            "claude-sonnet-5"
12033        );
12034        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
12035    }
12036
12037    #[test]
12038    fn model_selector_items_are_deduplicated_and_provider_qualified() {
12039        use rpi_ai::{Api, Model};
12040
12041        let mut gateway = Model::new(
12042            "gpt-5.6-sol",
12043            "GPT 5.6 Sol",
12044            Api::OpenaiCompletions,
12045            "routeryo-copy",
12046            "https://gateway.example.com",
12047        );
12048        let duplicate = gateway.clone();
12049        let anthropic = Model::new(
12050            "claude-sonnet-5",
12051            "Claude Sonnet 5",
12052            Api::AnthropicMessages,
12053            "anthropic",
12054            "https://api.anthropic.com",
12055        );
12056        gateway.headers = Some(std::collections::BTreeMap::from([(
12057            "authorization".into(),
12058            "Bearer test".into(),
12059        )]));
12060
12061        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
12062        assert_eq!(items.len(), 2);
12063        assert_eq!(items[0].value, "gpt-5.6-sol");
12064        assert_eq!(items[0].label, "GPT 5.6 Sol");
12065        assert_eq!(
12066            items[0].description.as_deref(),
12067            Some("routeryo-copy/gpt-5.6-sol (current)")
12068        );
12069        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
12070    }
12071
12072    #[test]
12073    fn model_selector_search_text_covers_provider_name_and_qualified_id() {
12074        use rpi_ai::{Api, Model};
12075
12076        let anthropic = Model::new(
12077            "claude-sonnet-5",
12078            "Claude Sonnet 5",
12079            Api::AnthropicMessages,
12080            "anthropic",
12081            "https://api.anthropic.com",
12082        );
12083        let items = model_selector_items(&[anthropic], "");
12084        let search = items[0].search_key();
12085        // Mirrors native `getModelSelectorSearchText`: provider-prefixed first,
12086        // then the display name.
12087        assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
12088        assert!(search.contains("Claude Sonnet 5"), "{search}");
12089
12090        // A provider query and a display-name query both match through the
12091        // real fuzzy filter used by the searchable selector.
12092        use rpi_tui::fuzzy_filter;
12093        for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
12094            let matched = fuzzy_filter(&items, query, |item| item.search_key());
12095            assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
12096        }
12097    }
12098
12099    #[test]
12100    fn model_selector_match_accepts_bare_and_qualified_ids() {
12101        use rpi_ai::{Api, Model};
12102
12103        let gateway = Model::new(
12104            "gpt-5.6-sol",
12105            "GPT 5.6 Sol",
12106            Api::OpenaiCompletions,
12107            "routeryo-copy",
12108            "https://gateway.example.com",
12109        );
12110        let anthropic = Model::new(
12111            "claude-sonnet-5",
12112            "Claude Sonnet 5",
12113            Api::AnthropicMessages,
12114            "anthropic",
12115            "https://api.anthropic.com",
12116        );
12117        let catalog = [gateway, anthropic];
12118        assert_eq!(
12119            find_model_selector_match(&catalog, "gpt-5.6-sol")
12120                .unwrap()
12121                .provider,
12122            "routeryo-copy"
12123        );
12124        assert_eq!(
12125            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
12126                .unwrap()
12127                .id,
12128            "gpt-5.6-sol"
12129        );
12130        assert_eq!(
12131            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
12132                .unwrap()
12133                .id,
12134            "claude-sonnet-5"
12135        );
12136        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
12137    }
12138
12139    #[test]
12140    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
12141        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
12142
12143        let failed = AssistantMessage {
12144            role: AssistantRole,
12145            content: Vec::new(),
12146            api: rpi_ai::Api::AnthropicMessages,
12147            provider: "anthropic".into(),
12148            model: "claude-sonnet-5".into(),
12149            response_model: None,
12150            response_id: None,
12151            usage: Usage::zero(),
12152            stop_reason: StopReason::Error,
12153            deferred: None,
12154            error_message: Some("upstream returned 401".into()),
12155            raw_stop_reason: None,
12156            end_turn: None,
12157            timestamp: 0,
12158        };
12159        assert_eq!(
12160            assistant_error_text(&failed).as_deref(),
12161            Some("upstream returned 401")
12162        );
12163
12164        let mut no_detail = failed;
12165        no_detail.error_message = Some("  ".into());
12166        assert_eq!(
12167            assistant_error_text(&no_detail).as_deref(),
12168            Some("Provider request failed.")
12169        );
12170
12171        let mut aborted = no_detail;
12172        aborted.stop_reason = StopReason::Aborted;
12173        aborted.error_message = Some("abort error: Request aborted".into());
12174        assert_eq!(
12175            assistant_error_text(&aborted).as_deref(),
12176            Some("abort error: Request aborted")
12177        );
12178
12179        aborted.error_message = None;
12180        assert_eq!(
12181            assistant_error_text(&aborted).as_deref(),
12182            Some("Request aborted.")
12183        );
12184    }
12185
12186    #[test]
12187    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
12188        assert_eq!(
12189            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
12190            "405\nMethod Not Allowed"
12191        );
12192        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
12193        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
12194        let long = "x".repeat(20_000);
12195        let cleaned = sanitize_error_message(&long);
12196        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
12197        assert!(cleaned.ends_with('…'));
12198    }
12199
12200    #[test]
12201    fn test_cycle_next_model_wraps_around() {
12202        use rpi_ai::{Api, Model};
12203        let mk = |id: &str| {
12204            Model::new(
12205                id,
12206                id,
12207                Api::AnthropicMessages,
12208                "anthropic",
12209                "https://api.anthropic.com",
12210            )
12211        };
12212        let catalog = [mk("a"), mk("b"), mk("c")];
12213        // Next after "a" is "b"; after "c" wraps to "a".
12214        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
12215        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
12216        // An unknown current id falls back to the first model.
12217        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
12218        // Empty catalog yields None.
12219        let empty: Vec<Model> = vec![];
12220        assert!(cycle_next_model(&empty, "a").is_none());
12221    }
12222
12223    #[test]
12224    fn test_cycle_prev_model_wraps_around() {
12225        use rpi_ai::{Api, Model};
12226        let mk = |id: &str| {
12227            Model::new(
12228                id,
12229                id,
12230                Api::AnthropicMessages,
12231                "anthropic",
12232                "https://api.anthropic.com",
12233            )
12234        };
12235        let catalog = [mk("a"), mk("b"), mk("c")];
12236        // Previous before "b" is "a"; before "a" wraps to "c".
12237        assert_eq!(cycle_prev_model(&catalog, "b").unwrap().id, "a");
12238        assert_eq!(cycle_prev_model(&catalog, "a").unwrap().id, "c");
12239        // An unknown current id falls back to the first model.
12240        assert_eq!(cycle_prev_model(&catalog, "zzz").unwrap().id, "a");
12241        // Empty catalog yields None.
12242        let empty: Vec<Model> = vec![];
12243        assert!(cycle_prev_model(&empty, "a").is_none());
12244    }
12245
12246    #[test]
12247    fn test_autocomplete_slash_suggestions_render() {
12248        // The autocomplete container should render at least one suggestion
12249        // line when the editor holds a `/` prefix, and clear when it doesn't.
12250        let state = Arc::new(TuiState {
12251            current_assistant: Arc::new(std::sync::Mutex::new(None)),
12252            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12253            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12254            themes_enabled: true,
12255            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
12256            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
12257            show_terminal_progress: true,
12258            status: std::sync::Mutex::new(RunStatus::Idle),
12259            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12260            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12261            editor_text: rpi_extensions::EditorTextMailbox::new(),
12262            auto_send: std::sync::Mutex::new(None),
12263            last_editor_text: std::sync::Mutex::new(String::new()),
12264            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
12265            js_preparation_cancel: std::sync::Mutex::new(None),
12266            user_bash_cancel: std::sync::Mutex::new(None),
12267            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12268            footer: Arc::new(FooterComponent::new()),
12269            status_container: Arc::new(Container::new()),
12270            chat_container: Arc::new(Container::new()),
12271            loader: Arc::new(Loader::new()),
12272            editor: Arc::new(Editor::simple()),
12273            last_assistant_text: std::sync::Mutex::new(String::new()),
12274            active_selector: std::sync::Mutex::new(None),
12275            active_extension_editor: std::sync::Mutex::new(None),
12276            active_extension_input: std::sync::Mutex::new(None),
12277            active_extension_cancel: std::sync::Mutex::new(None),
12278            autocomplete: AutocompleteManager::new(),
12279            autocomplete_container: Arc::new(Container::new()),
12280            autocomplete_max_visible: 5,
12281            pending_images: std::sync::Mutex::new(Vec::new()),
12282            theme_manager: Arc::new(ThemeManager::new()),
12283            tui: None,
12284            current_model_id: std::sync::Mutex::new(String::new()),
12285            show_images: std::sync::Mutex::new(true),
12286            cache_miss_notices: std::sync::Mutex::new(false),
12287            history: std::sync::Mutex::new(Vec::new()),
12288            history_index: std::sync::Mutex::new(-1),
12289            history_draft: std::sync::Mutex::new(None),
12290            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12291            scoped_edit: std::sync::Mutex::new(None),
12292            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
12293            extension_session: Arc::new(std::sync::Mutex::new(
12294                rpi_extensions::ExtensionSession::none(),
12295            )),
12296            search: Arc::new(AltScreenSearch::new()),
12297            search_bar: Arc::new(SearchBar::new()),
12298            pending_container: Arc::new(Container::new()),
12299            dequeue_hint: String::new(),
12300            pending_snapshot: std::sync::Mutex::new(
12301                rpi_harness::agent_harness::QueuedMessages::default(),
12302            ),
12303            selection_start: std::sync::Mutex::new(None),
12304            selection_end: std::sync::Mutex::new(None),
12305        });
12306        {
12307            let mut combined = CombinedAutocompleteProvider::new();
12308            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12309                build_builtin_registry().visible_entries(),
12310            )));
12311            state.autocomplete.set_provider(Arc::new(combined));
12312        }
12313
12314        let editor = Arc::new(Editor::simple());
12315        editor.set_text("/he");
12316        editor.set_cursor(0, 3);
12317        refresh_autocomplete(&state, &editor);
12318        let lines = state.autocomplete_container.render(80);
12319        let joined: String = lines.join("\n");
12320        assert!(
12321            joined.contains("/help"),
12322            "slash suggestions not rendered: {joined}"
12323        );
12324
12325        // Clear: no suggestions for plain text.
12326        editor.set_text("hello");
12327        editor.set_cursor(0, 5);
12328        refresh_autocomplete(&state, &editor);
12329        assert!(state.autocomplete_container.render(80).is_empty());
12330    }
12331
12332    #[test]
12333    fn test_select_list_swap_restores_editor() {
12334        // The editor-container swap: opening a selector replaces the editor
12335        // child; closing restores it. Verify the container child count + the
12336        // active_selector flag round-trip.
12337        let state = Arc::new(TuiState {
12338            current_assistant: Arc::new(std::sync::Mutex::new(None)),
12339            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12340            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12341            themes_enabled: true,
12342            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
12343            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
12344            show_terminal_progress: true,
12345            status: std::sync::Mutex::new(RunStatus::Idle),
12346            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12347            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12348            editor_text: rpi_extensions::EditorTextMailbox::new(),
12349            auto_send: std::sync::Mutex::new(None),
12350            last_editor_text: std::sync::Mutex::new(String::new()),
12351            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
12352            js_preparation_cancel: std::sync::Mutex::new(None),
12353            user_bash_cancel: std::sync::Mutex::new(None),
12354            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12355            footer: Arc::new(FooterComponent::new()),
12356            status_container: Arc::new(Container::new()),
12357            chat_container: Arc::new(Container::new()),
12358            loader: Arc::new(Loader::new()),
12359            editor: Arc::new(Editor::simple()),
12360            last_assistant_text: std::sync::Mutex::new(String::new()),
12361            active_selector: std::sync::Mutex::new(None),
12362            active_extension_editor: std::sync::Mutex::new(None),
12363            active_extension_input: std::sync::Mutex::new(None),
12364            active_extension_cancel: std::sync::Mutex::new(None),
12365            autocomplete: AutocompleteManager::new(),
12366            autocomplete_container: Arc::new(Container::new()),
12367            autocomplete_max_visible: 5,
12368            pending_images: std::sync::Mutex::new(Vec::new()),
12369            theme_manager: Arc::new(ThemeManager::new()),
12370            tui: None,
12371            current_model_id: std::sync::Mutex::new(String::new()),
12372            show_images: std::sync::Mutex::new(true),
12373            cache_miss_notices: std::sync::Mutex::new(false),
12374            history: std::sync::Mutex::new(Vec::new()),
12375            history_index: std::sync::Mutex::new(-1),
12376            history_draft: std::sync::Mutex::new(None),
12377            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12378            scoped_edit: std::sync::Mutex::new(None),
12379            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
12380            extension_session: Arc::new(std::sync::Mutex::new(
12381                rpi_extensions::ExtensionSession::none(),
12382            )),
12383            search: Arc::new(AltScreenSearch::new()),
12384            search_bar: Arc::new(SearchBar::new()),
12385            pending_container: Arc::new(Container::new()),
12386            dequeue_hint: String::new(),
12387            pending_snapshot: std::sync::Mutex::new(
12388                rpi_harness::agent_harness::QueuedMessages::default(),
12389            ),
12390            selection_start: std::sync::Mutex::new(None),
12391            selection_end: std::sync::Mutex::new(None),
12392        });
12393        let editor_container = Arc::new(Container::new());
12394        let editor = Arc::new(Editor::simple());
12395        editor_container.add_child(editor.clone());
12396        assert!(!state.selector_open());
12397
12398        let tui_terminal = Box::new(ProcessTerminal::new());
12399        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
12400        let list = Arc::new(SelectList::new(
12401            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
12402            5,
12403        ));
12404        open_selector(
12405            &state,
12406            &editor_container,
12407            &editor,
12408            &tui,
12409            list,
12410            SelectorKind::Theme,
12411        );
12412        assert!(state.selector_open());
12413        // list only (editor swapped out).
12414        assert_eq!(editor_container.child_count(), 1);
12415
12416        close_selector(&state, &editor_container, &editor, &tui);
12417        assert!(!state.selector_open());
12418        // editor restored.
12419        assert_eq!(editor_container.child_count(), 1);
12420    }
12421
12422    #[test]
12423    fn test_message_history_browse_restores_draft() {
12424        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
12425        // messages, browse older → newer → back past the newest restores the
12426        // draft the user was typing.
12427        let state = Arc::new(TuiState {
12428            current_assistant: Arc::new(std::sync::Mutex::new(None)),
12429            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12430            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12431            themes_enabled: true,
12432            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
12433            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
12434            show_terminal_progress: true,
12435            status: std::sync::Mutex::new(RunStatus::Idle),
12436            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12437            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12438            editor_text: rpi_extensions::EditorTextMailbox::new(),
12439            auto_send: std::sync::Mutex::new(None),
12440            last_editor_text: std::sync::Mutex::new(String::new()),
12441            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
12442            js_preparation_cancel: std::sync::Mutex::new(None),
12443            user_bash_cancel: std::sync::Mutex::new(None),
12444            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12445            footer: Arc::new(FooterComponent::new()),
12446            status_container: Arc::new(Container::new()),
12447            chat_container: Arc::new(Container::new()),
12448            loader: Arc::new(Loader::new()),
12449            editor: Arc::new(Editor::simple()),
12450            last_assistant_text: std::sync::Mutex::new(String::new()),
12451            active_selector: std::sync::Mutex::new(None),
12452            active_extension_editor: std::sync::Mutex::new(None),
12453            active_extension_input: std::sync::Mutex::new(None),
12454            active_extension_cancel: std::sync::Mutex::new(None),
12455            autocomplete: AutocompleteManager::new(),
12456            autocomplete_container: Arc::new(Container::new()),
12457            autocomplete_max_visible: 5,
12458            pending_images: std::sync::Mutex::new(Vec::new()),
12459            theme_manager: Arc::new(ThemeManager::new()),
12460            tui: None,
12461            current_model_id: std::sync::Mutex::new(String::new()),
12462            show_images: std::sync::Mutex::new(true),
12463            cache_miss_notices: std::sync::Mutex::new(false),
12464            history: std::sync::Mutex::new(Vec::new()),
12465            history_index: std::sync::Mutex::new(-1),
12466            history_draft: std::sync::Mutex::new(None),
12467            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12468            scoped_edit: std::sync::Mutex::new(None),
12469            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
12470            extension_session: Arc::new(std::sync::Mutex::new(
12471                rpi_extensions::ExtensionSession::none(),
12472            )),
12473            search: Arc::new(AltScreenSearch::new()),
12474            search_bar: Arc::new(SearchBar::new()),
12475            pending_container: Arc::new(Container::new()),
12476            dequeue_hint: String::new(),
12477            pending_snapshot: std::sync::Mutex::new(
12478                rpi_harness::agent_harness::QueuedMessages::default(),
12479            ),
12480            selection_start: std::sync::Mutex::new(None),
12481            selection_end: std::sync::Mutex::new(None),
12482        });
12483        let editor = Arc::new(Editor::simple());
12484
12485        push_history(&state, "first message");
12486        push_history(&state, "second message");
12487        // Consecutive duplicate is skipped.
12488        push_history(&state, "second message");
12489        push_history(&state, "   "); // empty → skipped
12490        assert_eq!(state.history.lock().unwrap().len(), 2);
12491        assert_eq!(state.history.lock().unwrap()[0], "second message");
12492
12493        // User starts typing a fresh prompt.
12494        editor.set_text("half-typed");
12495        editor.set_cursor(0, 11);
12496
12497        // ↑ → most recent.
12498        navigate_history(&state, &editor, -1);
12499        assert_eq!(editor.get_text(), "second message");
12500        assert_eq!(*state.history_index.lock().unwrap(), 0);
12501        // ↑ → older.
12502        navigate_history(&state, &editor, -1);
12503        assert_eq!(editor.get_text(), "first message");
12504        assert_eq!(*state.history_index.lock().unwrap(), 1);
12505        // ↑ past the oldest → stays (no wrap).
12506        navigate_history(&state, &editor, -1);
12507        assert_eq!(editor.get_text(), "first message");
12508        // ↓ → newer.
12509        navigate_history(&state, &editor, 1);
12510        assert_eq!(editor.get_text(), "second message");
12511        // ↓ past the newest → restores the draft.
12512        navigate_history(&state, &editor, 1);
12513        assert_eq!(editor.get_text(), "half-typed");
12514        assert_eq!(*state.history_index.lock().unwrap(), -1);
12515    }
12516
12517    #[test]
12518    fn test_accept_top_suggestion_replaces_prefix() {
12519        // `/he` + Tab → `/help ` (slash command provider inserts a space).
12520        let state = Arc::new(TuiState {
12521            current_assistant: Arc::new(std::sync::Mutex::new(None)),
12522            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12523            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12524            themes_enabled: true,
12525            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
12526            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
12527            show_terminal_progress: true,
12528            status: std::sync::Mutex::new(RunStatus::Idle),
12529            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12530            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12531            editor_text: rpi_extensions::EditorTextMailbox::new(),
12532            auto_send: std::sync::Mutex::new(None),
12533            last_editor_text: std::sync::Mutex::new(String::new()),
12534            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
12535            js_preparation_cancel: std::sync::Mutex::new(None),
12536            user_bash_cancel: std::sync::Mutex::new(None),
12537            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12538            footer: Arc::new(FooterComponent::new()),
12539            status_container: Arc::new(Container::new()),
12540            chat_container: Arc::new(Container::new()),
12541            loader: Arc::new(Loader::new()),
12542            editor: Arc::new(Editor::simple()),
12543            last_assistant_text: std::sync::Mutex::new(String::new()),
12544            active_selector: std::sync::Mutex::new(None),
12545            active_extension_editor: std::sync::Mutex::new(None),
12546            active_extension_input: std::sync::Mutex::new(None),
12547            active_extension_cancel: std::sync::Mutex::new(None),
12548            autocomplete: AutocompleteManager::new(),
12549            autocomplete_container: Arc::new(Container::new()),
12550            autocomplete_max_visible: 5,
12551            pending_images: std::sync::Mutex::new(Vec::new()),
12552            theme_manager: Arc::new(ThemeManager::new()),
12553            tui: None,
12554            current_model_id: std::sync::Mutex::new(String::new()),
12555            show_images: std::sync::Mutex::new(true),
12556            cache_miss_notices: std::sync::Mutex::new(false),
12557            history: std::sync::Mutex::new(Vec::new()),
12558            history_index: std::sync::Mutex::new(-1),
12559            history_draft: std::sync::Mutex::new(None),
12560            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12561            scoped_edit: std::sync::Mutex::new(None),
12562            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
12563            extension_session: Arc::new(std::sync::Mutex::new(
12564                rpi_extensions::ExtensionSession::none(),
12565            )),
12566            search: Arc::new(AltScreenSearch::new()),
12567            search_bar: Arc::new(SearchBar::new()),
12568            pending_container: Arc::new(Container::new()),
12569            dequeue_hint: String::new(),
12570            pending_snapshot: std::sync::Mutex::new(
12571                rpi_harness::agent_harness::QueuedMessages::default(),
12572            ),
12573            selection_start: std::sync::Mutex::new(None),
12574            selection_end: std::sync::Mutex::new(None),
12575        });
12576        {
12577            let mut combined = CombinedAutocompleteProvider::new();
12578            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12579                build_builtin_registry().visible_entries(),
12580            )));
12581            state.autocomplete.set_provider(Arc::new(combined));
12582        }
12583        let editor = Arc::new(Editor::simple());
12584        editor.set_text("/he");
12585        editor.set_cursor(0, 3);
12586        refresh_autocomplete(&state, &editor);
12587        let accepted = accept_top_suggestion(&state, &editor);
12588        assert!(accepted, "should accept the top suggestion");
12589        let text = editor.get_text();
12590        assert!(
12591            text.starts_with("/help"),
12592            "editor text should start with /help, got {text}"
12593        );
12594    }
12595
12596    #[test]
12597    fn configured_key_parser_supports_native_notation() {
12598        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
12599        assert_eq!(combo.code, KeyCode::Char('g'));
12600        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
12601        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
12602        assert_eq!(combo.code, KeyCode::BackTab);
12603    }
12604
12605    #[test]
12606    fn double_escape_trigger_has_half_second_window() {
12607        let now = std::time::Instant::now();
12608        assert!(!double_escape_trigger(None, now));
12609        assert!(double_escape_trigger(
12610            Some(now - std::time::Duration::from_millis(500)),
12611            now
12612        ));
12613        assert!(!double_escape_trigger(
12614            Some(now - std::time::Duration::from_millis(501)),
12615            now
12616        ));
12617    }
12618
12619    #[test]
12620    fn ctrl_c_clears_first_and_only_exits_inside_the_window() {
12621        // Native pi's `handleCtrlC`: a lone press clears the editor and never
12622        // quits by itself; only a second press within 500ms exits. It must not
12623        // abort — Esc owns that.
12624        let now = std::time::Instant::now();
12625        assert_eq!(ctrl_c_action(None, now), CtrlCAction::Clear);
12626        assert_eq!(
12627            ctrl_c_action(Some(now - std::time::Duration::from_millis(500)), now),
12628            CtrlCAction::Exit
12629        );
12630        // A slower second press is a fresh "clear", not a quit.
12631        assert_eq!(
12632            ctrl_c_action(Some(now - std::time::Duration::from_millis(501)), now),
12633            CtrlCAction::Clear
12634        );
12635    }
12636
12637    #[test]
12638    fn pasted_multi_line_block_becomes_one_prompt_not_many() {
12639        // End-to-end shape of the fix: replay the key stream a Windows paste
12640        // produces ("l1\nl2\nl3" + a trailing Enter) through the same pieces
12641        // the key loop uses — the burst discriminator for each Enter and the
12642        // editor for the text — and assert that *no* submit fired while the
12643        // block arrived, then exactly one submit on the user's own Enter.
12644        use std::sync::atomic::{AtomicUsize, Ordering};
12645
12646        let editor = rpi_tui::Editor::simple();
12647        let submits = Arc::new(AtomicUsize::new(0));
12648        let submits_for_cb = submits.clone();
12649        editor.on_submit(Arc::new(move |_text: &str| {
12650            submits_for_cb.fetch_add(1, Ordering::SeqCst);
12651        }));
12652
12653        // Every pasted key is delivered back-to-back, so `now` is unchanged and
12654        // the console queue is never empty until the paste is drained.
12655        let now = std::time::Instant::now();
12656        let mut last_text_key_at: Option<std::time::Instant> = None;
12657        let block = ["l1", "l2", "l3"];
12658        for (index, line) in block.iter().enumerate() {
12659            for ch in line.chars() {
12660                editor.insert(&ch.to_string());
12661                last_text_key_at = Some(now);
12662            }
12663            if index + 1 < block.len() {
12664                // The pasted newline: more input is still queued behind it.
12665                let more_queued = true;
12666                assert!(
12667                    enter_is_paste_burst(last_text_key_at, now, more_queued),
12668                    "pasted newline {index} must not submit"
12669                );
12670                editor.insert("\n");
12671                last_text_key_at = Some(now);
12672            }
12673        }
12674        assert_eq!(editor.get_text(), "l1\nl2\nl3");
12675        assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
12676
12677        // The user's own Enter: a real gap, nothing queued.
12678        let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
12679        assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
12680        editor.handle_key(crossterm::event::KeyEvent::new(
12681            KeyCode::Enter,
12682            KeyModifiers::NONE,
12683        ));
12684        assert_eq!(
12685            submits.load(Ordering::SeqCst),
12686            1,
12687            "typed Enter submits once"
12688        );
12689    }
12690
12691    #[test]
12692    fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
12693        // A Windows paste delivers CRLF; the editor must fold it to LF so the
12694        // rendered block is clean and the cursor arithmetic stays sound.
12695        let editor = rpi_tui::Editor::simple();
12696        editor.insert("alpha\r\nbeta\r\n");
12697        assert_eq!(editor.get_text(), "alpha\nbeta\n");
12698        assert!(!editor.get_text().contains('\r'));
12699    }
12700
12701    #[test]
12702    fn pasted_enter_is_not_a_submit() {
12703        let now = std::time::Instant::now();
12704
12705        // Queued input behind the Enter => paste, regardless of history.
12706        assert!(enter_is_paste_burst(None, now, true));
12707
12708        // Enter immediately after pasted characters => paste (also covers a
12709        // paste whose final line ends with a newline, where nothing is queued).
12710        assert!(enter_is_paste_burst(
12711            Some(now - std::time::Duration::from_millis(1)),
12712            now,
12713            false
12714        ));
12715        assert!(enter_is_paste_burst(
12716            Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
12717            now,
12718            false
12719        ));
12720
12721        // A typed Enter: nothing queued and a real gap since the last key.
12722        assert!(!enter_is_paste_burst(None, now, false));
12723        assert!(!enter_is_paste_burst(
12724            Some(now - PASTE_BURST_GAP),
12725            now,
12726            false
12727        ));
12728        assert!(!enter_is_paste_burst(
12729            Some(now - std::time::Duration::from_millis(120)),
12730            now,
12731            false
12732        ));
12733    }
12734
12735    #[test]
12736    fn paste_probe_ignores_the_enter_keys_own_release() {
12737        use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
12738        let release =
12739            KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
12740
12741        // Windows queues press+release together over RDP: the release behind a
12742        // lone Enter must NOT count as queued paste content.
12743        let mut queued = vec![Event::Key(release)].into_iter();
12744        let (more, stashed) = probe_paste_input(None, || queued.next());
12745        assert!(!more, "a key release alone is not queued paste input");
12746        assert!(stashed.is_none());
12747
12748        // A real follow-up key (the next pasted line) IS queued input and is
12749        // stashed for the caller instead of being lost.
12750        let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
12751        let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
12752        let (more, stashed) = probe_paste_input(None, || queued.next());
12753        assert!(more, "a queued key press is paste continuation");
12754        assert!(matches!(stashed, Some(Event::Key(_))));
12755
12756        // Nothing queued at all.
12757        let (more, stashed) = probe_paste_input(None, || None);
12758        assert!(!more);
12759        assert!(stashed.is_none());
12760
12761        // An already-stashed event counts as queued input, without reading more.
12762        let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
12763            panic!("must not read when an event is already stashed")
12764        });
12765        assert!(more);
12766        assert!(matches!(stashed, Some(Event::Key(_))));
12767    }
12768
12769    // ---- ask_user TUI bridge ----
12770
12771    #[test]
12772    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
12773        let flat = rpi_extensions::UiDialogRequest {
12774            request_id: "r1".into(),
12775            tool_call_id: Some("c1".into()),
12776            ui: serde_json::json!({
12777                "kind": "selector",
12778                "id": "proxy_port",
12779                "header": "Proxy",
12780                "question": "端口?",
12781                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
12782                "allowMultiple": true,
12783                "allowFreeform": false,
12784                "suggest": "1080"
12785            }),
12786            raw: serde_json::json!({}),
12787        };
12788        let prompt = parse_ask_user_prompt(&flat);
12789        assert_eq!(prompt.question_id, "proxy_port");
12790        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
12791        assert_eq!(prompt.options.len(), 2);
12792        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
12793        assert!(prompt.allow_multiple);
12794        assert!(!prompt.allow_freeform);
12795        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
12796
12797        let alias = rpi_extensions::UiDialogRequest {
12798            ui: serde_json::json!({
12799                "questions": [{"id": "q", "question": "Q?", "options": []}]
12800            }),
12801            ..flat.clone()
12802        };
12803        let prompt = parse_ask_user_prompt(&alias);
12804        assert_eq!(prompt.question_id, "q");
12805        assert!(prompt.allow_freeform, "no options defaults to freeform");
12806
12807        let confirm = rpi_extensions::UiDialogRequest {
12808            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
12809            ..flat.clone()
12810        };
12811        let prompt = parse_ask_user_prompt(&confirm);
12812        assert_eq!(prompt.kind, "confirm");
12813        assert_eq!(prompt.options.len(), 2);
12814        assert_eq!(prompt.options[0].0, "Yes");
12815    }
12816
12817    #[test]
12818    fn ask_user_bridge_answers_and_cancels_by_request_id() {
12819        let mailbox = rpi_extensions::UiDialogMailbox::new();
12820        mailbox.attach();
12821        let bridge = AskUserBridge::new(mailbox.clone());
12822
12823        // Two concurrent requests must not cross answers.
12824        mailbox
12825            .handle(serde_json::json!({
12826                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
12827                "ui": {"kind": "input", "question": "Port?"}
12828            }))
12829            .unwrap();
12830        mailbox
12831            .handle(serde_json::json!({
12832                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
12833                "ui": {"kind": "input", "question": "Level?"}
12834            }))
12835            .unwrap();
12836
12837        let first = bridge.take_pending().expect("r1 visible");
12838        assert_eq!(first.request_id, "r1");
12839        assert!(
12840            bridge.take_pending().is_none(),
12841            "only one request occupies the input slot"
12842        );
12843        bridge.respond("r1", serde_json::json!({"text": "7897"}));
12844        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
12845
12846        let second = bridge.take_pending().expect("r2 visible");
12847        assert_eq!(second.request_id, "r2");
12848        bridge.cancel("r2");
12849        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
12850
12851        // cancel_all drops anything still queued.
12852        mailbox
12853            .handle(serde_json::json!({
12854                "op": "open", "requestId": "r3",
12855                "ui": {"kind": "input", "question": "X?"}
12856            }))
12857            .unwrap();
12858        bridge.cancel_all();
12859        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
12860
12861        // A detached mailbox rejects new prompts (headless contract).
12862        bridge.shutdown();
12863        assert!(mailbox
12864            .handle(serde_json::json!({
12865                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
12866            }))
12867            .is_err());
12868    }
12869}