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;
27use std::io::IsTerminal;
28use std::sync::{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/// B5e: build the `AssistantMessageComponent` markdown-transformer closure the
75/// render path applies to raw assistant text before styling. Wraps any plugin
76/// `register_markdown_transformer` handlers registered in `snapshot` (chained
77/// in registration order: each handler's output feeds the next). `None` when
78/// no markdown transformers are registered (the component defaults to the
79/// identity transform + this avoids a closure allocation on the hot render
80/// path).
81///
82/// The closure captures an `Arc<RegistrySnapshot>` clone so it outlives the
83/// borrow that built it (the snapshot's `active` flag guards dispatch in
84/// `emit_resources_discover`/event translation; a reloaded session's old
85/// snapshot flips false, so a stale closure no-ops rather than driving a
86/// half-swapped registry — the transformer falls back to the input unchanged
87/// on an inactive snapshot, matching the plugin's per-handler skip-on-error).
88///
89/// This is the cycle-free seam: `rpi-tui` takes a `Fn(&str) -> String` trait
90/// object (no `rpi-extensions` dep); `rpi-cli` (which already depends on
91/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot`. The
92/// calling pattern mirrors `plugin_stub_smoke.rs`'s direct `RenderFn` round-
93/// trip (input `{"markdown":…}` → `render_fn` → reclaim `out` via the plugin's
94/// `free_string` → parse `{"markdown":…}`).
95fn build_markdown_transformer(
96    snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
97) -> Option<MarkdownTransformer> {
98    let snapshot = snapshot?;
99    // Pre-check: if no markdown renderers are registered, return None so the
100    // component uses the identity path (no per-delta closure call). The
101    // renderers list is a per-call `renderers_of` clone; snapshotting it once
102    // here keeps the closure cheap on the hot path.
103    let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
104    if renderers.is_empty() {
105        return None;
106    }
107    Some(Arc::new(move |raw: &str| -> String {
108        transform_markdown_chain(&snapshot, &renderers, raw)
109    }))
110}
111
112/// Drive the markdown-transformer chain for one input string. Each registered
113/// handler receives the previous handler's output (or the raw input for the
114/// first), as a `{"markdown": <text>}` JSON envelope; its `RenderFn` returns
115/// `{"markdown": <transformed>}` (rc=0) or an error (rc!=0). On any failure —
116/// nonzero rc, a panic across the FFI (caught), a missing `markdown` field, or
117/// an inactive snapshot — the chain short-circuits to the current text
118/// unchanged (per-handler skip-on-error, mirroring pi's `runner.ts` fan-out).
119fn transform_markdown_chain(
120    snapshot: &rpi_extensions::RegistrySnapshot,
121    renderers: &[rpi_extensions::RegisteredRenderer],
122    raw: &str,
123) -> String {
124    // A stale snapshot (post-/reload) must not drive a swapped-out registry.
125    // The renderers were captured from this snapshot; if it has gone inactive,
126    // fall back to the raw input so the UI never renders stale-transformed text
127    // from a dead plugin.
128    if !snapshot.is_active() {
129        return raw.to_string();
130    }
131
132    let mut current = raw.to_string();
133    for renderer in renderers {
134        let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
135            Ok(s) => s,
136            Err(_) => return current, // serialize failure — keep current, stop chain
137        };
138        // SAFETY: `render_fn` is a plugin-provided `extern "C" fn` over a
139        // borrowed `StbStringRef` + an out-param. The plugin warrants
140        // `poll`/`render` are non-blocking + thread-safe (the same contract
141        // the tool adapter relies on). `user_data` is the plugin's opaque
142        // pointer, stable for the registry lifetime (the keepalive keeps the
143        // cdylib mapped). We reclaim `out` via the plugin's `free_string`
144        // exactly once. The whole call is `catch_unwind`-wrapped — a plugin
145        // panic must not unwind across the FFI boundary (same policy as the
146        // tool partial cb + the runtime_action trampoline).
147        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
148            let mut out = rpi_plugin_sdk::StbString::empty();
149            let rc = (renderer.render_fn)(
150                rpi_plugin_sdk::StbStringRef::from_str(&input),
151                &mut out as *mut rpi_plugin_sdk::StbString,
152                renderer.user_data,
153            );
154            let text = if rc == 0 {
155                let s = out.to_string_lossy();
156                Some(s)
157            } else {
158                None
159            };
160            // Reclaim the plugin-owned `out` regardless of rc (rc!=0 may still
161            // have written an error JSON the plugin allocated). `free_with` is
162            // idempotent on an empty `StbString`.
163            out.free_with(Some(renderer.plugin_free_string));
164            text
165        }));
166        let out_text = match outcome {
167            Ok(Some(s)) => s,
168            Ok(None) => return current, // rc != 0 — skip this handler, keep current
169            Err(_) => return current,   // panic — skip, keep current (do not abort: the
170                                         // render path is not the action trampoline; a panicking transformer
171                                         // degrades to identity rather than killing the process. Logged via
172                                         // the `tracing` crate's panic hook.)
173        };
174        // Parse `{"markdown": <text>}`; lenient — a missing/non-string field
175        // keeps the current text (skip this handler).
176        let next = serde_json::from_str::<serde_json::Value>(&out_text)
177            .ok()
178            .and_then(|v| {
179                v.get("markdown")
180                    .and_then(|m| m.as_str())
181                    .map(|s| s.to_string())
182            })
183            .unwrap_or(current);
184        current = next;
185    }
186    current
187}
188
189// ===========================================================================
190// Slash commands — trait + registry
191// ===========================================================================
192//
193// Each built-in slash command is one `impl SlashCommand`. The commands are
194// registered at startup into a [`CommandRegistry`] (one source of truth) that
195// serves both dispatch ("given this token, run the command") and autocomplete
196// ("list the visible commands"). This replaces the old two-list + sync-test
197// arrangement, where `handle_slash_command` and `v1_slash_commands()` had to be
198// kept in lock-step by hand.
199//
200// `execute` runs on the blocking key/compose thread (the editor `on_submit`
201// callback and the Ctrl+L hotkey both land there), so it MUST stay synchronous:
202//   - commands needing async (`set_model`/`set_thinking_level`/`set_active_tools`)
203//     `tokio::spawn` the work and return immediately;
204//   - commands needing the main async loop (`compact`/`copy`/`exit`/`clear`/
205//     `user-input`) signal it via `ctx.tx.send(TuiMessage::…)`;
206//   - everything else mutates the chat container + requests a render directly.
207
208/// The borrowed world a slash command runs against. All fields are `Arc` (or a
209/// cheap `String` snapshot), so one `CommandContext` clones freely into each
210/// command without per-capture ceremony — this struct is exactly the set of
211/// `*_for_cb` clones the old submit closure used to make individually.
212#[derive(Clone)]
213struct CommandContext {
214    chat: Arc<Container>,
215    tui: Arc<TuiAltScreen>,
216    tx: mpsc::UnboundedSender<TuiMessage>,
217    state: Arc<TuiState>,
218    editor: Arc<Editor>,
219    editor_container: Arc<Container>,
220    lane: Arc<dyn AgentLane>,
221    model_catalog: Arc<Vec<rpi_ai::Model>>,
222    /// Lane model id snapshot, read once via `lane.get_model().await` BEFORE the
223    /// blocking key loop starts. Selectors/key loop can't await, so they read
224    /// this owned string instead. Semantically unchanged from pre-refactor.
225    lane_model_id: String,
226    cwd: std::path::PathBuf,
227    /// Harness resources snapshot (skills + prompt templates) for `/context`.
228    /// Captured once at TUI startup because the blocking submit thread can't
229    /// `.await get_resources()`.
230    resources: Arc<rpi_harness::types::AgentHarnessResources>,
231    /// B5d: the reload context `/reload` drives. `Arc<ReloadContext>` so the
232    /// blocking submit thread can cheaply clone it into the `ReloadCommand`
233    /// without an `.await` (the command can't drive reload directly — it signals
234    /// the main loop via `TuiMessage::ReloadExtensions`, which awaits the shared
235    /// `reload_extension_resources` routine on the async runtime).
236    reload_context: Arc<crate::session::ReloadContext>,
237    /// The product-layer session (`AgentSession`). Commands that need durable
238    /// reads, export, or usage stats go through this instead of reaching into
239    /// the harness directly, so the product surface is one object.
240    session: Arc<crate::agent_session::AgentSession>,
241}
242
243/// One slash command.
244trait SlashCommand: Send + Sync {
245    /// Canonical name, with the leading `/` (e.g. "/model").
246    fn name(&self) -> &str;
247    /// Aliases, also `/`-prefixed. Matched alongside `name()` during dispatch.
248    /// Use [`SlashCommand::alias_visible`] to also surface an alias in the
249    /// `/`-autocomplete list (most aliases stay hidden).
250    fn aliases(&self) -> &'static [&'static str] {
251        &[]
252    }
253    /// Whether the canonical name appears in the `/` autocomplete list. Hidden
254    /// commands (`/context`, `/name`, …) return `false`.
255    fn visible(&self) -> bool {
256        true
257    }
258    /// Aliases that should also appear in the `/` autocomplete list. Defaults to
259    /// none — most aliases (`/q`, `/m`, `/think`, `/resume`, `/v`) are kept off
260    /// the list to keep it short. `/new` and `/quit` override this to surface.
261    fn alias_visible(&self) -> &'static [&'static str] {
262        &[]
263    }
264    /// Description shown in autocomplete and `/help`. A non-empty description is
265    /// required to surface in autocomplete even when `visible()` is true.
266    fn description(&self) -> &'static str {
267        ""
268    }
269    fn description_owned(&self) -> String {
270        self.description().to_string()
271    }
272    /// Execute the command. Only invoked for inputs starting with `/` whose
273    /// first token matches `name()` or an alias. `args` is the whitespace-
274    /// trimmed remainder after the command token ("" when none). Must stay
275    /// synchronous (see the module-level note) — async work goes through
276    /// `ctx.tx.send(TuiMessage::…)` or `tokio::spawn`.
277    fn execute(&self, ctx: &CommandContext, args: &str);
278}
279
280/// Holds all registered slash commands; the single source of truth for both
281/// dispatch and the autocomplete list.
282struct CommandRegistry {
283    commands: Vec<Arc<dyn SlashCommand>>,
284}
285
286impl CommandRegistry {
287    fn new() -> Self {
288        Self {
289            commands: Vec::new(),
290        }
291    }
292
293    fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
294        self.commands.push(cmd);
295    }
296
297    /// Find the command whose `name()` or an alias matches `token` (e.g. "/q").
298    /// `token` is the first whitespace-delimited word of the input, `/`-prefixed.
299    fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
300        self.commands
301            .iter()
302            .find(|c| c.name() == token || c.aliases().contains(&token))
303    }
304
305    /// The autocomplete entries, derived from the registry so it can never drift
306    /// from what dispatch recognizes. Surfaces the canonical name when
307    /// `visible()` + non-empty description, plus any `alias_visible()` entries.
308    /// Order = registration order; built-ins are registered before templates,
309    /// so they win on a fuzzy tie (unchanged).
310    fn visible_entries(&self) -> Vec<SlashCommandEntry> {
311        let mut out: Vec<SlashCommandEntry> = Vec::new();
312        for c in &self.commands {
313            let description = c.description_owned();
314            if c.visible() && !description.is_empty() {
315                out.push(SlashCommandEntry {
316                    name: c.name().into(),
317                    description: description.clone(),
318                });
319            }
320            // Surfaced aliases share the command's description.
321            for alias in c.alias_visible() {
322                out.push(SlashCommandEntry {
323                    name: (*alias).into(),
324                    description: description.clone(),
325                });
326            }
327        }
328        out
329    }
330}
331
332/// Dispatch a ui_prompt_start event to extensions.
333fn dispatch_ui_prompt_event(state: &TuiState) {
334    use rpi_plugin_sdk::EventTag;
335
336    if let Ok(session) = state.extension_session.lock() {
337        if let Some(snapshot) = session.snapshot_arc() {
338            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
339        }
340    }
341}
342
343/// Dispatch a ui_prompt_end event to extensions.
344fn dispatch_ui_prompt_event_end(state: &TuiState) {
345    use rpi_plugin_sdk::EventTag;
346
347    if let Ok(session) = state.extension_session.lock() {
348        if let Some(snapshot) = session.snapshot_arc() {
349            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
350        }
351    }
352}
353
354/// Canonical key name for a key event, matching the vocabulary
355/// [`parse_configured_key`] accepts (`"space"`, `"enter"`, `"f1"`, `"a"`).
356/// `None` for keys that cannot be named (media keys, unknown codes).
357fn key_shortcut_name(code: KeyCode) -> Option<&'static str> {
358    Some(match code {
359        KeyCode::Char(' ') => "space",
360        KeyCode::Enter => "enter",
361        KeyCode::Esc => "escape",
362        KeyCode::Tab => "tab",
363        KeyCode::BackTab => "backtab",
364        KeyCode::Backspace => "backspace",
365        KeyCode::Delete => "delete",
366        KeyCode::Up => "up",
367        KeyCode::Down => "down",
368        KeyCode::Left => "left",
369        KeyCode::Right => "right",
370        KeyCode::Home => "home",
371        KeyCode::End => "end",
372        KeyCode::PageUp => "pageup",
373        KeyCode::PageDown => "pagedown",
374        _ => return None,
375    })
376}
377
378/// Lower-case name for a printable ASCII key (`a`, `1`, `,`), or `None` when the
379/// key needs a dedicated name from [`key_shortcut_name`].
380fn char_shortcut_name(ch: char) -> Option<String> {
381    if ch.is_ascii_graphic() {
382        Some(ch.to_ascii_lowercase().to_string())
383    } else {
384        None
385    }
386}
387
388/// Route one key event to extension `Input` handlers when the key is claimed as
389/// a shortcut. Returns `true` when the key was claimed (the caller must then
390/// skip normal editor handling).
391///
392/// Only keys an extension explicitly claimed via `register_shortcut` are
393/// routed, and only while the editor is empty, so a claimed key never steals
394/// ordinary typing. Press **and** Release (and Repeat) are dispatched, which is
395/// what lets a push-to-talk extension measure how long a key was held — the
396/// normal editor path deliberately drops Release.
397fn dispatch_key_event(state: &TuiState, key: &KeyEvent, editor: &Editor) -> bool {
398    use rpi_plugin_sdk::EventTag;
399
400    let name = key_shortcut_name(key.code)
401        .map(str::to_string)
402        .or_else(|| match key.code {
403            KeyCode::Char(ch) => char_shortcut_name(ch),
404            KeyCode::F(n) => Some(format!("f{n}")),
405            _ => None,
406        });
407    let Some(name) = name else {
408        return false;
409    };
410
411    let Ok(session) = state.extension_session.lock() else {
412        return false;
413    };
414    let Some(snapshot) = session.snapshot_arc() else {
415        return false;
416    };
417    // Nothing claimed this key (or nobody subscribes to Input) — not ours.
418    if !snapshot.has_shortcut(&name) || snapshot.handlers_for(EventTag::Input).is_empty() {
419        return false;
420    }
421    // Only claim the key while the editor is empty: with a draft in progress the
422    // key belongs to typing (space especially).
423    if !editor.get_text().is_empty() {
424        return false;
425    }
426
427    let kind = match key.kind {
428        KeyEventKind::Press => "press",
429        KeyEventKind::Repeat => "repeat",
430        KeyEventKind::Release => "release",
431    };
432    let payload = serde_json::json!({
433        "type": "key",
434        "key": name,
435        "kind": kind,
436        "ctrl": key.modifiers.contains(KeyModifiers::CONTROL),
437        "alt": key.modifiers.contains(KeyModifiers::ALT),
438        "shift": key.modifiers.contains(KeyModifiers::SHIFT),
439    })
440    .to_string();
441    // The handler decides whether it actually wants the key: it returns the
442    // `CLAIMED` code while its feature is active and `CONTINUE` otherwise, so a
443    // registered-but-disabled shortcut (push-to-talk toggled off) still lets the
444    // key reach the editor normally.
445    rpi_extensions::dispatch_data_event_claiming(&snapshot, EventTag::Input, &payload)
446}
447
448/// Resolve a command for a `/`-prefixed input and run it when one matches.
449///
450/// Unknown slash-prefixed input is deliberately left for the normal prompt
451/// path. In particular, the upstream skill-command behavior keeps an unknown
452/// `/skill:name` intact so the model can still handle it; it must not be
453/// converted into a local error before input handlers or the agent see it.
454/// Returns `true` only when a registered slash command handled the input.
455fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) -> bool {
456    let mut parts = text.split_whitespace();
457    let token = parts.next().unwrap_or("");
458    let args = parts.collect::<Vec<_>>().join(" ");
459    match registry.find(token) {
460        Some(cmd) => {
461            cmd.execute(ctx, &args);
462            true
463        }
464        None => false,
465    }
466}
467
468/// Current wall-clock time in milliseconds since the Unix epoch. Used for
469/// `bashExecution` message timestamps (agent-loop messages carry real times, so
470/// the transcript record must too).
471fn now_ms() -> i64 {
472    std::time::SystemTime::now()
473        .duration_since(std::time::UNIX_EPOCH)
474        .map(|d| d.as_millis() as i64)
475        .unwrap_or(0)
476}
477
478/// Parse a submitted line into a user shell command.
479///
480/// Mirrors upstream's `text.startsWith("!")` handling
481/// (`interactive-mode.ts:3225`): `!cmd` runs and is recorded in the session
482/// context, `!!cmd` is excluded from it. Returns `None` for a bare `!` / `!!`
483/// (or whitespace only), so the line falls through to the normal prompt path.
484fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
485    let rest = text.strip_prefix('!')?;
486    let (command, exclude_from_context) = match rest.strip_prefix('!') {
487        Some(command) => (command, true),
488        None => (rest, false),
489    };
490    let command = command.trim();
491    if command.is_empty() {
492        return None;
493    }
494    Some((command, exclude_from_context))
495}
496
497/// Run a `!command` submitted from the editor.
498///
499/// The command executes out-of-band — it never reaches the model. Output
500/// streams into a [`BashExecutionComponent`] in the transcript and the
501/// terminal result is handed to the main loop, which persists it as a
502/// `bashExecution` message (skipped from context for `!!`). Mirrors
503/// `handleBashCommand` (`interactive-mode.ts:6729`).
504fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
505    let comp = Arc::new(BashExecutionComponent::new_with_context(
506        command,
507        exclude_from_context,
508    ));
509    comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
510    ctx.chat.add_child(comp.clone());
511
512    // Register the cancellation slot before spawning so an Esc arriving on the
513    // key thread can never miss the run (upstream's `_bashAbortControllers`).
514    let cancel = ctx.state.begin_user_bash();
515    ctx.tui.request_render(false);
516
517    let env: Arc<dyn rpi_tools::ExecutionEnv> =
518        Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
519    let tui = ctx.tui.clone();
520    let tx = ctx.tx.clone();
521    let cwd = ctx.cwd.clone();
522    let command_owned = command.to_string();
523
524    tokio::spawn(async move {
525        let comp_chunk = comp.clone();
526        let tui_chunk = tui.clone();
527        let on_chunk: Box<
528            dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
529        > = Box::new(move |_chunk, get_progress| {
530            // The capture layer reports the complete tail captured so far, not
531            // a delta; `append_output` replaces its snapshot accordingly.
532            comp_chunk.append_output(&get_progress().output);
533            tui_chunk.request_render(false);
534        });
535        let options = rpi_tools::shell_output::ShellCaptureOptions {
536            cwd: Some(cwd),
537            cancel: Some(&cancel),
538            on_chunk: Some(on_chunk),
539            return_execution_errors: true,
540            ..Default::default()
541        };
542        let result =
543            rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
544                .await;
545
546        // `Ok` carries the real exit code/cancellation; `Err` is a transport
547        // level failure (spawn failure, invalid env) which we surface inline.
548        let (exit_code, cancelled, output, truncated, full_output_path) = match result {
549            Ok(capture) => (
550                capture.exit_code,
551                capture.cancelled,
552                capture.output.clone(),
553                capture.truncated,
554                capture
555                    .full_output_path
556                    .as_ref()
557                    .map(|path| path.to_string_lossy().into_owned()),
558            ),
559            Err(error) => (
560                None,
561                false,
562                format!("Bash command failed: {error}"),
563                false,
564                None,
565            ),
566        };
567        comp.append_output(&output);
568        comp.set_complete(
569            exit_code,
570            cancelled,
571            BashTruncation {
572                truncated,
573                full_output_path: full_output_path.clone(),
574            },
575        );
576        tui.render_now(false);
577
578        let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
579            command: command_owned,
580            output,
581            exit_code,
582            cancelled,
583            truncated,
584            full_output_path,
585            exclude_from_context,
586        }));
587    });
588}
589
590/// Encode a crossterm key into the raw key data consumed by the Node TUI
591/// compatibility layer. Plain keys retain the usual terminal sequences;
592/// modified functional keys use Kitty CSI-u so Shift/Alt/Ctrl combinations are
593/// not collapsed into their unmodified equivalent (notably Shift+Enter).
594struct ExtensionCommand {
595    name: String,
596    description: String,
597    session: crate::session::ExtensionSessionCell,
598}
599
600impl SlashCommand for ExtensionCommand {
601    fn name(&self) -> &str {
602        &self.name
603    }
604
605    fn description(&self) -> &'static str {
606        "extension command"
607    }
608
609    fn description_owned(&self) -> String {
610        self.description.clone()
611    }
612
613    fn execute(&self, ctx: &CommandContext, args: &str) {
614        let result = invoke_extension_command(&self.session, &self.name, args);
615        handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
616    }
617}
618
619fn invoke_extension_command(
620    session: &crate::session::ExtensionSessionCell,
621    name: &str,
622    args: &str,
623) -> Option<serde_json::Value> {
624    let command = session
625        .lock()
626        .ok()
627        .and_then(|s| s.snapshot_arc())
628        .and_then(|snap| {
629            snap.commands()
630                .iter()
631                .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
632                .cloned()
633        })?;
634    let input = serde_json::json!({ "args": args, "command": name });
635    let input = serde_json::to_string(&input).ok()?;
636    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
637        let mut out = rpi_plugin_sdk::StbString::empty();
638        let rc = (command.handler)(
639            rpi_plugin_sdk::StbStringRef::from_str(&input),
640            &mut out as *mut rpi_plugin_sdk::StbString,
641            command.user_data,
642        );
643        let text = if rc == 0 {
644            Some(out.to_string_lossy())
645        } else {
646            None
647        };
648        rpi_extensions::host_free_string(out);
649        text
650    }))
651    .ok()
652    .flatten()?;
653    serde_json::from_str(&outcome).ok()
654}
655
656fn handle_extension_ui_result(
657    result: Option<serde_json::Value>,
658    ctx: &CommandContext,
659    session: crate::session::ExtensionSessionCell,
660    command_name: String,
661) {
662    let Some(value) = result else {
663        add_error_message(&ctx.chat, "Extension command failed.");
664        ctx.tui.request_render(false);
665        return;
666    };
667    // A cancellation continuation may intentionally return JSON null. Native
668    // pi resolves the pending promise with `undefined` and does not add a
669    // visible "null" message to the transcript.
670    if value.is_null() {
671        ctx.tui.request_render(false);
672        return;
673    }
674    match value.get("kind").and_then(|v| v.as_str()) {
675        Some("message") | None => {
676            let fallback = value.to_string();
677            let text = value
678                .get("text")
679                .and_then(|v| v.as_str())
680                .unwrap_or(&fallback)
681                .to_string();
682            if !text.is_empty() {
683                add_note_message(&ctx.chat, &text);
684            }
685            ctx.tui.request_render(false);
686        }
687        Some("selector") => open_extension_selector(ctx, session, command_name, value),
688        Some("editor") => open_extension_editor(ctx, session, command_name, value),
689        // upstream exposes `ctx.ui.input(title, placeholder)` separately
690        // from the multiline editor. Render it as a focused single-line
691        // dialog in the swapped input slot.
692        Some("input") => open_extension_input(ctx, session, command_name, value),
693        Some(other) => {
694            add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
695            ctx.tui.request_render(false);
696        }
697    }
698}
699
700/// Render the title used by the upstream extension dialogs.  Keeping it in
701/// the swapped editor container makes the question stay visible while the
702/// extension waits for the answer, instead of adding a transient chat note.
703fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
704    let colors = current_theme().colors;
705    let text = if bold {
706        tui_bold(title)
707    } else {
708        title.to_string()
709    };
710    Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
711}
712
713fn extension_dialog_hint(label: &str) -> Arc<Text> {
714    Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
715}
716
717/// Markdown-aware version of `extension_dialog_title` for extension dialogs
718/// that may contain markdown content (tables, lists, bold, etc).
719fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
720    let text = if bold {
721        format!("**{}**", title)
722    } else {
723        title.to_string()
724    };
725    Arc::new(Markdown::new(text, 1, 0))
726}
727
728/// Markdown-aware version of `extension_dialog_hint` for extension dialogs
729/// that may contain markdown content.
730fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
731    Arc::new(Markdown::new(label, 1, 0))
732}
733
734/// Take and run the cancellation callback for the active extension dialog.
735/// Taking it before invoking the callback breaks the temporary Arc cycle: the
736/// callback owns the command context so it can process a follow-up result.
737fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
738    let callback = state.active_extension_cancel.lock().unwrap().take();
739    if let Some(callback) = callback {
740        callback();
741        true
742    } else {
743        false
744    }
745}
746
747fn open_extension_input(
748    ctx: &CommandContext,
749    session: crate::session::ExtensionSessionCell,
750    command_name: String,
751    value: serde_json::Value,
752) {
753    let title = value
754        .get("title")
755        .and_then(|v| v.as_str())
756        .filter(|title| !title.is_empty())
757        .unwrap_or("Input");
758    let input = value
759        .get("placeholder")
760        .and_then(|v| v.as_str())
761        .map(Input::with_placeholder)
762        .unwrap_or_default();
763    let input = Arc::new(input);
764    if let Some(initial) = value
765        .get("initialText")
766        .or_else(|| value.get("text"))
767        .and_then(|v| v.as_str())
768    {
769        input.set_value(initial);
770    }
771    input.set_focused(true);
772
773    let frame = Arc::new(Container::new());
774    frame.add_child(Arc::new(DynamicBorder::new()));
775    frame.add_child(Arc::new(Spacer::new(1)));
776    frame.add_child(extension_dialog_title(title, false));
777    frame.add_child(Arc::new(Spacer::new(1)));
778    frame.add_child(input.clone());
779    frame.add_child(Arc::new(Spacer::new(1)));
780    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
781    frame.add_child(Arc::new(Spacer::new(1)));
782    frame.add_child(Arc::new(DynamicBorder::new()));
783
784    *ctx.state.active_extension_editor.lock().unwrap() = None;
785    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
786    ctx.editor_container.clear();
787    ctx.editor_container.add_child(frame);
788
789    let state = ctx.state.clone();
790    let ec = ctx.editor_container.clone();
791    let original = ctx.editor.clone();
792    let tui = ctx.tui.clone();
793    let session_submit = session.clone();
794    let command_submit = command_name.clone();
795    let ctx_submit = ctx.clone();
796    input.on_submit(Arc::new(move |text| {
797        let args = serde_json::json!({ "action": "input", "value": text, "text": text });
798        let result = invoke_extension_command(
799            &session_submit,
800            &command_submit,
801            &serde_json::to_string(&args).unwrap_or_default(),
802        );
803        close_extension_editor(&state, &ec, &original, &tui);
804        handle_extension_ui_result(
805            result,
806            &ctx_submit,
807            session_submit.clone(),
808            command_submit.clone(),
809        );
810    }));
811
812    let state_cancel = ctx.state.clone();
813    let ec_cancel = ctx.editor_container.clone();
814    let original_cancel = ctx.editor.clone();
815    let tui_cancel = ctx.tui.clone();
816    let session_cancel = session.clone();
817    let command_cancel = command_name.clone();
818    let ctx_cancel = ctx.clone();
819    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
820        let args = serde_json::json!({ "action": "cancel" });
821        let result = invoke_extension_command(
822            &session_cancel,
823            &command_cancel,
824            &serde_json::to_string(&args).unwrap_or_default(),
825        );
826        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
827        handle_extension_ui_result(
828            result,
829            &ctx_cancel,
830            session_cancel.clone(),
831            command_cancel.clone(),
832        );
833    }));
834
835    ctx.tui.set_focus(Some(input));
836    ctx.tui.request_render(false);
837}
838
839fn open_extension_selector(
840    ctx: &CommandContext,
841    session: crate::session::ExtensionSessionCell,
842    command_name: String,
843    value: serde_json::Value,
844) {
845    let items = value
846        .get("items")
847        .and_then(|v| v.as_array())
848        .map(|items| {
849            items
850                .iter()
851                .filter_map(|item| {
852                    // upstream's selector accepts `string[]`; the Rust ABI
853                    // also permits `{value,label,description}` objects.
854                    let value = if let Some(value) = item.as_str() {
855                        value
856                    } else {
857                        item.get("value")?.as_str()?
858                    };
859                    let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
860                    let mut out = SelectItem::new(value, label);
861                    if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
862                        out = out.with_description(desc);
863                    }
864                    Some(out)
865                })
866                .collect::<Vec<_>>()
867        })
868        .unwrap_or_default();
869    if items.is_empty() {
870        add_error_message(&ctx.chat, "Extension selector has no items.");
871        ctx.tui.request_render(false);
872        return;
873    }
874    let list = Arc::new(SelectList::new(items, 10));
875    let title = value
876        .get("title")
877        .and_then(|v| v.as_str())
878        .filter(|title| !title.is_empty())
879        .unwrap_or("Select");
880    let frame = Arc::new(Container::new());
881    frame.add_child(Arc::new(DynamicBorder::new()));
882    frame.add_child(Arc::new(Spacer::new(1)));
883    frame.add_child(extension_dialog_title(title, true));
884    frame.add_child(Arc::new(Spacer::new(1)));
885    frame.add_child(list.clone());
886    frame.add_child(Arc::new(Spacer::new(1)));
887    frame.add_child(extension_dialog_hint(
888        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
889    ));
890    frame.add_child(Arc::new(Spacer::new(1)));
891    frame.add_child(Arc::new(DynamicBorder::new()));
892
893    let state = ctx.state.clone();
894    let ec = ctx.editor_container.clone();
895    let editor = ctx.editor.clone();
896    let tui = ctx.tui.clone();
897    let session_select = session.clone();
898    let command_select = command_name.clone();
899    let ctx_select = ctx.clone();
900    list.on_select(Arc::new(move |item| {
901        let args = serde_json::json!({ "action": "select", "value": item.value });
902        let result = invoke_extension_command(
903            &session_select,
904            &command_select,
905            &serde_json::to_string(&args).unwrap_or_default(),
906        );
907        close_selector(&state, &ec, &editor, &tui);
908        handle_extension_ui_result(
909            result,
910            &ctx_select,
911            session_select.clone(),
912            command_select.clone(),
913        );
914    }));
915    let state_cancel = ctx.state.clone();
916    let ec_cancel = ctx.editor_container.clone();
917    let editor_cancel = ctx.editor.clone();
918    let tui_cancel = ctx.tui.clone();
919    list.on_cancel(Arc::new(move || {
920        if !run_extension_cancel(&state_cancel) {
921            close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
922        }
923    }));
924    let state_cancel = ctx.state.clone();
925    let ec_cancel = ctx.editor_container.clone();
926    let editor_cancel = ctx.editor.clone();
927    let tui_cancel = ctx.tui.clone();
928    let session_cancel = session.clone();
929    let command_cancel = command_name.clone();
930    let ctx_cancel = ctx.clone();
931    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
932        let args = serde_json::json!({ "action": "cancel" });
933        let result = invoke_extension_command(
934            &session_cancel,
935            &command_cancel,
936            &serde_json::to_string(&args).unwrap_or_default(),
937        );
938        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
939        handle_extension_ui_result(
940            result,
941            &ctx_cancel,
942            session_cancel.clone(),
943            command_cancel.clone(),
944        );
945    }));
946    open_selector_with_view(
947        &ctx.state,
948        &ctx.editor_container,
949        &ctx.editor,
950        &ctx.tui,
951        SelectorView::List(list),
952        frame,
953        SelectorKind::Extension,
954    );
955}
956
957fn open_extension_editor(
958    ctx: &CommandContext,
959    session: crate::session::ExtensionSessionCell,
960    command_name: String,
961    value: serde_json::Value,
962) {
963    let initial = value
964        .get("initialText")
965        .or_else(|| value.get("text"))
966        .and_then(|v| v.as_str())
967        .unwrap_or_default()
968        .to_string();
969    let title = value
970        .get("title")
971        .and_then(|v| v.as_str())
972        .filter(|title| !title.is_empty())
973        .unwrap_or("Editor");
974    let editor = Arc::new(Editor::new(
975        EditorOptions {
976            padding_x: 1,
977            autocomplete_max_visible: 0,
978            placeholder: value
979                .get("placeholder")
980                .and_then(|v| v.as_str())
981                .map(str::to_string),
982            initial_text: Some(initial),
983        },
984        EditorStyle {
985            prompt: "> ".to_string(),
986            placeholder: String::new(),
987        },
988        Arc::new(rpi_tui::Keybindings::new()),
989    ));
990    editor.set_focused(true);
991    let frame = Arc::new(Container::new());
992    frame.add_child(Arc::new(DynamicBorder::new()));
993    frame.add_child(Arc::new(Spacer::new(1)));
994    frame.add_child(extension_dialog_title(title, false));
995    frame.add_child(Arc::new(Spacer::new(1)));
996    frame.add_child(editor.clone());
997    frame.add_child(Arc::new(Spacer::new(1)));
998    frame.add_child(extension_dialog_hint(
999        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1000    ));
1001    frame.add_child(Arc::new(Spacer::new(1)));
1002    frame.add_child(Arc::new(DynamicBorder::new()));
1003
1004    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1005    *ctx.state.active_extension_input.lock().unwrap() = None;
1006    ctx.editor_container.clear();
1007    ctx.editor_container.add_child(frame);
1008
1009    let state = ctx.state.clone();
1010    let ec = ctx.editor_container.clone();
1011    let original = ctx.editor.clone();
1012    let tui = ctx.tui.clone();
1013    let session_submit = session.clone();
1014    let command_submit = command_name.clone();
1015    let ctx_submit = ctx.clone();
1016    editor.on_submit(Arc::new(move |text| {
1017        let args = serde_json::json!({ "action": "edit", "text": text });
1018        let result = invoke_extension_command(
1019            &session_submit,
1020            &command_submit,
1021            &serde_json::to_string(&args).unwrap_or_default(),
1022        );
1023        close_extension_editor(&state, &ec, &original, &tui);
1024        handle_extension_ui_result(
1025            result,
1026            &ctx_submit,
1027            session_submit.clone(),
1028            command_submit.clone(),
1029        );
1030    }));
1031
1032    let state_cancel = ctx.state.clone();
1033    let ec_cancel = ctx.editor_container.clone();
1034    let original_cancel = ctx.editor.clone();
1035    let tui_cancel = ctx.tui.clone();
1036    let session_cancel = session.clone();
1037    let command_cancel = command_name.clone();
1038    let ctx_cancel = ctx.clone();
1039    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1040        let args = serde_json::json!({ "action": "cancel" });
1041        let result = invoke_extension_command(
1042            &session_cancel,
1043            &command_cancel,
1044            &serde_json::to_string(&args).unwrap_or_default(),
1045        );
1046        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1047        handle_extension_ui_result(
1048            result,
1049            &ctx_cancel,
1050            session_cancel.clone(),
1051            command_cancel.clone(),
1052        );
1053    }));
1054
1055    ctx.tui.set_focus(Some(editor));
1056    ctx.tui.request_render(false);
1057}
1058
1059fn close_extension_editor(
1060    state: &Arc<TuiState>,
1061    editor_container: &Arc<Container>,
1062    editor: &Arc<Editor>,
1063    tui: &Arc<TuiAltScreen>,
1064) {
1065    editor_container.clear();
1066    editor_container.add_child(editor.clone());
1067    state.autocomplete_container.clear();
1068    *state.active_extension_editor.lock().unwrap() = None;
1069    *state.active_extension_input.lock().unwrap() = None;
1070    *state.active_extension_cancel.lock().unwrap() = None;
1071    editor.set_focused(true);
1072    tui.set_focus(Some(editor.clone()));
1073    tui.request_render(false);
1074}
1075
1076/// Sentinel option value that switches a freeform-capable selector into the
1077/// single-line input slot (native `allowFreeform` affordance).
1078const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1079
1080/// One in-flight `ask_user` request being shown in the TUI. Unlike
1081/// [`JsDialogBridge`], the plugin side is a synchronous `poll` loop that reads
1082/// answers back through the shared [`rpi_extensions::UiDialogMailbox`], so this
1083/// bridge only tracks which request id currently occupies the input slot.
1084#[derive(Clone)]
1085struct AskUserBridge {
1086    mailbox: rpi_extensions::UiDialogMailbox,
1087    visible: Arc<Mutex<Option<String>>>,
1088}
1089
1090impl AskUserBridge {
1091    fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1092        Self {
1093            mailbox,
1094            visible: Arc::new(Mutex::new(None)),
1095        }
1096    }
1097
1098    /// Mark the interactive consumer as available. Idempotent.
1099    fn attach(&self) {
1100        self.mailbox.attach();
1101    }
1102
1103    /// Take the next pending request and mark it visible. Only the single key
1104    /// loop calls this while no other dialog is open, so the visible-id
1105    /// bookkeeping is race-free without a separate lock.
1106    fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1107        let mut visible = self.visible.lock().ok()?;
1108        if visible.is_some() {
1109            return None;
1110        }
1111        let request = self.mailbox.take_pending()?;
1112        *visible = Some(request.request_id.clone());
1113        Some(request)
1114    }
1115
1116    fn clear_visible(&self, id: &str) {
1117        if let Ok(mut visible) = self.visible.lock() {
1118            if visible.as_deref() == Some(id) {
1119                *visible = None;
1120            }
1121        }
1122    }
1123
1124    /// Record an answer for `id` and release the input slot.
1125    fn respond(&self, id: &str, answer: serde_json::Value) {
1126        let _ = self.mailbox.respond(id, answer);
1127        self.clear_visible(id);
1128    }
1129
1130    /// Cancel `id` and release the input slot.
1131    fn cancel(&self, id: &str) {
1132        let _ = self.mailbox.cancel(id);
1133        self.clear_visible(id);
1134    }
1135
1136    /// Cancel every outstanding request (run abort). Pending prompts from the
1137    /// aborted turn must not surface in a later turn.
1138    fn cancel_all(&self) {
1139        self.mailbox.cancel_all();
1140        if let Ok(mut visible) = self.visible.lock() {
1141            *visible = None;
1142        }
1143    }
1144
1145    /// Detach + cancel everything (TUI shutdown) so a parked plugin `poll`
1146    /// observes a terminal state instead of blocking on a dead session.
1147    fn shutdown(&self) {
1148        self.mailbox.detach();
1149        if let Ok(mut visible) = self.visible.lock() {
1150            *visible = None;
1151        }
1152    }
1153}
1154
1155/// Parsed presentation data for one `ask_user` question. Supports both the
1156/// flat native question contract and the earlier `questions[0]` alias, plus a
1157/// `confirm` summary.
1158#[derive(Clone)]
1159struct AskUserPrompt {
1160    question_id: String,
1161    question: String,
1162    header: Option<String>,
1163    context: Option<String>,
1164    options: Vec<(String, Option<String>)>,
1165    allow_multiple: bool,
1166    allow_freeform: bool,
1167    suggest: Option<String>,
1168    kind: String,
1169}
1170
1171fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1172    keys.iter().find_map(|key| {
1173        value
1174            .get(*key)
1175            .and_then(serde_json::Value::as_str)
1176            .map(str::trim)
1177            .filter(|text| !text.is_empty())
1178            .map(str::to_string)
1179    })
1180}
1181
1182fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1183    keys.iter()
1184        .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1185}
1186
1187fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1188    let mut options = Vec::new();
1189    let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1190        return options;
1191    };
1192    for item in items {
1193        if let Some(title) = item.as_str() {
1194            let title = title.trim();
1195            if !title.is_empty() {
1196                options.push((title.to_string(), None));
1197            }
1198            continue;
1199        }
1200        let Some(object) = item.as_object() else {
1201            continue;
1202        };
1203        let title = ["title", "label", "text", "value", "name"]
1204            .iter()
1205            .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1206            .map(str::trim)
1207            .filter(|title| !title.is_empty());
1208        let Some(title) = title else { continue };
1209        let description = object
1210            .get("description")
1211            .and_then(serde_json::Value::as_str)
1212            .map(str::trim)
1213            .filter(|text| !text.is_empty())
1214            .map(str::to_string);
1215        options.push((title.to_string(), description));
1216    }
1217    options
1218}
1219
1220fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1221    let ui = &request.ui;
1222    let kind = ui
1223        .get("kind")
1224        .and_then(serde_json::Value::as_str)
1225        .unwrap_or("selector")
1226        .to_string();
1227    // A request may carry a single flat question or a `questions[]` array. The
1228    // plugin emits one question per request, but accepting `questions[0]` keeps
1229    // the host compatible with hosts/plugins that batch.
1230    let nested = ui
1231        .get("questions")
1232        .and_then(serde_json::Value::as_array)
1233        .and_then(|items| items.first());
1234    let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1235
1236    let question = ask_user_str(source, &["question", "summary", "title"])
1237        .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1238        .unwrap_or_default();
1239    let question_id = ask_user_str(source, &["id", "questionId"])
1240        .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1241        .unwrap_or_else(|| request.request_id.clone());
1242    let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1243    let context = ask_user_str(source, &["context", "message"])
1244        .or_else(|| ask_user_str(ui, &["context", "message"]));
1245    let mut options = ask_user_options(source);
1246    if options.is_empty() {
1247        options = ask_user_options(ui);
1248    }
1249    if kind == "confirm" && options.is_empty() {
1250        options = vec![("Yes".to_string(), None), ("No".to_string(), None)];
1251    }
1252    let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1253        .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1254        .unwrap_or(false);
1255    // Freeform defaults on when there is nothing to pick from, matching the
1256    // "no options ⇒ free input" contract.
1257    let allow_freeform =
1258        ask_user_bool(source, &["allowFreeform", "allow_freeform", "allow_custom"])
1259            .or_else(|| ask_user_bool(ui, &["allowFreeform", "allow_freeform", "allow_custom"]))
1260            .unwrap_or(options.is_empty());
1261    let suggest = ask_user_str(source, &["suggest", "placeholder"])
1262        .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1263
1264    AskUserPrompt {
1265        question_id,
1266        question,
1267        header,
1268        context,
1269        options,
1270        allow_multiple,
1271        allow_freeform,
1272        suggest,
1273        kind,
1274    }
1275}
1276
1277/// Build the shared header frame for an ask-user prompt.
1278/// Uses markdown-aware rendering for question and context content.
1279fn ask_user_frame(prompt: &AskUserPrompt, body: Arc<dyn Component>, hint: &str) -> Arc<Container> {
1280    let frame = Arc::new(Container::new());
1281    frame.add_child(Arc::new(DynamicBorder::new()));
1282    frame.add_child(Arc::new(Spacer::new(1)));
1283    if let Some(header) = prompt.header.as_deref() {
1284        frame.add_child(extension_dialog_title_md(header, true));
1285    }
1286    if !prompt.question.trim().is_empty() {
1287        frame.add_child(extension_dialog_title_md(
1288            &prompt.question,
1289            prompt.header.is_none(),
1290        ));
1291    }
1292    if let Some(context) = prompt.context.as_deref() {
1293        frame.add_child(extension_dialog_hint_md(context));
1294    }
1295    frame.add_child(Arc::new(Spacer::new(1)));
1296    frame.add_child(body);
1297    frame.add_child(Arc::new(Spacer::new(1)));
1298    frame.add_child(extension_dialog_hint(hint));
1299    frame.add_child(Arc::new(Spacer::new(1)));
1300    frame.add_child(Arc::new(DynamicBorder::new()));
1301    frame
1302}
1303
1304/// Open one `ask_user` request in the TUI input slot.
1305fn open_ask_user_dialog(
1306    ctx: &CommandContext,
1307    bridge: AskUserBridge,
1308    request: rpi_extensions::UiDialogRequest,
1309) {
1310    let prompt = parse_ask_user_prompt(&request);
1311    if prompt.kind == "input" || prompt.options.is_empty() {
1312        open_ask_user_input(ctx, bridge, request, prompt);
1313    } else {
1314        open_ask_user_selector(ctx, bridge, request, prompt);
1315    }
1316}
1317
1318fn open_ask_user_selector(
1319    ctx: &CommandContext,
1320    bridge: AskUserBridge,
1321    request: rpi_extensions::UiDialogRequest,
1322    prompt: AskUserPrompt,
1323) {
1324    let multi = prompt.allow_multiple && prompt.kind != "confirm";
1325    let mut items: Vec<SelectItem> = Vec::new();
1326    for (title, description) in &prompt.options {
1327        let item = SelectItem::new(title, title);
1328        let item = match description.as_deref() {
1329            Some(description) => item.with_description(description),
1330            None => item,
1331        };
1332        items.push(item);
1333    }
1334    // A freeform-capable single selector gets an extra sentinel row that opens
1335    // the input slot for a typed answer.
1336    if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
1337        items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
1338    }
1339    let list = if multi {
1340        Arc::new(SelectList::new_multi(items, 10))
1341    } else {
1342        Arc::new(SelectList::new(items, 10))
1343    };
1344
1345    let request_id = request.request_id.clone();
1346    let state = ctx.state.clone();
1347    let editor_container = ctx.editor_container.clone();
1348    let editor = ctx.editor.clone();
1349    let tui = ctx.tui.clone();
1350
1351    // Single-select: respond with the chosen title, or fall through to the
1352    // input slot for the freeform sentinel.
1353    let bridge_select = bridge.clone();
1354    let request_for_freeform = request.clone();
1355    let prompt_for_freeform = AskUserPrompt {
1356        question_id: prompt.question_id.clone(),
1357        question: prompt.question.clone(),
1358        header: prompt.header.clone(),
1359        context: prompt.context.clone(),
1360        options: Vec::new(),
1361        allow_multiple: false,
1362        allow_freeform: true,
1363        suggest: prompt.suggest.clone(),
1364        kind: "input".to_string(),
1365    };
1366    let state_select = state.clone();
1367    let ec_select = editor_container.clone();
1368    let editor_select = editor.clone();
1369    let tui_select = tui.clone();
1370    let ctx_select = ctx.clone();
1371    list.on_select(Arc::new(move |item: &SelectItem| {
1372        if item.value == ASK_USER_FREEFORM {
1373            close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1374            open_ask_user_input(
1375                &ctx_select,
1376                bridge_select.clone(),
1377                request_for_freeform.clone(),
1378                prompt_for_freeform.clone(),
1379            );
1380            return;
1381        }
1382        bridge_select.respond(
1383            &request_id,
1384            serde_json::json!({
1385                "values": [item.display_value()],
1386                "kind": "selector",
1387            }),
1388        );
1389        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1390    }));
1391
1392    if multi {
1393        let bridge_multi = bridge.clone();
1394        let request_id_multi = request.request_id.clone();
1395        let state_multi = state.clone();
1396        let ec_multi = editor_container.clone();
1397        let editor_multi = editor.clone();
1398        let tui_multi = tui.clone();
1399        list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
1400            let values: Vec<String> = selected
1401                .iter()
1402                .map(|item| item.display_value().to_string())
1403                .collect();
1404            bridge_multi.respond(
1405                &request_id_multi,
1406                serde_json::json!({"values": values, "kind": "selector"}),
1407            );
1408            close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
1409        }));
1410    }
1411
1412    let bridge_cancel = bridge.clone();
1413    let request_id_cancel = request.request_id.clone();
1414    let state_cancel = state.clone();
1415    let ec_cancel = editor_container.clone();
1416    let editor_cancel = editor.clone();
1417    let tui_cancel = tui.clone();
1418    list.on_cancel(Arc::new(move || {
1419        bridge_cancel.cancel(&request_id_cancel);
1420        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1421    }));
1422
1423    let hint = if multi {
1424        "Space toggle · Enter submit · Esc cancel"
1425    } else {
1426        "Enter select · Esc cancel"
1427    };
1428    let frame = ask_user_frame(&prompt, list.clone(), hint);
1429    open_selector_with_view(
1430        &state,
1431        &editor_container,
1432        &editor,
1433        &tui,
1434        SelectorView::List(list),
1435        frame,
1436        SelectorKind::Extension,
1437    );
1438}
1439
1440fn open_ask_user_input(
1441    ctx: &CommandContext,
1442    bridge: AskUserBridge,
1443    request: rpi_extensions::UiDialogRequest,
1444    prompt: AskUserPrompt,
1445) {
1446    let placeholder = prompt
1447        .suggest
1448        .clone()
1449        .unwrap_or_else(|| "Type your answer…".to_string());
1450    let input = Arc::new(Input::with_placeholder(&placeholder));
1451    input.set_focused(true);
1452
1453    let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
1454    *ctx.state.active_extension_editor.lock().unwrap() = None;
1455    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1456    ctx.editor_container.clear();
1457    ctx.editor_container.add_child(frame);
1458    ctx.tui.set_focus(Some(input.clone()));
1459    ctx.tui.request_render(false);
1460
1461    let state = ctx.state.clone();
1462    let ec = ctx.editor_container.clone();
1463    let editor = ctx.editor.clone();
1464    let tui = ctx.tui.clone();
1465    let bridge_submit = bridge.clone();
1466    let request_id = request.request_id.clone();
1467    input.on_submit(Arc::new(move |text: &str| {
1468        bridge_submit.respond(
1469            &request_id,
1470            serde_json::json!({
1471                "values": [text],
1472                "value": text,
1473                "text": text,
1474                "kind": "input",
1475            }),
1476        );
1477        close_extension_editor(&state, &ec, &editor, &tui);
1478    }));
1479
1480    let state_cancel = ctx.state.clone();
1481    let ec_cancel = ctx.editor_container.clone();
1482    let editor_cancel = ctx.editor.clone();
1483    let tui_cancel = ctx.tui.clone();
1484    let bridge_cancel = bridge.clone();
1485    let request_id_cancel = request.request_id.clone();
1486    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1487        bridge_cancel.cancel(&request_id_cancel);
1488        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1489    }));
1490}
1491
1492// ---- Built-in command implementations ----
1493
1494struct HelpCommand;
1495impl SlashCommand for HelpCommand {
1496    fn name(&self) -> &'static str {
1497        "/help"
1498    }
1499    fn aliases(&self) -> &'static [&'static str] {
1500        &["/?"]
1501    }
1502    fn description(&self) -> &'static str {
1503        "Show available commands"
1504    }
1505    fn execute(&self, ctx: &CommandContext, _args: &str) {
1506        add_help_message(&ctx.chat);
1507        ctx.tui.request_render(false);
1508    }
1509}
1510
1511struct ClearChatCommand;
1512impl SlashCommand for ClearChatCommand {
1513    fn name(&self) -> &'static str {
1514        "/clear"
1515    }
1516    fn aliases(&self) -> &'static [&'static str] {
1517        &["/new"]
1518    }
1519    // `/new` carries its own weight as a discoverable entry, so surface it.
1520    fn alias_visible(&self) -> &'static [&'static str] {
1521        &["/new"]
1522    }
1523    fn description(&self) -> &'static str {
1524        "Clear the conversation"
1525    }
1526    fn execute(&self, ctx: &CommandContext, _args: &str) {
1527        let _ = ctx.tx.send(TuiMessage::ClearChat);
1528    }
1529}
1530
1531struct ExitCommand;
1532impl SlashCommand for ExitCommand {
1533    fn name(&self) -> &'static str {
1534        "/exit"
1535    }
1536    fn aliases(&self) -> &'static [&'static str] {
1537        &["/quit", "/q"]
1538    }
1539    // `/quit` is surfaced (matches pi's BUILTIN list); `/q` stays a hidden alias.
1540    fn alias_visible(&self) -> &'static [&'static str] {
1541        &["/quit"]
1542    }
1543    fn description(&self) -> &'static str {
1544        "Exit the application"
1545    }
1546    fn execute(&self, ctx: &CommandContext, _args: &str) {
1547        ctx.state.cancel_js_preparation();
1548        let _ = ctx.tx.send(TuiMessage::Exit);
1549    }
1550}
1551
1552struct VersionCommand;
1553impl SlashCommand for VersionCommand {
1554    fn name(&self) -> &'static str {
1555        "/version"
1556    }
1557    fn aliases(&self) -> &'static [&'static str] {
1558        &["/v"]
1559    }
1560    fn description(&self) -> &'static str {
1561        "Show version information"
1562    }
1563    fn execute(&self, ctx: &CommandContext, _args: &str) {
1564        add_version_message(&ctx.chat);
1565        ctx.tui.request_render(false);
1566    }
1567}
1568
1569struct ChangelogCommand;
1570impl SlashCommand for ChangelogCommand {
1571    fn name(&self) -> &'static str {
1572        "/changelog"
1573    }
1574    fn description(&self) -> &'static str {
1575        "Show recent release changes"
1576    }
1577    fn execute(&self, ctx: &CommandContext, _args: &str) {
1578        add_changelog_message(&ctx.chat);
1579        ctx.tui.request_render(false);
1580    }
1581}
1582
1583struct HotkeysCommand;
1584impl SlashCommand for HotkeysCommand {
1585    fn name(&self) -> &'static str {
1586        "/hotkeys"
1587    }
1588    fn description(&self) -> &'static str {
1589        "Show keyboard shortcuts"
1590    }
1591    fn execute(&self, ctx: &CommandContext, _args: &str) {
1592        add_hotkeys_message(&ctx.chat);
1593        ctx.tui.request_render(false);
1594    }
1595}
1596
1597struct ModelCommand;
1598impl SlashCommand for ModelCommand {
1599    fn name(&self) -> &'static str {
1600        "/model"
1601    }
1602    fn aliases(&self) -> &'static [&'static str] {
1603        &["/m"]
1604    }
1605    fn description(&self) -> &'static str {
1606        "Choose a model (selector)"
1607    }
1608    fn execute(&self, ctx: &CommandContext, args: &str) {
1609        let term = args.trim();
1610        if !term.is_empty() {
1611            // /model <name> — direct switch by id (pi handleModelCommand).
1612            //
1613            // upstream only switches on an EXACT match; a term that matches
1614            // nothing falls through to the selector with the query prefilled
1615            // instead of erroring out (`showModelSelector(searchTerm)`).
1616            if let Some(model) = find_model_selector_match(&ctx.model_catalog, term) {
1617                let model_id = model.id.clone();
1618                ctx.state.set_current_model(&model);
1619                let lane = ctx.lane.clone();
1620                tokio::spawn(async move {
1621                    let _ = lane.set_model(model).await;
1622                });
1623                add_note_message(
1624                    &ctx.chat,
1625                    &format!(
1626                        "Model set to {} — applies to the next message.",
1627                        short_model_name(&model_id)
1628                    ),
1629                );
1630                ctx.tui.request_render(false);
1631                return;
1632            }
1633        }
1634        open_model_selector(
1635            &ctx.state,
1636            &ctx.editor_container,
1637            &ctx.editor,
1638            &ctx.tui,
1639            &ctx.model_catalog,
1640            &ctx.lane,
1641            &ctx.lane_model_id,
1642            &ctx.chat,
1643            (!term.is_empty()).then_some(term),
1644        );
1645    }
1646}
1647
1648struct ThinkingCommand;
1649impl SlashCommand for ThinkingCommand {
1650    fn name(&self) -> &'static str {
1651        "/thinking"
1652    }
1653    fn aliases(&self) -> &'static [&'static str] {
1654        &["/think"]
1655    }
1656    fn description(&self) -> &'static str {
1657        "Set thinking level (selector)"
1658    }
1659    fn execute(&self, ctx: &CommandContext, args: &str) {
1660        let level_name = args.trim();
1661        if !level_name.is_empty() {
1662            // /thinking <level> — direct set (pi supports the param form).
1663            let Some(level) = thinking_level_from_name(level_name) else {
1664                add_error_message(
1665                    &ctx.chat,
1666                    &format!(
1667                        "Unknown thinking level \"{level_name}\". Valid: {}",
1668                        crate::args::VALID_THINKING_LEVELS.join(", ")
1669                    ),
1670                );
1671                ctx.tui.request_render(false);
1672                return;
1673            };
1674            let lane = ctx.lane.clone();
1675            let footer = ctx.state.footer.clone();
1676            tokio::spawn(async move {
1677                let _ = lane.set_thinking_level(level).await;
1678            });
1679            footer.set_thinking_level(Some(thinking_level_name(level)));
1680            add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
1681            ctx.tui.request_render(false);
1682            return;
1683        }
1684        open_thinking_selector(
1685            &ctx.state,
1686            &ctx.editor_container,
1687            &ctx.editor,
1688            &ctx.tui,
1689            &ctx.lane,
1690            &ctx.model_catalog,
1691            &ctx.lane_model_id,
1692            &ctx.chat,
1693        );
1694    }
1695}
1696
1697struct ToolsCommand;
1698impl SlashCommand for ToolsCommand {
1699    fn name(&self) -> &'static str {
1700        "/tools"
1701    }
1702    fn description(&self) -> &'static str {
1703        "Toggle tools on/off"
1704    }
1705    fn execute(&self, ctx: &CommandContext, _args: &str) {
1706        open_tools_selector(
1707            &ctx.state,
1708            &ctx.editor_container,
1709            &ctx.editor,
1710            &ctx.tui,
1711            &ctx.lane,
1712            &ctx.chat,
1713        );
1714    }
1715}
1716
1717struct ImagesCommand;
1718impl SlashCommand for ImagesCommand {
1719    fn name(&self) -> &'static str {
1720        "/images"
1721    }
1722    fn description(&self) -> &'static str {
1723        "Toggle inline images"
1724    }
1725    fn execute(&self, ctx: &CommandContext, _args: &str) {
1726        open_images_selector(
1727            &ctx.state,
1728            &ctx.editor_container,
1729            &ctx.editor,
1730            &ctx.tui,
1731            &ctx.chat,
1732        );
1733    }
1734}
1735
1736struct SessionCommand;
1737impl SlashCommand for SessionCommand {
1738    fn name(&self) -> &'static str {
1739        "/session"
1740    }
1741    fn aliases(&self) -> &'static [&'static str] {
1742        &["/resume"]
1743    }
1744    fn description(&self) -> &'static str {
1745        "List saved sessions"
1746    }
1747    fn execute(&self, ctx: &CommandContext, _args: &str) {
1748        open_session_selector(
1749            &ctx.state,
1750            &ctx.editor_container,
1751            &ctx.editor,
1752            &ctx.tui,
1753            &ctx.cwd,
1754            &ctx.tx,
1755        );
1756    }
1757}
1758
1759struct ThemeCommand;
1760impl SlashCommand for ThemeCommand {
1761    fn name(&self) -> &'static str {
1762        "/theme"
1763    }
1764    fn description(&self) -> &'static str {
1765        "Choose a theme (selector)"
1766    }
1767    fn execute(&self, ctx: &CommandContext, args: &str) {
1768        let name = args.trim().to_ascii_lowercase();
1769        if !name.is_empty() {
1770            // /theme <name> — direct apply + persist (matches /settings Theme).
1771            let preset = match name.as_str() {
1772                "light" => ThemePreset::Light,
1773                "monochrome" => ThemePreset::Monochrome,
1774                "dark" => ThemePreset::Dark,
1775                _ => {
1776                    add_error_message(
1777                        &ctx.chat,
1778                        &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
1779                    );
1780                    ctx.tui.request_render(false);
1781                    return;
1782                }
1783            };
1784            apply_theme_preset(preset);
1785            let mut settings = crate::settings::load_settings().unwrap_or_default();
1786            settings.theme = Some(name.clone());
1787            let _ = crate::settings::save_settings(&settings);
1788            add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
1789            ctx.tui.request_render(false);
1790            ctx.tui.render_now(true);
1791            return;
1792        }
1793        open_theme_selector(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1794    }
1795}
1796
1797struct CompactCommand;
1798impl SlashCommand for CompactCommand {
1799    fn name(&self) -> &'static str {
1800        "/compact"
1801    }
1802    fn description(&self) -> &'static str {
1803        "Compact the conversation"
1804    }
1805    fn execute(&self, ctx: &CommandContext, _args: &str) {
1806        let _ = ctx.tx.send(TuiMessage::Compact);
1807    }
1808}
1809
1810struct CopyCommand;
1811impl SlashCommand for CopyCommand {
1812    fn name(&self) -> &'static str {
1813        "/copy"
1814    }
1815    fn description(&self) -> &'static str {
1816        "Copy last reply to clipboard"
1817    }
1818    fn execute(&self, ctx: &CommandContext, _args: &str) {
1819        let _ = ctx.tx.send(TuiMessage::Copy);
1820    }
1821}
1822
1823struct ExportCommand;
1824impl SlashCommand for ExportCommand {
1825    fn name(&self) -> &'static str {
1826        "/export"
1827    }
1828    fn description(&self) -> &'static str {
1829        "Export session (supports: md, html, jsonl)"
1830    }
1831    fn execute(&self, ctx: &CommandContext, args: &str) {
1832        let format = args.trim().to_lowercase();
1833        if format.is_empty() || format == "md" || format == "markdown" {
1834            let _ = ctx.tx.send(TuiMessage::ExportSession);
1835        } else if format == "html" {
1836            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
1837                crate::export::ExportFormat::Html,
1838            ));
1839        } else if format == "jsonl" {
1840            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
1841                crate::export::ExportFormat::Jsonl,
1842            ));
1843        } else {
1844            // Invalid format, show error
1845            add_error_message(
1846                &ctx.chat,
1847                &format!(
1848                    "Unknown export format: {}. Supported: md, html, jsonl",
1849                    args.trim()
1850                ),
1851            );
1852            ctx.tui.request_render(false);
1853        }
1854    }
1855}
1856
1857struct ForkCommand;
1858impl SlashCommand for ForkCommand {
1859    fn name(&self) -> &'static str {
1860        "/fork"
1861    }
1862    fn description(&self) -> &'static str {
1863        "Fork the session into a new one"
1864    }
1865    fn execute(&self, ctx: &CommandContext, _args: &str) {
1866        let _ = ctx.tx.send(TuiMessage::ForkSession);
1867    }
1868}
1869
1870/// `/clone` is the upstream spelling for duplicating the current session.
1871/// Reuse the same durable fork path as `/fork`; both create a child session
1872/// and rebind the live harness to it.
1873struct CloneCommand;
1874impl SlashCommand for CloneCommand {
1875    fn name(&self) -> &'static str {
1876        "/clone"
1877    }
1878    fn description(&self) -> &'static str {
1879        "Duplicate the current session"
1880    }
1881    fn execute(&self, ctx: &CommandContext, _args: &str) {
1882        let _ = ctx.tx.send(TuiMessage::ForkSession);
1883    }
1884}
1885
1886struct TreeCommand;
1887impl SlashCommand for TreeCommand {
1888    fn name(&self) -> &'static str {
1889        "/tree"
1890    }
1891    fn description(&self) -> &'static str {
1892        "Navigate the current session tree"
1893    }
1894    fn execute(&self, ctx: &CommandContext, _args: &str) {
1895        let _ = ctx.tx.send(TuiMessage::OpenTree);
1896    }
1897}
1898
1899struct LoginCommand;
1900impl SlashCommand for LoginCommand {
1901    fn name(&self) -> &'static str {
1902        "/login"
1903    }
1904    fn description(&self) -> &'static str {
1905        "Save an Anthropic API key"
1906    }
1907    fn execute(&self, ctx: &CommandContext, args: &str) {
1908        let key = args.trim();
1909        if key.is_empty() {
1910            add_note_message(&ctx.chat, "Usage: /login <api-key>");
1911        } else {
1912            let result = crate::config::upsert_credential(
1913                "anthropic",
1914                crate::config::Credential::ApiKey {
1915                    key: Some(key.to_string()),
1916                    env: None,
1917                },
1918            );
1919            match result {
1920                Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
1921                Err(error) => {
1922                    add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
1923                }
1924            }
1925        }
1926        ctx.tui.request_render(false);
1927    }
1928}
1929
1930struct LogoutCommand;
1931impl SlashCommand for LogoutCommand {
1932    fn name(&self) -> &'static str {
1933        "/logout"
1934    }
1935    fn description(&self) -> &'static str {
1936        "Remove saved Anthropic credentials"
1937    }
1938    fn execute(&self, ctx: &CommandContext, _args: &str) {
1939        match crate::config::delete_credential("anthropic") {
1940            Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
1941            Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
1942            Err(error) => {
1943                add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
1944            }
1945        }
1946        ctx.tui.request_render(false);
1947    }
1948}
1949
1950fn set_project_trust_for_command(
1951    cwd: &std::path::Path,
1952    value: Option<bool>,
1953) -> Result<(), crate::config::ConfigError> {
1954    crate::config::set_project_trust(cwd, value)
1955}
1956
1957struct TrustCommand;
1958impl SlashCommand for TrustCommand {
1959    fn name(&self) -> &'static str {
1960        "/trust"
1961    }
1962    fn description(&self) -> &'static str {
1963        "Trust the current project"
1964    }
1965    fn execute(&self, ctx: &CommandContext, args: &str) {
1966        let value = match args.trim().to_ascii_lowercase().as_str() {
1967            "" | "yes" | "y" | "true" => Some(true),
1968            "no" | "n" | "false" => Some(false),
1969            "clear" | "reset" | "none" => None,
1970            _ => {
1971                add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
1972                ctx.tui.request_render(false);
1973                return;
1974            }
1975        };
1976        match set_project_trust_for_command(&ctx.cwd, value) {
1977            Ok(()) => {
1978                let label = match value {
1979                    Some(true) => "trusted",
1980                    Some(false) => "untrusted",
1981                    None => "trust decision cleared",
1982                };
1983                add_note_message(&ctx.chat, &format!("Current project marked {label}."));
1984            }
1985            Err(error) => add_error_message(
1986                &ctx.chat,
1987                &format!("Could not save trust decision: {error}"),
1988            ),
1989        }
1990        ctx.tui.request_render(false);
1991    }
1992}
1993
1994struct NameCommand;
1995impl SlashCommand for NameCommand {
1996    fn name(&self) -> &'static str {
1997        "/name"
1998    }
1999    fn description(&self) -> &'static str {
2000        "Set session display name"
2001    }
2002    fn execute(&self, ctx: &CommandContext, args: &str) {
2003        let name = args.trim();
2004        if name.is_empty() {
2005            add_note_message(
2006                &ctx.chat,
2007                "Usage: /name <display name> — sets the current session's name.",
2008            );
2009            ctx.tui.request_render(false);
2010            return;
2011        }
2012        let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2013    }
2014}
2015
2016struct ImportCommand;
2017impl SlashCommand for ImportCommand {
2018    fn name(&self) -> &'static str {
2019        "/import"
2020    }
2021    fn description(&self) -> &'static str {
2022        "Import a session file (path)"
2023    }
2024    fn execute(&self, ctx: &CommandContext, args: &str) {
2025        let path = args.trim();
2026        if path.is_empty() {
2027            add_note_message(
2028                &ctx.chat,
2029                "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2030            );
2031            ctx.tui.request_render(false);
2032            return;
2033        }
2034        let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2035    }
2036}
2037
2038struct SettingsCommand;
2039impl SlashCommand for SettingsCommand {
2040    fn name(&self) -> &'static str {
2041        "/settings"
2042    }
2043    fn description(&self) -> &'static str {
2044        "Open settings menu"
2045    }
2046    fn execute(&self, ctx: &CommandContext, _args: &str) {
2047        open_settings_selector(
2048            &ctx.state,
2049            &ctx.editor_container,
2050            &ctx.editor,
2051            &ctx.tui,
2052            &ctx.lane,
2053            &ctx.model_catalog,
2054            &ctx.lane_model_id,
2055            &ctx.chat,
2056        );
2057    }
2058}
2059
2060struct ScopedModelsCommand;
2061impl SlashCommand for ScopedModelsCommand {
2062    fn name(&self) -> &'static str {
2063        "/scoped-models"
2064    }
2065    fn description(&self) -> &'static str {
2066        "Choose models for Ctrl+P cycling"
2067    }
2068    fn execute(&self, ctx: &CommandContext, _args: &str) {
2069        open_scoped_models_selector(
2070            &ctx.state,
2071            &ctx.editor_container,
2072            &ctx.editor,
2073            &ctx.tui,
2074            &ctx.model_catalog,
2075            &ctx.chat,
2076        );
2077    }
2078}
2079
2080struct ShareCommand;
2081impl SlashCommand for ShareCommand {
2082    fn name(&self) -> &'static str {
2083        "/share"
2084    }
2085    fn description(&self) -> &'static str {
2086        "Share session (gist via gh, or clipboard)"
2087    }
2088    fn execute(&self, ctx: &CommandContext, _args: &str) {
2089        let _ = ctx.tx.send(TuiMessage::ShareSession);
2090    }
2091}
2092
2093/// `/context` — lists discovered context files, skills, and prompt templates.
2094/// Hidden from autocomplete (needs the resources snapshot to be meaningful as a
2095/// discovery surface; like `/name`, it's recognized-v1 but kept off the list).
2096struct ContextCommand;
2097impl SlashCommand for ContextCommand {
2098    fn name(&self) -> &'static str {
2099        "/context"
2100    }
2101    fn visible(&self) -> bool {
2102        false
2103    }
2104    fn execute(&self, ctx: &CommandContext, _args: &str) {
2105        show_context_panel(&ctx.chat, &ctx.resources);
2106        // Live session stats are async, so ask the main loop to append them.
2107        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2108        ctx.tui.request_render(false);
2109    }
2110}
2111
2112/// `/usage` — token/cost/cache totals read through the product-layer
2113/// `AgentSession`. This is the same numbers the footer shows, but expanded and
2114/// with the cache-hit breakdown that the one-line footer cannot fit.
2115struct UsageCommand;
2116impl SlashCommand for UsageCommand {
2117    fn name(&self) -> &'static str {
2118        "/usage"
2119    }
2120    fn description(&self) -> &'static str {
2121        "Show token, cost, and cache totals for this session"
2122    }
2123    fn execute(&self, ctx: &CommandContext, _args: &str) {
2124        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2125        ctx.tui.request_render(false);
2126    }
2127}
2128
2129/// `/reload` — re-run extension + resource discovery into the LIVE harness
2130/// (B5d): reload the cdylib plugins, invalidate the old `ActionBridge` +
2131/// registry snapshot, rebuild skills/prompts/context/SYSTEM.md/APPEND_SYSTEM.md
2132/// + the `TeeEmitter`, and push the rebuilt state via the B5d harness setters.
2133/// The command itself runs on the blocking submit thread, so it can't drive
2134/// the async `reload_extension_resources` routine directly — it signals the main
2135/// loop via `TuiMessage::ReloadExtensions`, which awaits it on the async runtime.
2136/// (A plugin's `runtime_action(Reload)` signals the same loop via the
2137/// `ReloadMailbox` the TUI installs — the B5d async-reload design avoids the
2138/// self-unmapping race a synchronous plugin-initiated reload would have.)
2139struct ReloadCommand;
2140impl SlashCommand for ReloadCommand {
2141    fn name(&self) -> &'static str {
2142        "/reload"
2143    }
2144    fn description(&self) -> &'static str {
2145        "Reload extensions, skills, prompts"
2146    }
2147    fn execute(&self, ctx: &CommandContext, _args: &str) {
2148        // Signal the main loop. It owns the `&AgentHarness` borrow the
2149        // `reload_extension_resources` routine needs (the blocking submit thread
2150        // only has the context's `Arc<ReloadContext>` + the `Arc<dyn AgentLane>`).
2151        add_note_message(&ctx.chat, "Reloading extensions + resources…");
2152        ctx.tui.request_render(false);
2153        let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2154    }
2155}
2156
2157/// Build the full command registry: active built-ins first (so they win on a
2158/// fuzzy autocomplete tie), then the v1-out-of-scope stubs. Prompt-template
2159/// commands are merged in separately by the autocomplete builder (they dispatch
2160/// via template expansion, not this registry).
2161fn build_builtin_registry() -> CommandRegistry {
2162    let mut r = CommandRegistry::new();
2163    r.register(Arc::new(HelpCommand));
2164    r.register(Arc::new(ClearChatCommand));
2165    r.register(Arc::new(ExitCommand));
2166    r.register(Arc::new(VersionCommand));
2167    r.register(Arc::new(ChangelogCommand));
2168    r.register(Arc::new(ModelCommand));
2169    r.register(Arc::new(ThinkingCommand));
2170    r.register(Arc::new(ToolsCommand));
2171    r.register(Arc::new(ImagesCommand));
2172    r.register(Arc::new(SessionCommand));
2173    r.register(Arc::new(ThemeCommand));
2174    r.register(Arc::new(CompactCommand));
2175    r.register(Arc::new(CopyCommand));
2176    r.register(Arc::new(HotkeysCommand));
2177    r.register(Arc::new(ContextCommand));
2178    r.register(Arc::new(UsageCommand));
2179    // Recognized but inert in v1 (one struct backs them all). The TS builtins
2180    // out of v1 scope; each carries a description so autocomplete surfaces its
2181    // existence even though running it reports "not supported".
2182    r.register(Arc::new(NameCommand));
2183    r.register(Arc::new(SettingsCommand));
2184    r.register(Arc::new(ScopedModelsCommand));
2185    r.register(Arc::new(ExportCommand));
2186    r.register(Arc::new(ImportCommand));
2187    r.register(Arc::new(ShareCommand));
2188    r.register(Arc::new(ForkCommand));
2189    r.register(Arc::new(CloneCommand));
2190    r.register(Arc::new(TreeCommand));
2191    r.register(Arc::new(TrustCommand));
2192    r.register(Arc::new(LoginCommand));
2193    r.register(Arc::new(LogoutCommand));
2194    r.register(Arc::new(ReloadCommand));
2195    r
2196}
2197
2198fn register_extension_commands(
2199    registry: &mut CommandRegistry,
2200    session: crate::session::ExtensionSessionCell,
2201) {
2202    let commands = session
2203        .lock()
2204        .ok()
2205        .and_then(|s| s.snapshot_arc())
2206        .map(|snap| snap.commands().to_vec())
2207        .unwrap_or_default();
2208    for command in commands {
2209        let name = if command.name.starts_with('/') {
2210            command.name.clone()
2211        } else {
2212            format!("/{}", command.name)
2213        };
2214        if registry.find(&name).is_some() {
2215            continue;
2216        }
2217        registry.register(Arc::new(ExtensionCommand {
2218            name,
2219            description: command.description,
2220            session: session.clone(),
2221        }));
2222    }
2223}
2224
2225// ===========================================================================
2226// Channel + helpers
2227// ===========================================================================
2228
2229/// Message type for communication between the key/callback threads and the
2230/// main async loop.
2231enum TuiMessage {
2232    UserInput(String),
2233    OpenTree,
2234    NavigateTree(String),
2235    Exit,
2236    /// Clear the transcript (from `/clear`).
2237    ClearChat,
2238    /// Compact the conversation (from `/compact`).
2239    Compact,
2240    /// Copy the last assistant reply to the clipboard (from `/copy`).
2241    Copy,
2242    /// Hot-switch to another saved session (from the `/session` selector):
2243    /// the payload is the session id the selector's item value carried.
2244    SwitchSession(String),
2245    /// Export the current session to a markdown file (from `/export`).
2246    ExportSession,
2247    /// Export session with specific format
2248    ExportSessionWithFormat(crate::export::ExportFormat),
2249    /// Show live usage/context stats (from `/usage` and `/context`).
2250    ShowUsage,
2251    /// Fork the current session into a new one and switch to it (from `/fork`).
2252    ForkSession,
2253    /// Rename the current session (from `/name <name>`).
2254    SetSessionName(String),
2255    /// Import a JSONL session file into the session dir and switch to it
2256    /// (from `/import <path>`).
2257    ImportSession(String),
2258    /// Share the current session (`/share`): `gh gist create` when the gh CLI
2259    /// is available, otherwise copy the transcript to the clipboard.
2260    ShareSession,
2261    /// `/reload` — re-run extension + resource discovery into the live harness
2262    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
2263    /// mailbox) signal the main loop, which awaits
2264    /// `reload_extension_resources` on the async runtime.
2265    ReloadExtensions,
2266    /// Result returned after Ctrl+G edits a temporary file in an external
2267    /// editor. Handling it on the async loop keeps editor mutation single-
2268    /// threaded with the rest of the TUI state.
2269    ExternalEditorResult(Result<String, String>),
2270    /// A user-initiated `!command` / `!!command` shell run finished. Routed to
2271    /// the main loop so the `bashExecution` transcript record is persisted in
2272    /// order relative to agent runs (upstream's `recordBashResult` /
2273    /// `_pendingBashMessages`).
2274    UserBashFinished(UserBashReport),
2275}
2276
2277/// Terminal result of a user-initiated `!command` shell run.
2278///
2279/// Carries everything needed to build the persisted `bashExecution` message
2280/// plus the `!!` context-exclusion flag. See `start_user_bash`.
2281struct UserBashReport {
2282    command: String,
2283    output: String,
2284    exit_code: Option<i32>,
2285    cancelled: bool,
2286    truncated: bool,
2287    full_output_path: Option<String>,
2288    exclude_from_context: bool,
2289}
2290
2291/// Extract the concatenated text content from an assistant message (mirrors
2292/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
2293fn assistant_text(msg: &AssistantMessage) -> String {
2294    msg.content
2295        .iter()
2296        .filter_map(|c| match c {
2297            Content::Text(t) => Some(t.text.clone()),
2298            _ => None,
2299        })
2300        .collect()
2301}
2302
2303/// The user message's text (Text content or the text blocks of a Blocks
2304/// payload — images are skipped, consistent with the v1 text-only prompt path).
2305fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
2306    msg.content
2307        .iter()
2308        .filter_map(|c| match c {
2309            Content::Text(t) => Some(t.text.clone()),
2310            _ => None,
2311        })
2312        .collect::<Vec<_>>()
2313        .join("\n")
2314}
2315
2316fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2317    match &msg.content {
2318        rpi_ai::types::UserContent::Text(s) => s.clone(),
2319        rpi_ai::types::UserContent::Blocks(blocks) => blocks
2320            .iter()
2321            .filter_map(|c| match c {
2322                Content::Text(t) => Some(t.text.clone()),
2323                _ => None,
2324            })
2325            .collect(),
2326    }
2327}
2328
2329/// The catalog allowed in the Ctrl+P cycle: the `/scoped-models` set from
2330/// settings.json when present, otherwise every model. The current model is
2331/// always included (fallback) so cycling can never strand the user off-scope.
2332fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
2333    let scoped = crate::settings::load_settings()
2334        .ok()
2335        .and_then(|s| s.scoped_models)
2336        .unwrap_or_default();
2337    if scoped.is_empty() {
2338        return catalog.to_vec();
2339    }
2340    let mut out: Vec<rpi_ai::Model> = catalog
2341        .iter()
2342        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
2343        .cloned()
2344        .collect();
2345    // Never strand the user: if the current model isn't in scope, keep it.
2346    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
2347        if let Some(cur) = catalog
2348            .iter()
2349            .find(|m| m.id.eq_ignore_ascii_case(current_id))
2350        {
2351            out.push(cur.clone());
2352        }
2353    }
2354    out
2355}
2356
2357/// Interactive `/settings` menu.
2358///
2359/// A [`SettingsList`] of editable settings (upstream's `SettingsSelectorComponent`):
2360/// Enter/Space cycles a row's value in place, and the theme/model/thinking/scope
2361/// rows open a sub-selector that replaces the active selector (the
2362/// `active_selector` slot is single). Value changes apply immediately where the
2363/// running TUI can honor them and are always persisted to `settings.json`, so
2364/// rows marked "applies on restart" take effect next launch.
2365fn open_settings_selector(
2366    state: &Arc<TuiState>,
2367    editor_container: &Arc<Container>,
2368    editor: &Arc<Editor>,
2369    tui: &Arc<TuiAltScreen>,
2370    lane: &Arc<dyn AgentLane>,
2371    catalog: &[rpi_ai::Model],
2372    lane_model_id: &str,
2373    chat: &Arc<Container>,
2374) {
2375    let settings = crate::settings::load_settings().unwrap_or_default();
2376    let items = settings_menu_items(&settings, state, lane_model_id);
2377    let list = Arc::new(SettingsList::new(items));
2378
2379    // ---- submenu rows ----
2380    // These reuse the existing sub-selectors, which already persist their own
2381    // choices. The settings menu is replaced by the sub-selector (single
2382    // `active_selector` slot); cancelling the sub-selector returns to the editor.
2383    let state_sel = state.clone();
2384    let ec_sel = editor_container.clone();
2385    let editor_sel = editor.clone();
2386    let tui_sel = tui.clone();
2387    let lane_sel = lane.clone();
2388    let catalog_sel = catalog.to_vec();
2389    let lane_model_sel = lane_model_id.to_string();
2390    let chat_sel = chat.clone();
2391    list.on_select(Arc::new(move |item| match item.key.as_str() {
2392        "theme" => {
2393            open_settings_theme_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel, &chat_sel)
2394        }
2395        "model" => open_settings_model_selector(
2396            &state_sel,
2397            &ec_sel,
2398            &editor_sel,
2399            &tui_sel,
2400            &lane_sel,
2401            &catalog_sel,
2402            &lane_model_sel,
2403            &chat_sel,
2404        ),
2405        "thinking" => open_settings_thinking_selector(
2406            &state_sel,
2407            &ec_sel,
2408            &editor_sel,
2409            &tui_sel,
2410            &lane_sel,
2411            &catalog_sel,
2412            &lane_model_sel,
2413            &chat_sel,
2414        ),
2415        "scoped-models" => open_scoped_models_selector(
2416            &state_sel,
2417            &ec_sel,
2418            &editor_sel,
2419            &tui_sel,
2420            &catalog_sel,
2421            &chat_sel,
2422        ),
2423        _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
2424    }));
2425
2426    // ---- value rows ----
2427    // Apply live where possible, then persist so the choice survives a restart.
2428    let state_change = state.clone();
2429    let chat_change = chat.clone();
2430    let tui_change = tui.clone();
2431    list.on_change(Arc::new(move |key, value| {
2432        let mut settings = crate::settings::load_settings().unwrap_or_default();
2433        let applied = apply_setting_change(&state_change, key, value, &mut settings);
2434        let saved = crate::settings::save_settings(&settings);
2435        if let Some(note) = applied {
2436            let suffix = if saved.is_ok() { "" } else { " (not saved)" };
2437            add_note_message(&chat_change, &format!("{note}{suffix}"));
2438        }
2439        tui_change.request_render(false);
2440    }));
2441
2442    let state_cancel = state.clone();
2443    let ec_cancel = editor_container.clone();
2444    let editor_cancel = editor.clone();
2445    let tui_cancel = tui.clone();
2446    list.on_cancel(Arc::new(move || {
2447        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2448    }));
2449
2450    open_selector_with_view(
2451        state,
2452        editor_container,
2453        editor,
2454        tui,
2455        SelectorView::Settings(list.clone()),
2456        list,
2457        SelectorKind::Settings,
2458    );
2459}
2460
2461/// Build the `/settings` rows from the loaded settings plus the live TUI state.
2462///
2463/// Descriptions carry "(applies on restart)" for values resolved once at
2464/// startup, so the menu never implies an effect the running session cannot
2465/// apply.
2466fn settings_menu_items(
2467    settings: &crate::settings::Settings,
2468    state: &Arc<TuiState>,
2469    lane_model_id: &str,
2470) -> Vec<SettingItem> {
2471    let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
2472    let current_model = settings
2473        .default_model
2474        .clone()
2475        .unwrap_or_else(|| lane_model_id.to_string());
2476    let current_thinking = settings
2477        .default_thinking_level
2478        .clone()
2479        .unwrap_or_else(|| "(default)".to_string());
2480    let scoped_desc = match &settings.scoped_models {
2481        Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
2482        _ => "all models".to_string(),
2483    };
2484    let hide_thinking = state.hide_thinking();
2485    let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
2486    let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
2487    let http_idle = settings
2488        .http_idle_timeout_ms()
2489        .map(|ms| {
2490            if ms == 0 {
2491                "disabled".to_string()
2492            } else {
2493                format!("{}s", ms / 1000)
2494            }
2495        })
2496        .unwrap_or_else(|| "5m".to_string());
2497
2498    vec![
2499        SettingItem::new("theme", "Theme", &current_theme)
2500            .with_description("Color theme for the interface")
2501            .with_submenu(),
2502        SettingItem::new("model", "Default model", &current_model)
2503            .with_description("Saved default model for new sessions")
2504            .with_submenu(),
2505        SettingItem::new("thinking", "Default thinking", &current_thinking)
2506            .with_description("Saved default thinking level")
2507            .with_submenu(),
2508        SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
2509            .with_description("Models enabled for Ctrl+P cycling")
2510            .with_submenu(),
2511        SettingItem::new(
2512            "hide-thinking",
2513            "Hide thinking",
2514            bool_setting_value(hide_thinking),
2515        )
2516        .with_description("Hide thinking blocks in assistant responses")
2517        .with_values(&["true", "false"]),
2518        SettingItem::new(
2519            "show-images",
2520            "Show images",
2521            bool_setting_value(show_images),
2522        )
2523        .with_description("Render images inline in the terminal")
2524        .with_values(&["true", "false"]),
2525        SettingItem::new(
2526            "cache-miss-notices",
2527            "Cache miss notices",
2528            bool_setting_value(show_cache_miss),
2529        )
2530        .with_description("Transcript notices for prompt-cache costs")
2531        .with_values(&["true", "false"]),
2532        SettingItem::new(
2533            "quiet-startup",
2534            "Quiet startup",
2535            bool_setting_value(settings.quiet_startup.unwrap_or(false)),
2536        )
2537        .with_description("Disable the verbose startup listing (applies on restart)")
2538        .with_values(&["true", "false"]),
2539        SettingItem::new(
2540            "terminal-progress",
2541            "Terminal progress",
2542            bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
2543        )
2544        .with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
2545        .with_values(&["true", "false"]),
2546        SettingItem::new(
2547            "fullscreen-copy-on-select",
2548            "Fullscreen copy on select",
2549            bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
2550        )
2551        .with_description("Copy selected text automatically in fullscreen mode")
2552        .with_values(&["true", "false"]),
2553        SettingItem::new(
2554            "double-escape-action",
2555            "Double-escape action",
2556            settings
2557                .double_escape_action
2558                .clone()
2559                .unwrap_or_else(|| "tree".to_string())
2560                .as_str(),
2561        )
2562        .with_description("Esc Esc with an empty editor (applies on restart)")
2563        .with_values(&["tree", "fork", "none"]),
2564        SettingItem::new(
2565            "editor-padding",
2566            "Editor padding",
2567            settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
2568        )
2569        .with_description("Horizontal editor padding, 0-3 (applies on restart)")
2570        .with_values(&["0", "1", "2", "3"]),
2571        SettingItem::new(
2572            "autocomplete-max-items",
2573            "Autocomplete max items",
2574            settings
2575                .autocomplete_max_visible
2576                .unwrap_or(5)
2577                .to_string()
2578                .as_str(),
2579        )
2580        .with_description("Max autocomplete rows, 3-20 (applies on restart)")
2581        .with_values(&["3", "5", "7", "10", "15", "20"]),
2582        SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
2583            .with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
2584            .with_values(&["30s", "1m", "5m", "disabled"]),
2585    ]
2586}
2587
2588/// `"true"` / `"false"` for a settings row value.
2589fn bool_setting_value(value: bool) -> &'static str {
2590    if value {
2591        "true"
2592    } else {
2593        "false"
2594    }
2595}
2596
2597/// Apply one `/settings` value change to the running TUI and to `settings`.
2598///
2599/// Returns a note to show in the transcript when the change is user-visible;
2600/// `None` for changes that only take effect on the next launch.
2601fn apply_setting_change(
2602    state: &Arc<TuiState>,
2603    key: &str,
2604    value: &str,
2605    settings: &mut crate::settings::Settings,
2606) -> Option<String> {
2607    let as_bool = || value == "true";
2608    match key {
2609        // Live: hide/show thinking blocks in the transcript immediately.
2610        "hide-thinking" => {
2611            state.set_hide_thinking(as_bool());
2612            settings.hide_thinking_block = Some(as_bool());
2613            Some(format!(
2614                "Thinking blocks {}.",
2615                if as_bool() { "hidden" } else { "shown" }
2616            ))
2617        }
2618        // Live: the image flag is consulted when images would be rendered.
2619        "show-images" => {
2620            if let Ok(mut guard) = state.show_images.lock() {
2621                *guard = as_bool();
2622            }
2623            settings.show_images = Some(as_bool());
2624            Some(format!(
2625                "Inline images {}.",
2626                if as_bool() { "enabled" } else { "disabled" }
2627            ))
2628        }
2629        // Live: read from `state` once per cache-miss notice.
2630        "cache-miss-notices" => {
2631            if let Ok(mut guard) = state.cache_miss_notices.lock() {
2632                *guard = as_bool();
2633            }
2634            settings.show_cache_miss_notices = Some(as_bool());
2635            Some(format!(
2636                "Cache miss notices {}.",
2637                if as_bool() { "enabled" } else { "disabled" }
2638            ))
2639        }
2640        // Live: read from settings on each selection mouse-up.
2641        "fullscreen-copy-on-select" => {
2642            settings.fullscreen_copy_on_select = Some(as_bool());
2643            Some(format!(
2644                "Copy on select {}.",
2645                if as_bool() { "enabled" } else { "disabled" }
2646            ))
2647        }
2648        // Persisted only: resolved once at startup.
2649        "quiet-startup" => {
2650            settings.quiet_startup = Some(as_bool());
2651            None
2652        }
2653        "terminal-progress" => {
2654            settings.show_terminal_progress = Some(as_bool());
2655            None
2656        }
2657        "double-escape-action" => {
2658            settings.double_escape_action = Some(value.to_string());
2659            None
2660        }
2661        "editor-padding" => {
2662            settings.editor_padding_x = value.parse().ok();
2663            None
2664        }
2665        "autocomplete-max-items" => {
2666            settings.autocomplete_max_visible = value.parse().ok();
2667            None
2668        }
2669        "http-idle-timeout" => {
2670            settings.http_idle_timeout = Some(match value {
2671                "disabled" => serde_json::Value::String("disabled".to_string()),
2672                "30s" => serde_json::json!(30_000),
2673                "1m" => serde_json::json!(60_000),
2674                _ => serde_json::json!(300_000),
2675            });
2676            None
2677        }
2678        _ => None,
2679    }
2680}
2681
2682/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
2683fn open_settings_theme_selector(
2684    state: &Arc<TuiState>,
2685    editor_container: &Arc<Container>,
2686    editor: &Arc<Editor>,
2687    tui: &Arc<TuiAltScreen>,
2688    chat: &Arc<Container>,
2689) {
2690    let items = vec![
2691        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
2692        SelectItem::new("light", "Light").with_description("Light background"),
2693        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
2694    ];
2695    let list = Arc::new(SelectList::new(items, 10));
2696
2697    let state_sel = state.clone();
2698    let ec_sel = editor_container.clone();
2699    let editor_sel = editor.clone();
2700    let tui_sel = tui.clone();
2701    let chat_sel = chat.clone();
2702    list.on_select(Arc::new(move |item| {
2703        let preset = match item.value.as_str() {
2704            "light" => Some(ThemePreset::Light),
2705            "monochrome" => Some(ThemePreset::Monochrome),
2706            "dark" => Some(ThemePreset::Dark),
2707            name => {
2708                add_error_message(&chat_sel, &format!("Unknown theme `{name}`."));
2709                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2710                tui_sel.render_now(true);
2711                return;
2712            }
2713        };
2714        let Some(preset) = preset else { return };
2715        apply_theme_preset(preset);
2716        state_sel.theme_manager.apply_preset(preset);
2717        let mut settings = crate::settings::load_settings().unwrap_or_default();
2718        settings.theme = Some(item.value.clone());
2719        let saved = crate::settings::save_settings(&settings);
2720        add_note_message(
2721            &chat_sel,
2722            &format!(
2723                "Theme set to {} (saved{})",
2724                item.label,
2725                if saved.is_ok() { "" } else { ", not saved" },
2726            ),
2727        );
2728        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2729        tui_sel.render_now(true);
2730    }));
2731    let state_cancel = state.clone();
2732    let ec_cancel = editor_container.clone();
2733    let editor_cancel = editor.clone();
2734    let tui_cancel = tui.clone();
2735    list.on_cancel(Arc::new(move || {
2736        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2737    }));
2738
2739    open_selector(
2740        state,
2741        editor_container,
2742        editor,
2743        tui,
2744        list,
2745        SelectorKind::Settings,
2746    );
2747}
2748
2749/// Choose the default model AND persist it (`/settings` → Default model):
2750/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
2751/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
2752fn open_settings_model_selector(
2753    state: &Arc<TuiState>,
2754    editor_container: &Arc<Container>,
2755    editor: &Arc<Editor>,
2756    tui: &Arc<TuiAltScreen>,
2757    lane: &Arc<dyn AgentLane>,
2758    catalog: &[rpi_ai::Model],
2759    lane_model_id: &str,
2760    chat: &Arc<Container>,
2761) {
2762    let items = model_selector_items(catalog, lane_model_id);
2763    if items.is_empty() {
2764        add_note_message(chat, "No models in the catalog.");
2765        tui.request_render(false);
2766        return;
2767    }
2768    let list = Arc::new(SelectList::new(items, 10));
2769
2770    let catalog_arc = catalog.to_vec();
2771    let state_sel = state.clone();
2772    let ec_sel = editor_container.clone();
2773    let editor_sel = editor.clone();
2774    let tui_sel = tui.clone();
2775    let chat_sel = chat.clone();
2776    let lane_sel = lane.clone();
2777    list.on_select(Arc::new(move |item| {
2778        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
2779            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
2780            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2781            return;
2782        };
2783        state_sel.set_current_model(&model);
2784        let lane = lane_sel.clone();
2785        tokio::spawn(async move {
2786            let _ = lane.set_model(model).await;
2787        });
2788        let mut settings = crate::settings::load_settings().unwrap_or_default();
2789        settings.default_model = Some(item.value.clone());
2790        let saved = crate::settings::save_settings(&settings);
2791        add_note_message(
2792            &chat_sel,
2793            &format!(
2794                "Default model set to {} (saved{}",
2795                short_model_name(&item.value),
2796                if saved.is_ok() { ")" } else { ", not saved)" },
2797            ),
2798        );
2799        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2800    }));
2801    let state_cancel = state.clone();
2802    let ec_cancel = editor_container.clone();
2803    let editor_cancel = editor.clone();
2804    let tui_cancel = tui.clone();
2805    list.on_cancel(Arc::new(move || {
2806        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2807    }));
2808
2809    open_searchable_selector(
2810        state,
2811        editor_container,
2812        editor,
2813        tui,
2814        Some("Default model"),
2815        Some("Type to filter by name, provider, or id"),
2816        list,
2817        SelectorKind::Settings,
2818    );
2819}
2820
2821/// Convert the authenticated runtime catalog into selector rows. Keep the
2822/// model id as the value so `/model <id>` and the selection callback share one
2823/// lookup path, while making the provider visible for OpenAI-compatible
2824/// gateways where the same model id may exist at multiple endpoints.
2825fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
2826    let mut seen = std::collections::HashSet::new();
2827    catalog
2828        .iter()
2829        .filter(|m| {
2830            seen.insert((
2831                m.api.clone(),
2832                m.provider.to_ascii_lowercase(),
2833                m.id.to_ascii_lowercase(),
2834            ))
2835        })
2836        .map(|m| {
2837            let label = if m.name.is_empty() {
2838                short_model_name(&m.id)
2839            } else {
2840                m.name.clone()
2841            };
2842            let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
2843                && m.provider.eq_ignore_ascii_case("anthropic")
2844            {
2845                m.id.clone()
2846            } else {
2847                format!("{}/{}", m.provider, m.id)
2848            };
2849            let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
2850                " (current)"
2851            } else {
2852                ""
2853            };
2854            // upstream ranks provider-prefixed queries first, so the bare id
2855            // is not the leading token (`getModelSelectorSearchText`).
2856            let name = if m.name.is_empty() {
2857                String::new()
2858            } else {
2859                format!(" {}", m.name)
2860            };
2861            let search_text = format!(
2862                "{} {}/{} {} {}{}",
2863                m.provider, m.provider, m.id, m.provider, m.id, name
2864            );
2865            SelectItem::new(&m.id, &label)
2866                .with_description(&format!("{identity}{marker}"))
2867                .with_search_text(&search_text)
2868        })
2869        .collect()
2870}
2871
2872/// Resolve a selector input by either bare model id or the qualified
2873/// `provider/model` identity shown for gateway models. This keeps manual
2874/// `/model ...` input consistent with the rows rendered by the selector.
2875fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
2876    let (provider, id) = input
2877        .split_once('/')
2878        .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
2879        .map_or((None, input), |(provider, id)| (Some(provider), id));
2880    catalog
2881        .iter()
2882        .find(|model| {
2883            model.id.eq_ignore_ascii_case(id)
2884                && provider.map_or(true, |provider| {
2885                    model.provider.eq_ignore_ascii_case(provider)
2886                        || (provider.eq_ignore_ascii_case("anthropic")
2887                            && matches!(model.api, rpi_ai::Api::AnthropicMessages))
2888                })
2889        })
2890        .cloned()
2891}
2892
2893/// Choose the default thinking level AND persist it (`/settings` → Default
2894/// thinking): applies live via `lane.set_thinking_level` and saves
2895/// `defaultThinkingLevel` to settings.json.
2896fn open_settings_thinking_selector(
2897    state: &Arc<TuiState>,
2898    editor_container: &Arc<Container>,
2899    editor: &Arc<Editor>,
2900    tui: &Arc<TuiAltScreen>,
2901    lane: &Arc<dyn AgentLane>,
2902    catalog: &[rpi_ai::Model],
2903    lane_model_id: &str,
2904    chat: &Arc<Container>,
2905) {
2906    let model = catalog
2907        .iter()
2908        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
2909    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
2910        .map(|m| m.supported_thinking_levels())
2911        .unwrap_or_else(|| {
2912            use rpi_ai::types::ThinkingLevel::*;
2913            vec![Off, Minimal, Low, Medium, High]
2914        });
2915    let mut items: Vec<SelectItem> = Vec::new();
2916    for lvl in &levels {
2917        let name = thinking_level_name(*lvl);
2918        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
2919    }
2920    if items.is_empty() {
2921        add_note_message(chat, "This model has no supported thinking levels.");
2922        tui.request_render(false);
2923        return;
2924    }
2925    let list = Arc::new(SelectList::new(items, 10));
2926
2927    let state_sel = state.clone();
2928    let ec_sel = editor_container.clone();
2929    let editor_sel = editor.clone();
2930    let tui_sel = tui.clone();
2931    let chat_sel = chat.clone();
2932    let lane_sel = lane.clone();
2933    list.on_select(Arc::new(move |item| {
2934        let Some(level) = thinking_level_from_name(&item.value) else {
2935            add_note_message(
2936                &chat_sel,
2937                &format!("Unknown thinking level: {}.", item.label),
2938            );
2939            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2940            return;
2941        };
2942        let lane = lane_sel.clone();
2943        let footer_sel = state_sel.footer.clone();
2944        tokio::spawn(async move {
2945            let _ = lane.set_thinking_level(level).await;
2946        });
2947        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
2948        let mut settings = crate::settings::load_settings().unwrap_or_default();
2949        settings.default_thinking_level = Some(item.value.clone());
2950        let saved = crate::settings::save_settings(&settings);
2951        add_note_message(
2952            &chat_sel,
2953            &format!(
2954                "Default thinking set to {} (saved{}",
2955                item.label,
2956                if saved.is_ok() { ")" } else { ", not saved)" },
2957            ),
2958        );
2959        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2960    }));
2961    let state_cancel = state.clone();
2962    let ec_cancel = editor_container.clone();
2963    let editor_cancel = editor.clone();
2964    let tui_cancel = tui.clone();
2965    list.on_cancel(Arc::new(move || {
2966        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2967    }));
2968
2969    open_selector(
2970        state,
2971        editor_container,
2972        editor,
2973        tui,
2974        list,
2975        SelectorKind::Settings,
2976    );
2977}
2978
2979/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
2980/// item toggles it in the in-progress set (the selector stays open); Esc saves
2981/// the set to settings.json and closes. The active scoped set is echoed after
2982/// each toggle so the user sees the current selection.
2983fn open_scoped_models_selector(
2984    state: &Arc<TuiState>,
2985    editor_container: &Arc<Container>,
2986    editor: &Arc<Editor>,
2987    tui: &Arc<TuiAltScreen>,
2988    catalog: &[rpi_ai::Model],
2989    chat: &Arc<Container>,
2990) {
2991    if catalog.is_empty() {
2992        add_note_message(chat, "No models in the catalog.");
2993        tui.request_render(false);
2994        return;
2995    }
2996    // Seed the edit set from the saved scoped models.
2997    let seed: Vec<String> = crate::settings::load_settings()
2998        .ok()
2999        .and_then(|s| s.scoped_models)
3000        .unwrap_or_default();
3001    *state.scoped_edit.lock().unwrap() = Some(seed);
3002
3003    let mut items: Vec<SelectItem> = Vec::new();
3004    for m in catalog {
3005        items.push(SelectItem::new(&m.id, &m.id));
3006    }
3007    let list = Arc::new(SelectList::new(items, 10));
3008
3009    let state_sel = state.clone();
3010    let chat_sel = chat.clone();
3011    let tui_sel = tui.clone();
3012    list.on_select(Arc::new(move |item| {
3013        // Toggle the model in the in-progress set; the selector stays open.
3014        let mut set = state_sel.scoped_edit.lock().unwrap();
3015        let set = set.get_or_insert_with(Vec::new);
3016        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3017            set.remove(pos);
3018            add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3019        } else {
3020            set.push(item.value.clone());
3021            add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3022        }
3023        tui_sel.request_render(false);
3024    }));
3025    let state_cancel = state.clone();
3026    let ec_cancel = editor_container.clone();
3027    let editor_cancel = editor.clone();
3028    let tui_cancel = tui.clone();
3029    let chat_cancel = chat.clone();
3030    list.on_cancel(Arc::new(move || {
3031        // Save the edited set to settings.json and close.
3032        let set = state_cancel
3033            .scoped_edit
3034            .lock()
3035            .unwrap()
3036            .take()
3037            .unwrap_or_default();
3038        let mut settings = crate::settings::load_settings().unwrap_or_default();
3039        settings.scoped_models = if set.is_empty() {
3040            None
3041        } else {
3042            Some(set.clone())
3043        };
3044        match crate::settings::save_settings(&settings) {
3045            Ok(()) => {
3046                if set.is_empty() {
3047                    add_note_message(&chat_cancel, "Ctrl+P cycles all models (scope cleared).");
3048                } else {
3049                    add_note_message(
3050                        &chat_cancel,
3051                        &format!("Ctrl+P cycle scope: {}", set.join(", ")),
3052                    );
3053                }
3054            }
3055            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3056        }
3057        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3058    }));
3059
3060    open_selector(
3061        state,
3062        editor_container,
3063        editor,
3064        tui,
3065        list,
3066        SelectorKind::ScopedModels,
3067    );
3068}
3069
3070/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
3071/// PATH, create a gist of the exported markdown; otherwise fall back to the
3072/// clipboard (best-effort) and note the local path.
3073async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3074    use std::process::Stdio;
3075
3076    // Reuse the export builder for the transcript text.
3077    let tree = harness.session().view("main");
3078    let entries = match tree
3079        .find_entries(&EntryQuery {
3080            entry_type: None,
3081            custom_type: None,
3082            // Exports append entries top-to-bottom, so use chronological order
3083            // instead of the session query default (newest-first).
3084            order: Some(EntryOrder::OldestFirst),
3085            limit: None,
3086            cursor: None,
3087        })
3088        .await
3089    {
3090        Ok(e) => e,
3091        Err(e) => {
3092            add_error_message(chat, &format!("Could not read session: {e}"));
3093            return;
3094        }
3095    };
3096    let mut md = String::from("# Session\n\n");
3097    for e in entries {
3098        let Entry::Message(me) = e else { continue };
3099        match &me.message {
3100            AgentMessage::User(u) => {
3101                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3102            }
3103            AgentMessage::Assistant(a) => {
3104                let text = assistant_text(a);
3105                if !text.is_empty() {
3106                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3107                }
3108            }
3109            _ => {}
3110        }
3111    }
3112
3113    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
3114    let gh = std::process::Command::new("gh")
3115        .arg("gist")
3116        .arg("create")
3117        .arg("--filename")
3118        .arg("session.md")
3119        .arg("-")
3120        .stdin(Stdio::piped())
3121        .stdout(Stdio::piped())
3122        .stderr(Stdio::null())
3123        .spawn();
3124    if let Ok(mut child) = gh {
3125        use std::io::Write;
3126        if let Some(mut stdin) = child.stdin.take() {
3127            let _ = stdin.write_all(md.as_bytes());
3128            let _ = stdin.flush();
3129        }
3130        let out = child.wait_with_output().ok();
3131        if let Some(out) = out {
3132            if out.status.success() {
3133                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3134                add_note_message(chat, &format!("Shared session: {url}"));
3135                return;
3136            }
3137        }
3138        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3139    } else {
3140        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3141    }
3142    // Clipboard fallback (or transcript echo when the clipboard feature is off).
3143    if copy_to_clipboard(&md) {
3144        add_note_message(chat, "Session transcript copied to the clipboard.");
3145    } else {
3146        add_note_message(
3147            chat,
3148            "Clipboard unavailable — use /export to write the transcript to a file.",
3149        );
3150    }
3151}
3152
3153// Session export is driven through the product-layer `AgentSession`
3154// (`crate::agent_session`), which owns format selection + default filenames —
3155// the TUI loop calls `AgentSession::export*` directly, so there is no separate
3156// helper here.
3157
3158/// Fork the current session into a new JSONL session and switch to it (TS
3159/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
3160/// session the user continues in). Uses the repo's `fork_typed`, then swaps
3161/// the harness backing and renders the (empty-ish) fork transcript.
3162/// Hot-switch the harness to another saved session: abort any in-flight run,
3163/// open the target session file, swap the durable backing, and re-render the
3164/// transcript from the new history (mirrors pi's `/session` resume-in-place).
3165/// Shared by the `/session` selector, `/import`, and `/fork`. The current
3166/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
3167async fn switch_to_session(
3168    harness: &AgentHarness,
3169    lane: &Arc<dyn AgentLane>,
3170    id: &str,
3171    cwd: &std::path::Path,
3172    chat: &Arc<Container>,
3173    state: &Arc<TuiState>,
3174) -> bool {
3175    if *state.status.lock().unwrap() == RunStatus::Working {
3176        state.set_status(RunStatus::Aborting);
3177        let _ = lane.abort().await;
3178    }
3179    let cwd_str = cwd.to_string_lossy().to_string();
3180    match crate::session::open_session_by_id(id, &cwd_str).await {
3181        Ok(new_session) => {
3182            let _ = harness.set_session(new_session).await;
3183            chat.clear();
3184            add_welcome_message(chat);
3185            render_session_history(
3186                harness,
3187                chat,
3188                state.markdown_transformer(),
3189                Some(state.extension_session.clone()),
3190            )
3191            .await;
3192            state.set_status(RunStatus::Idle);
3193            add_note_message(chat, &format!("Switched to session {id}."));
3194            true
3195        }
3196        Err(e) => {
3197            state.set_status(RunStatus::Idle);
3198            add_error_message(chat, &format!("Could not open session {id}: {e}"));
3199            false
3200        }
3201    }
3202}
3203
3204/// `/import <path>`: copy a JSONL session file into the default session dir,
3205/// then hot-switch to it (the file name becomes its id — matching the
3206/// selector/`open_session_by_id` containment rules).
3207async fn import_session(
3208    harness: &AgentHarness,
3209    lane: &Arc<dyn AgentLane>,
3210    path: &str,
3211    cwd: &std::path::Path,
3212    chat: &Arc<Container>,
3213    state: &Arc<TuiState>,
3214) {
3215    use std::path::Path as FsPath;
3216
3217    let src = FsPath::new(path);
3218    if !src.is_file() {
3219        add_error_message(chat, &format!("Import source not found: {path}"));
3220        return;
3221    }
3222    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3223        add_error_message(chat, "Import source has no file name.");
3224        return;
3225    };
3226    if !fname.ends_with(".jsonl") {
3227        add_error_message(chat, "Import source must be a .jsonl session file.");
3228        return;
3229    }
3230    let dir = crate::session::default_session_dir(cwd);
3231    if let Err(e) = std::fs::create_dir_all(&dir) {
3232        add_error_message(chat, &format!("Could not create session dir: {e}"));
3233        return;
3234    }
3235    let dest = dir.join(fname);
3236    match std::fs::copy(src, &dest) {
3237        Ok(_) => {
3238            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3239            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3240                add_note_message(chat, &format!("Imported session from {path}"));
3241            }
3242        }
3243        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3244    }
3245}
3246
3247async fn fork_session(
3248    harness: &AgentHarness,
3249    cwd: &std::path::Path,
3250    chat: &Arc<Container>,
3251    state: &Arc<TuiState>,
3252) {
3253    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3254    use rpi_tools::FileSystem;
3255
3256    let cwd_str = cwd.to_string_lossy().to_string();
3257    let dir = crate::session::default_session_dir(cwd);
3258    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3259    let fs: Arc<dyn FileSystem> = env.clone();
3260    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3261        fs,
3262        sessions_root: dir.to_string_lossy().into_owned(),
3263        clock: Arc::new(rpi_harness::session::memory::SystemClock),
3264        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3265    });
3266    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
3267    // it from the session list by the current session's id.
3268    let id = harness.session().storage().metadata().id.clone();
3269    let metas = match crate::session::list_session_metadata(&cwd_str).await {
3270        Ok(m) => m,
3271        Err(e) => {
3272            add_error_message(chat, &format!("Could not list sessions: {e}"));
3273            return;
3274        }
3275    };
3276    let Some(source) = metas.iter().find(|m| m.id == id) else {
3277        add_error_message(chat, &format!("Current session {id} not found on disk."));
3278        return;
3279    };
3280    let fork_storage = match repo
3281        .fork_typed(
3282            source,
3283            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3284                id: None,
3285                parent_session_id: Some(source.id.clone()),
3286                cwd: cwd_str.clone(),
3287                metadata: None,
3288            },
3289            &rpi_harness::session::types::ForkOptions::default(),
3290        )
3291        .await
3292    {
3293        Ok(s) => s,
3294        Err(e) => {
3295            add_error_message(chat, &format!("Could not fork session: {e}"));
3296            return;
3297        }
3298    };
3299    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3300    let _ = harness.set_session(new_session).await;
3301    chat.clear();
3302    add_welcome_message(chat);
3303    render_session_history(
3304        harness,
3305        chat,
3306        state.markdown_transformer(),
3307        Some(state.extension_session.clone()),
3308    )
3309    .await;
3310    state.set_status(RunStatus::Idle);
3311    add_note_message(chat, "Forked into a new session.");
3312}
3313
3314/// Render the restored session's prior transcript (user + assistant messages)
3315/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
3316/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
3317/// any session read failure just starts with an empty transcript.
3318///
3319/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
3320/// the identity path. Each restored assistant component installs it so replayed
3321/// history renders through the same `register_markdown_transformer` handlers
3322/// the live stream does.
3323async fn render_session_history(
3324    harness: &AgentHarness,
3325    chat: &Arc<Container>,
3326    transformer: Option<MarkdownTransformer>,
3327    extension_session: Option<crate::session::ExtensionSessionCell>,
3328) {
3329    let tree = harness.session().view("main");
3330    let entries = match tree
3331        .find_entries(&EntryQuery {
3332            entry_type: None,
3333            custom_type: None,
3334            // Session queries default to newest-first for selectors and
3335            // pagination. The transcript appends children top-to-bottom, so
3336            // restored history must explicitly be chronological.
3337            order: Some(EntryOrder::OldestFirst),
3338            limit: None,
3339            cursor: None,
3340        })
3341        .await
3342    {
3343        Ok(e) => e,
3344        Err(_) => return,
3345    };
3346    let mut rendered_any = false;
3347    for e in entries {
3348        match e {
3349            Entry::Message(me) => match &me.message {
3350                AgentMessage::User(u) => {
3351                    add_user_message(chat, &user_message_text(u));
3352                    rendered_any = true;
3353                }
3354                AgentMessage::Assistant(a) => {
3355                    let comp = Arc::new(AssistantMessageComponent::new(
3356                        AssistantMessageOptions::default(),
3357                    ));
3358                    if let Some(t) = &transformer {
3359                        comp.set_markdown_transformer(Some(t.clone()));
3360                    }
3361                    comp.update_blocks(&assistant_blocks(a));
3362                    chat.add_child(comp);
3363                    // Single trailing spacer: the next transcript entry (user or
3364                    // assistant) follows one blank line below.
3365                    chat.add_child(Arc::new(Spacer::new(1)));
3366                    if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3367                        add_note_message(chat, &text);
3368                    }
3369                    if let Some(error) = assistant_error_text(a) {
3370                        add_error_message(chat, &error);
3371                    }
3372                    rendered_any = true;
3373                }
3374                AgentMessage::ToolResult(result) => {
3375                    // Tool results are persisted as separate message entries,
3376                    // not as part of the assistant text. Restore them as
3377                    // completed tool panels so resumed sessions show the
3378                    // command output as well as the user's prompts.
3379                    let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
3380                    comp.set_result(&tool_result_message_text(result), result.is_error);
3381                    if tool_result_requests_markdown(result.details.as_ref()) {
3382                        comp.set_result_markdown(true);
3383                    }
3384                    chat.add_child(comp);
3385                    chat.add_child(Arc::new(Spacer::new(1)));
3386                    rendered_any = true;
3387                }
3388                AgentMessage::Custom(custom) => {
3389                    if let Some(session) = &extension_session {
3390                        if let Some(component) = extension_message_component(
3391                            session,
3392                            &custom.role,
3393                            &serde_json::json!({
3394                                "customType": custom.role,
3395                                "content": custom.content,
3396                                "details": custom.data,
3397                            }),
3398                            transformer.clone(),
3399                        ) {
3400                            chat.add_child(component);
3401                            chat.add_child(Arc::new(Spacer::new(1)));
3402                            rendered_any = true;
3403                            continue;
3404                        }
3405                    }
3406                    add_note_message(chat, &custom_message_fallback(&custom));
3407                    rendered_any = true;
3408                }
3409            },
3410            Entry::Compaction(compaction) => {
3411                add_note_message(
3412                    chat,
3413                    &format!(
3414                        "Compacted {} tokens: {}",
3415                        compaction.tokens_before, compaction.summary
3416                    ),
3417                );
3418                rendered_any = true;
3419            }
3420            Entry::BranchSummary(summary) => {
3421                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3422                rendered_any = true;
3423            }
3424            Entry::Custom(custom) => {
3425                let rendered = extension_session.as_ref().and_then(|session| {
3426                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
3427                });
3428                if let Some(component) = rendered {
3429                    chat.add_child(component);
3430                    chat.add_child(Arc::new(Spacer::new(1)));
3431                    rendered_any = true;
3432                } else if let Some(text) =
3433                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3434                {
3435                    add_note_message(chat, &text);
3436                    rendered_any = true;
3437                }
3438            }
3439            Entry::ModelChange(change) => {
3440                add_note_message(
3441                    chat,
3442                    &format!("Model changed to {}:{}", change.provider, change.model_id),
3443                );
3444                rendered_any = true;
3445            }
3446            Entry::ThinkingLevel(change) => {
3447                add_note_message(
3448                    chat,
3449                    &format!("Thinking level: {:?}", change.thinking_level),
3450                );
3451                rendered_any = true;
3452            }
3453            Entry::ActiveTools(change) => {
3454                add_note_message(
3455                    chat,
3456                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
3457                );
3458                rendered_any = true;
3459            }
3460        }
3461    }
3462    if rendered_any {
3463        // No trailing spacer here — each entry already adds its own trailing
3464        // Spacer(1), so an extra would double the bottom gap.
3465    }
3466}
3467
3468fn invoke_extension_renderer(
3469    session: &crate::session::ExtensionSessionCell,
3470    kind: rpi_extensions::RegisteredRendererKind,
3471    payload: &serde_json::Value,
3472) -> Option<serde_json::Value> {
3473    let snapshot = session.lock().ok()?.snapshot_arc()?;
3474    let input = serde_json::to_string(payload).ok()?;
3475    for renderer in snapshot.renderers_of(kind) {
3476        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3477            let mut out = rpi_plugin_sdk::StbString::empty();
3478            let rc = (renderer.render_fn)(
3479                rpi_plugin_sdk::StbStringRef::from_str(&input),
3480                &mut out as *mut rpi_plugin_sdk::StbString,
3481                renderer.user_data,
3482            );
3483            let text = if rc == 0 {
3484                Some(out.to_string_lossy())
3485            } else {
3486                None
3487            };
3488            out.free_with(Some(renderer.plugin_free_string));
3489            text
3490        }))
3491        .ok()
3492        .flatten();
3493        let Some(text) = outcome else { continue };
3494        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3495            return Some(value);
3496        }
3497    }
3498    None
3499}
3500
3501fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3502    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3503        let text = lines
3504            .iter()
3505            .filter_map(|line| line.as_str())
3506            .collect::<Vec<_>>()
3507            .join("\n");
3508        return Some(Arc::new(Text::new(text, 0, 0)));
3509    }
3510    let text = value.get("text").and_then(|v| v.as_str())?;
3511    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3512        let component = Arc::new(AssistantMessageComponent::new(
3513            AssistantMessageOptions::default(),
3514        ));
3515        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3516        Some(component)
3517    } else {
3518        Some(Arc::new(Text::new(text, 0, 0)))
3519    }
3520}
3521
3522fn extension_message_component(
3523    session: &crate::session::ExtensionSessionCell,
3524    custom_type: &str,
3525    payload: &serde_json::Value,
3526    transformer: Option<MarkdownTransformer>,
3527) -> Option<Arc<dyn rpi_tui::Component>> {
3528    let value = invoke_extension_renderer(
3529        session,
3530        rpi_extensions::RegisteredRendererKind::Message,
3531        payload,
3532    )?;
3533    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3534        let text = value.get("text").and_then(|v| v.as_str())?;
3535        let component = Arc::new(AssistantMessageComponent::new(
3536            AssistantMessageOptions::default(),
3537        ));
3538        if let Some(transformer) = transformer {
3539            component.set_markdown_transformer(Some(transformer));
3540        }
3541        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3542        return Some(component);
3543    }
3544    extension_text_component(&value)
3545        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
3546}
3547
3548/// Render usage from a completed assistant message through the registered
3549/// message renderers. Hosts without a token-usage renderer return `None`.
3550fn extension_usage_text(
3551    session: Option<&crate::session::ExtensionSessionCell>,
3552    usage: &rpi_ai::types::Usage,
3553) -> Option<String> {
3554    let session = session?;
3555    let payload = serde_json::json!({
3556        "customType": "token-usage",
3557        "usage": usage,
3558    });
3559    let value = invoke_extension_renderer(
3560        session,
3561        rpi_extensions::RegisteredRendererKind::Message,
3562        &payload,
3563    )?;
3564    value
3565        .get("text")
3566        .and_then(|value| value.as_str())
3567        .filter(|text| !text.trim().is_empty())
3568        .map(ToOwned::to_owned)
3569}
3570
3571fn extension_entry_component(
3572    session: &crate::session::ExtensionSessionCell,
3573    custom_type: &str,
3574    data: Option<serde_json::Value>,
3575) -> Option<Arc<dyn rpi_tui::Component>> {
3576    let payload = serde_json::json!({
3577        "customType": custom_type,
3578        "data": data,
3579    });
3580    let value = invoke_extension_renderer(
3581        session,
3582        rpi_extensions::RegisteredRendererKind::Entry,
3583        &payload,
3584    )?;
3585    extension_text_component(&value)
3586}
3587
3588/// Project an assistant message's content into the provider-free
3589/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
3590/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
3591/// are rendered by their own components in the transcript.
3592/// Whether startup intentionally opened a session that already has history.
3593fn launch_restores_history(args: &Args) -> bool {
3594    args.continue_session
3595        || args.resume
3596        || args.session.is_some()
3597        || args.session_id.is_some()
3598        || args.fork.is_some()
3599}
3600
3601fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
3602    let content = custom
3603        .content
3604        .iter()
3605        .filter_map(|item| match item {
3606            Content::Text(text) => Some(text.text.as_str()),
3607            _ => None,
3608        })
3609        .collect::<Vec<_>>()
3610        .join("\n");
3611    if content.is_empty() {
3612        format!("{}: {}", custom.role, custom.data)
3613    } else {
3614        format!("{}: {}", custom.role, content)
3615    }
3616}
3617
3618/// Path to the enclosing repository's `.git/HEAD`, if any.
3619fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
3620    let mut dir = Some(cwd);
3621    while let Some(current) = dir {
3622        let head = current.join(".git").join("HEAD");
3623        if head.exists() {
3624            return Some(head);
3625        }
3626        dir = current.parent();
3627    }
3628    None
3629}
3630
3631/// Current git branch for `cwd`, or `None` outside a repository / on error.
3632/// Reads `.git/HEAD` directly (no `git` subprocess) so the footer can call it
3633/// cheaply at startup.
3634fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
3635    let mut dir = Some(cwd);
3636    while let Some(current) = dir {
3637        let head = current.join(".git").join("HEAD");
3638        if let Ok(contents) = std::fs::read_to_string(&head) {
3639            let trimmed = contents.trim();
3640            if let Some(reference) = trimmed.strip_prefix("ref: ") {
3641                let branch = reference
3642                    .rsplit('/')
3643                    .next()
3644                    .unwrap_or(reference)
3645                    .trim()
3646                    .to_string();
3647                if !branch.is_empty() {
3648                    return Some(branch);
3649                }
3650            }
3651            if !trimmed.is_empty() {
3652                return Some(trimmed.chars().take(7).collect());
3653            }
3654        }
3655        dir = current.parent();
3656    }
3657    None
3658}
3659
3660/// The name displayed for a model id (last path segment / after the final
3661/// `:`), to keep the footer compact.
3662fn short_model_name(id: &str) -> String {
3663    id.rsplit([':', '/'])
3664        .next()
3665        .filter(|s| !s.is_empty())
3666        .unwrap_or(id)
3667        .to_string()
3668}
3669
3670// ===========================================================================
3671// Streaming run status
3672// ===========================================================================
3673
3674/// The live status of the agent run, fed to the footer + status slot.
3675#[derive(Clone, Copy, PartialEq, Eq)]
3676enum RunStatus {
3677    Idle,
3678    Working,
3679    Aborting,
3680}
3681
3682/// Which selector overlay (if any) is currently swapped into the editor slot.
3683#[derive(Clone, Copy, PartialEq, Eq)]
3684enum SelectorKind {
3685    /// `/model` — available models (live switch via `lane.set_model`).
3686    Model,
3687    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
3688    Thinking,
3689    /// `/tools` — toggle builtin tools on/off.
3690    Tools,
3691    /// `/images` — toggle inline image rendering.
3692    Images,
3693    /// `/session` — browse and switch saved JSONL sessions.
3694    Session,
3695    /// `/theme` — dark / light / monochrome presets applied live.
3696    Theme,
3697    /// `/scoped-models` — multi-toggle Ctrl+P cycle scope.
3698    ScopedModels,
3699    /// `/settings` — interactive settings menu (and its sub-selectors).
3700    Settings,
3701    /// `/tree` — navigate to an existing entry in the current session.
3702    Tree,
3703    /// Extension-provided selector; uses the same keyboard contract.
3704    Extension,
3705}
3706
3707/// The component receiving keys while a selector is open.
3708///
3709/// Plain selectors route straight to the [`SelectList`]; searchable ones route
3710/// through [`SearchableSelectList`] so typing filters instead of being ignored.
3711/// Both variants expose the underlying list for callback registration and
3712/// selection inspection.
3713#[derive(Clone)]
3714enum SelectorView {
3715    /// A bare `SelectList` (no search box).
3716    List(Arc<SelectList>),
3717    /// A `SelectList` wrapped with a fuzzy-filter input.
3718    Searchable(Arc<SearchableSelectList>),
3719    /// The `/settings` menu: value rows cycle in place, submenu rows open a
3720    /// nested selector.
3721    Settings(Arc<SettingsList>),
3722}
3723
3724impl SelectorView {
3725    /// Route a key to the appropriate child.
3726    fn handle_key(&self, key: KeyEvent) {
3727        match self {
3728            Self::List(list) => list.handle_key(key),
3729            Self::Searchable(searchable) => searchable.handle_key(key),
3730            Self::Settings(settings) => settings.handle_key(key),
3731        }
3732    }
3733}
3734
3735/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
3736/// and the render-tick task.
3737/// A draft injected by an extension via `SetEditorText` with `autoSendMs`:
3738/// the TUI places it in the prompt editor and submits it once `deadline`
3739/// passes, *unless the user touches the draft first*.
3740///
3741/// "Touching" is either a keystroke (the key loop clears this — the pressed
3742/// key is still dispatched to the editor, so the user simply keeps typing) or
3743/// any difference between the live editor text and `text` (the render tick's
3744/// safety net for programmatic/`Paste` changes that bypass the key path).
3745#[derive(Debug, Clone)]
3746struct AutoSendPending {
3747    /// When an untouched draft is submitted.
3748    deadline: std::time::Instant,
3749    /// The text as injected; a mismatch means the user edited it.
3750    text: String,
3751}
3752
3753/// What one tick of the auto-send countdown should do. Split out of
3754/// [`poll_auto_send`] so the submit/cancel rules are unit-testable without a
3755/// live TUI.
3756#[derive(Debug, PartialEq, Eq)]
3757enum AutoSendStep {
3758    /// No countdown is running.
3759    Idle,
3760    /// Abandon the countdown and leave the draft alone: the user edited it, or
3761    /// a run started underneath us.
3762    Cancel,
3763    /// The countdown elapsed untouched — submit the draft.
3764    Submit,
3765    /// Still counting down; the caller renders the remaining time.
3766    Wait(std::time::Duration),
3767}
3768
3769/// Pure decision for one tick of the auto-send countdown.
3770fn auto_send_step(
3771    pending: Option<&AutoSendPending>,
3772    current_text: &str,
3773    idle: bool,
3774    now: std::time::Instant,
3775) -> AutoSendStep {
3776    let Some(pending) = pending else {
3777        return AutoSendStep::Idle;
3778    };
3779    // Any divergence from the injected text is the user's own edit; an emptied
3780    // editor (a slash command ran, Esc cleared it) has nothing left to send.
3781    if current_text != pending.text || current_text.trim().is_empty() {
3782        return AutoSendStep::Cancel;
3783    }
3784    if !idle {
3785        return AutoSendStep::Cancel;
3786    }
3787    let remaining = pending.deadline.saturating_duration_since(now);
3788    if remaining.is_zero() {
3789        AutoSendStep::Submit
3790    } else {
3791        AutoSendStep::Wait(remaining)
3792    }
3793}
3794
3795struct TuiState {
3796    /// The in-flight streaming assistant message (cleared on finalize).
3797    /// `Arc`-shared with the [`TranscriptView`] the event drain renders through,
3798    /// so the render-tick / reload / toggle paths keep direct access.
3799    current_assistant: Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
3800    /// Tool-execution components keyed by `tool_call_id` (shared with the view).
3801    tool_components: Arc<std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
3802    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
3803    /// generic tool map so bash output streams into a `BashExecutionComponent`
3804    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
3805    bash_components: Arc<std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
3806    /// Whether package/custom themes may be selected in this session.
3807    /// Persisted display preference toggled by Ctrl+T (shared with the view).
3808    hide_thinking: Arc<std::sync::Mutex<bool>>,
3809    /// Global tool-output expansion preference toggled by Ctrl+O (shared with
3810    /// the view).
3811    tool_outputs_expanded: Arc<std::sync::Mutex<bool>>,
3812    /// Whether the native-style terminal progress indicator is enabled.
3813    show_terminal_progress: bool,
3814    /// Run status for the status indicator + interrupt routing.
3815    status: std::sync::Mutex<RunStatus>,
3816    /// Extension status registry (`SetStatus` runtime action). The render tick
3817    /// repaints the footer only when `ext_status_revision` moved.
3818    ext_status: rpi_extensions::ExtensionStatusMailbox,
3819    /// Revision of `ext_status` as of the last footer write.
3820    ext_status_revision: std::sync::atomic::AtomicU64,
3821    /// Session editor-text queue (`SetEditorText`, runtime action 19). The
3822    /// render tick drains it into the prompt editor; a voice plugin drops a
3823    /// transcription in as an editable draft rather than sending it.
3824    editor_text: rpi_extensions::EditorTextMailbox,
3825    /// Pending auto-send for an injected draft (see [`AutoSendPending`]).
3826    /// `None` when no countdown is running.
3827    auto_send: std::sync::Mutex<Option<AutoSendPending>>,
3828    /// The editor text as of the last tick, used to detect `EditorChange`
3829    /// events (see [`TuiState::sync_editor_change`]).
3830    last_editor_text: std::sync::Mutex<String>,
3831    /// Set by the TUI itself right before it writes the editor (an extension
3832    /// draft injection, the auto-send clear). The next `EditorChange` is then
3833    /// attributed to `extension` rather than to the user — without this a
3834    /// plugin could not tell its own text from a human typing.
3835    programmatic_editor_write: std::sync::atomic::AtomicBool,
3836    /// Cancellation signal for the short phase that starts the persistent JS
3837    /// host and runs `before_agent_start`. The key thread can trigger this
3838    /// directly while the async message loop is awaiting the blocking worker.
3839    js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
3840    /// Cancellation signal for a user-initiated `!command` / `!!command` shell
3841    /// run. `Some` while the command executes; Esc/Ctrl+C cancels it. `None`
3842    /// when no user bash is running.
3843    user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
3844    /// `bashExecution` messages produced by `!command` while an agent run was
3845    /// in flight. Flushed to the lane on `AgentEnd` so transcript order matches
3846    /// upstream's `_pendingBashMessages` (never spliced mid-turn).
3847    pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
3848    /// The footer, updated live by the drain task.
3849    footer: Arc<FooterComponent>,
3850    /// The status-container (status slot in the dock) — cleared/filled with a
3851    /// loader while a run is active.
3852    status_container: Arc<Container>,
3853    /// The chat transcript container.
3854    chat_container: Arc<Container>,
3855    /// The active loader shown while `Working`.
3856    loader: Arc<Loader>,
3857    /// The editor, so status changes can drive its top-border working
3858    /// indicator (the spinner + elapsed time shown inside the input box border).
3859    editor: Arc<Editor>,
3860    /// The last finalized assistant text (for `/copy`). Updated by the drain
3861    /// task on `MessageEnd` / `AgentEnd`.
3862    last_assistant_text: std::sync::Mutex<String>,
3863    /// The active selector overlay, swapped into the editor slot. `Some` while
3864    /// a selector is open; the key loop routes to it first and restores the
3865    /// editor on done/cancel.
3866    active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
3867    /// Extension-provided editor currently occupying the input slot.
3868    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
3869    /// Single-line input currently occupying the input slot for an extension.
3870    active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
3871    /// Callback used to resolve an extension dialog with a cancellation action.
3872    active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
3873    /// The autocomplete manager (slash + @file providers) consulted on every
3874    /// editor keystroke.
3875    autocomplete: AutocompleteManager,
3876    /// The container rendered above the editor holding the live autocomplete
3877    /// suggestion list (cleared when there are no suggestions).
3878    autocomplete_container: Arc<Container>,
3879    /// Maximum number of autocomplete rows rendered above the editor.
3880    autocomplete_max_visible: usize,
3881    /// Images queued from clipboard paste and attached to the next prompt.
3882    pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
3883    /// The owned theme manager — `/theme` applies presets here. The global
3884    /// `theme()` is read-only after OnceLock init, so per-instance state is the
3885    /// only way to apply a preset at runtime.
3886    theme_manager: Arc<ThemeManager>,
3887    /// The alt-screen handle, held so `set_status` can reflect run state in the
3888    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
3889    /// that never call `set_status` with a title.
3890    tui: Option<Arc<TuiAltScreen>>,
3891    /// The model id currently shown in the footer + used as the Ctrl+P
3892    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+P switch) so
3893    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
3894    current_model_id: std::sync::Mutex<String>,
3895    /// Whether inline image rendering is enabled (`/images` toggle). Stored
3896    /// even though image wiring is minimal this pass — the flag is consulted
3897    /// where images would be shown and echoed back by `/images`.
3898    show_images: std::sync::Mutex<bool>,
3899    /// Transcript notices for prompt-cache costs and provider recovery
3900    /// diagnostics (upstream `showCacheMissNotices`, default `false`).
3901    cache_miss_notices: std::sync::Mutex<bool>,
3902    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
3903    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
3904    history: std::sync::Mutex<Vec<String>>,
3905    /// Browse index while recalling history: -1 = not browsing, 0 = most
3906    /// recent, 1 = older, … Reset to -1 on every submit.
3907    history_index: std::sync::Mutex<isize>,
3908    /// The editor text captured when entering browse mode, restored when the
3909    /// user navigates back past the newest entry (TS `historyDraft`).
3910    history_draft: std::sync::Mutex<Option<String>>,
3911    /// Full cache-waste tracker (pi `detectCacheMiss`/`cache-stats.ts`):
3912    /// counts and prices prompt-cache misses across turns using the same
3913    /// noise floor, idle-gap, and model-change logic as the batch scan.
3914    cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
3915    /// The in-progress scoped-models selection while the `/scoped-models`
3916    /// selector is open (toggle per item, Esc saves). `None` when not editing.
3917    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
3918    /// B5e: the live assistant-markdown transformer, built from the current
3919    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
3920    /// when no markdown transformers are registered (identity render path).
3921    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
3922    /// closure no-ops once its snapshot's `active` flag flips false) and
3923    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
3924    /// transform takes effect on the visible streaming message immediately.
3925    /// New assistant components pick up whatever closure is current at
3926    /// construction time via [`install_markdown_transformer`].
3927    markdown_transformer: Arc<std::sync::Mutex<Option<MarkdownTransformer>>>,
3928    /// Live extension registry used by message/entry renderer dispatch.
3929    extension_session: crate::session::ExtensionSessionCell,
3930    /// Transcript search handler (Ctrl+Shift+F).
3931    search: Arc<AltScreenSearch>,
3932    /// Search bar component shown when search is active.
3933    search_bar: Arc<SearchBar>,
3934    /// Dock slot listing queued steering/follow-up messages while a run is
3935    /// active (upstream's `pendingMessagesContainer`). Empty when nothing is
3936    /// queued, so it renders zero rows and never affects the layout.
3937    pending_container: Arc<Container>,
3938    /// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`),
3939    /// shown in the pending-messages hint row.
3940    dequeue_hint: String,
3941    /// Last queue snapshot rendered into `pending_container`. Lets
3942    /// `set_pending_queue` skip redundant rebuilds + frame requests when the
3943    /// polled queue is unchanged.
3944    pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
3945    /// Fullscreen selection tracking for auto-copy.
3946    selection_start: std::sync::Mutex<Option<(u16, u16)>>,
3947    selection_end: std::sync::Mutex<Option<(u16, u16)>>,
3948}
3949
3950/// How many submitted messages are kept for ↑ recall (mirrors the TS
3951/// editor's 100-entry cap).
3952const HISTORY_LIMIT: usize = 100;
3953
3954/// Footer-status prefix used while a voice/extension draft counts down to an
3955/// auto-send. Doubles as the marker that lets [`TuiState::clear_auto_send`]
3956/// know the footer is showing *our* text before blanking it.
3957const AUTO_SEND_PREFIX: &str = "Auto-send in ";
3958
3959/// Keep a few rows of overlap so page scrolling preserves visual context,
3960/// matching the upstream fullscreen viewport behavior.
3961const PAGE_SCROLL_OVERLAP: usize = 4;
3962
3963/// upstream scrolls a small chunk for each wheel notch rather than moving the
3964/// transcript one physical row at a time. Three lines stays precise while
3965/// avoiding the sluggish feel of the previous implementation.
3966const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
3967
3968/// Parse the compact key notation used by upstream settings (for example
3969/// `ctrl+g`, `shift+tab`, or `escape`) into crossterm's representation.
3970fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
3971    let mut modifiers = KeyModifiers::NONE;
3972    let mut key = None;
3973    for part in value.trim().to_ascii_lowercase().split('+') {
3974        match part {
3975            "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
3976            "shift" => modifiers |= KeyModifiers::SHIFT,
3977            "alt" | "option" => modifiers |= KeyModifiers::ALT,
3978            "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
3979            part if !part.is_empty() => key = Some(part.to_string()),
3980            _ => {}
3981        }
3982    }
3983    let key = key?;
3984    let code = match key.as_str() {
3985        "esc" | "escape" => KeyCode::Esc,
3986        "enter" | "return" => KeyCode::Enter,
3987        "tab" => {
3988            if modifiers.contains(KeyModifiers::SHIFT) {
3989                return Some(rpi_tui::KeyCombo::new(
3990                    KeyCode::BackTab,
3991                    modifiers & !KeyModifiers::SHIFT,
3992                ));
3993            }
3994            KeyCode::Tab
3995        }
3996        "backspace" | "back" => KeyCode::Backspace,
3997        "delete" | "del" => KeyCode::Delete,
3998        "up" | "arrowup" => KeyCode::Up,
3999        "down" | "arrowdown" => KeyCode::Down,
4000        "left" | "arrowleft" => KeyCode::Left,
4001        "right" | "arrowright" => KeyCode::Right,
4002        "home" => KeyCode::Home,
4003        "end" => KeyCode::End,
4004        "pageup" | "page-up" => KeyCode::PageUp,
4005        "pagedown" | "page-down" => KeyCode::PageDown,
4006        "space" => KeyCode::Char(' '),
4007        "f1" => KeyCode::F(1),
4008        "f2" => KeyCode::F(2),
4009        "f3" => KeyCode::F(3),
4010        "f4" => KeyCode::F(4),
4011        "f5" => KeyCode::F(5),
4012        "f6" => KeyCode::F(6),
4013        "f7" => KeyCode::F(7),
4014        "f8" => KeyCode::F(8),
4015        "f9" => KeyCode::F(9),
4016        "f10" => KeyCode::F(10),
4017        "f11" => KeyCode::F(11),
4018        "f12" => KeyCode::F(12),
4019        value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4020        _ => return None,
4021    };
4022    Some(rpi_tui::KeyCombo::new(code, modifiers))
4023}
4024
4025fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4026    let mut bindings = rpi_tui::Keybindings::new();
4027    let settings = crate::settings::load_settings().unwrap_or_default();
4028    let Some(overrides) = settings.keybindings else {
4029        rpi_tui::set_keybindings(bindings.clone());
4030        return Arc::new(bindings);
4031    };
4032    let known: &[(&str, rpi_tui::KeybindingId)] = &[
4033        ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4034        ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4035        ("app.exit", rpi_tui::keybindings::keys::EXIT),
4036        ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4037        (
4038            "app.model.cycleForward",
4039            rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4040        ),
4041        ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4042        (
4043            "app.thinking.toggle",
4044            rpi_tui::keybindings::keys::THINKING_TOGGLE,
4045        ),
4046        (
4047            "app.editor.external",
4048            rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4049        ),
4050        (
4051            "app.thinking.cycle",
4052            rpi_tui::keybindings::keys::THINKING_CYCLE,
4053        ),
4054        (
4055            "app.clipboard.pasteImage",
4056            rpi_tui::keybindings::keys::PASTE_IMAGE,
4057        ),
4058        ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4059    ];
4060    for (name, id) in known {
4061        let Some(value) = overrides.get(*name) else {
4062            continue;
4063        };
4064        let values: Vec<String> = match value {
4065            serde_json::Value::String(value) => vec![value.clone()],
4066            serde_json::Value::Array(values) => values
4067                .iter()
4068                .filter_map(|v| v.as_str().map(str::to_string))
4069                .collect(),
4070            serde_json::Value::Null => Vec::new(),
4071            _ => continue,
4072        };
4073        let combos: Vec<_> = values
4074            .iter()
4075            .filter_map(|value| parse_configured_key(value))
4076            .collect();
4077        if values.is_empty() || !combos.is_empty() {
4078            bindings.set(id, combos);
4079        }
4080    }
4081    rpi_tui::set_keybindings(bindings.clone());
4082    Arc::new(bindings)
4083}
4084
4085fn keybinding_matches(
4086    bindings: &rpi_tui::Keybindings,
4087    event: &crossterm::event::KeyEvent,
4088    id: rpi_tui::KeybindingId,
4089) -> bool {
4090    if bindings.matches(event, id) {
4091        return true;
4092    }
4093    // crossterm reports Shift+Tab as BackTab on some terminals and as Tab
4094    // plus Shift on others. Treat both forms as the same configured action.
4095    if event.code == KeyCode::BackTab {
4096        let normalized =
4097            crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4098        bindings.matches(&normalized, id)
4099    } else {
4100        false
4101    }
4102}
4103
4104/// Display text for the `app.message.dequeue` binding (e.g. `Alt+Q`), used in
4105/// the pending-messages hint row. Mirrors upstream's `getAppKeyDisplay`;
4106/// falls back to upstream's platform default when nothing is configured.
4107fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
4108    let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
4109    if keys.is_empty() {
4110        return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
4111    }
4112    keys.iter()
4113        .map(|combo| combo.display())
4114        .collect::<Vec<_>>()
4115        .join("/")
4116}
4117
4118/// Whether `last`/`now` fall inside the same 500ms double-press window. Native
4119/// pi uses one window for both Esc's double-escape action and Ctrl+C's "press
4120/// twice to exit", so this helper serves both.
4121fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4122    last.is_some_and(|previous| {
4123        now.duration_since(previous) <= std::time::Duration::from_millis(500)
4124    })
4125}
4126
4127/// What one Ctrl+C press should do, per upstream's `handleCtrlC`.
4128#[derive(Debug, PartialEq, Eq)]
4129enum CtrlCAction {
4130    /// First press: clear the editor and arm the double-press window.
4131    Clear,
4132    /// The window was already open → quit.
4133    Exit,
4134}
4135
4136/// upstream's `handleCtrlC`: a press inside the 500ms window quits, otherwise
4137/// it clears. It never aborts a run — cancelling is `app.interrupt` (Esc)'s job.
4138fn ctrl_c_action(last_sigint: Option<std::time::Instant>, now: std::time::Instant) -> CtrlCAction {
4139    if double_escape_trigger(last_sigint, now) {
4140        CtrlCAction::Exit
4141    } else {
4142        CtrlCAction::Clear
4143    }
4144}
4145
4146/// How long to wait for queued console input before treating a bare Enter as a
4147/// real submit. Pasted input is already in the queue, so this only needs to
4148/// cover scheduler latency — small enough that a human Enter feels instant.
4149const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4150
4151/// Maximum gap between a pasted character and the next pasted key. A human
4152/// cannot deliver two keypresses this fast, so anything tighter is paste.
4153const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4154
4155/// Windows crossterm reads console KEY_EVENTs, not bracketed-paste payloads.
4156/// Buffer a rapid text run before handing it to the editor as one paste.
4157/// A lone typed character is released promptly; a confirmed run may pause
4158/// briefly while the terminal feeds the rest of the clipboard.
4159#[derive(Default)]
4160struct WindowsPasteRun {
4161    text: String,
4162    last_at: Option<std::time::Instant>,
4163    burst: bool,
4164}
4165
4166impl WindowsPasteRun {
4167    fn grace(&self) -> std::time::Duration {
4168        std::time::Duration::from_millis(if self.burst { 120 } else { 15 })
4169    }
4170
4171    fn feed(&mut self, ch: char, now: std::time::Instant) {
4172        if self
4173            .last_at
4174            .is_some_and(|at| now.duration_since(at) <= PASTE_BURST_GAP)
4175        {
4176            self.burst = true;
4177        }
4178        self.text.push(ch);
4179        self.last_at = Some(now);
4180    }
4181
4182    fn flush(&mut self, editor: &Editor) {
4183        if !self.text.is_empty() {
4184            editor.handle_paste(&self.text);
4185        }
4186        *self = Self::default();
4187    }
4188}
4189
4190/// Never turn a lone Enter into a newline. A queued next key proves this is
4191/// an interior pasted newline. At the end of a paste, require clipboard
4192/// corroboration before consuming Enter; otherwise preserve submit.
4193fn windows_enter_in_paste(
4194    run: &WindowsPasteRun,
4195    more_queued: bool,
4196    clipboard: Option<&str>,
4197) -> bool {
4198    if more_queued {
4199        return true;
4200    }
4201    if run.text.is_empty() {
4202        return false;
4203    }
4204    run.burst
4205        && clipboard.is_some_and(|text| {
4206            let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
4207            let prefix = format!("{}\n", run.text);
4208            normalized.starts_with(&prefix) && normalized.matches('\n').count() >= 10
4209        })
4210}
4211
4212/// Whether a bare Enter is pasted content (insert a newline) rather than a
4213/// submit.
4214///
4215/// `crossterm` 0.27 only implements bracketed paste on Unix; on Windows the
4216/// console event source never produces `Event::Paste`, so a pasted block
4217/// arrives as plain key events with a bare Enter per line. Two timing signals
4218/// separate a pasted Enter from a typed one:
4219///
4220/// - `more_queued`: real key input is already sitting in the console input
4221///   queue. Callers must feed this through [`probe_paste_input`], which drops
4222///   the key's own Release events — Windows queues press+release together over
4223///   RDP, and a bare poll cannot tell that Release apart from paste content.
4224/// - `last_text_key_at`: the Enter lands within [`PASTE_BURST_GAP`] of the
4225///   preceding pasted character, which also catches a paste that *ends* with a
4226///   newline (nothing queued behind it, but it was not typed by hand).
4227///
4228/// NOTE: the caller gates this on `cfg!(windows)`. On Unix, bracketed paste
4229/// delivers real pastes as `Event::Paste`, and the timing heuristic would
4230/// misclassify a remote/mobile client's coalesced text+Enter burst as paste
4231/// ("回车变成了换行").
4232#[cfg(test)]
4233fn enter_is_paste_burst(
4234    last_text_key_at: Option<std::time::Instant>,
4235    now: std::time::Instant,
4236    more_queued: bool,
4237) -> bool {
4238    if more_queued {
4239        return true;
4240    }
4241    last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4242}
4243
4244/// Decide whether more *real* input is queued behind the current key, for the
4245/// Windows paste-burst check.
4246///
4247/// Windows emits a `KeyEventKind::Release` for every press, usually queued
4248/// right behind the `Press`. A bare non-blocking poll therefore reports "more
4249/// input" for a lone Enter — which made a remote/RDP Enter look like a paste
4250/// burst. This drains and discards release events and returns the first
4251/// *meaningful* event it read (stashed so the caller's loop still processes it)
4252/// plus whether any real input was queued. `next_event` returns `None` when
4253/// nothing is available within the probe window.
4254fn probe_paste_input(
4255    pending: Option<Event>,
4256    mut next_event: impl FnMut() -> Option<Event>,
4257) -> (bool, Option<Event>) {
4258    let mut pending = pending;
4259    let mut more_queued = pending.is_some();
4260    while !more_queued {
4261        match next_event() {
4262            Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
4263            Some(ev) => {
4264                pending = Some(ev);
4265                more_queued = true;
4266            }
4267            None => break,
4268        }
4269    }
4270    (more_queued, pending)
4271}
4272
4273fn transcript_page_size(viewport_height: usize) -> i32 {
4274    viewport_height
4275        .saturating_sub(PAGE_SCROLL_OVERLAP)
4276        .max(1)
4277        .min(i32::MAX as usize) as i32
4278}
4279
4280fn should_dispatch_key(kind: KeyEventKind) -> bool {
4281    kind != KeyEventKind::Release
4282}
4283
4284/// Last-resort quit, run on the key worker thread.
4285///
4286/// The async loop owns `run_prompt_streaming(..).await` for the whole run, so a
4287/// queued [`TuiMessage::Exit`] is not read while a run is in flight. When the
4288/// user asks to quit a second time — the first Ctrl+C only reached `Aborting` —
4289/// treat the run as wedged, restore the terminal, and exit.
4290///
4291/// `130` is the conventional `128 + SIGINT` status, so a wrapper script can tell
4292/// a forced quit from a clean one.
4293fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
4294    // Best effort: leave the alternate buffer and restore cooked mode so the
4295    // user's shell is usable afterwards. `stop` also joins the render
4296    // scheduler, so no in-flight frame can repaint over the restored screen.
4297    tui.stop(Default::default());
4298    eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
4299    std::process::exit(130);
4300}
4301
4302/// upstream's `handleCtrlZ` (`app.suspend`): hand the terminal back to the
4303/// shell and stop the process. `raise(SIGTSTP)` returns once the user resumes
4304/// us with `fg`/`bg`, at which point we re-enter the alternate screen.
4305#[cfg(unix)]
4306fn handle_suspend(tui: &Arc<TuiAltScreen>) {
4307    tui.suspend();
4308    // A `Result` here would mean the signal could not be raised; there is no
4309    // useful recovery, so resume either way and let the user retry.
4310    let _ = nix::sys::signal::raise(nix::sys::signal::Signal::SIGTSTP);
4311    tui.resume();
4312}
4313
4314/// upstream binds no suspend key on Windows (`app.suspend`'s `defaultKeys` is
4315/// empty there), so [`keys::SUSPEND`] never matches and this is unreachable.
4316#[cfg(not(unix))]
4317fn handle_suspend(_tui: &Arc<TuiAltScreen>) {}
4318
4319/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
4320fn format_tokens(n: i64) -> String {
4321    if n >= 1_000_000 {
4322        format!("{:.1}M", n as f64 / 1_000_000.0)
4323    } else if n >= 1_000 {
4324        format!("{:.1}K", n as f64 / 1_000.0)
4325    } else {
4326        n.to_string()
4327    }
4328}
4329
4330/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
4331/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
4332/// resets the browse state so a fresh prompt never resumes mid-history.
4333fn push_history(state: &Arc<TuiState>, text: &str) {
4334    let trimmed = text.trim().to_string();
4335    if trimmed.is_empty() {
4336        return;
4337    }
4338    let mut history = state.history.lock().unwrap();
4339    if history.first() == Some(&trimmed) {
4340        return;
4341    }
4342    history.insert(0, trimmed);
4343    history.truncate(HISTORY_LIMIT);
4344    *state.history_index.lock().unwrap() = -1;
4345    *state.history_draft.lock().unwrap() = None;
4346}
4347
4348/// Submit a draft as a user prompt through the *same* path the Enter key takes:
4349/// render the user bubble, record history, then hand the text to the main loop
4350/// as [`TuiMessage::UserInput`]. Using the editor path (rather than a direct
4351/// `SendUserMessage`) is what makes a voice transcription show up as a user
4352/// message — the harness emits no user `message_start` for a directly-sent
4353/// prompt.
4354fn submit_draft(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>, text: &str) {
4355    if text.trim().is_empty() || !state.try_start_working() {
4356        return;
4357    }
4358    add_user_message(&state.chat_container, text);
4359    if let Some(tui) = &state.tui {
4360        if let Some(scroll) = tui.get_primary_scroll_view() {
4361            scroll.scroll_to_end();
4362        }
4363        tui.request_render(false);
4364    }
4365    push_history(state, text);
4366    let _ = tx.send(TuiMessage::UserInput(text.to_string()));
4367}
4368
4369/// Advance a pending auto-send countdown. Returns `true` when the caller needs
4370/// to repaint.
4371///
4372/// The draft is abandoned — leaving the user's text in place and clearing the
4373/// footer countdown — when the user edits it (the editor no longer matches the
4374/// injected text), when a run has started, or when the editor was cleared.
4375/// Otherwise the remaining time is written to the footer; once it reaches zero
4376/// the draft is submitted.
4377fn poll_auto_send(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>) -> bool {
4378    // Clone out of the lock before touching the editor/footer so no other
4379    // thread can block behind this tick.
4380    let pending = state.auto_send.lock().unwrap().clone();
4381    let step = auto_send_step(
4382        pending.as_ref(),
4383        &state.editor.get_text(),
4384        *state.status.lock().unwrap() == RunStatus::Idle,
4385        std::time::Instant::now(),
4386    );
4387
4388    match step {
4389        AutoSendStep::Idle => false,
4390        AutoSendStep::Cancel => {
4391            state.clear_auto_send();
4392            true
4393        }
4394        AutoSendStep::Submit => {
4395            let text = state.editor.get_text();
4396            state.clear_auto_send();
4397            state.clear_editor_programmatically();
4398            submit_draft(state, tx, &text);
4399            true
4400        }
4401        AutoSendStep::Wait(remaining) => {
4402            let secs = remaining.as_millis() as f64 / 1000.0;
4403            let label = format!("{AUTO_SEND_PREFIX}{secs:.1}s · any key to edit");
4404            if state.footer.get_status() != label {
4405                state.footer.set_status(&label);
4406                true
4407            } else {
4408                false
4409            }
4410        }
4411    }
4412}
4413
4414/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
4415/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
4416/// current editor text as the draft; navigating back past the newest entry
4417/// restores that draft.
4418fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
4419    let history = state.history.lock().unwrap();
4420    if history.is_empty() {
4421        return;
4422    }
4423    let mut index = state.history_index.lock().unwrap();
4424    let new_index = *index - direction as isize;
4425    if new_index < -1 || new_index >= history.len() as isize {
4426        return;
4427    }
4428    if *index == -1 && new_index >= 0 {
4429        // Entering browse mode: stash the current input.
4430        *state.history_draft.lock().unwrap() = Some(editor.get_text());
4431    }
4432    *index = new_index;
4433    if new_index == -1 {
4434        // Exited browse mode: restore the draft (or clear if there was none).
4435        let draft = state.history_draft.lock().unwrap().take();
4436        match draft {
4437            Some(d) => {
4438                let len = d.len();
4439                editor.set_text(&d);
4440                editor.set_cursor(0, len);
4441            }
4442            None => editor.set_text(""),
4443        }
4444    } else {
4445        let text = history[new_index as usize].clone();
4446        let len = text.len();
4447        editor.set_text(&text);
4448        editor.set_cursor(0, len);
4449    }
4450}
4451
4452impl TuiState {
4453    fn cancel_js_preparation(&self) -> bool {
4454        let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4455        if let Some(cancellation) = cancellation {
4456            cancellation.cancel();
4457            true
4458        } else {
4459            false
4460        }
4461    }
4462
4463    /// Whether a user-initiated `!command` shell run is currently active.
4464    fn user_bash_running(&self) -> bool {
4465        self.user_bash_cancel.lock().unwrap().is_some()
4466    }
4467
4468    /// Register a cancellation token for a new user bash run, cancelling any
4469    /// previous one (mirrors `begin_js_preparation`).
4470    fn begin_user_bash(&self) -> CancellationToken {
4471        let cancellation = CancellationToken::new();
4472        if let Some(previous) = self
4473            .user_bash_cancel
4474            .lock()
4475            .unwrap()
4476            .replace(cancellation.clone())
4477        {
4478            previous.cancel();
4479        }
4480        cancellation
4481    }
4482
4483    /// Clear the user-bash slot. Called when the command finishes so the
4484    /// running guard and the Esc router both see idle state.
4485    fn finish_user_bash(&self) {
4486        self.user_bash_cancel.lock().unwrap().take();
4487    }
4488
4489    /// Cancel a running user bash command. Returns `true` when one was active
4490    /// (mirrors `cancel_js_preparation`). The spawned task clears the slot
4491    /// itself once `execute_shell_with_capture` returns.
4492    fn cancel_user_bash(&self) -> bool {
4493        let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
4494        if let Some(cancellation) = cancellation {
4495            cancellation.cancel();
4496            true
4497        } else {
4498            false
4499        }
4500    }
4501
4502    fn set_status(&self, status: RunStatus) {
4503        *self.status.lock().unwrap() = status;
4504        self.apply_status(status);
4505    }
4506
4507    /// The dock container listing queued messages (upstream's
4508    /// `pendingMessagesContainer`).
4509    fn pending_container(&self) -> &Arc<Container> {
4510        &self.pending_container
4511    }
4512
4513    /// Render the current queue into the pending dock slot. Mirrors native
4514    /// pi's `updatePendingMessagesDisplay`: `Steering: <text>` /
4515    /// `Follow-up: <text>` rows (dim) plus a `↳ <key> to edit all queued
4516    /// messages` hint. A no-op when the snapshot is unchanged, so the poll
4517    /// caller can invoke it freely without forcing frames.
4518    fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
4519        {
4520            let mut current = self.pending_snapshot.lock().unwrap();
4521            if *current == messages {
4522                return;
4523            }
4524            *current = messages.clone();
4525        }
4526        let container = &self.pending_container;
4527        container.clear();
4528        if messages.is_empty() {
4529            return;
4530        }
4531        let colors = current_theme().colors;
4532        container.add_child(Arc::new(Spacer::new(1)));
4533        for text in &messages.steering {
4534            container.add_child(Arc::new(Text::new(
4535                colors.muted.fg(&format!("Steering: {text}")),
4536                1,
4537                0,
4538            )));
4539        }
4540        for text in &messages.follow_up {
4541            container.add_child(Arc::new(Text::new(
4542                colors.muted.fg(&format!("Follow-up: {text}")),
4543                1,
4544                0,
4545            )));
4546        }
4547        container.add_child(Arc::new(Text::new(
4548            colors.muted.fg(&format!(
4549                "↳ {} to edit all queued messages",
4550                self.dequeue_hint
4551            )),
4552            1,
4553            0,
4554        )));
4555    }
4556
4557    /// Atomically reserve the single interactive run slot. The editor callback
4558    /// runs on a different thread from the async prompt loop, so checking and
4559    /// setting in separate steps would allow rapid Enter presses to queue more
4560    /// than one operation.
4561    fn try_start_working(&self) -> bool {
4562        let mut status = self.status.lock().unwrap();
4563        if *status != RunStatus::Idle {
4564            return false;
4565        }
4566        *status = RunStatus::Working;
4567        drop(status);
4568        self.apply_status(RunStatus::Working);
4569        true
4570    }
4571
4572    fn apply_status(&self, status: RunStatus) {
4573        match status {
4574            RunStatus::Working => {
4575                // The status dock already shows the active loader. Keep the
4576                // footer focused on the model and shortcuts instead of
4577                // repeating a second `Working…` at the bottom of the TUI.
4578                self.footer.set_status("");
4579                // Reflect the in-flight turn in the terminal window/tab title
4580                // (OSC 2). No-op when `tui` is absent (unit tests).
4581                if let Some(tui) = &self.tui {
4582                    tui.set_title(&crate::brand::window_title("⟳"));
4583                }
4584                self.status_container.clear();
4585                // The working indicator renders inside the editor's top border.
4586                // `show_terminal_progress` disables it entirely.
4587                if self.show_terminal_progress {
4588                    self.editor.set_working(Some(WorkingState {
4589                        frame: 0,
4590                        started_at: std::time::Instant::now(),
4591                        message: "Working…".to_string(),
4592                    }));
4593                } else {
4594                    self.editor.set_working(None);
4595                }
4596            }
4597            RunStatus::Aborting => {
4598                self.footer.set_status("Aborting…");
4599                // Do not leave a frozen "Working" spinner on screen after the
4600                // render tick intentionally stops advancing in this state.
4601                self.loader.stop();
4602                self.status_container.clear();
4603                self.editor.set_working(None);
4604            }
4605            RunStatus::Idle => {
4606                self.footer.set_status("");
4607                if let Some(tui) = &self.tui {
4608                    tui.set_title(&crate::brand::window_title(""));
4609                }
4610                self.loader.stop();
4611                self.status_container.clear();
4612                self.editor.set_working(None);
4613            }
4614        }
4615    }
4616
4617    /// Push the extension status registry into the footer when it changed.
4618    /// Returns `true` when the footer was rewritten (the caller then requests a
4619    /// repaint — an idle session would otherwise never redraw).
4620    fn sync_extension_status(&self) -> bool {
4621        let revision = self.ext_status.revision();
4622        if self
4623            .ext_status_revision
4624            .load(std::sync::atomic::Ordering::SeqCst)
4625            == revision
4626        {
4627            return false;
4628        }
4629        self.ext_status_revision
4630            .store(revision, std::sync::atomic::Ordering::SeqCst);
4631        self.footer.set_extension_status(&self.ext_status.text());
4632        true
4633    }
4634
4635    /// Drain the extension editor-text queue (`SetEditorText`) into the prompt
4636    /// editor. Returns `true` when at least one edit was applied (the caller
4637    /// then repaints).
4638    ///
4639    /// An edit with an `autoSendMs` arms [`AutoSendPending`] so an *untouched*
4640    /// draft is submitted without a keystroke; the user can still steal it back
4641    /// by typing (see the key loop) or by any edit that diverges from the
4642    /// injected text (see [`poll_auto_send`]). The injected text is the draft,
4643    /// so submitting it goes through the normal prompt path and renders a user
4644    /// bubble — unlike `SendUserMessage`, which emits no user `message_start`.
4645    fn drain_editor_text(&self) -> bool {
4646        let mut applied = false;
4647        while let Some(edit) = self.editor_text.take_pending() {
4648            let current = self.editor.get_text();
4649            let text = match edit.mode {
4650                rpi_extensions::EditorTextMode::Replace => edit.text,
4651                rpi_extensions::EditorTextMode::Append => {
4652                    if current.is_empty() {
4653                        edit.text
4654                    } else {
4655                        // A blank separator keeps two dictations readable; an
4656                        // existing trailing space is not doubled.
4657                        let mut joined = current.trim_end().to_string();
4658                        joined.push(' ');
4659                        joined.push_str(&edit.text);
4660                        joined
4661                    }
4662                }
4663            };
4664            set_editor_text_caret_at_end(&self.editor, &text);
4665            // The `EditorChange` this produces is ours, not the user's.
4666            self.programmatic_editor_write
4667                .store(true, std::sync::atomic::Ordering::SeqCst);
4668            let pending = edit
4669                .auto_send_ms
4670                .filter(|ms| *ms > 0)
4671                .map(|ms| AutoSendPending {
4672                    deadline: std::time::Instant::now() + std::time::Duration::from_millis(ms),
4673                    text: text.clone(),
4674                });
4675            *self.auto_send.lock().unwrap() = pending;
4676            applied = true;
4677        }
4678        applied
4679    }
4680
4681    /// Clear the editor as **host housekeeping**, not as a user edit.
4682    ///
4683    /// The clear that follows every submit is the important one: it is the host
4684    /// tidying up, and reporting it as `source:"user"` told a hands-free voice
4685    /// extension "the human just took the keyboard" — so `/voice auto` switched
4686    /// itself off the instant it was enabled (the command's own editor clear
4687    /// arrives on the next tick, ~80ms after the mode turned on) and the
4688    /// microphone never stayed open. A real user clear (Esc, select-all +
4689    /// delete) goes through the editor itself and is still `user`.
4690    fn clear_editor_programmatically(&self) {
4691        self.programmatic_editor_write
4692            .store(true, std::sync::atomic::Ordering::SeqCst);
4693        self.editor.clear();
4694    }
4695
4696    /// Drop a pending auto-send, clearing the footer countdown when it still
4697    /// shows ours (so an unrelated run status is never clobbered).
4698    fn clear_auto_send(&self) {
4699        if self.auto_send.lock().unwrap().take().is_some()
4700            && self.footer.get_status().starts_with(AUTO_SEND_PREFIX)
4701        {
4702            self.footer.set_status("");
4703        }
4704    }
4705
4706    /// Cancel a pending auto-send because the user pressed a key. Returns
4707    /// `true` when a countdown was running. The key is *still* dispatched
4708    /// normally, so it lands in the draft as the user's first correction.
4709    fn cancel_auto_send_on_key(&self) -> bool {
4710        let had = self.auto_send.lock().unwrap().is_some();
4711        if had {
4712            self.clear_auto_send();
4713        }
4714        had
4715    }
4716
4717    /// Notify extensions when the prompt draft changed since the last tick.
4718    ///
4719    /// This is how a voice extension learns the user is composing again, so it
4720    /// can stop reading the previous answer aloud (barge-in) — the draft text
4721    /// itself is not shipped, only `chars`/`empty`/`source`.
4722    ///
4723    /// `source` separates a human edit from one the TUI made on a plugin's
4724    /// behalf (`SetEditorText`, or clearing the draft after an auto-send). A
4725    /// hands-free extension needs that distinction: its own injected
4726    /// transcription must not be mistaken for the user taking over the
4727    /// keyboard, which would make it stop listening on every single turn.
4728    ///
4729    /// Polled from the render tick rather than pushed from [`Editor::on_change`]:
4730    /// that callback slot already has an owner (the bash-mode border colour) and
4731    /// runs while the editor's callback lock is held, where re-entering
4732    /// extension code is needlessly risky.
4733    fn sync_editor_change(&self) {
4734        use rpi_plugin_sdk::EventTag;
4735
4736        let Some(payload) = self.take_editor_change() else {
4737            return;
4738        };
4739        // Cheap pre-check so the common (no subscriber) case never touches a
4740        // plugin: session lock + snapshot read only.
4741        let Ok(session) = self.extension_session.lock() else {
4742            return;
4743        };
4744        let Some(snapshot) = session.snapshot_arc() else {
4745            return;
4746        };
4747        if snapshot.handlers_for(EventTag::EditorChange).is_empty() {
4748            return;
4749        }
4750        rpi_extensions::dispatch_data_event(
4751            &snapshot,
4752            EventTag::EditorChange,
4753            &payload.to_string(),
4754        );
4755    }
4756
4757    /// Build the `EditorChange` payload when the draft changed since the last
4758    /// tick, or `None` when it did not. Split out of
4759    /// [`Self::sync_editor_change`] so the attribution rule is unit-testable
4760    /// without a live extension registry.
4761    ///
4762    /// The attribution is consumed here — even when the caller then finds no
4763    /// subscriber — so a stale "programmatic" mark can never mislabel a later
4764    /// real keystroke.
4765    fn take_editor_change(&self) -> Option<serde_json::Value> {
4766        let text = self.editor.get_text();
4767        {
4768            let mut last = self.last_editor_text.lock().unwrap();
4769            if *last == text {
4770                return None;
4771            }
4772            *last = text.clone();
4773        }
4774        let programmatic = self
4775            .programmatic_editor_write
4776            .swap(false, std::sync::atomic::Ordering::SeqCst);
4777        Some(serde_json::json!({
4778            "chars": text.chars().count(),
4779            "empty": text.is_empty(),
4780            "source": if programmatic {
4781                // The TUI wrote it (an injected draft / the auto-send clear).
4782                "extension"
4783            } else {
4784                "user"
4785            },
4786        }))
4787    }
4788
4789    /// The bash panel has its own `Running...` spinner. Keep the global
4790    /// `Working...` loader out of the status slot while any bash tool is active
4791    /// so the same operation is not presented as two simultaneous loaders.
4792    fn sync_working_loader_with_bash(&self) {
4793        if *self.status.lock().unwrap() != RunStatus::Working {
4794            return;
4795        }
4796
4797        // The working indicator now lives inside the editor's top border and
4798        // stays visible for the whole Working state, so no status-slot swap is
4799        // needed when bash panels (which render their own spinner in the
4800        // transcript) appear or disappear.
4801        self.status_container.clear();
4802    }
4803
4804    fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4805        *self.status.lock().unwrap() = RunStatus::Working;
4806        self.footer.set_status("");
4807        if let Some(tui) = &self.tui {
4808            tui.set_title(&crate::brand::window_title("↻"));
4809        }
4810        self.loader.stop();
4811        self.status_container.clear();
4812        self.status_container
4813            .add_child(Arc::new(StatusIndicator::retry(
4814                attempt,
4815                max_retries,
4816                std::time::Duration::from_millis(delay_ms),
4817            )));
4818    }
4819
4820    /// Whether a selector overlay is currently open (routes keys to it first).
4821    fn selector_open(&self) -> bool {
4822        self.active_selector.lock().unwrap().is_some()
4823    }
4824
4825    fn extension_editor_open(&self) -> bool {
4826        self.active_extension_editor.lock().unwrap().is_some()
4827    }
4828
4829    fn extension_input_open(&self) -> bool {
4830        self.active_extension_input.lock().unwrap().is_some()
4831    }
4832
4833    fn extension_dialog_open(&self) -> bool {
4834        self.extension_editor_open() || self.extension_input_open()
4835    }
4836
4837    fn set_hide_thinking(&self, hide: bool) {
4838        *self.hide_thinking.lock().unwrap() = hide;
4839        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4840            comp.set_hide_thinking(hide);
4841        }
4842    }
4843
4844    fn hide_thinking(&self) -> bool {
4845        *self.hide_thinking.lock().unwrap()
4846    }
4847
4848    fn toggle_thinking(&self) -> bool {
4849        let next = !self.hide_thinking();
4850        self.set_hide_thinking(next);
4851        next
4852    }
4853
4854    fn toggle_tool_outputs(&self) -> bool {
4855        let next = !*self.tool_outputs_expanded.lock().unwrap();
4856        *self.tool_outputs_expanded.lock().unwrap() = next;
4857        for comp in self.tool_components.lock().unwrap().values() {
4858            comp.set_expanded(next);
4859        }
4860        for comp in self.bash_components.lock().unwrap().values() {
4861            comp.set_expanded(next);
4862        }
4863        next
4864    }
4865
4866    /// The model id currently tracked as active (footer + Ctrl+P anchor).
4867    fn current_model_id(&self) -> String {
4868        self.current_model_id.lock().unwrap().clone()
4869    }
4870
4871    /// Update the tracked model id + footer label after a switch (live or
4872    /// cycle). Called from the `/model` on_select and the Ctrl+P handler.
4873    fn set_current_model(&self, model: &rpi_ai::Model) {
4874        *self.current_model_id.lock().unwrap() = model.id.clone();
4875        self.footer.set_model(&short_model_name(&model.id));
4876        // A model switch changes the context window, so the `%/{window}` badge
4877        // must rescale against the same last-reported token count.
4878        self.footer.set_context_window(model.context_window as i64);
4879    }
4880
4881    /// B5e: read a clone of the current assistant-markdown transformer (if any).
4882    /// New assistant components call this at construction so they render with
4883    /// whatever plugin `register_markdown_transformer` handlers are live.
4884    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4885        self.markdown_transformer.lock().unwrap().clone()
4886    }
4887
4888    /// Build a [`TranscriptView`] over the shared component maps. Cheap (clones
4889    /// a handful of `Arc`s) and lets the event drain render the transcript
4890    /// through the same code path as the remote client.
4891    ///
4892    /// [`TranscriptView`]: crate::transcript_view::TranscriptView
4893    fn transcript_view(&self) -> crate::transcript_view::TranscriptView {
4894        crate::transcript_view::TranscriptView::with_maps(
4895            self.chat_container.clone(),
4896            self.current_assistant.clone(),
4897            self.tool_components.clone(),
4898            self.bash_components.clone(),
4899            self.hide_thinking.clone(),
4900            self.tool_outputs_expanded.clone(),
4901            self.markdown_transformer.clone(),
4902        )
4903    }
4904
4905    /// B5e: swap the live transformer. Used at startup (install the first
4906    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
4907    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
4908    /// transform should take effect on the VISIBLE streaming message too, so
4909    /// this re-installs on the in-flight `current_assistant` component — its
4910    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
4911    /// `None` clears the transform (identity), e.g. a reload that unregisters
4912    /// every markdown transformer.
4913    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4914        *self.markdown_transformer.lock().unwrap() = transformer.clone();
4915        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4916            comp.set_markdown_transformer(transformer);
4917        }
4918    }
4919
4920    fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4921        self.pending_images.lock().unwrap().push(image);
4922    }
4923
4924    fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4925        std::mem::take(&mut *self.pending_images.lock().unwrap())
4926    }
4927}
4928
4929// ===========================================================================
4930// interactive_tui — the entry point
4931// ===========================================================================
4932
4933#[derive(Debug, PartialEq)]
4934struct TuiStartupSettings {
4935    editor_padding_x: usize,
4936    autocomplete_max_visible: usize,
4937    hide_thinking: bool,
4938    quiet_startup: bool,
4939    show_terminal_progress: bool,
4940    /// Render images inline (`terminal.showImages` / flat `showImages`).
4941    show_images: bool,
4942    /// Transcript notices for prompt-cache costs (`showCacheMissNotices`).
4943    cache_miss_notices: bool,
4944}
4945
4946fn preferred_project_setting<T>(
4947    project_settings: &[crate::settings::Settings],
4948    field: impl Fn(&crate::settings::Settings) -> Option<T>,
4949) -> Option<T> {
4950    project_settings.iter().find_map(field)
4951}
4952
4953fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4954    verbose || !quiet_startup
4955}
4956
4957/// `project_settings` is ordered by precedence; an explicit `Some`
4958/// wins even when the value is `false` or zero.
4959fn resolve_tui_startup_settings(
4960    global: &crate::settings::Settings,
4961    project_settings: &[crate::settings::Settings],
4962    project_trusted: bool,
4963) -> TuiStartupSettings {
4964    let project_settings = if project_trusted {
4965        project_settings
4966    } else {
4967        &[]
4968    };
4969    let editor_padding_x =
4970        preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4971            .or(global.editor_padding_x)
4972            .unwrap_or(1)
4973            .min(16);
4974    let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4975        settings.autocomplete_max_visible
4976    })
4977    .or(global.autocomplete_max_visible)
4978    .unwrap_or(5)
4979    .clamp(1, 20);
4980    let hide_thinking =
4981        preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4982            .or(global.hide_thinking_block)
4983            .unwrap_or(false);
4984    let quiet_startup =
4985        preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4986            .or(global.quiet_startup)
4987            .unwrap_or(false);
4988    let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
4989        settings.show_terminal_progress()
4990    })
4991    .or_else(|| global.show_terminal_progress())
4992    .unwrap_or(true);
4993    let show_images =
4994        preferred_project_setting(project_settings, |settings| settings.show_images())
4995            .or_else(|| global.show_images())
4996            .unwrap_or(true);
4997    let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
4998        settings.show_cache_miss_notices
4999    })
5000    .or(global.show_cache_miss_notices)
5001    .unwrap_or(false);
5002
5003    TuiStartupSettings {
5004        editor_padding_x,
5005        autocomplete_max_visible,
5006        hide_thinking,
5007        quiet_startup,
5008        show_terminal_progress,
5009        show_images,
5010        cache_miss_notices,
5011    }
5012}
5013
5014/// TUI-based interactive mode.
5015///
5016/// `event_rx` carries the live `AgentEvent` stream (installed by
5017/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
5018/// fn), it falls back to a blocking, await-final-text path.
5019///
5020/// `model_catalog` is the read-only catalog the `/model` selector displays.
5021///
5022/// This implementation mirrors the TypeScript `InteractiveMode` class:
5023/// build the layout root once, drain `AgentEvent`s into UI mutations that
5024/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
5025/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
5026/// autocomplete are layered on via the editor-container swap pattern.
5027pub async fn interactive_tui(
5028    harness: &AgentHarness,
5029    event_rx: Option<broadcast::Receiver<AgentEvent>>,
5030    args: &Args,
5031    model_catalog: Vec<rpi_ai::Model>,
5032    initial: Option<String>,
5033    extra_messages: &[String],
5034    initial_images: Vec<rpi_ai::types::ImageContent>,
5035    theme: Option<&str>,
5036    _no_themes: bool,
5037    reload_context: &crate::session::ReloadContext,
5038) -> i32 {
5039    let lane: Arc<dyn AgentLane> = harness.lane("main");
5040    // Reuse the startup snapshot captured before provider/session setup. An
5041    // extension may mutate the process cwd while loading; that must not change
5042    // which project settings or package update roots this TUI observes.
5043    let cwd = reload_context.cwd.clone();
5044    let saved_settings = crate::settings::load_settings().unwrap_or_default();
5045    let project_trusted = reload_context.project_trusted;
5046    let project_settings = if project_trusted {
5047        crate::settings::load_project_settings(&cwd)
5048    } else {
5049        Vec::new()
5050    };
5051    let tui_settings =
5052        resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5053    let editor_padding_x = tui_settings.editor_padding_x;
5054    let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5055    let mut hide_thinking = tui_settings.hide_thinking;
5056    let show_terminal_progress = tui_settings.show_terminal_progress;
5057    let show_images = tui_settings.show_images;
5058    let cache_miss_notices = tui_settings.cache_miss_notices;
5059    let quiet_startup = tui_settings.quiet_startup;
5060    // rpi only checks its own update channel.
5061    // Session display preferences (durable, latest-wins) override the
5062    // settings-file defaults. These are per-session facts stored as custom
5063    // entries (see `rpi_harness::session::values`), so resuming a session with
5064    // `-c`/`-r` restores the thinking/tool-output display the user last chose
5065    // there — independent of global settings. Absent keys leave the settings
5066    // value untouched.
5067    let session_values = rpi_harness::session::SessionValues::load(harness.session())
5068        .await
5069        .unwrap_or_default();
5070    if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5071        hide_thinking = persisted;
5072    }
5073
5074    // Resolve the active model once, up front. The full id feeds the TuiState
5075    // tracking field + the selectors/key loop (which run on a blocking thread
5076    // and can't await `lane.get_model()`); the short name feeds the footer.
5077    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5078    let model_name = short_model_name(&lane_model_id);
5079
5080    // Snapshot startup capabilities for the welcome screen. Both accessors
5081    // return defensive clones, so rendering this summary does not retain a
5082    // harness lock or trigger a second resource scan.
5083    let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5084    // AgentHarness uses an empty active-name list as the default "all tools"
5085    // state. Do not expose that implementation sentinel as `Tools (0) none`
5086    // in the welcome banner (it is especially visible on the first prompt).
5087    if active_tool_names.is_empty() {
5088        active_tool_names = harness
5089            .get_tools()
5090            .await
5091            .unwrap_or_default()
5092            .into_iter()
5093            .map(|tool| tool.tool.schema().name.clone())
5094            .collect();
5095    }
5096    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5097    let skill_names: Vec<String> = resources_snapshot
5098        .skills
5099        .as_deref()
5100        .unwrap_or(&[])
5101        .iter()
5102        .map(|skill| skill.name.clone())
5103        .collect();
5104
5105    // Channel between the key/callback threads and the main async loop.
5106    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
5107
5108    // Apply the saved theme before constructing transcript components. Some
5109    // components keep styled text, so doing this after the welcome banner left
5110    // the first screen in the dark palette until it was rebuilt.
5111    let theme_manager = Arc::new(ThemeManager::new());
5112    if let Some(preset) = match theme {
5113        Some("light") => Some(ThemePreset::Light),
5114        Some("monochrome") => Some(ThemePreset::Monochrome),
5115        Some("dark") => Some(ThemePreset::Dark),
5116        _ => None,
5117    } {
5118        apply_theme_preset(preset);
5119        theme_manager.apply_preset(preset);
5120    }
5121
5122    // ---- TUI + containers ----
5123    let terminal = Box::new(ProcessTerminal::new());
5124    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
5125    // Plugin `ask_user` prompts (runtime action 17). Attaching marks this
5126    // session as interactive so a detached/headless host fails loudly instead
5127    // of returning a fabricated answer. The mailbox is session-scoped (held in
5128    // `ReloadContext`) so in-flight prompts survive a `/reload`.
5129    let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
5130    ask_user_bridge.attach();
5131    tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
5132
5133    let chat_container = Arc::new(Container::new());
5134    if should_show_startup_listing(args.verbose, quiet_startup) {
5135        add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5136    }
5137
5138    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
5139    // banner once, then write the sentinel. The banner covers the theme hint;
5140    // the theme stays pickable via `/theme`. See `extras.rs`.
5141    crate::extras::maybe_first_time_setup(&chat_container);
5142
5143    // A --continue/--resume/--session launch opens on an existing JSONL
5144    // session — render its prior user/assistant transcript so the user sees
5145    // where they left off (tool executions are skipped: their live display
5146    // belongs to the current run, and replaying old results would be noise).
5147    let initial_transformer = build_markdown_transformer(
5148        reload_context
5149            .extension_session
5150            .lock()
5151            .unwrap()
5152            .snapshot_arc(),
5153    );
5154    // A normal launch creates a fresh session and must not replay records from
5155    // another/project harness. Only explicit restore/fork modes render prior
5156    // conversation history. This fixes stale prompts appearing every startup.
5157    if launch_restores_history(args) {
5158        render_session_history(
5159            &harness,
5160            &chat_container,
5161            initial_transformer.clone(),
5162            Some(reload_context.extension_session.clone()),
5163        )
5164        .await;
5165    }
5166
5167    // `document_container` wraps the welcome header + chat so the scrollview
5168    // follows the whole transcript (mirrors TS `documentContainer`).
5169    let document_container = Arc::new(Container::new());
5170    document_container.add_child(chat_container.clone());
5171    // Add bottom padding to the output area for visual breathing room.
5172    document_container.add_child(Arc::new(Spacer::new(1)));
5173
5174    let scroll_view = Arc::new(ScrollView::new(
5175        document_container.clone(),
5176        ScrollViewOptions {
5177            follow: FollowMode::End,
5178            primary: true,
5179            overscroll: OverscrollMode::Chain,
5180            // upstream keeps transcript chrome out of the way. Our Auto mode
5181            // has no hide timer yet and therefore became effectively permanent
5182            // after the first wheel event, unlike the upstream experience.
5183            scrollbar: ScrollbarMode::Hidden,
5184            ..Default::default()
5185        },
5186    ));
5187
5188    // ---- Editor ----
5189    // Bordered box matching upstream: no `> ` prompt, no placeholder — the
5190    // editor renders full-width `─` top/bottom borders with padding-only lines
5191    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
5192    let keybindings = configured_keybindings();
5193    let editor = Arc::new(Editor::new(
5194        EditorOptions {
5195            padding_x: editor_padding_x,
5196            autocomplete_max_visible,
5197            ..Default::default()
5198        },
5199        EditorStyle::default(),
5200        keybindings.clone(),
5201    ));
5202
5203    // Bash-mode border: a `!`-prefixed draft recolors the editor border to the
5204    // bash accent (upstream's `updateEditorBorderColor` / `isBashMode`). The
5205    // editor fires `on_change` for keystrokes and for programmatic
5206    // `clear`/`set_text`, so the color also resets after a `!command` submits.
5207    {
5208        let editor_for_change = editor.clone();
5209        editor.on_change(Arc::new(move |text: &str| {
5210            let colors = current_theme().colors;
5211            let is_bash = text.trim_start().starts_with('!');
5212            editor_for_change.set_border_color(if is_bash {
5213                Some(colors.bash_mode)
5214            } else {
5215                None
5216            });
5217        }));
5218    }
5219
5220    // ---- Footer + status ----
5221    let footer = Arc::new(FooterComponent::new());
5222    footer.set_model(&model_name);
5223    footer.set_cwd(&cwd.to_string_lossy());
5224    footer.set_git_branch(git_branch_for(&cwd).as_deref());
5225    if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5226        footer.set_context_window(m.context_window as i64);
5227    }
5228    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");
5229
5230    // Live git-branch refresh (native fs-watch on `.git/HEAD`): a checkout or
5231    // commit updates the footer's branch without a manual refresh.
5232    let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5233        let footer_ref = footer.clone();
5234        let cwd_ref = cwd.clone();
5235        crate::fs_watch::watch_path(head, move || {
5236            footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5237        })
5238    });
5239
5240    let status_container = Arc::new(Container::new());
5241    let loader = Arc::new(Loader::with_text("Working…"));
5242
5243    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
5244    // Prompt templates discovered at session build (Part A2) are surfaced as
5245    // `/`-prefixed entries alongside the built-in slash commands: typing
5246    // `/<name>` in the editor expands the template (mirrors pi
5247    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
5248    // the template's frontmatter description (or a fallback) so the autocomplete
5249    // popover shows what each template does.
5250    //
5251    // We snapshot the full resources once (skills + prompt-templates): the
5252    // autocomplete builder consumes the templates, and the `/context` command
5253    // (fired from the blocking submit handler, which can't `.await`) reads the
5254    // snapshot to render the discovered-resources panel without touching the
5255    // harness async accessor.
5256    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5257        .prompt_templates
5258        .clone()
5259        .unwrap_or_default()
5260        .iter()
5261        .map(|t| SlashCommandEntry {
5262            name: format!("/{}", t.name),
5263            description: t
5264                .description
5265                .clone()
5266                .unwrap_or_else(|| "Expand prompt template".to_string()),
5267        })
5268        .collect();
5269    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5270        Arc::new(resources_snapshot);
5271    // Build the built-in command registry once — the single source of truth for
5272    // both dispatch and the built-in autocomplete entries. The discovered
5273    // prompt-template commands are merged into the autocomplete list separately
5274    // (they dispatch via template expansion, not the registry); built-ins come
5275    // first so they win on a fuzzy tie.
5276    let mut command_registry = build_builtin_registry();
5277    register_extension_commands(
5278        &mut command_registry,
5279        reload_context.extension_session.clone(),
5280    );
5281    let registry = Arc::new(command_registry);
5282    let mut all_slash_commands = registry.visible_entries();
5283    all_slash_commands.extend(template_slash_commands);
5284    let autocomplete = AutocompleteManager::new();
5285    {
5286        let mut combined = CombinedAutocompleteProvider::new();
5287        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5288            all_slash_commands,
5289        )));
5290        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5291            cwd.clone(),
5292        )));
5293        autocomplete.set_provider(Arc::new(combined));
5294    }
5295    let autocomplete_container = Arc::new(Container::new());
5296
5297    let tool_outputs_expanded = session_values
5298        .get_as::<bool>("display.tool_outputs_expanded")
5299        .unwrap_or(false);
5300    let state = Arc::new(TuiState {
5301        current_assistant: Arc::new(std::sync::Mutex::new(None)),
5302        tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
5303        bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
5304        hide_thinking: Arc::new(std::sync::Mutex::new(hide_thinking)),
5305        tool_outputs_expanded: Arc::new(std::sync::Mutex::new(tool_outputs_expanded)),
5306        show_terminal_progress,
5307        status: std::sync::Mutex::new(RunStatus::Idle),
5308        ext_status: reload_context.ext_status.clone(),
5309        ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
5310        editor_text: reload_context.editor_text.clone(),
5311        auto_send: std::sync::Mutex::new(None),
5312        last_editor_text: std::sync::Mutex::new(String::new()),
5313        programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
5314        js_preparation_cancel: std::sync::Mutex::new(None),
5315        user_bash_cancel: std::sync::Mutex::new(None),
5316        pending_bash_messages: std::sync::Mutex::new(Vec::new()),
5317        footer: footer.clone(),
5318        status_container: status_container.clone(),
5319        chat_container: chat_container.clone(),
5320        loader: loader.clone(),
5321        editor: editor.clone(),
5322        last_assistant_text: std::sync::Mutex::new(String::new()),
5323        active_selector: std::sync::Mutex::new(None),
5324        active_extension_editor: std::sync::Mutex::new(None),
5325        active_extension_input: std::sync::Mutex::new(None),
5326        active_extension_cancel: std::sync::Mutex::new(None),
5327        autocomplete,
5328        autocomplete_container: autocomplete_container.clone(),
5329        autocomplete_max_visible,
5330        pending_images: std::sync::Mutex::new(Vec::new()),
5331        theme_manager,
5332        tui: Some(tui.clone()),
5333        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5334        show_images: std::sync::Mutex::new(show_images),
5335        cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
5336        history: std::sync::Mutex::new(Vec::new()),
5337        history_index: std::sync::Mutex::new(-1),
5338        history_draft: std::sync::Mutex::new(None),
5339        cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5340        scoped_edit: std::sync::Mutex::new(None),
5341        markdown_transformer: Arc::new(std::sync::Mutex::new(initial_transformer)),
5342        extension_session: reload_context.extension_session.clone(),
5343        search: Arc::new(AltScreenSearch::new()),
5344        search_bar: Arc::new(SearchBar::new()),
5345        pending_container: Arc::new(Container::new()),
5346        dequeue_hint: pending_dequeue_hint(&keybindings),
5347        pending_snapshot: std::sync::Mutex::new(
5348            rpi_harness::agent_harness::QueuedMessages::default(),
5349        ),
5350        selection_start: std::sync::Mutex::new(None),
5351        selection_end: std::sync::Mutex::new(None),
5352    });
5353
5354    // Capture the model catalog + cwd for the selector builders + the key loop
5355    // (the callbacks fire on blocking threads and need owned data).
5356    let model_catalog_arc = Arc::new(model_catalog.clone());
5357    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5358
5359    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
5360    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
5361    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
5362    //
5363    // The scrollview gets `basis(0)` so the constrained stack allocator starts
5364    // it at zero height and grows it to fill the space the dock does not need
5365    // — this keeps the dock (editor borders + footer) pinned to the bottom and
5366    // never shrinks it below the editor's 3 rows (top + content + bottom). The
5367    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
5368    // never clip the input panel below its minimum.
5369    let editor_container = Arc::new(Container::new());
5370    editor_container.add_child(editor.clone());
5371
5372    let dock = Arc::new(VStack::from_children(vec![
5373        StackChild::Entry(StackEntry::new(state.pending_container().clone())),
5374        StackChild::Entry(StackEntry::new(status_container.clone())),
5375        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5376        // NOTE: no Spacer here. The scroll content already ends with a blank line
5377        // (`document_container`), and a second one in the dock stacked into a
5378        // two-row gap between the last message and the input box. One row of
5379        // breathing room is the whole safe area now; the transcript keeps its
5380        // own padding so a long message never touches the editor border.
5381        StackChild::Entry(
5382            StackEntry::new(editor_container.clone())
5383                .shrink(0)
5384                .min_size(3),
5385        ),
5386        StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5387        StackChild::Entry(StackEntry::new(footer.clone())),
5388    ]));
5389
5390    let root = VStack::from_children(vec![
5391        StackChild::Entry(
5392            StackEntry::new(scroll_view.clone())
5393                .basis(0)
5394                .grow(1)
5395                .shrink(1)
5396                .min_size(1),
5397        ),
5398        StackChild::Entry(StackEntry::new(dock).shrink(1)),
5399    ]);
5400
5401    tui.set_layout_root(Some(Arc::new(root)));
5402    tui.set_focus(Some(editor.clone()));
5403    editor.set_focused(true);
5404
5405    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
5406    //
5407    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
5408    // the old version made individually) + the registry, then routes `/`-text
5409    // through `dispatch_slash` and sends plain text directly. Each command's
5410    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
5411    // chat mutation) — the handler itself stays a thin router.
5412    //
5413    // One `CommandContext` is built and cloned for both the submit handler and
5414    // the key loop (Ctrl+L routes `/model` through the same registry); all
5415    // fields are `Arc`/cheap, so the clones are free.
5416    //
5417    // The product-layer `AgentSession` wraps a clone of the harness (the
5418    // harness handle is cheap and shares the session/bus), so `/usage`,
5419    // `/export`, and future product commands go through one surface.
5420    let scoped_models = args
5421        .model
5422        .as_deref()
5423        .map(crate::agent_session::ScopedModel::parse)
5424        .into_iter()
5425        .collect();
5426    let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5427        harness.clone(),
5428        model_catalog.clone(),
5429        scoped_models,
5430        cwd.clone(),
5431    ));
5432    let ctx = CommandContext {
5433        chat: chat_container.clone(),
5434        tui: tui.clone(),
5435        tx: tx.clone(),
5436        state: state.clone(),
5437        editor: editor.clone(),
5438        editor_container: editor_container.clone(),
5439        lane: lane.clone(),
5440        model_catalog: model_catalog_arc.clone(),
5441        lane_model_id: lane_model_id.clone(),
5442        cwd: cwd.clone(),
5443        resources: resources_arc.clone(),
5444        reload_context: Arc::new(reload_context.clone()),
5445        session: agent_session.clone(),
5446    };
5447
5448    let ctx_for_cb = ctx.clone();
5449    let registry_for_cb = registry.clone();
5450    editor.on_submit(Arc::new(move |text: &str| {
5451        let text = text.trim();
5452        if text.is_empty() {
5453            return;
5454        }
5455
5456        if text.starts_with('/') && dispatch_slash(text, &ctx_for_cb, &registry_for_cb) {
5457            return;
5458        }
5459
5460        // User-initiated shell mode: `!command` runs in the background and is
5461        // recorded in the session context; `!!command` is excluded from it.
5462        // Handled before the agent prompt path so it never reaches the model.
5463        // Mirrors `interactive-mode.ts:3225`.
5464        if let Some((command, exclude_from_context)) = parse_user_bash(text) {
5465            if ctx_for_cb.state.user_bash_running() {
5466                add_error_message(
5467                    &ctx_for_cb.chat,
5468                    "A bash command is already running. Press Esc to cancel it first.",
5469                );
5470                ctx_for_cb.tui.request_render(false);
5471                return;
5472            }
5473            start_user_bash(&ctx_for_cb, command, exclude_from_context);
5474            return;
5475        }
5476
5477        let run_status = *ctx_for_cb.state.status.lock().unwrap();
5478        if run_status != RunStatus::Idle {
5479            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5480            let lane = ctx_for_cb.lane.clone();
5481            let chat = ctx_for_cb.chat.clone();
5482            let tui = ctx_for_cb.tui.clone();
5483            let state = ctx_for_cb.state.clone();
5484            tokio::spawn(async move {
5485                // Queue immediately while the agent loop is still running.
5486                // Routing this through the TUI's main channel delayed it until
5487                // `prompt_text()` returned, after the loop's drain points had
5488                // passed, so the queued message appeared to disappear.
5489                //
5490                // upstream's `steer()` enqueues unconditionally, even after
5491                // the run ends, and the message stays in the queue for the
5492                // next run. This matches the TS design and avoids the race
5493                // between `activeRun` clearing and the status check.
5494                if let Err(error) = lane.steer(message).await {
5495                    add_error_message(&chat, &format!("Could not queue message: {error}"));
5496                    tui.render_now(false);
5497                    return;
5498                }
5499                // Surface the queued entry in the pending dock immediately;
5500                // the drain-time refresh only fires on the next agent event.
5501                refresh_pending_messages(&state, &lane).await;
5502                tui.request_render(false);
5503            });
5504            // A queued prompt is echoed only once the loop actually consumes
5505            // it: the run's `AgentEvent::MessageStart` renders the user bubble
5506            // (upstream renders `message_start` for user messages). Until
5507            // then it is surfaced by the pending-messages display, so the
5508            // transcript never shows a message that is still waiting.
5509            ctx_for_cb.tui.request_render(false);
5510            return;
5511        }
5512
5513        if !ctx_for_cb.state.try_start_working() {
5514            return;
5515        }
5516
5517        // Render the user bubble here, NOT from `AgentEvent::MessageStart`.
5518        //
5519        // The harness persists the prompt itself and then drives
5520        // `run_agent_loop` with an EMPTY prompts vec (so the prompt is not
5521        // double-counted in the provider context), which means the loop emits
5522        // no `message_start` for it — see
5523        // `directly_sent_prompt_emits_no_user_message_start` in
5524        // `crates/rpi-harness/tests/harness_run_e2e.rs`. Queued steering /
5525        // follow-up prompts DO get a `message_start` (the loop drains those
5526        // itself), so those are deliberately rendered from the event instead.
5527        add_user_message(&ctx_for_cb.chat, text);
5528        // A new prompt starts a fresh interaction at the tail even when the
5529        // user had scrolled up to inspect older output.
5530        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
5531            scroll.scroll_to_end();
5532        }
5533        ctx_for_cb.tui.request_render(false);
5534        // Remember the message for ↑ recall (slash commands are not part of
5535        // the replayable message history).
5536        push_history(&ctx_for_cb.state, text);
5537        if ctx_for_cb
5538            .tx
5539            .send(TuiMessage::UserInput(text.to_string()))
5540            .is_err()
5541        {
5542            ctx_for_cb.state.set_status(RunStatus::Idle);
5543        }
5544    }));
5545
5546    // Turn on the native-pi frame throttle before the first frame: from here on
5547    // `request_render(false)` parks a frame and the scheduler thread paints at
5548    // most one per `MIN_RENDER_INTERVAL_MS` (16 ms). A provider that streams one
5549    // `MessageUpdate` per delta would otherwise force a full transcript
5550    // re-render + full-viewport terminal write per token, which is what made
5551    // long output saturate the terminal (and its GPU compositor). Immediate
5552    // renders (selectors, the first frame, `render_now(..)`) are unaffected.
5553    tui.start_render_scheduler();
5554    tui.start_readerless();
5555
5556    // Consume local self-update results even when network checks are disabled.
5557    // Keep package discovery separate so package managers and Git remotes
5558    // cannot delay an rpi update notice or a prior helper failure.
5559    let mut update_check_handles = Vec::new();
5560    let rpi_chat = chat_container.clone();
5561    let rpi_tui = tui.clone();
5562    update_check_handles.push(tokio::spawn(async move {
5563        let report = crate::updates::check_rpi_startup().await;
5564        if !report.is_empty() {
5565            add_update_notices(&rpi_chat, &report);
5566        }
5567        rpi_tui.request_render(false);
5568    }));
5569
5570    // Only the rpi self-update check runs.
5571
5572    // ---- Streaming drain task ----
5573    let drain_handle = if let Some(rx) = event_rx {
5574        let tui_drain = tui.clone();
5575        let state_drain = state.clone();
5576        let chat_drain = chat_container.clone();
5577        let lane_drain = lane.clone();
5578        Some(tokio::spawn(async move {
5579            drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
5580        }))
5581    } else {
5582        None
5583    };
5584
5585    // ---- B5d: plugin→TUI reload bridge ----
5586    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
5587    // (its cdylib would be unmapped while the call frame is still on the stack).
5588    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
5589    // (an `UnboundedSender<()>`); this task drains those signals and forwards
5590    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
5591    // `reload_extension_resources` routine asynchronously. The mailbox is the
5592    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
5593    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
5594    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
5595    reload_context.mailbox.install(reload_sig_tx);
5596    let reload_tx = tx.clone();
5597    let reload_bridge_handle = tokio::spawn(async move {
5598        while reload_sig_rx.recv().await.is_some() {
5599            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
5600                break; // main loop gone — stop forwarding
5601            }
5602        }
5603    });
5604
5605    // ---- Render-tick task (advances the loader spinner while Working) ----
5606    //
5607    // The `Loader` only advances its frame on render; without a periodic
5608    // `request_render` the spinner visibly freezes between events.
5609    let tui_tick = tui.clone();
5610    let state_tick = state.clone();
5611    // The tick, not the key loop, drives the voice-draft auto-send: it must
5612    // fire even when the user types nothing.
5613    let tx_tick = tx.clone();
5614    let tick_handle = tokio::spawn(async move {
5615        // 80ms per frame — upstream's `DEFAULT_INTERVAL_MS`, i.e. a full
5616        // 10-frame cycle every 800ms. The tick only *requests a repaint*; the
5617        // frame is advanced by `Editor::render` (mirroring `Loader::render`),
5618        // so streaming output — which schedules a frame per token — spins it
5619        // faster, and this interval is the floor when the model is silent.
5620        //
5621        // Advancing here as well would double-step every tick and make the idle
5622        // spinner run at 40ms/frame, twice upstream's rate.
5623        let mut interval = tokio::time::interval(std::time::Duration::from_millis(
5624            rpi_tui::loader::SPINNER_FRAME_MS,
5625        ));
5626        interval.tick().await; // discard immediate
5627        loop {
5628            interval.tick().await;
5629            // Extension status (langfuse ✓ …, …) — cheap revision check, and the
5630            // only reason an idle session repaints its footer.
5631            if state_tick.sync_extension_status() {
5632                tui_tick.request_render(false);
5633            }
5634            // Extension editor-text injection (`SetEditorText`): a voice plugin
5635            // drops a transcription in as an editable draft.
5636            if state_tick.drain_editor_text() {
5637                tui_tick.request_render(false);
5638            }
5639            // Tell extensions the draft changed (voice barge-in). Runs after the
5640            // drain so an injected transcription also counts as a change.
5641            state_tick.sync_editor_change();
5642            // Advance a draft's auto-send countdown (submit / cancel / repaint).
5643            if poll_auto_send(&state_tick, &tx_tick) {
5644                tui_tick.request_render(false);
5645            }
5646            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
5647            if working {
5648                // A live transcript panel — a running bash command *or* a
5649                // running tool call — computes its elapsed readout from
5650                // `Instant::now()` at render time. A frame that reuses the
5651                // cached scroll content never rebuilds it, so the readout
5652                // froze at the second of the last full rebuild (reported: a
5653                // `search` call stuck at "2.0s" with no way to tell it was
5654                // still running). Rebuild while any such panel is live.
5655                let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
5656                let has_live_panel = {
5657                    let tools = state_tick.tool_components.lock().unwrap();
5658                    transcript_has_live_panel(bash_present, &tools)
5659                };
5660                if has_live_panel {
5661                    tui_tick.request_render(false);
5662                } else {
5663                    // Only the dock loader animates. Keep the already-rendered
5664                    // transcript instead of rebuilding a long history at 12.5
5665                    // frames per second.
5666                    tui_tick.request_render_reusing_scroll_content();
5667                }
5668            }
5669        }
5670    });
5671
5672    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
5673    let running = Arc::new(std::sync::Mutex::new(true));
5674    let running_key = running.clone();
5675    let tx_for_key = tx.clone();
5676    let tui_for_key = tui.clone();
5677    let editor_for_key = editor.clone();
5678    let scroll_for_key = scroll_view.clone();
5679    let lane_for_key = lane.clone();
5680    let state_for_key = state.clone();
5681    let model_catalog_for_key = model_catalog_arc.clone();
5682    let ask_user_for_key = ask_user_bridge.clone();
5683    // Ctrl+L routes through the same registry as `/model` (one path, not two),
5684    // so the key loop needs the same `CommandContext` + registry the submit
5685    // handler uses. All fields are `Arc`/cheap, so this clone is free.
5686    let ctx_for_key = ctx.clone();
5687    let registry_for_key = registry.clone();
5688    let keybindings_for_key = keybindings.clone();
5689    let double_escape_action = crate::settings::load_settings()
5690        .ok()
5691        .and_then(|settings| settings.double_escape_action)
5692        .unwrap_or_else(|| "tree".to_string())
5693        .to_ascii_lowercase();
5694
5695    let key_handle = tokio::task::spawn_blocking(move || {
5696        let mut last_escape_time = None;
5697        // upstream's `handleCtrlC` "press twice to exit" window: the instant of
5698        // the last lone Ctrl+C. Shares the 500ms with Esc's double-escape.
5699        let mut last_sigint_time: Option<std::time::Instant> = None;
5700        // One event peeked at by the paste-burst probe (which must discard a
5701        // key's Release but never lose a meaningful event it read). Restored
5702        // at the top of the next loop iteration.
5703        let mut pending_event: Option<Event> = None;
5704        let mut windows_paste_run = WindowsPasteRun::default();
5705        if crate::key_trace::enabled() {
5706            crate::key_trace::note(&format!(
5707                "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
5708                std::process::id(),
5709                std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
5710                crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
5711            ));
5712        }
5713        loop {
5714            if !*running_key.lock().unwrap() {
5715                break;
5716            }
5717            if !state_for_key.selector_open() && !state_for_key.extension_dialog_open() {
5718                if let Some(request) = ask_user_for_key.take_pending() {
5719                    open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
5720                }
5721            }
5722            let ev = if let Some(ev) = pending_event.take() {
5723                ev
5724            } else {
5725                // `event::read()` blocks indefinitely. Poll first so shutdown can
5726                // stop and join this worker even when no further key arrives.
5727                match crossterm::event::poll(
5728                    if cfg!(windows) && !windows_paste_run.text.is_empty() {
5729                        windows_paste_run.grace()
5730                    } else {
5731                        std::time::Duration::from_millis(50)
5732                    },
5733                ) {
5734                    Ok(true) => {}
5735                    Ok(false) => {
5736                        if cfg!(windows) && !windows_paste_run.text.is_empty() {
5737                            windows_paste_run.flush(&editor_for_key);
5738                            refresh_autocomplete(&state_for_key, &editor_for_key);
5739                            tui_for_key.request_render_reusing_scroll_content();
5740                        }
5741                        continue;
5742                    }
5743                    Err(_) => {
5744                        state_for_key.cancel_js_preparation();
5745                        let _ = tx_for_key.send(TuiMessage::Exit);
5746                        break;
5747                    }
5748                }
5749                let Ok(ev) = crossterm::event::read() else {
5750                    state_for_key.cancel_js_preparation();
5751                    let _ = tx_for_key.send(TuiMessage::Exit);
5752                    break;
5753                };
5754                ev
5755            };
5756            // `Event::Resize` is delivered as its own event (not a Key). With
5757            // `start_readerless` there is no competing terminal-reader thread to
5758            // handle it, so refresh the cached terminal size here and force a
5759            // full redraw so the constrained layout re-fits the new dimensions.
5760            if let Event::Resize(_cols, _rows) = ev {
5761                tui_for_key.refresh_size();
5762                continue;
5763            }
5764            // Mouse wheel scrolls the transcript (pi supports wheel
5765            // scrolling). Previously every non-Key event was dropped, so a
5766            // wheel had zero effect — "滚动还是不行".
5767            if let Event::Mouse(m) = ev {
5768                use crossterm::event::MouseEventKind;
5769                match m.kind {
5770                    MouseEventKind::ScrollUp => {
5771                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
5772                        if scroll_for_key.scroll_by(delta) != delta {
5773                            tui_for_key.request_render_reusing_scroll_content();
5774                        }
5775                    }
5776                    MouseEventKind::ScrollDown => {
5777                        let delta = MOUSE_WHEEL_SCROLL_LINES;
5778                        if scroll_for_key.scroll_by(delta) != delta {
5779                            tui_for_key.request_render_reusing_scroll_content();
5780                        }
5781                    }
5782                    MouseEventKind::Down(crossterm::event::MouseButton::Right) if cfg!(windows) => {
5783                        // upstream treats Windows right-click as a clipboard
5784                        // paste, rather than letting the console deliver the
5785                        // clipboard contents as individual key events. This is
5786                        // what preserves the complete payload for
5787                        // Editor::handle_paste and large-paste folding.
5788                        if let Some(text) = read_clipboard_text() {
5789                            if !text.is_empty() {
5790                                editor_for_key.handle_paste(&text);
5791                                refresh_autocomplete(&state_for_key, &editor_for_key);
5792                                tui_for_key.request_render_reusing_scroll_content();
5793                            }
5794                        }
5795                    }
5796                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
5797                        // Start selection tracking. `selection_end` doubles as the
5798                        // "a real drag happened" marker: a plain tap never sets
5799                        // it, so it can never overwrite the clipboard.
5800                        *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
5801                        *state_for_key.selection_end.lock().unwrap() = None;
5802                    }
5803                    MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
5804                        // Motion with the button held = a genuine selection drag.
5805                        // (Terminals only report this with button-event mouse
5806                        // tracking, which `ProcessTerminal` enables.)
5807                        if state_for_key.selection_start.lock().unwrap().is_some() {
5808                            *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
5809                        }
5810                    }
5811                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
5812                        // End selection and auto-copy if enabled
5813                        if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
5814                            let end = (m.column, m.row);
5815
5816                            // Only auto-copy when the pointer actually MOVED with
5817                            // the button held (`Drag` set `selection_end`). A plain
5818                            // click/tap — even one whose Down/Up cells differ by a
5819                            // pixel over RDP — must not touch the clipboard: it used
5820                            // to fire `extract_selected_text`, which copies whatever
5821                            // rendered TUI line sat under the pointer, silently
5822                            // clobbering the user's real clipboard so the next paste
5823                            // returned that stale text.
5824                            let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
5825                            *state_for_key.selection_end.lock().unwrap() = Some(end);
5826
5827                            // Check if auto-copy is enabled
5828                            let auto_copy = crate::settings::load_settings()
5829                                .ok()
5830                                .and_then(|s| s.fullscreen_copy_on_select)
5831                                .unwrap_or(true);
5832
5833                            if auto_copy && is_drag {
5834                                // Try to extract selected text from chat container
5835                                if let Some(selected_text) =
5836                                    extract_selected_text(&state_for_key.chat_container, start, end)
5837                                {
5838                                    if !selected_text.trim().is_empty() {
5839                                        let _ = copy_to_clipboard(&selected_text);
5840                                    }
5841                                }
5842                            }
5843                        }
5844                        *state_for_key.selection_start.lock().unwrap() = None;
5845                        *state_for_key.selection_end.lock().unwrap() = None;
5846                    }
5847                    _ => {}
5848                }
5849                continue;
5850            }
5851            let Event::Key(key) = ev else {
5852                if let Event::Paste(text) = ev {
5853                    crate::key_trace::note(&format!(
5854                        "paste event ({} bytes, bracketed paste supported)",
5855                        text.len()
5856                    ));
5857                    let candidate = text.trim().trim_matches(['\"', '\'']);
5858                    let path = std::path::PathBuf::from(candidate);
5859                    if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5860                        if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5861                            add_image_preview(&state_for_key.chat_container, &image);
5862                            state_for_key.queue_image(image);
5863                            add_note_message(
5864                                &state_for_key.chat_container,
5865                                "Dropped image attached to the next prompt.",
5866                            );
5867                            tui_for_key.request_render(false);
5868                            continue;
5869                        }
5870                    }
5871                    editor_for_key.handle_paste(&text);
5872                    refresh_autocomplete(&state_for_key, &editor_for_key);
5873                    tui_for_key.request_render_reusing_scroll_content();
5874                }
5875                continue;
5876            };
5877            // Diagnostic trace (off unless RPI_DEBUG_KEYS is set): records what
5878            // actually arrived before any routing decision, so a client that
5879            // sends LF for Enter is visible as `Char('j') mods=CONTROL`.
5880            crate::key_trace::key(&key, "");
5881            // A key press during a voice-draft auto-send countdown cancels the
5882            // countdown — the user wants to edit, not send. The key still falls
5883            // through to the editor, so it doubles as their first correction.
5884            if key.kind != KeyEventKind::Release && state_for_key.cancel_auto_send_on_key() {
5885                tui_for_key.request_render(false);
5886            }
5887            // Extension shortcuts see Press *and* Release (this is what lets a
5888            // push-to-talk extension time a hold). Checked before the
5889            // release-drop below; a claimed key with an empty editor is routed
5890            // to the plugin's `Input` handler instead of the editor.
5891            if dispatch_key_event(&state_for_key, &key, &editor_for_key) {
5892                continue;
5893            }
5894            // Drop releases but preserve Repeat so holding arrows, Backspace,
5895            // PageUp, etc. behaves naturally. Windows emits Press + Release
5896            // for a tap; terminals with keyboard enhancement may additionally
5897            // emit Repeat while a key is held.
5898            if !should_dispatch_key(key.kind) {
5899                continue;
5900            }
5901
5902            // Prompt preparation runs on a blocking worker before the agent
5903            // lane owns the turn. Cancel it directly: an abort queued only to
5904            // the lane cannot wake a JS factory or lifecycle hook that never
5905            // resolves. This check precedes custom/dialog routing because
5906            // those components may themselves have been opened by the hook.
5907            let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5908                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5909                || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5910            if prompt_abort && state_for_key.cancel_js_preparation() {
5911                state_for_key.set_status(RunStatus::Aborting);
5912                if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5913                    close_extension_editor(
5914                        &state_for_key,
5915                        &ctx_for_key.editor_container,
5916                        &editor_for_key,
5917                        &tui_for_key,
5918                    );
5919                }
5920                tui_for_key.set_render_suspended(false);
5921                tui_for_key.request_render(false);
5922                continue;
5923            }
5924
5925            // Ctrl+C cancels an open selector before it reaches the global
5926            // abort/exit handler. Route through Esc so selector callbacks run.
5927            if key.modifiers == KeyModifiers::CONTROL
5928                && key.code == KeyCode::Char('c')
5929                && state_for_key.selector_open()
5930            {
5931                let selector = state_for_key
5932                    .active_selector
5933                    .lock()
5934                    .unwrap()
5935                    .clone()
5936                    .expect("selector_open guaranteed Some")
5937                    .0;
5938                selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5939                tui_for_key.request_render_reusing_scroll_content();
5940                continue;
5941            }
5942
5943            // Extension dialogs own the input slot while awaiting a result.
5944            // Esc and Ctrl+C both resolve the pending command with cancel;
5945            // all other keys go to the active native editor/input widget.
5946            if state_for_key.extension_dialog_open() {
5947                let cancel = key.code == KeyCode::Esc
5948                    || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5949                if cancel {
5950                    if !run_extension_cancel(&state_for_key) {
5951                        close_extension_editor(
5952                            &state_for_key,
5953                            &ctx_for_key.editor_container,
5954                            &editor_for_key,
5955                            &tui_for_key,
5956                        );
5957                    }
5958                } else if let Some(extension_editor) = state_for_key
5959                    .active_extension_editor
5960                    .lock()
5961                    .unwrap()
5962                    .clone()
5963                {
5964                    extension_editor.handle_key(key);
5965                } else if let Some(extension_input) =
5966                    state_for_key.active_extension_input.lock().unwrap().clone()
5967                {
5968                    extension_input.handle_key(key);
5969                }
5970                tui_for_key.request_render_reusing_scroll_content();
5971                continue;
5972            }
5973
5974            // 0. Ctrl+C: upstream's `handleCtrlC`. A second press inside the
5975            //    double-press window quits; any other press only clears the
5976            //    editor (text *and* selection — native `clearEditor`). It never
5977            //    aborts — `app.interrupt` (Esc) owns cancelling a run — so a
5978            //    stray Ctrl+C while streaming can no longer kill the turn.
5979            //    Open selectors and extension dialogs are handled above so
5980            //    their cancellation callbacks get first chance.
5981            if keybinding_matches(
5982                &keybindings_for_key,
5983                &key,
5984                rpi_tui::keybindings::keys::CLEAR,
5985            ) {
5986                // A held key auto-repeats: only a fresh Press may arm or
5987                // consume the window, so holding Ctrl+C cannot turn one tap
5988                // into the quit.
5989                if key.kind != KeyEventKind::Repeat {
5990                    let now = std::time::Instant::now();
5991                    match ctrl_c_action(last_sigint_time, now) {
5992                        CtrlCAction::Exit => {
5993                            last_sigint_time = None;
5994                            // The async loop parks inside
5995                            // `run_prompt_streaming(..).await` for the whole run,
5996                            // so a queued `Exit` would not be read until the run
5997                            // returns — force the quit while one is active. Either
5998                            // way the TUI stays quittable.
5999                            if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
6000                                let _ = tx_for_key.send(TuiMessage::Exit);
6001                            } else {
6002                                emergency_exit(&tui_for_key);
6003                            }
6004                        }
6005                        CtrlCAction::Clear => {
6006                            editor_for_key.clear();
6007                            refresh_autocomplete(&state_for_key, &editor_for_key);
6008                            last_sigint_time = Some(now);
6009                            tui_for_key.request_render(false);
6010                        }
6011                    }
6012                }
6013                continue;
6014            }
6015
6016            // 0b. Ctrl+X: upstream's `app.message.copy` — copy the editor
6017            //     selection, else the last assistant reply. Native's Ctrl+C no
6018            //     longer copies, so selection-copy lives here now.
6019            if !state_for_key.selector_open()
6020                && keybinding_matches(
6021                    &keybindings_for_key,
6022                    &key,
6023                    rpi_tui::keybindings::keys::MESSAGE_COPY,
6024                )
6025            {
6026                if editor_for_key.has_selection() && editor_for_key.copy_selection() {
6027                    add_note_message(&state_for_key.chat_container, "Copied!");
6028                } else {
6029                    copy_last_assistant(&state_for_key, &state_for_key.chat_container);
6030                }
6031                tui_for_key.request_render(false);
6032                continue;
6033            }
6034
6035            // 0c. Ctrl+Z (Unix only): upstream's `app.suspend` — hand the
6036            //     terminal back to the shell and stop ourselves; `SIGTSTP`
6037            //     returns when the user resumes with `fg`. Windows binds no key
6038            //     here (native's `defaultKeys` is empty), so this never fires.
6039            if keybinding_matches(
6040                &keybindings_for_key,
6041                &key,
6042                rpi_tui::keybindings::keys::SUSPEND,
6043            ) {
6044                handle_suspend(&tui_for_key);
6045                continue;
6046            }
6047
6048            // 1. A selector overlay is open → route to it first. Only Esc
6049            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
6050            //    escape to the selector; on done/cancel the selector callbacks
6051            //    restore the editor and clear `active_selector`.
6052            if state_for_key.selector_open() {
6053                // Esc always cancels the selector (even with modifiers off).
6054                // Route through `SelectList::handle_key(Esc)` so the list's
6055                // `on_cancel` fires (the `/scoped-models` toggle selector saves
6056                // its edits there) — the old shortcut called `close_selector`
6057                // directly and skipped the callback.
6058                if key.code == KeyCode::Esc {
6059                    let (selector, _kind) = state_for_key
6060                        .active_selector
6061                        .lock()
6062                        .unwrap()
6063                        .clone()
6064                        .expect("selector_open guaranteed Some");
6065                    selector.handle_key(key);
6066                    continue;
6067                }
6068                let (selector, _kind) = state_for_key
6069                    .active_selector
6070                    .lock()
6071                    .unwrap()
6072                    .clone()
6073                    .expect("selector_open guaranteed Some");
6074                selector.handle_key(key);
6075                tui_for_key.request_render_reusing_scroll_content();
6076                continue;
6077            }
6078
6079            // 2a. Ctrl+D: upstream's `app.exit`. `handleCtrlD` only fires when
6080            //     the editor is empty; with text present the key falls through to
6081            //     the editor's deleteCharForward (`tui.editor.deleteCharForward`).
6082            //     It never aborts — that is `app.interrupt` (Esc)'s job.
6083            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
6084                if !editor_for_key.get_text().is_empty() {
6085                    // Editor holds text — delete the char forward (pi parity).
6086                    editor_for_key.handle_key(key);
6087                    refresh_autocomplete(&state_for_key, &editor_for_key);
6088                    tui_for_key.request_render_reusing_scroll_content();
6089                    continue;
6090                }
6091                // Empty editor → quit. A parked run needs the forced path: a
6092                // queued `Exit` is unread until the run returns.
6093                if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
6094                    let _ = tx_for_key.send(TuiMessage::Exit);
6095                } else {
6096                    emergency_exit(&tui_for_key);
6097                }
6098                continue;
6099            }
6100
6101            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
6102            //     selector is open Esc already cancelled it above; when idle,
6103            //     Esc falls through to the editor (no-op-ish). Only fire while
6104            //     Working so an idle Esc doesn't abort a non-existent run.
6105            if keybinding_matches(
6106                &keybindings_for_key,
6107                &key,
6108                rpi_tui::keybindings::keys::INTERRUPT,
6109            ) {
6110                let status = *state_for_key.status.lock().unwrap();
6111                if status == RunStatus::Working {
6112                    state_for_key.set_status(RunStatus::Aborting);
6113                    ask_user_for_key.cancel_all();
6114                    if !run_extension_cancel(&state_for_key)
6115                        && state_for_key.extension_dialog_open()
6116                    {
6117                        close_extension_editor(
6118                            &state_for_key,
6119                            &ctx_for_key.editor_container,
6120                            &editor_for_key,
6121                            &tui_for_key,
6122                        );
6123                    }
6124                    // upstream's `onEscape` restores the queue before it
6125                    // aborts, so an interrupt hands the staged messages back.
6126                    abort_run_restoring_queue(
6127                        lane_for_key.clone(),
6128                        editor_for_key.clone(),
6129                        state_for_key.clone(),
6130                        tui_for_key.clone(),
6131                    );
6132                    continue;
6133                }
6134                // A running `!command` takes Esc next (upstream's `onEscape`
6135                // precedence: streaming → bash → bash-mode editor →
6136                // double-escape). The run slot stays occupied until the capture
6137                // resolves, so a second Esc is a no-op rather than a
6138                // double-escape trigger.
6139                if state_for_key.user_bash_running() {
6140                    state_for_key.cancel_user_bash();
6141                    tui_for_key.request_render(false);
6142                    continue;
6143                }
6144                if status == RunStatus::Idle
6145                    && editor_for_key.get_text().trim().is_empty()
6146                    && double_escape_action != "none"
6147                {
6148                    let now = std::time::Instant::now();
6149                    if double_escape_trigger(last_escape_time, now) {
6150                        last_escape_time = None;
6151                        match double_escape_action.as_str() {
6152                            "tree" => {
6153                                let _ = tx_for_key.send(TuiMessage::OpenTree);
6154                            }
6155                            "fork" => {
6156                                let _ = tx_for_key.send(TuiMessage::ForkSession);
6157                            }
6158                            _ => {}
6159                        }
6160                    } else {
6161                        last_escape_time = Some(now);
6162                    }
6163                }
6164                continue;
6165            }
6166
6167            // 2c. Ctrl+G: edit the current draft in the user's external
6168            // editor, matching upstream's VISUAL/EDITOR fallback chain.
6169            if keybinding_matches(
6170                &keybindings_for_key,
6171                &key,
6172                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
6173            ) {
6174                launch_external_editor(editor_for_key.get_expanded_text(), tx_for_key.clone());
6175                continue;
6176            }
6177
6178            // 2d. Ctrl+O: toggle all tool output panels between compact and
6179            // expanded rendering (upstream's global output toggle).
6180            if keybinding_matches(
6181                &keybindings_for_key,
6182                &key,
6183                rpi_tui::keybindings::keys::TOOLS_EXPAND,
6184            ) {
6185                let expanded = state_for_key.toggle_tool_outputs();
6186                // Persist per-session so a later `-c`/`-r` restores it.
6187                let session = ctx_for_key.session.clone();
6188                tokio::spawn(async move {
6189                    let _ = session
6190                        .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
6191                        .await;
6192                });
6193                tui_for_key.request_render(false);
6194                continue;
6195            }
6196
6197            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
6198            if keybinding_matches(
6199                &keybindings_for_key,
6200                &key,
6201                rpi_tui::keybindings::keys::THINKING_TOGGLE,
6202            ) {
6203                let hide = state_for_key.toggle_thinking();
6204                // Persist per-session so a later `-c`/`-r` restores it.
6205                let session = ctx_for_key.session.clone();
6206                tokio::spawn(async move {
6207                    let _ = session
6208                        .set_value("display.hide_thinking", serde_json::json!(hide))
6209                        .await;
6210                });
6211                tui_for_key.request_render(false);
6212                continue;
6213            }
6214
6215            // 2f. Ctrl+P: cycle to the next model in the catalog after the one
6216            //     currently tracked in `current_model_id`, apply it live via
6217            //     `lane.set_model` (takes effect on the next user message — the
6218            //     in-flight run's config is already snapshotted), and update the
6219            //     footer. `set_model` is async so it runs on a spawned task.
6220            if keybinding_matches(
6221                &keybindings_for_key,
6222                &key,
6223                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6224            ) {
6225                let current = state_for_key.current_model_id();
6226                // Cycle within the `/scoped-models` set (settings.json) when
6227                // configured; otherwise the full catalog.
6228                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
6229                if let Some(next) = cycle_next_model(&scope, &current) {
6230                    state_for_key.set_current_model(&next);
6231                    let lane = lane_for_key.clone();
6232                    tokio::spawn(async move {
6233                        let _ = lane.set_model(next).await;
6234                    });
6235                    tui_for_key.request_render_reusing_scroll_content();
6236                }
6237                continue;
6238            }
6239
6240            // 2f-bis. Shift+Ctrl+P (Alt+P on Windows): cycle to the *previous*
6241            //     model, the mirror of the Ctrl+P hotkey above.
6242            if keybinding_matches(
6243                &keybindings_for_key,
6244                &key,
6245                rpi_tui::keybindings::keys::MODEL_CYCLE_BACKWARD,
6246            ) {
6247                let current = state_for_key.current_model_id();
6248                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
6249                if let Some(prev) = cycle_prev_model(&scope, &current) {
6250                    state_for_key.set_current_model(&prev);
6251                    let lane = lane_for_key.clone();
6252                    tokio::spawn(async move {
6253                        let _ = lane.set_model(prev).await;
6254                    });
6255                    tui_for_key.request_render_reusing_scroll_content();
6256                }
6257                continue;
6258            }
6259
6260            // 2f. Shift+Tab / BackTab: cycle the current model's supported
6261            // thinking levels, matching upstream's thinking-level shortcut.
6262            if keybinding_matches(
6263                &keybindings_for_key,
6264                &key,
6265                rpi_tui::keybindings::keys::THINKING_CYCLE,
6266            ) {
6267                let lane = lane_for_key.clone();
6268                let catalog = model_catalog_for_key.clone();
6269                let state = state_for_key.clone();
6270                tokio::spawn(async move {
6271                    let Ok(current_model) = lane.get_model().await else {
6272                        return;
6273                    };
6274                    let levels = catalog
6275                        .iter()
6276                        .find(|model| {
6277                            model.provider == current_model.provider && model.id == current_model.id
6278                        })
6279                        .map(|model| model.supported_thinking_levels())
6280                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6281                    if levels.is_empty() {
6282                        return;
6283                    }
6284                    let current = lane
6285                        .get_thinking_level()
6286                        .await
6287                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6288                    let next = levels
6289                        .iter()
6290                        .position(|level| *level == current)
6291                        .map(|index| levels[(index + 1) % levels.len()])
6292                        .unwrap_or(levels[0]);
6293                    if lane.set_thinking_level(next).await.is_ok() {
6294                        state
6295                            .footer
6296                            .set_thinking_level(Some(thinking_level_name(next)));
6297                        state.tui.as_ref().map(|tui| tui.request_render(false));
6298                    }
6299                });
6300                continue;
6301            }
6302
6303            // Ctrl+V (or a configured paste-image key): if the system
6304            // clipboard holds an image, queue it for the next prompt;
6305            // otherwise paste the clipboard TEXT into the editor. Previously
6306            // a text-only clipboard fell through to the editor's Ctrl+V,
6307            // which yanks from the internal KILL RING — not the system
6308            // clipboard. On platforms without bracketed-paste `Event::Paste`
6309            // (Windows console, some remote clients) that meant Ctrl+V pasted
6310            // stale/other content instead of the user's real clipboard
6311            // ("不是系统的粘贴的内容").
6312            if keybinding_matches(
6313                &keybindings_for_key,
6314                &key,
6315                rpi_tui::keybindings::keys::PASTE_IMAGE,
6316            ) {
6317                match read_clipboard_image() {
6318                    Ok(Some(image)) => {
6319                        add_image_preview(&state_for_key.chat_container, &image);
6320                        state_for_key.queue_image(image);
6321                        add_note_message(
6322                            &state_for_key.chat_container,
6323                            "Clipboard image attached to the next prompt.",
6324                        );
6325                        tui_for_key.request_render(false);
6326                        continue;
6327                    }
6328                    Ok(None) | Err(_) => {}
6329                }
6330                // The arboard text fallback is Windows-only. On Unix a real
6331                // paste arrives as `Event::Paste` (bracketed paste is enabled
6332                // by `ProcessTerminal::enter_raw_mode`), so reading the
6333                // SERVER's clipboard here would paste stale TUI content on a
6334                // remote/mobile client — copy-on-select can have written the
6335                // welcome "Skills (…)" line into the server clipboard, and
6336                // Ctrl+V on the client returns that instead of the user's own
6337                // clipboard ("不是系统的粘贴的内容"). On Windows the console
6338                // never emits `Event::Paste`, so the arboard read is the only
6339                // paste source and must stay.
6340                if cfg!(windows) {
6341                    if let Some(text) = read_clipboard_text() {
6342                        if !text.is_empty() {
6343                            editor_for_key.handle_paste(&text);
6344                            refresh_autocomplete(&state_for_key, &editor_for_key);
6345                            tui_for_key.request_render_reusing_scroll_content();
6346                            continue;
6347                        }
6348                    }
6349                }
6350            }
6351
6352            // 3. Ctrl+Shift+F: open transcript search
6353            if key
6354                .modifiers
6355                .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6356                && key.code == KeyCode::Char('F')
6357            {
6358                state_for_key.search.activate();
6359                state_for_key.search_bar.set_visible(true);
6360                state_for_key.search_bar.set_query("");
6361                state_for_key.search_bar.set_match_info(0, 0);
6362                tui_for_key.request_render(false);
6363                continue;
6364            }
6365
6366            // Handle search mode input
6367            if state_for_key.search.is_active() {
6368                let search_bar = state_for_key.search_bar.clone();
6369                match key.code {
6370                    KeyCode::Esc => {
6371                        state_for_key.search.deactivate();
6372                        search_bar.set_visible(false);
6373                        tui_for_key.request_render(false);
6374                        continue;
6375                    }
6376                    KeyCode::Enter => {
6377                        if key.modifiers.contains(KeyModifiers::SHIFT) {
6378                            state_for_key.search.previous_match();
6379                        } else {
6380                            state_for_key.search.next_match();
6381                        }
6382                        // Update search bar with current match info
6383                        let match_index = state_for_key.search.get_match_index();
6384                        let match_count = state_for_key.search.get_match_count();
6385                        search_bar.set_match_info(match_index, match_count);
6386                        tui_for_key.request_render(false);
6387                        continue;
6388                    }
6389                    KeyCode::Backspace => {
6390                        state_for_key.search.backspace();
6391                        // Re-search with updated query
6392                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6393                        state_for_key.search.find_matches(&lines);
6394                        // Update search bar
6395                        let query = state_for_key.search.get_query();
6396                        let match_count = state_for_key.search.get_match_count();
6397                        search_bar.set_query(&query);
6398                        search_bar.set_match_info(0, match_count);
6399                        tui_for_key.request_render(false);
6400                        continue;
6401                    }
6402                    KeyCode::Char(c) => {
6403                        state_for_key.search.append_char(c);
6404                        // Re-search with updated query
6405                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6406                        state_for_key.search.find_matches(&lines);
6407                        // Update search bar
6408                        let query = state_for_key.search.get_query();
6409                        let match_count = state_for_key.search.get_match_count();
6410                        search_bar.set_query(&query);
6411                        search_bar.set_match_info(0, match_count);
6412                        tui_for_key.request_render(false);
6413                        continue;
6414                    }
6415                    _ => continue,
6416                }
6417            }
6418
6419            // 3. Ctrl+L: open the model selector. Routed through the `/model`
6420            //    command so the hotkey and the slash command share one path
6421            //    (TS binds Ctrl+L to model-select).
6422            if keybinding_matches(
6423                &keybindings_for_key,
6424                &key,
6425                rpi_tui::keybindings::keys::MODEL_SELECT,
6426            ) {
6427                if let Some(cmd) = registry_for_key.find("/model") {
6428                    cmd.execute(&ctx_for_key, "");
6429                }
6430                continue;
6431            }
6432
6433            // 4. Tab: accept the top autocomplete suggestion (if any).
6434            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6435                if accept_top_suggestion(&state_for_key, &editor_for_key) {
6436                    tui_for_key.request_render_reusing_scroll_content();
6437                }
6438                continue;
6439            }
6440
6441            // 5. Global transcript scroll. PageUp/PageDown use the actual
6442            // viewport height with four rows of overlap (upstream behavior),
6443            // while Home/End jump to the transcript boundaries.
6444            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6445                let delta = -transcript_page_size(scroll_for_key.viewport_height());
6446                if scroll_for_key.scroll_by(delta) != delta {
6447                    tui_for_key.request_render_reusing_scroll_content();
6448                }
6449                continue;
6450            }
6451            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6452                let delta = transcript_page_size(scroll_for_key.viewport_height());
6453                if scroll_for_key.scroll_by(delta) != delta {
6454                    tui_for_key.request_render_reusing_scroll_content();
6455                }
6456                continue;
6457            }
6458            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
6459                scroll_for_key.scroll_to_start();
6460                tui_for_key.request_render_reusing_scroll_content();
6461                continue;
6462            }
6463            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
6464                scroll_for_key.scroll_to_end();
6465                tui_for_key.request_render_reusing_scroll_content();
6466                continue;
6467            }
6468
6469            // 5b. ↑/↓ browse submitted-message history when the editor is
6470            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
6471            //     without the surprise of replacing typed text. When the
6472            //     editor holds content, ↑/↓ fall through to cursor movement
6473            //     (typing "hello", pressing ↑ at the start, must never swap
6474            //     the draft for a history entry — reported as "text
6475            //     disappeared"). Once browsing, ↓ walks back and restores the
6476            //     draft.
6477            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
6478                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6479                if editor_for_key.get_text().is_empty() || browsing {
6480                    navigate_history(&state_for_key, &editor_for_key, -1);
6481                    tui_for_key.request_render_reusing_scroll_content();
6482                    continue;
6483                }
6484            }
6485            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
6486                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6487                if editor_for_key.get_text().is_empty() || browsing {
6488                    navigate_history(&state_for_key, &editor_for_key, 1);
6489                    tui_for_key.request_render_reusing_scroll_content();
6490                    continue;
6491                }
6492            }
6493
6494            // `app.message.dequeue` (Alt+Q on Windows, Alt+Up elsewhere):
6495            // restore every queued steering/follow-up message into the editor
6496            // so it can be edited before resending. Mirrors upstream's
6497            // `handleDequeue`; the pending-messages display shows the hint.
6498            if keybinding_matches(
6499                &keybindings_for_key,
6500                &key,
6501                rpi_tui::keybindings::keys::DEQUEUE,
6502            ) {
6503                // Handled here, on the key worker — NOT via the mailbox. The
6504                // async main loop parks inside `run_prompt_streaming(..).await`
6505                // for the whole run, and a queue only exists *while* a run is
6506                // in flight, so a `TuiMessage::Dequeue` sat unread until the
6507                // run finished (after the lane had already consumed the queue).
6508                // Alt+Q therefore looked like a no-op while it was most needed.
6509                // Spawn the drain so it lands while the user is still looking at
6510                // the queue, exactly like the Alt+Enter `follow_up` path above.
6511                // Routed through `handle_dequeue` (not the raw restore) so the
6512                // `Restored N queued message(s)` status note fires, mirroring
6513                // upstream's `handleDequeue`.
6514                tokio::spawn(handle_dequeue(
6515                    lane_for_key.clone(),
6516                    editor_for_key.clone(),
6517                    state_for_key.clone(),
6518                    tui_for_key.clone(),
6519                ));
6520                continue;
6521            }
6522
6523            // `app.message.followUp` (Alt+Enter; Ctrl+Q and Alt+Enter on
6524            // Windows) queues a follow-up while a run is active. It is handled
6525            // here because Editor treats only a bare Enter as submit; when idle
6526            // the same key keeps the normal prompt behavior.
6527            if keybinding_matches(
6528                &keybindings_for_key,
6529                &key,
6530                rpi_tui::keybindings::keys::MESSAGE_FOLLOW_UP,
6531            ) {
6532                let prompt = editor_for_key.get_expanded_text().trim().to_string();
6533                if prompt.is_empty() {
6534                    continue;
6535                }
6536                editor_for_key.clear();
6537                let status = *state_for_key.status.lock().unwrap();
6538                if status == RunStatus::Idle {
6539                    if state_for_key.try_start_working() {
6540                        // Direct send: the harness emits no user `message_start`
6541                        // for it, so render the bubble here (see the submit
6542                        // handler in `interactive_tui`).
6543                        add_user_message(&state_for_key.chat_container, &prompt);
6544                        push_history(&state_for_key, &prompt);
6545                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
6546                    }
6547                } else {
6548                    // Follow-ups are echoed by the loop's
6549                    // `AgentEvent::MessageStart` once they are consumed; while
6550                    // they wait, the pending-messages display shows them.
6551                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
6552                    let lane = lane_for_key.clone();
6553                    let chat = state_for_key.chat_container.clone();
6554                    let tui = tui_for_key.clone();
6555                    let state = state_for_key.clone();
6556                    tokio::spawn(async move {
6557                        if let Err(error) = lane.follow_up(message).await {
6558                            add_error_message(&chat, &format!("Could not queue message: {error}"));
6559                            tui.request_render(false);
6560                            return;
6561                        }
6562                        refresh_pending_messages(&state, &lane).await;
6563                        tui.request_render(false);
6564                    });
6565                }
6566                tui_for_key.request_render(false);
6567                continue;
6568            }
6569
6570            // Windows' console backend has no Event::Paste. Assemble text keys
6571            // into one payload; only absorb Enter when another key is queued
6572            // or the clipboard confirms a trailing pasted newline. A standalone
6573            // Enter must reach Editor::handle_key so it submits normally.
6574            if cfg!(windows) {
6575                let text_key = match key.code {
6576                    KeyCode::Char(c)
6577                        if !key
6578                            .modifiers
6579                            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
6580                    {
6581                        Some(c)
6582                    }
6583                    _ => None,
6584                };
6585                if let Some(ch) = text_key {
6586                    if ch == '\r' || ch == '\n' {
6587                        // Treat these the same as KeyCode::Enter below.
6588                    } else {
6589                        windows_paste_run.feed(ch, std::time::Instant::now());
6590                        continue;
6591                    }
6592                }
6593                if key.modifiers.is_empty()
6594                    && matches!(
6595                        key.code,
6596                        KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
6597                    )
6598                {
6599                    let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
6600                        if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
6601                            crossterm::event::read().ok()
6602                        } else {
6603                            None
6604                        }
6605                    });
6606                    pending_event = stashed;
6607                    let clipboard = if !more_queued && windows_paste_run.burst {
6608                        read_clipboard_text()
6609                    } else {
6610                        None
6611                    };
6612                    if windows_enter_in_paste(&windows_paste_run, more_queued, clipboard.as_deref())
6613                    {
6614                        windows_paste_run.feed('\n', std::time::Instant::now());
6615                        crate::key_trace::note("windows paste run -> newline");
6616                        continue;
6617                    }
6618                    crate::key_trace::note("windows paste run -> flush + submit");
6619                    windows_paste_run.flush(&editor_for_key);
6620                } else {
6621                    windows_paste_run.flush(&editor_for_key);
6622                }
6623            }
6624
6625            // 6. Otherwise forward to the editor + refresh autocomplete. The
6626            //    editor maps unmodified Enter and the encodings a remote client
6627            //    may send for it (`Char('\n')`/`Char('\r')`, and the raw LF
6628            //    crossterm reports as `Ctrl+J`) to submit, so a remote Enter
6629            //    sends instead of inserting a newline.
6630            editor_for_key.handle_key(key);
6631            refresh_autocomplete(&state_for_key, &editor_for_key);
6632            tui_for_key.request_render_reusing_scroll_content();
6633        }
6634    });
6635
6636    // ---- Session lifecycle (P1): SessionStart ----
6637    // The TUI + key worker are up and the session is ready to accept input.
6638    // Notify subscribed extensions now so they can initialize session-scoped
6639    // state. A veto here is **advisory** (the session is already up) — warn and
6640    // continue. No-op when no extension subscribes.
6641    if let Some(reason) = crate::session::dispatch_session_event_async(
6642        reload_context,
6643        rpi_plugin_sdk::EventTag::SessionStart,
6644    )
6645    .await
6646    {
6647        tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
6648    }
6649
6650    // ---- Initial prompts (run before reading from the channel) ----
6651    let mut prompts: Vec<String> = Vec::new();
6652    if let Some(init) = initial {
6653        prompts.push(init);
6654    }
6655    for m in extra_messages {
6656        prompts.push(m.clone());
6657    }
6658    // ---- Continue a run that recovery repaired, the way upstream does ----
6659    // This happens before any user input, and only when nothing was passed on the
6660    // command line: an explicit `-p` means the user has already said what they
6661    // want next. Driven here rather than inside the harness so the run's output
6662    // goes through the same streaming/render path as any other run, and so
6663    // startup is never blocked by a run the user cannot see.
6664    if prompts.is_empty() && lane.has_pending_resume().await {
6665        run_prompt_streaming(
6666            &lane,
6667            "",
6668            true,
6669            &tui,
6670            &state,
6671            drain_handle.is_some(),
6672            Vec::new(),
6673        )
6674        .await;
6675    }
6676
6677    let mut images = initial_images;
6678    for prompt in prompts {
6679        if !*running.lock().unwrap() {
6680            break;
6681        }
6682        // The harness emits no user `message_start` for a directly-sent prompt
6683        // (it drives the loop with an empty prompts vec), so the initial
6684        // `-p` / `--prompt` bubbles are rendered here.
6685        add_user_message(&chat_container, &prompt);
6686        tui.request_render(false);
6687        run_prompt_streaming(
6688            &lane,
6689            &prompt,
6690            false,
6691            &tui,
6692            &state,
6693            drain_handle.is_some(),
6694            std::mem::take(&mut images),
6695        )
6696        .await;
6697    }
6698
6699    // ---- Main loop: process submitted input + lifecycle messages ----
6700    loop {
6701        if !*running.lock().unwrap() {
6702            break;
6703        }
6704        match rx.recv().await {
6705            Some(TuiMessage::UserInput(prompt)) => {
6706                // Clear the editor so the next prompt starts fresh (the submit
6707                // handler runs on the blocking key thread and can't mutate the
6708                // editor state safely there; clearing here, on the async loop,
6709                // keeps it on one thread). Marked programmatic: this is the host
6710                // tidying up after a submit, not the user typing — reporting it
6711                // as a user edit made a hands-free voice session switch itself
6712                // off the moment it was started.
6713                state.clear_editor_programmatically();
6714                let prompt_images = state.take_pending_images();
6715                if !prompt_images.is_empty() {
6716                    add_note_message(
6717                        &chat_container,
6718                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6719                    );
6720                }
6721                run_prompt_streaming(
6722                    &lane,
6723                    &prompt,
6724                    false,
6725                    &tui,
6726                    &state,
6727                    drain_handle.is_some(),
6728                    prompt_images,
6729                )
6730                .await;
6731            }
6732            Some(TuiMessage::ExternalEditorResult(result)) => {
6733                match result {
6734                    Ok(text) => {
6735                        let cursor = text.chars().count();
6736                        editor.set_text(&text);
6737                        editor.set_cursor(0, cursor);
6738                        add_note_message(&chat_container, "Draft updated from external editor.");
6739                    }
6740                    Err(error) => add_error_message(&chat_container, &error),
6741                }
6742                tui.request_render(false);
6743            }
6744            Some(TuiMessage::UserBashFinished(report)) => {
6745                // Free the run slot first: the guard and the Esc router both
6746                // read it, so a finished command must never block the next one.
6747                state.finish_user_bash();
6748                let message = rpi_harness::messages::bash_execution_message(
6749                    report.command,
6750                    report.output,
6751                    report.exit_code,
6752                    report.cancelled,
6753                    report.truncated,
6754                    report.full_output_path,
6755                    Some(report.exclude_from_context),
6756                    now_ms(),
6757                );
6758                // While a run is in flight the message is deferred so it is not
6759                // spliced into the middle of a turn's tool sequence; it is
6760                // flushed at `AgentEnd` (upstream `_pendingBashMessages`).
6761                if *state.status.lock().unwrap() == RunStatus::Idle {
6762                    if let Err(error) = lane.append_message(message).await {
6763                        add_error_message(
6764                            &chat_container,
6765                            &format!("Could not record bash result: {error}"),
6766                        );
6767                        tui.request_render(false);
6768                    }
6769                } else {
6770                    state.pending_bash_messages.lock().unwrap().push(message);
6771                }
6772            }
6773            Some(TuiMessage::OpenTree) => {
6774                if *state.status.lock().unwrap() != RunStatus::Idle {
6775                    add_note_message(
6776                        &chat_container,
6777                        "Wait for the current run to finish before opening the tree.",
6778                    );
6779                    tui.request_render(false);
6780                } else {
6781                    open_tree_selector(
6782                        &harness,
6783                        &state,
6784                        &editor_container,
6785                        &editor,
6786                        &tui,
6787                        &chat_container,
6788                        &tx,
6789                    )
6790                    .await;
6791                }
6792            }
6793            Some(TuiMessage::NavigateTree(entry_id)) => {
6794                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6795                    Ok(result) => match result.outcome {
6796                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6797                            chat_container.clear();
6798                            add_welcome_message(&chat_container);
6799                            render_session_history(
6800                                &harness,
6801                                &chat_container,
6802                                state.markdown_transformer(),
6803                                Some(state.extension_session.clone()),
6804                            )
6805                            .await;
6806                            add_note_message(
6807                                &chat_container,
6808                                "Moved to the selected session entry.",
6809                            );
6810                        }
6811                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6812                            add_error_message(&chat_container, &error.message);
6813                        }
6814                        _ => add_note_message(
6815                            &chat_container,
6816                            "The selected entry could not be opened.",
6817                        ),
6818                    },
6819                    Err(error) => add_error_message(
6820                        &chat_container,
6821                        &format!("Could not navigate session tree: {error}"),
6822                    ),
6823                }
6824                tui.request_render(false);
6825            }
6826            Some(TuiMessage::ClearChat) => {
6827                chat_container.clear();
6828                add_welcome_message(&chat_container);
6829                tui.request_render(false);
6830            }
6831            Some(TuiMessage::Compact) => {
6832                run_compact(&lane, &tui, &state).await;
6833            }
6834            Some(TuiMessage::Copy) => {
6835                copy_last_assistant(&state, &chat_container);
6836                tui.request_render(false);
6837            }
6838            Some(TuiMessage::Exit) => {
6839                *running.lock().unwrap() = false;
6840                break;
6841            }
6842            Some(TuiMessage::SwitchSession(id)) => {
6843                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6844                tui.request_render(false);
6845            }
6846            Some(TuiMessage::ImportSession(path)) => {
6847                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6848                tui.request_render(false);
6849            }
6850            Some(TuiMessage::ShareSession) => {
6851                share_session(&harness, &chat_container).await;
6852                tui.request_render(false);
6853            }
6854            Some(TuiMessage::SetSessionName(name)) => {
6855                let outcome = harness.session().set_name(Some(&name)).await;
6856                match outcome {
6857                    Ok(_) => add_note_message(
6858                        &chat_container,
6859                        &format!("Session renamed to \"{name}\"."),
6860                    ),
6861                    Err(e) => add_error_message(
6862                        &chat_container,
6863                        &format!("Could not rename session: {e}"),
6864                    ),
6865                }
6866                tui.request_render(false);
6867            }
6868            Some(TuiMessage::ExportSession) => {
6869                let file_name = agent_session
6870                    .default_export_file_name(crate::export::ExportFormat::Markdown)
6871                    .await;
6872                let path = cwd.join(&file_name);
6873                match agent_session.export_to_markdown(&path).await {
6874                    Ok(_) => add_note_message(
6875                        &chat_container,
6876                        &format!("Exported session to {}", path.display()),
6877                    ),
6878                    Err(e) => {
6879                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
6880                    }
6881                }
6882                tui.request_render(false);
6883            }
6884            Some(TuiMessage::ExportSessionWithFormat(format)) => {
6885                let file_name = agent_session.default_export_file_name(format.clone()).await;
6886                let path = cwd.join(&file_name);
6887                match agent_session.export(format, &path).await {
6888                    Ok(_) => add_note_message(
6889                        &chat_container,
6890                        &format!("Exported session to {}", path.display()),
6891                    ),
6892                    Err(e) => {
6893                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
6894                    }
6895                }
6896                tui.request_render(false);
6897            }
6898            Some(TuiMessage::ShowUsage) => {
6899                let stats = agent_session.usage_stats().await.ok();
6900                let total = agent_session.total_usage().await.ok();
6901                let snapshot = agent_session.snapshot().await.ok();
6902                match stats {
6903                    Some(stats) => {
6904                        let mut lines = vec![
6905                            "## Usage".to_string(),
6906                            String::new(),
6907                            format!("- messages: {}", stats.message_count),
6908                            format!("- total tokens: {}", stats.total_tokens),
6909                            format!("- cached tokens: {}", stats.cached_tokens),
6910                            format!("- uncached tokens: {}", stats.uncached_tokens),
6911                            format!("- cost: ${:.4}", stats.cost_total),
6912                        ];
6913                        match stats.cache_hit_ratio() {
6914                            Some(ratio) => {
6915                                lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
6916                            }
6917                            None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
6918                        }
6919                        if let Some(total) = total {
6920                            lines.push(String::new());
6921                            lines.push(format!(
6922                                "- last cumulative input/output: {} / {}",
6923                                total.input, total.output
6924                            ));
6925                        }
6926                        if let Some(snapshot) = snapshot {
6927                            lines.push(String::new());
6928                            lines.push(format!("- lane: `{}`", snapshot.lane));
6929                            lines.push(format!(
6930                                "- leaf: `{}`",
6931                                snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
6932                            ));
6933                            if let Some(active) = &snapshot.active {
6934                                lines.push(format!(
6935                                    "- active operation: {} (`{}`)",
6936                                    active.kind.as_str(),
6937                                    active.run_id
6938                                ));
6939                            }
6940                        }
6941                        add_note_message(&chat_container, &lines.join("\n"));
6942                    }
6943                    None => {
6944                        add_error_message(&chat_container, "Could not read session usage stats.")
6945                    }
6946                }
6947                tui.request_render(false);
6948            }
6949            Some(TuiMessage::ForkSession) => {
6950                fork_session(&harness, &cwd, &chat_container, &state).await;
6951                tui.request_render(false);
6952            }
6953            Some(TuiMessage::ReloadExtensions) => {
6954                // B5d: drive the shared reload routine on the async runtime,
6955                // then surface the outcome. `reload_context` was passed into
6956                // `interactive_tui` and is the same `Arc<ReloadContext>` the
6957                // `ReloadCommand` + the plugin mailbox both route through —
6958                // clone the `Arc` out so the borrow of `harness` (the main
6959                // loop's `&AgentHarness`) lives across the await.
6960                let reload_ctx = ctx.reload_context.clone();
6961                add_note_message(&chat_container, "Reloading extensions + resources…");
6962                tui.request_render(false);
6963                let outcome =
6964                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6965                // B5e: the reload swapped a fresh `ExtensionSession` into the
6966                // context's cell. Rebuild the markdown transformer from that
6967                // fresh snapshot and install it on the in-flight streaming
6968                // component (so a reloaded plugin's transformer takes effect on
6969                // the visible message immediately) + future components (they
6970                // read `state.markdown_transformer()` at construction). The old
6971                // closure no-ops once its snapshot's `active` flag flips false
6972                // (reload already did that before the swap).
6973                let fresh_transformer = build_markdown_transformer(
6974                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6975                );
6976                state.set_markdown_transformer_with_reinstall(fresh_transformer);
6977                if outcome.had_errors {
6978                    // Hard failure: the summary itself carries the error detail
6979                    // (e.g. "Settings reload failed: …"), so render it as an
6980                    // error without an extra stderr pointer.
6981                    add_error_message(&chat_container, &outcome.summary);
6982                } else if outcome.had_warnings {
6983                    // Reload succeeded but produced soft diagnostics (skill
6984                    // shadowing, name validation, …). The details are printed
6985                    // to stderr; keep this as an informational note so a
6986                    // successful reload isn't shown as a red ✗ error.
6987                    add_note_message(
6988                        &chat_container,
6989                        &format!(
6990                            "{} (with warnings — see stderr for details).",
6991                            outcome.summary
6992                        ),
6993                    );
6994                } else {
6995                    add_note_message(&chat_container, &outcome.summary);
6996                }
6997                tui.request_render(false);
6998            }
6999            None => break,
7000        }
7001    }
7002
7003    // ---- Shutdown ----
7004    ask_user_bridge.shutdown();
7005    *running.lock().unwrap() = false;
7006    for handle in update_check_handles {
7007        handle.abort();
7008        let _ = handle.await;
7009    }
7010    // The input worker checks `running` at least every 50ms. Join it before
7011    // restoring cooked mode so no late event read races terminal cleanup.
7012    let _ = key_handle.await;
7013    tick_handle.abort();
7014    if let Some(handle) = drain_handle {
7015        handle.abort();
7016    }
7017    // Drop the reload bridge: clearing the mailbox closes the signal channel,
7018    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
7019    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
7020    reload_context.mailbox.clear();
7021    reload_bridge_handle.abort();
7022    tui.stop(Default::default());
7023
7024    // upstream prints how to resume the session after an interactive exit so
7025    // a terminal that scrolled away can be recovered. Mirrors
7026    // `formatResumeCommand` (id + `--session-dir` only when non-default).
7027    let session_id = harness.session().storage().metadata().id;
7028    if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
7029        println!("\x1b[2mTo resume this session:\x1b[0m {command}");
7030    }
7031    println!("\nGoodbye!");
7032    let _ = args;
7033
7034    0
7035}
7036
7037// ===========================================================================
7038// Run a single prompt (streaming or blocking)
7039// ===========================================================================
7040
7041fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
7042    std::thread::spawn(move || {
7043        let file = match tempfile::Builder::new()
7044            .prefix("rpi-draft-")
7045            .suffix(".md")
7046            .tempfile()
7047        {
7048            Ok(file) => file,
7049            Err(error) => {
7050                let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7051                    "Could not create editor file: {error}"
7052                ))));
7053                return;
7054            }
7055        };
7056        if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
7057            let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7058                "Could not write editor file: {error}"
7059            ))));
7060            return;
7061        }
7062        let editor = std::env::var("RPI_EXTERNAL_EDITOR")
7063            .ok()
7064            .filter(|value| !value.trim().is_empty())
7065            .or_else(|| std::env::var("VISUAL").ok())
7066            .or_else(|| std::env::var("EDITOR").ok())
7067            .unwrap_or_else(|| {
7068                if cfg!(windows) {
7069                    "notepad".to_string()
7070                } else {
7071                    "nano".to_string()
7072                }
7073            });
7074        let status = std::process::Command::new(&editor)
7075            .arg(file.path())
7076            .status();
7077        let result = match status {
7078            Ok(status) if status.success() => std::fs::read_to_string(file.path())
7079                .map_err(|error| format!("Could not read editor file: {error}")),
7080            Ok(status) => Err(format!("External editor exited with {status}")),
7081            Err(error) => Err(format!(
7082                "Could not launch external editor `{editor}`: {error}"
7083            )),
7084        };
7085        let _ = tx.send(TuiMessage::ExternalEditorResult(result));
7086    });
7087}
7088
7089/// Apply the authoritative run result when it wins the race with the async
7090/// event drain, then detach the live component from further partial updates.
7091fn reconcile_streamed_assistant_completion(
7092    current_assistant: &Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
7093    last_assistant_text: &Mutex<String>,
7094    final_message: Option<&AssistantMessage>,
7095) {
7096    let final_blocks = final_message.map(assistant_blocks);
7097    let final_text = final_message.map(assistant_text);
7098    let component = current_assistant.lock().unwrap().take();
7099
7100    if let Some(component) = component {
7101        if let Some(blocks) = final_blocks.as_deref() {
7102            component.update_blocks(blocks);
7103        }
7104        component.set_streaming(false);
7105    }
7106
7107    if let Some(text) = final_text.filter(|text| !text.is_empty()) {
7108        *last_assistant_text.lock().unwrap() = text;
7109    }
7110}
7111
7112/// Drive a single prompt through the lane. When `streaming` is true, the
7113/// `AgentEvent` drain task renders the response live and the completed outcome
7114/// reconciles its final snapshot. When false (no `event_rx`), this falls back
7115/// to the blocking await-final-text path.
7116
7117/// Drive a single prompt through the lane. When `streaming` is true, the
7118/// `AgentEvent` drain task renders the response live and the completed outcome
7119/// reconciles its final snapshot. When false (no `event_rx`), this falls back
7120/// to the blocking await-final-text path.
7121async fn run_prompt_streaming(
7122    lane: &Arc<dyn AgentLane>,
7123    prompt: &str,
7124    resume: bool,
7125    tui: &Arc<TuiAltScreen>,
7126    state: &Arc<TuiState>,
7127    streaming: bool,
7128    images: Vec<rpi_ai::types::ImageContent>,
7129) {
7130    state.set_status(RunStatus::Working);
7131    tui.request_render(false);
7132
7133    let outcome = if resume {
7134        // Continuing an interrupted run: no new user turn, just the provider call
7135        // the interrupted run never reached. upstream behaves the same way.
7136        match lane.resume_pending().await {
7137            Ok(Some(result)) => Ok(result),
7138            // Nothing to resume after all: leave the status alone and let the
7139            // normal prompt path take over.
7140            Ok(None) => {
7141                state.set_status(RunStatus::Idle);
7142                tui.request_render(false);
7143                return;
7144            }
7145            Err(error) => Err(error),
7146        }
7147    } else {
7148        lane.prompt_text(prompt, images).await
7149    };
7150
7151    if streaming {
7152        // Broadcast delivery is asynchronous: the harness result can resolve
7153        // before the drain task processes MessageEnd. Reconcile from the
7154        // authoritative outcome before detaching the component so the final
7155        // streamed tail cannot be left at an earlier partial snapshot.
7156        let final_message = match &outcome {
7157            Ok(result) => match &result.outcome {
7158                HarnessRunOutcome::Completed { final_message, .. }
7159                | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7160                HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7161                HarnessRunOutcome::Suspended { .. } => None,
7162            },
7163            Err(_) => None,
7164        };
7165        reconcile_streamed_assistant_completion(
7166            &state.current_assistant,
7167            &state.last_assistant_text,
7168            final_message,
7169        );
7170    }
7171
7172    state.set_status(RunStatus::Idle);
7173    // Guarantee the pending-messages display reconciles at the end of every
7174    // run/prompt — covers aborts and the non-streaming path, where no
7175    // `AgentEnd` event necessarily reaches the drain task.
7176    refresh_pending_messages(state, lane).await;
7177
7178    // Authoritative context-badge fallback. The streaming drain normally
7179    // updates it from `MessageEnd`, but a `Lagged` broadcast or the blocking
7180    // path can skip that event; reconcile from the run outcome so the badge
7181    // always reflects the newest real response.
7182    if let Ok(result) = &outcome {
7183        let final_message: Option<&AssistantMessage> = match &result.outcome {
7184            HarnessRunOutcome::Completed { final_message, .. }
7185            | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7186            HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7187            HarnessRunOutcome::Suspended { .. } => None,
7188        };
7189        if let Some(a) = final_message {
7190            record_context_usage(&state.footer, a);
7191        }
7192    }
7193
7194    match outcome {
7195        Ok(result) => match &result.outcome {
7196            HarnessRunOutcome::Failed {
7197                error,
7198                final_message,
7199                ..
7200            } => {
7201                // Only add an error line if the stream did NOT already render
7202                // an assistant message for it (drain task leaves
7203                // current_assistant Some only on an abrupt end).
7204                // A final assistant error is emitted by the event drain only
7205                // in streaming mode. In regular mode there is no drain task,
7206                // so suppressing this branch merely hides 405/auth/network
7207                // diagnostics from the user.
7208                let already_rendered = streaming && final_message.is_some();
7209                if !already_rendered {
7210                    let msg = final_message
7211                        .as_ref()
7212                        .and_then(|m| m.error_message.clone())
7213                        .unwrap_or_else(|| format!("{error:?}"));
7214                    add_error_message(&state.chat_container, &msg);
7215                }
7216            }
7217            HarnessRunOutcome::Suspended { .. } => {
7218                add_error_message(
7219                    &state.chat_container,
7220                    "Run suspended (deferred) — resume is not supported in v1.",
7221                );
7222            }
7223            HarnessRunOutcome::Aborted { final_message, .. } => {
7224                // Aborted runs render their own partial/final message via the
7225                // stream; only add a note on the blocking fallback path.
7226                if !streaming {
7227                    add_error_message(&state.chat_container, "Request aborted.");
7228                    let _ = final_message; // (rendered by the stream in streaming mode)
7229                }
7230            }
7231            HarnessRunOutcome::Completed { final_message, .. } => {
7232                if !streaming {
7233                    let text = assistant_text(final_message);
7234                    if !text.is_empty() {
7235                        add_assistant_message_blocking(
7236                            &state.chat_container,
7237                            &text,
7238                            state.markdown_transformer(),
7239                        );
7240                        *state.last_assistant_text.lock().unwrap() = text;
7241                    }
7242                }
7243            }
7244        },
7245        Err(e) => {
7246            add_error_message(&state.chat_container, &e.to_string());
7247        }
7248    }
7249
7250    tui.request_render(false);
7251}
7252
7253/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
7254/// Reports the outcome as a transcript note; v1's compaction summarizes the
7255/// session in place, so no streaming display is wired (compaction emits no
7256/// `AgentEvent`s — only the harness bus `RunEnd`).
7257async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
7258    state.set_status(RunStatus::Working);
7259    tui.request_render(false);
7260    match lane.compact(None).await {
7261        Ok(_) => {
7262            add_note_message(&state.chat_container, "Conversation compacted.");
7263            // The last assistant usage describes the pre-compaction context;
7264            // reset the badge to `?` until the next response reports the new
7265            // (much smaller) prompt size — mirrors pi's `percent: null`.
7266            state.footer.set_context_tokens(None);
7267        }
7268        Err(e) => {
7269            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
7270        }
7271    }
7272    state.set_status(RunStatus::Idle);
7273    tui.request_render(false);
7274}
7275
7276/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
7277/// when no clipboard is available (or the `clipboard` feature is off), prints a
7278/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
7279fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
7280    let text = state.last_assistant_text.lock().unwrap().clone();
7281    if text.is_empty() {
7282        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
7283        return;
7284    }
7285    if copy_to_clipboard(&text) {
7286        add_note_message(chat, "Copied last reply to the clipboard.");
7287    } else {
7288        // Clipboard unavailable — print the text to the transcript so the user
7289        // can select/copy it manually (degrades gracefully in headless envs).
7290        let preview: String = text.chars().take(200).collect();
7291        add_note_message(
7292            chat,
7293            &format!(
7294                "Clipboard unavailable. Last reply: {preview}{}",
7295                if text.chars().count() > 200 {
7296                    "…"
7297                } else {
7298                    ""
7299                }
7300            ),
7301        );
7302    }
7303}
7304
7305/// Extract text from the chat container within the given screen coordinates.
7306/// Returns None if the selection is invalid or empty.
7307fn extract_selected_text(
7308    chat_container: &Arc<Container>,
7309    start: (u16, u16),
7310    end: (u16, u16),
7311) -> Option<String> {
7312    use rpi_tui::Component;
7313
7314    // Get the rendered lines from the chat container
7315    let lines = chat_container.render(80); // Use a reasonable width
7316    if lines.is_empty() {
7317        return None;
7318    }
7319
7320    // Normalize coordinates (ensure start is before end)
7321    let (start_row, end_row) = if start.1 <= end.1 {
7322        (start.1 as usize, end.1 as usize)
7323    } else {
7324        (end.1 as usize, start.1 as usize)
7325    };
7326
7327    // Clamp to valid range
7328    let start_row = start_row.min(lines.len().saturating_sub(1));
7329    let end_row = end_row.min(lines.len().saturating_sub(1));
7330
7331    if start_row > end_row {
7332        return None;
7333    }
7334
7335    // Extract the selected lines
7336    let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7337
7338    if selected_lines.is_empty() {
7339        return None;
7340    }
7341
7342    Some(selected_lines.join("\n"))
7343}
7344
7345/// Best-effort clipboard write. Enabled only with the `clipboard` feature
7346/// (`arboard`) on non-Android platforms; otherwise returns `false` so the
7347/// caller degrades to a hint.
7348#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7349fn copy_to_clipboard(text: &str) -> bool {
7350    match arboard::Clipboard::new() {
7351        Ok(mut cb) => cb.set_text(text).is_ok(),
7352        Err(_) => false,
7353    }
7354}
7355
7356#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7357fn copy_to_clipboard(_text: &str) -> bool {
7358    false
7359}
7360
7361/// Best-effort clipboard text read for Ctrl+V paste. The optional `clipboard`
7362/// feature keeps headless builds free of platform clipboard dependencies;
7363/// on Android the OS owns clipboard access so this degrades to `None`.
7364#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7365fn read_clipboard_text() -> Option<String> {
7366    let mut clipboard = arboard::Clipboard::new().ok()?;
7367    clipboard.get_text().ok()
7368}
7369
7370#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7371fn read_clipboard_text() -> Option<String> {
7372    None
7373}
7374
7375/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
7376/// `ImageContent` contract. The optional clipboard feature keeps headless
7377/// builds free of platform clipboard dependencies.
7378#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7379fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7380    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
7381    let image = match clipboard.get_image() {
7382        Ok(image) => image,
7383        Err(_) => return Ok(None),
7384    };
7385    let width =
7386        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
7387    let height =
7388        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
7389    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
7390        return Err("clipboard image dimensions are outside the supported range".into());
7391    }
7392    let mut bytes = Vec::new();
7393    {
7394        let mut encoder = png::Encoder::new(&mut bytes, width, height);
7395        encoder.set_color(png::ColorType::Rgba);
7396        encoder.set_depth(png::BitDepth::Eight);
7397        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
7398        writer
7399            .write_image_data(&image.bytes)
7400            .map_err(|e| e.to_string())?;
7401    }
7402    Ok(Some(rpi_ai::types::ImageContent {
7403        kind: rpi_ai::types::ImageContentType,
7404        data: base64::engine::general_purpose::STANDARD.encode(bytes),
7405        mime_type: "image/png".into(),
7406    }))
7407}
7408
7409fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
7410    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
7411        let mut options = ImageOptions::default();
7412        options.width = Some(48);
7413        options.alt_text = Some("Attached image".into());
7414        chat.add_child(Arc::new(Image::from_data(bytes, options)));
7415        chat.add_child(Arc::new(Spacer::new(1)));
7416    }
7417}
7418
7419#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7420fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7421    Ok(None)
7422}
7423
7424/// Blocking fallback (no `event_rx`): render the final assistant text as a
7425/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
7426/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
7427/// the identity path. The blocking path only fires when `event_rx` is absent,
7428/// so it shares the same transformer the streaming path installs on its
7429/// components.
7430fn add_assistant_message_blocking(
7431    container: &Arc<Container>,
7432    text: &str,
7433    transformer: Option<MarkdownTransformer>,
7434) {
7435    if text.is_empty() {
7436        return;
7437    }
7438    let msg = Arc::new(AssistantMessageComponent::new(
7439        AssistantMessageOptions::default(),
7440    ));
7441    if let Some(t) = &transformer {
7442        msg.set_markdown_transformer(Some(t.clone()));
7443    }
7444    msg.update_text(text);
7445    container.add_child(msg);
7446    container.add_child(Arc::new(Spacer::new(1)));
7447}
7448
7449// ===========================================================================
7450// AgentEvent drain task — the streaming core
7451// ===========================================================================
7452
7453/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
7454/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
7455/// lifetime of the TUI.
7456/// Re-read the lane's queue and update the pending-messages dock slot.
7457/// Cheap (two short mutex locks) and a no-op when the snapshot is unchanged,
7458/// so it is safe to call on every relevant event.
7459async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
7460    if let Ok(queued) = lane.queued_messages().await {
7461        state.set_pending_queue(queued);
7462    }
7463}
7464
7465/// Whether the transcript holds a panel that repaints its elapsed readout from
7466/// the clock on every frame: a running bash command, or a tool call still in
7467/// [`ToolStatus::Running`]. Such a panel needs a real transcript rebuild each
7468/// render tick — a frame that reuses the cached scroll content would leave its
7469/// timer frozen at the second of the last full rebuild.
7470fn transcript_has_live_panel(
7471    bash_components_present: bool,
7472    tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
7473) -> bool {
7474    bash_components_present
7475        || tool_components
7476            .values()
7477            .any(|component| component.status() == ToolStatus::Running)
7478}
7479
7480/// Prepend restored queued messages to the draft the user may already have
7481/// typed (upstream's `restoreQueuedMessagesToEditor` places them first).
7482fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
7483    if current.trim().is_empty() {
7484        queued_text.to_string()
7485    } else {
7486        format!("{queued_text}\n\n{current}")
7487    }
7488}
7489
7490/// Place the caret at the END of `text` after `set_text`. `set_text` resets the
7491/// caret to `(0, 0)`, and the last line's **byte** length is the right column:
7492/// a char count was wrong for multibyte drafts, and row 0 was wrong for a
7493/// multi-line one (upstream's `setTextInternal(text, "end")`).
7494fn set_editor_text_caret_at_end(editor: &Arc<Editor>, text: &str) {
7495    editor.set_text(text);
7496    let last_row = text.split('\n').count().saturating_sub(1);
7497    let last_len = text.split('\n').next_back().map(str::len).unwrap_or(0);
7498    editor.set_cursor(last_row, last_len);
7499}
7500
7501/// Drain every queued steering/follow-up message out of the lane and restore it
7502/// to the editor (upstream's `restoreQueuedMessagesToEditor`). Returns how many
7503/// were restored; the queue is consumed either way, so the dock drops its rows.
7504///
7505/// Runs on a spawned task rather than through the [`TuiMessage`] mailbox: the
7506/// async main loop owns `run_prompt_streaming(..).await` for the whole run, and
7507/// a queue only exists *while* a run is in flight — so a `TuiMessage::Dequeue`
7508/// was never serviced until the run ended, after the lane had already consumed
7509/// the queue. That made Alt+Q a no-op while it was most needed.
7510async fn restore_queued_messages_to_editor(
7511    lane: Arc<dyn AgentLane>,
7512    editor: Arc<Editor>,
7513    state: Arc<TuiState>,
7514    tui: Arc<TuiAltScreen>,
7515) -> usize {
7516    match lane.clear_queue().await {
7517        Ok(queued) => {
7518            let all: Vec<String> = queued
7519                .steering
7520                .iter()
7521                .chain(queued.follow_up.iter())
7522                .cloned()
7523                .collect();
7524            if !all.is_empty() {
7525                let queued_text = all.join("\n\n");
7526                // Read the EXPANDED draft: `set_text` below clears the folded
7527                // paste store, so any `[paste #id …]` marker still in the
7528                // editor must be materialized first or it would be left behind
7529                // as literal text.
7530                let current = editor.get_expanded_text();
7531                let combined = merge_queued_into_draft(&queued_text, &current);
7532                set_editor_text_caret_at_end(&editor, &combined);
7533            }
7534            // The lane drained the queue; refresh so the dock drops the rows.
7535            refresh_pending_messages(&state, &lane).await;
7536            tui.request_render(false);
7537            all.len()
7538        }
7539        Err(error) => {
7540            add_error_message(
7541                &state.chat_container,
7542                &format!("Could not restore queued messages: {error}"),
7543            );
7544            tui.request_render(false);
7545            0
7546        }
7547    }
7548}
7549
7550/// `app.message.dequeue`: restore the queued messages and report the count, like
7551/// upstream's `handleDequeue` (`Restored N queued message(s)` / `No queued
7552/// messages to restore`). The status is a dim transcript note (rpi's analog of
7553/// native `showStatus`).
7554async fn handle_dequeue(
7555    lane: Arc<dyn AgentLane>,
7556    editor: Arc<Editor>,
7557    state: Arc<TuiState>,
7558    tui: Arc<TuiAltScreen>,
7559) {
7560    let restored =
7561        restore_queued_messages_to_editor(lane, editor, state.clone(), tui.clone()).await;
7562    if restored == 0 {
7563        add_note_message(&state.chat_container, "No queued messages to restore");
7564    } else {
7565        let plural = if restored > 1 { "s" } else { "" };
7566        add_note_message(
7567            &state.chat_container,
7568            &format!("Restored {restored} queued message{plural} to editor"),
7569        );
7570    }
7571    tui.request_render(false);
7572}
7573
7574/// Cancel the active run the native-pi way: pull every queued steering /
7575/// follow-up message back into the editor FIRST, then abort. upstream's
7576/// `onEscape` does exactly this during streaming
7577/// (`restoreQueuedMessagesToEditor({abort: true})`), so a cancel never silently
7578/// swallows messages the user staged while the run was in flight — they land in
7579/// the editor to be re-edited or resent.
7580fn abort_run_restoring_queue(
7581    lane: Arc<dyn AgentLane>,
7582    editor: Arc<Editor>,
7583    state: Arc<TuiState>,
7584    tui: Arc<TuiAltScreen>,
7585) {
7586    tokio::spawn(async move {
7587        let _ = restore_queued_messages_to_editor(lane.clone(), editor, state, tui.clone()).await;
7588        let _ = lane.abort().await;
7589        tui.request_render(false);
7590    });
7591}
7592
7593async fn drain_agent_events(
7594    mut rx: broadcast::Receiver<AgentEvent>,
7595    tui: Arc<TuiAltScreen>,
7596    state: Arc<TuiState>,
7597    chat: Arc<Container>,
7598    lane: Arc<dyn AgentLane>,
7599) {
7600    loop {
7601        match rx.recv().await {
7602            Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
7603            Err(broadcast::error::RecvError::Lagged(_)) => {
7604                // We dropped some intermediate deltas; the next MessageUpdate/
7605                // MessageEnd carries a full partial snapshot so the UI re-syncs.
7606                continue;
7607            }
7608            Err(broadcast::error::RecvError::Closed) => break,
7609        }
7610    }
7611}
7612
7613/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
7614/// (`interactive-mode.ts:3068-3396`).
7615async fn handle_agent_event(
7616    event: AgentEvent,
7617    tui: &Arc<TuiAltScreen>,
7618    state: &Arc<TuiState>,
7619    chat: &Arc<Container>,
7620    lane: &Arc<dyn AgentLane>,
7621) {
7622    match event {
7623        AgentEvent::AgentStart => {
7624            state.set_status(RunStatus::Working);
7625            tui.request_render(false);
7626        }
7627
7628        AgentEvent::AgentEnd { .. } => {
7629            // Finalize any still-streaming assistant message.
7630            state.transcript_view().finalize_assistant();
7631            state.set_status(RunStatus::Idle);
7632            // Flush any `!command` results that completed mid-run: the run's
7633            // tool sequence is closed, so appending now keeps transcript order
7634            // intact (upstream's `flushPendingBashMessages`).
7635            let pending: Vec<AgentMessage> =
7636                std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
7637            for message in pending {
7638                if let Err(error) = lane.append_message(message).await {
7639                    add_error_message(chat, &format!("Could not record bash result: {error}"));
7640                }
7641            }
7642            // The run drained the queue; refresh so consumed entries drop out
7643            // of the pending-messages display.
7644            refresh_pending_messages(state, lane).await;
7645            tui.request_render(false);
7646        }
7647
7648        AgentEvent::RetryScheduled {
7649            attempt,
7650            max_retries,
7651            delay_ms,
7652            ..
7653        } => {
7654            state.show_retry(attempt, max_retries, delay_ms);
7655            tui.request_render(false);
7656        }
7657
7658        AgentEvent::TurnStart => {
7659            // A new turn: reset the streaming-assistant guard so the next
7660            // MessageStart creates a fresh component.
7661            state.transcript_view().finalize_assistant();
7662        }
7663
7664        AgentEvent::TurnEnd {
7665            message,
7666            tool_results,
7667        } => {
7668            // Finalize the assistant message for this turn.
7669            let view = state.transcript_view();
7670            if let AgentMessage::Assistant(a) = &message {
7671                view.finalize_assistant_blocks(&assistant_blocks(a));
7672            } else {
7673                view.finalize_assistant();
7674            }
7675            // Tool results whose components were never ended by a
7676            // ToolExecutionEnd are handled by the normal path; there is nothing
7677            // extra to finalize here.
7678            let _ = tool_results;
7679            tui.request_render(false);
7680        }
7681
7682        AgentEvent::MessageStart { message } => match message {
7683            AgentMessage::Assistant(a) => {
7684                // The shared transcript view renders this through the same
7685                // component path as the remote client. It installs the live
7686                // markdown transformer + hide-thinking preference itself.
7687                state.transcript_view().apply(
7688                    &UiEvent::AssistantStart {
7689                        blocks: assistant_blocks(&a),
7690                    },
7691                    tui.width(),
7692                );
7693                tui.request_render(false);
7694            }
7695            AgentMessage::Custom(custom) => {
7696                let payload = serde_json::json!({
7697                    "customType": custom.role,
7698                    "content": custom.content,
7699                    "details": custom.data,
7700                    "expanded": false,
7701                    "outputPad": 1,
7702                });
7703                if let Some(component) = extension_message_component(
7704                    &state.extension_session,
7705                    &custom.role,
7706                    &payload,
7707                    state.markdown_transformer(),
7708                ) {
7709                    chat.add_child(component);
7710                    chat.add_child(Arc::new(Spacer::new(1)));
7711                    tui.request_render(false);
7712                } else {
7713                    add_note_message(chat, &custom_message_fallback(&custom));
7714                    tui.request_render(false);
7715                }
7716            }
7717            // Queued (steering / follow-up) user messages are rendered here,
7718            // not at queue time, so a message that is still waiting never looks
7719            // delivered. The loop drains those itself and emits
7720            // `message_start` for them (`agent_loop.rs` pending-message drain).
7721            //
7722            // A DIRECTLY-sent prompt does NOT reach this arm: the harness
7723            // persists it up front and runs the loop with an empty prompts vec,
7724            // so no `message_start` is emitted for it — the submit handler
7725            // renders that bubble instead.
7726            AgentMessage::User(user) => {
7727                state.transcript_view().add_user(&user_message_text(&user));
7728                // A consumed entry must drop out of the pending display.
7729                refresh_pending_messages(state, lane).await;
7730                tui.request_render(false);
7731            }
7732            // ToolResult / other starts are echoed via the tool-execution
7733            // components; ignore the dupes.
7734            _ => {}
7735        },
7736
7737        AgentEvent::MessageUpdate {
7738            message,
7739            assistant_message_event,
7740        } => {
7741            let _ = assistant_message_event; // the snapshot is authoritative
7742            let a: &rpi_ai::types::AssistantMessage = &message;
7743            let text = assistant_text(a);
7744            // The shared view creates/updates tool + bash panels from the
7745            // snapshot's finalized tool calls and streams the block list, so a
7746            // thinking block renders live exactly as the remote client does.
7747            state.transcript_view().apply(
7748                &UiEvent::AssistantUpdate {
7749                    blocks: assistant_blocks(a),
7750                    tool_calls: assistant_tool_calls(a),
7751                },
7752                tui.width(),
7753            );
7754            // A freshly-created bash panel hides the global `Working…` loader.
7755            state.sync_working_loader_with_bash();
7756            *state.last_assistant_text.lock().unwrap() = text;
7757            tui.request_render(false);
7758        }
7759
7760        AgentEvent::MessageEnd { message } => {
7761            if let AgentMessage::Assistant(a) = &message {
7762                let text = assistant_text(a);
7763                state
7764                    .transcript_view()
7765                    .finalize_assistant_blocks(&assistant_blocks(a));
7766                // Cache the finalized text for `/copy`.
7767                if !text.is_empty() {
7768                    *state.last_assistant_text.lock().unwrap() = text;
7769                }
7770                // Cache-miss notice (`maybeShowCacheMissNotice`): the previous
7771                // turn's input established a cacheable prefix; a prompt that
7772                // re-reads less than it should means the prefix was re-billed.
7773                // Uses the shared cache-stats logic (noise floor, idle gap,
7774                // model change) and prices the miss when known.
7775                let usage = &a.usage;
7776                // Footer usage totals + cache hit rate (pi footer.ts).
7777                state.footer.add_usage(
7778                    usage.input,
7779                    usage.output,
7780                    usage.cache_read,
7781                    usage.cache_write,
7782                    usage.cost.total,
7783                );
7784                let denom = usage.input + usage.cache_read + usage.cache_write;
7785                if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
7786                    state
7787                        .footer
7788                        .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
7789                }
7790                // Context-window badge (`?/512k` → `63.2%/512k`): the provider
7791                // reports the prompt token count for this request, which is the
7792                // context size the footer displays.
7793                record_context_usage(&state.footer, a);
7794                let miss = state
7795                    .cache_tracker
7796                    .lock()
7797                    .unwrap()
7798                    .observe(a, &rpi_harness::cache_stats::NoPrices);
7799                // upstream gates these behind `showCacheMissNotices`
7800                // (default `false`); the tracker still runs so the footer's
7801                // cache-hit rate stays accurate either way.
7802                let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
7803                if let Some(miss) = miss.filter(|_| cache_miss_notices) {
7804                    let cost = if miss.missed_cost > 0.0 {
7805                        format!(" (~${:.4})", miss.missed_cost)
7806                    } else {
7807                        String::new()
7808                    };
7809                    add_note_message(
7810                        &state.chat_container,
7811                        &format!(
7812                            "Cache miss: {} tokens re-billed{}",
7813                            format_tokens(miss.missed_tokens),
7814                            cost
7815                        ),
7816                    );
7817                }
7818                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
7819                    add_note_message(chat, &text);
7820                }
7821                // Error assistant messages carry the provider diagnostic in
7822                // `error_message`, not in text content. The assistant
7823                // component is empty for these messages, so surface the
7824                // diagnostic as a visible error row in the transcript.
7825                if let Some(error) = assistant_error_text(a) {
7826                    add_error_message(chat, &error);
7827                }
7828            }
7829            tui.request_render(false);
7830        }
7831
7832        AgentEvent::ToolExecutionStart {
7833            tool_call_id,
7834            tool_name,
7835            args,
7836        } => {
7837            state.transcript_view().apply(
7838                &UiEvent::ToolStart {
7839                    id: tool_call_id,
7840                    name: tool_name,
7841                    args,
7842                },
7843                tui.width(),
7844            );
7845            state.sync_working_loader_with_bash();
7846            tui.request_render(false);
7847        }
7848
7849        AgentEvent::ToolExecutionUpdate {
7850            tool_call_id,
7851            tool_name,
7852            args,
7853            partial_result,
7854        } => {
7855            state.transcript_view().apply(
7856                &UiEvent::ToolUpdate {
7857                    id: tool_call_id,
7858                    name: tool_name,
7859                    args,
7860                    result: crate::remote::protocol::tool_result_json(&partial_result),
7861                },
7862                tui.width(),
7863            );
7864            state.sync_working_loader_with_bash();
7865            tui.request_render(false);
7866        }
7867
7868        AgentEvent::ToolExecutionEnd {
7869            tool_call_id,
7870            tool_name,
7871            result,
7872            is_error,
7873        } => {
7874            state.transcript_view().apply(
7875                &UiEvent::ToolEnd {
7876                    id: tool_call_id,
7877                    name: tool_name,
7878                    result: crate::remote::protocol::tool_result_json(&result),
7879                    is_error,
7880                },
7881                tui.width(),
7882            );
7883            state.sync_working_loader_with_bash();
7884            // Bash tool completion is user-visible: immediate render ensures the
7885            // result is displayed even when the scheduler thread is busy or the
7886            // throttle window has not elapsed.
7887            tui.render_now(false);
7888        }
7889    }
7890}
7891
7892/// Update the footer's context-window badge from one assistant response.
7893///
7894/// `Usage::context_tokens()` is the prompt size the provider reported for that
7895/// request — the value pi's footer renders as `{percent}%/{window}`. Aborted
7896/// and errored turns are skipped (their usage is not a real context
7897/// measurement, mirroring pi's `lastAssistantUsageInfo`) so the last valid
7898/// count stays on screen instead of flickering to `?`.
7899fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
7900    if message.stop_reason == rpi_ai::StopReason::Aborted
7901        || message.stop_reason == rpi_ai::StopReason::Error
7902    {
7903        return;
7904    }
7905    let tokens = message.usage.context_tokens();
7906    if tokens > 0 {
7907        footer.set_context_tokens(Some(tokens));
7908    }
7909}
7910
7911/// Return the diagnostic carried by a failed or aborted provider request.
7912/// Providers may omit `error_message`; keep a stable fallback so a terminal
7913/// request failure can never render as an empty transcript turn.
7914fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7915    let fallback = match message.stop_reason {
7916        rpi_ai::StopReason::Error => "Provider request failed.",
7917        rpi_ai::StopReason::Aborted => "Request aborted.",
7918        _ => return None,
7919    };
7920    Some(
7921        message
7922            .error_message
7923            .as_deref()
7924            .filter(|text| !text.trim().is_empty())
7925            .unwrap_or(fallback)
7926            .to_string(),
7927    )
7928}
7929
7930/// Whether a tool result opts into markdown rendering of its body.
7931///
7932/// A tool signals this by setting `details.markdown = true` on its result
7933/// (the `rpi-todo` extension does this for its progress bar + table payload).
7934/// The host then hands the body to the markdown renderer instead of printing
7935/// it as plain text — see `ToolExecutionComponent::set_result_markdown`.
7936/// Absent or non-`true` details keep the existing plain-text rendering, so
7937/// every other tool is unaffected.
7938fn tool_result_requests_markdown(details: Option<&serde_json::Value>) -> bool {
7939    details
7940        .and_then(|details| details.get("markdown"))
7941        .and_then(|value| value.as_bool())
7942        .unwrap_or(false)
7943}
7944
7945// ===========================================================================
7946// Selectors — editor-container swap (TS showSelector pattern)
7947// ===========================================================================
7948
7949/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
7950/// hiding the editor while the selector is open. Records the selector in
7951/// `state.active_selector` so the key loop routes to it.
7952fn open_selector(
7953    state: &Arc<TuiState>,
7954    editor_container: &Arc<Container>,
7955    editor: &Arc<Editor>,
7956    tui: &Arc<TuiAltScreen>,
7957    list: Arc<SelectList>,
7958    kind: SelectorKind,
7959) {
7960    open_selector_with_view(
7961        state,
7962        editor_container,
7963        editor,
7964        tui,
7965        SelectorView::List(list.clone()),
7966        list,
7967        kind,
7968    );
7969}
7970
7971/// Open a searchable selector: the list plus a fuzzy-filter search box.
7972///
7973/// Returns the wrapper so callers can prefill the query (upstream's
7974/// `initialSearchInput`). Mirrors `SelectSubmenu` with `searchable: true`.
7975fn open_searchable_selector(
7976    state: &Arc<TuiState>,
7977    editor_container: &Arc<Container>,
7978    editor: &Arc<Editor>,
7979    tui: &Arc<TuiAltScreen>,
7980    title: Option<&str>,
7981    description: Option<&str>,
7982    list: Arc<SelectList>,
7983    kind: SelectorKind,
7984) -> Arc<SearchableSelectList> {
7985    let searchable = Arc::new(SearchableSelectList::new(title, description, list));
7986    open_selector_with_view(
7987        state,
7988        editor_container,
7989        editor,
7990        tui,
7991        SelectorView::Searchable(searchable.clone()),
7992        searchable.clone(),
7993        kind,
7994    );
7995    // `TuiAltScreen::set_focus` only records the focused component; components
7996    // manage their own focus flag. Without this the search caret never renders
7997    // (and IME candidate positioning stays on the editor's old caret).
7998    searchable.set_focused(true);
7999    searchable
8000}
8001
8002/// Open a selector with an optional framed view. Native extension selectors
8003/// wrap the list with a title and hint while built-in selectors keep the list
8004/// as the complete view.
8005///
8006/// `view_router` receives keys (a bare list, or a searchable wrapper) while
8007/// `view` is what renders — the two are the same object for every built-in
8008/// selector, but extension selectors frame a plain list.
8009fn open_selector_with_view<V: Component + 'static>(
8010    state: &Arc<TuiState>,
8011    editor_container: &Arc<Container>,
8012    editor: &Arc<Editor>,
8013    tui: &Arc<TuiAltScreen>,
8014    view_router: SelectorView,
8015    view: Arc<V>,
8016    kind: SelectorKind,
8017) {
8018    // Dispatch ui_prompt_start event
8019    dispatch_ui_prompt_event(state);
8020
8021    // Unfocus the editor so its cursor marker doesn't render behind the list.
8022    editor.set_focused(false);
8023    // A selector replaces the editor slot. Drop stale slash/@file
8024    // suggestions so they cannot reappear after the selector closes.
8025    state.autocomplete_container.clear();
8026    // Swap: clear the container and add the selector view.
8027    editor_container.clear();
8028    editor_container.add_child(view.clone());
8029    *state.active_selector.lock().unwrap() = Some((view_router, kind));
8030    let focused: Arc<dyn Component> = view;
8031    tui.set_focus(Some(focused));
8032    tui.request_render(false);
8033}
8034
8035/// Restore the editor into the `editor_container` and clear the active
8036/// selector. Called by selector `on_cancel` and the Esc handler.
8037fn close_selector(
8038    state: &Arc<TuiState>,
8039    editor_container: &Arc<Container>,
8040    editor: &Arc<Editor>,
8041    tui: &Arc<TuiAltScreen>,
8042) {
8043    // Dispatch ui_prompt_end event
8044    dispatch_ui_prompt_event_end(state);
8045
8046    editor_container.clear();
8047    editor_container.add_child(editor.clone());
8048    state.autocomplete_container.clear();
8049    editor.set_focused(true);
8050    *state.active_selector.lock().unwrap() = None;
8051    *state.active_extension_cancel.lock().unwrap() = None;
8052    tui.set_focus(Some(editor.clone()));
8053    tui.request_render(false);
8054}
8055
8056/// Build + open the `/model` selector. Items are the resolved catalog (display
8057/// label = model name; description = id), with the current model marked.
8058/// Selecting applies the model **live** via `lane.set_model` (takes effect on
8059/// the next user message — the in-flight run's config is already snapshotted),
8060/// updates the footer, and notes the next-prompt effect.
8061fn open_model_selector(
8062    state: &Arc<TuiState>,
8063    editor_container: &Arc<Container>,
8064    editor: &Arc<Editor>,
8065    tui: &Arc<TuiAltScreen>,
8066    catalog: &[rpi_ai::Model],
8067    lane: &Arc<dyn AgentLane>,
8068    lane_model_id: &str,
8069    chat: &Arc<Container>,
8070    initial_search: Option<&str>,
8071) -> Option<Arc<SearchableSelectList>> {
8072    let items = model_selector_items(catalog, lane_model_id);
8073    if items.is_empty() {
8074        add_note_message(
8075            chat,
8076            "No models in the catalog. Use --model at startup to select one.",
8077        );
8078        tui.request_render(false);
8079        return None;
8080    }
8081    let list = Arc::new(SelectList::new(items, 10));
8082
8083    // Capture the catalog + lane so the on_select closure can resolve the
8084    // chosen Model and apply it. `on_select` fires on the blocking key thread,
8085    // so the async `set_model` runs on a spawned task (matches Ctrl+P).
8086    let catalog_arc = catalog.to_vec();
8087    let state_sel = state.clone();
8088    let ec_sel = editor_container.clone();
8089    let editor_sel = editor.clone();
8090    let tui_sel = tui.clone();
8091    let chat_sel = chat.clone();
8092    let lane_sel = lane.clone();
8093    list.on_select(Arc::new(move |item| {
8094        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
8095            add_note_message(
8096                &chat_sel,
8097                &format!("Model {} not found in catalog.", item.label),
8098            );
8099            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8100            return;
8101        };
8102        state_sel.set_current_model(&model);
8103        let lane = lane_sel.clone();
8104        tokio::spawn(async move {
8105            let _ = lane.set_model(model).await;
8106        });
8107        add_note_message(
8108            &chat_sel,
8109            &format!(
8110                "Model set to {} — applies to the next message.",
8111                short_model_name(&item.value)
8112            ),
8113        );
8114        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8115    }));
8116    let state_cancel = state.clone();
8117    let ec_cancel = editor_container.clone();
8118    let editor_cancel = editor.clone();
8119    let tui_cancel = tui.clone();
8120    list.on_cancel(Arc::new(move || {
8121        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8122    }));
8123
8124    let searchable = open_searchable_selector(
8125        state,
8126        editor_container,
8127        editor,
8128        tui,
8129        Some("Select model"),
8130        Some("Type to filter by name, provider, or id"),
8131        list,
8132        SelectorKind::Model,
8133    );
8134    if let Some(term) = initial_search {
8135        searchable.set_search_text(term);
8136    }
8137    Some(searchable)
8138}
8139
8140/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
8141/// Returns `None` only when the catalog is empty or the current id isn't
8142/// found (in which case the first entry is returned — a no-op if it IS the
8143/// current). Used by the Ctrl+P model-cycle hotkey.
8144fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8145    if catalog.is_empty() {
8146        return None;
8147    }
8148    let idx = catalog
8149        .iter()
8150        .position(|m| m.id.eq_ignore_ascii_case(current_id));
8151    match idx {
8152        Some(i) => {
8153            let next = (i + 1) % catalog.len();
8154            Some(catalog[next].clone())
8155        }
8156        None => Some(catalog[0].clone()),
8157    }
8158}
8159
8160/// Cycle to the catalog entry *before* `current_id`, wrapping to the last —
8161/// the mirror of [`cycle_next_model`] for the previous-model hotkey.
8162fn cycle_prev_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8163    if catalog.is_empty() {
8164        return None;
8165    }
8166    let idx = catalog
8167        .iter()
8168        .position(|m| m.id.eq_ignore_ascii_case(current_id));
8169    match idx {
8170        Some(i) => {
8171            let prev = (i + catalog.len() - 1) % catalog.len();
8172            Some(catalog[prev].clone())
8173        }
8174        None => Some(catalog[0].clone()),
8175    }
8176}
8177
8178/// Build + open the `/session` selector. Lists JSONL session files under the
8179/// default session dir (`<cwd>/.rpi/sessions`
8180/// fallback), newest-first, labelled with the session name or first prompt so
8181/// two sessions are distinguishable. Header-only sessions are skipped: there is
8182/// nothing to switch to.
8183fn open_session_selector(
8184    state: &Arc<TuiState>,
8185    editor_container: &Arc<Container>,
8186    editor: &Arc<Editor>,
8187    tui: &Arc<TuiAltScreen>,
8188    cwd: &std::path::Path,
8189    tx: &mpsc::UnboundedSender<TuiMessage>,
8190) {
8191    let dir = crate::session::default_session_dir(cwd);
8192    let mut items: Vec<SelectItem> = Vec::new();
8193    for path in crate::session::list_session_files_sync(&dir) {
8194        let summary = crate::session::summarize_session_file(&path);
8195        if summary.empty {
8196            continue;
8197        }
8198        let stem = path
8199            .file_stem()
8200            .and_then(|s| s.to_str())
8201            .unwrap_or("(unnamed)")
8202            .to_string();
8203        let label = crate::session::session_display_label(&summary);
8204        // The file stem is `<timestamp>_<uuid>`; the id tail disambiguates rows
8205        // that share a timestamp.
8206        let id_tail = stem.rsplit('_').next().unwrap_or(&stem);
8207        let short_id = crate::session::short_session_id(id_tail);
8208        let description = format!(
8209            "{} · {}",
8210            crate::session::format_session_bytes(summary.bytes),
8211            short_id,
8212        );
8213        items.push(
8214            SelectItem::new(&stem, &label)
8215                .with_description(&description)
8216                .with_search_text(&format!("{label} {short_id} {stem}")),
8217        );
8218    }
8219    if items.is_empty() {
8220        add_note_message(
8221            &state.chat_container,
8222            "No saved sessions found. Sessions are created automatically in interactive mode.",
8223        );
8224        tui.request_render(false);
8225        return;
8226    }
8227    let mut list = SelectList::new(items, 10);
8228    list.set_layout(rpi_tui::SelectListLayoutOptions {
8229        min_primary_column_width: Some(56),
8230        max_primary_column_width: Some(72),
8231        truncate_primary: None,
8232    });
8233    let list = Arc::new(list);
8234
8235    let state_sel = state.clone();
8236    let ec_sel = editor_container.clone();
8237    let editor_sel = editor.clone();
8238    let tui_sel = tui.clone();
8239    let tx_sel = tx.clone();
8240    list.on_select(Arc::new(move |item| {
8241        // Close the selector first, then ask the async loop to hot-switch:
8242        // opening the session file + swapping the harness backing is async
8243        // (repo list/open) and must not run on the blocking key thread.
8244        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8245        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
8246    }));
8247    let state_cancel = state.clone();
8248    let ec_cancel = editor_container.clone();
8249    let editor_cancel = editor.clone();
8250    let tui_cancel = tui.clone();
8251    list.on_cancel(Arc::new(move || {
8252        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8253    }));
8254
8255    open_searchable_selector(
8256        state,
8257        editor_container,
8258        editor,
8259        tui,
8260        Some("Resume session"),
8261        Some("Type to filter sessions"),
8262        list,
8263        SelectorKind::Session,
8264    );
8265}
8266
8267fn custom_entry_display_text(
8268    custom_type: &str,
8269    data: Option<&serde_json::Value>,
8270) -> Option<String> {
8271    let data = data?;
8272    let text = data
8273        .get("summary")
8274        .or_else(|| data.get("text"))
8275        .or_else(|| data.get("output"))
8276        .and_then(|value| value.as_str())
8277        .filter(|value| !value.trim().is_empty())?;
8278    let label = match custom_type {
8279        "compactionSummary" => "Compaction summary",
8280        "branchSummary" => "Branch summary",
8281        "bashExecution" => "Command output",
8282        other => other,
8283    };
8284    Some(format!("{label}: {text}"))
8285}
8286
8287/// Open a selector for the current session's persisted entry tree. Selecting a
8288/// message moves the main lane leaf to that entry, then the caller reloads the
8289/// visible branch from durable storage.
8290async fn open_tree_selector(
8291    harness: &AgentHarness,
8292    state: &Arc<TuiState>,
8293    editor_container: &Arc<Container>,
8294    editor: &Arc<Editor>,
8295    tui: &Arc<TuiAltScreen>,
8296    chat: &Arc<Container>,
8297    tx: &mpsc::UnboundedSender<TuiMessage>,
8298) {
8299    let entries = match harness
8300        .session()
8301        .view("main")
8302        .find_entries(&EntryQuery {
8303            order: Some(EntryOrder::OldestFirst),
8304            ..Default::default()
8305        })
8306        .await
8307    {
8308        Ok(entries) => entries,
8309        Err(error) => {
8310            add_error_message(chat, &format!("Could not read session tree: {error}"));
8311            tui.request_render(false);
8312            return;
8313        }
8314    };
8315    let current = harness.session().get_leaf_id().await.ok().flatten();
8316    let items: Vec<SelectItem> = entries
8317        .iter()
8318        .map(|entry| {
8319            let label = if current.as_deref() == Some(entry.id()) {
8320                format!("{} #{} (current)", entry.entry_type(), entry.seq())
8321            } else {
8322                format!("{} #{}", entry.entry_type(), entry.seq())
8323            };
8324            let short_id = &entry.id()[..entry.id().len().min(12)];
8325            // Search on the entry type, sequence, and id so `/tree` stays
8326            // usable in long sessions.
8327            let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
8328            SelectItem::new(entry.id(), &label)
8329                .with_description(short_id)
8330                .with_search_text(&search_text)
8331        })
8332        .collect();
8333    if items.is_empty() {
8334        add_note_message(chat, "The current session has no entries to navigate.");
8335        tui.request_render(false);
8336        return;
8337    }
8338    let list = Arc::new(SelectList::new(items, 12));
8339    let state_sel = state.clone();
8340    let ec_sel = editor_container.clone();
8341    let editor_sel = editor.clone();
8342    let tui_sel = tui.clone();
8343    let tx_sel = tx.clone();
8344    list.on_select(Arc::new(move |item| {
8345        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
8346        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8347    }));
8348    let state_cancel = state.clone();
8349    let ec_cancel = editor_container.clone();
8350    let editor_cancel = editor.clone();
8351    let tui_cancel = tui.clone();
8352    list.on_cancel(Arc::new(move || {
8353        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8354    }));
8355    open_searchable_selector(
8356        state,
8357        editor_container,
8358        editor,
8359        tui,
8360        Some("Session tree"),
8361        Some("Type to filter by entry type, sequence, or id"),
8362        list,
8363        SelectorKind::Tree,
8364    );
8365}
8366
8367/// Build + open the `/theme` selector. Built-in presets and enabled package
8368/// themes are shown; selecting applies the theme live and re-renders.
8369fn open_theme_selector(
8370    state: &Arc<TuiState>,
8371    editor_container: &Arc<Container>,
8372    editor: &Arc<Editor>,
8373    tui: &Arc<TuiAltScreen>,
8374) {
8375    let items = vec![
8376        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
8377        SelectItem::new("light", "Light").with_description("Light background"),
8378        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
8379    ];
8380    let list = Arc::new(SelectList::new(items, 10));
8381
8382    let state_sel = state.clone();
8383    let ec_sel = editor_container.clone();
8384    let editor_sel = editor.clone();
8385    let tui_sel = tui.clone();
8386    let chat_sel = state.chat_container.clone();
8387    list.on_select(Arc::new(move |item| {
8388        let preset = match item.value.as_str() {
8389            "light" => Some(ThemePreset::Light),
8390            "monochrome" => Some(ThemePreset::Monochrome),
8391            "dark" => Some(ThemePreset::Dark),
8392            name => {
8393                add_error_message(&chat_sel, &format!("Unknown theme `{name}`."));
8394                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8395                tui_sel.render_now(true);
8396                return;
8397            }
8398        };
8399        let Some(preset) = preset else { return };
8400        apply_theme_preset(preset);
8401        state_sel.theme_manager.apply_preset(preset);
8402        // A quick accent note so the user sees the change registered even if
8403        // the terminal's own colors mask the preset difference.
8404        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8405        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8406        tui_sel.render_now(true);
8407    }));
8408    let state_cancel = state.clone();
8409    let ec_cancel = editor_container.clone();
8410    let editor_cancel = editor.clone();
8411    let tui_cancel = tui.clone();
8412    list.on_cancel(Arc::new(move || {
8413        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8414    }));
8415
8416    open_selector(
8417        state,
8418        editor_container,
8419        editor,
8420        tui,
8421        list,
8422        SelectorKind::Theme,
8423    );
8424}
8425
8426// ===========================================================================
8427// Feasible selectors — /thinking, /tools, /images
8428// ===========================================================================
8429
8430/// One-line descriptions for each thinking level, ported from
8431/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
8432fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8433    use rpi_ai::types::ThinkingLevel::*;
8434    match level {
8435        Off => "Off — No reasoning",
8436        Minimal => "Minimal — Brief reasoning (~1k tokens)",
8437        Low => "Low — Light reasoning (~1k tokens)",
8438        Medium => "Medium — Moderate reasoning (~80% of max)",
8439        High => "High — Extensive reasoning (~95% of max)",
8440        Xhigh => "Xhigh — Near-maximal reasoning",
8441        Max => "Max — Maximum reasoning",
8442    }
8443}
8444
8445/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
8446/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
8447fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8448    use rpi_ai::types::ThinkingLevel::*;
8449    match level {
8450        Off => "off",
8451        Minimal => "minimal",
8452        Low => "low",
8453        Medium => "medium",
8454        High => "high",
8455        Xhigh => "xhigh",
8456        Max => "max",
8457    }
8458}
8459
8460/// Parse a thinking-level name back to the enum (case-insensitive). Returns
8461/// `None` for an unknown name; used by the `/thinking` selector callback.
8462fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
8463    use rpi_ai::types::ThinkingLevel::*;
8464    match name.to_ascii_lowercase().as_str() {
8465        "off" => Some(Off),
8466        "minimal" => Some(Minimal),
8467        "low" => Some(Low),
8468        "medium" => Some(Medium),
8469        "high" => Some(High),
8470        "xhigh" => Some(Xhigh),
8471        "max" => Some(Max),
8472        _ => None,
8473    }
8474}
8475
8476/// Build + open the `/thinking` selector. Items are the levels the current
8477/// model supports (`Model::supported_thinking_levels`), each with a
8478/// description; the current level (read beforehand via `lane.get_thinking_level`)
8479/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
8480///
8481/// `on_select` fires on the blocking key thread, so it can't await
8482/// `lane.get_thinking_level()` to know the current level — the opener resolves
8483/// it first (best-effort) and preselects; the toggle on_select just applies
8484/// whatever was picked.
8485fn open_thinking_selector(
8486    state: &Arc<TuiState>,
8487    editor_container: &Arc<Container>,
8488    editor: &Arc<Editor>,
8489    tui: &Arc<TuiAltScreen>,
8490    lane: &Arc<dyn AgentLane>,
8491    catalog: &[rpi_ai::Model],
8492    lane_model_id: &str,
8493    chat: &Arc<Container>,
8494) {
8495    // Find the current model in the catalog to read its supported levels. If
8496    // absent, fall back to all levels so the selector still opens.
8497    let model = catalog
8498        .iter()
8499        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
8500    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
8501        .map(|m| m.supported_thinking_levels())
8502        .unwrap_or_else(|| {
8503            use rpi_ai::types::ThinkingLevel::*;
8504            vec![Off, Minimal, Low, Medium, High]
8505        });
8506    let mut items: Vec<SelectItem> = Vec::new();
8507    for lvl in &levels {
8508        let name = thinking_level_name(*lvl);
8509        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
8510    }
8511    if items.is_empty() {
8512        add_note_message(chat, "This model has no supported thinking levels.");
8513        tui.request_render(false);
8514        return;
8515    }
8516    let list = Arc::new(SelectList::new(items, 10));
8517
8518    let state_sel = state.clone();
8519    let ec_sel = editor_container.clone();
8520    let editor_sel = editor.clone();
8521    let tui_sel = tui.clone();
8522    let chat_sel = chat.clone();
8523    let lane_sel = lane.clone();
8524    list.on_select(Arc::new(move |item| {
8525        let Some(level) = thinking_level_from_name(&item.value) else {
8526            add_note_message(
8527                &chat_sel,
8528                &format!("Unknown thinking level: {}.", item.label),
8529            );
8530            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8531            return;
8532        };
8533        let lane = lane_sel.clone();
8534        let footer_sel = state_sel.footer.clone();
8535        tokio::spawn(async move {
8536            let _ = lane.set_thinking_level(level).await;
8537        });
8538        // Reflect the chosen level in the footer's model suffix (pi parity:
8539        // `model • thinking off` / `model • medium`). The shown text for the
8540        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
8541        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8542        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8543        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8544    }));
8545    let state_cancel = state.clone();
8546    let ec_cancel = editor_container.clone();
8547    let editor_cancel = editor.clone();
8548    let tui_cancel = tui.clone();
8549    list.on_cancel(Arc::new(move || {
8550        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8551    }));
8552
8553    open_selector(
8554        state,
8555        editor_container,
8556        editor,
8557        tui,
8558        list,
8559        SelectorKind::Thinking,
8560    );
8561}
8562
8563/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
8564/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
8565/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
8566/// — the blocking key thread can't await) and selecting a tool **toggles** it
8567/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
8568fn open_tools_selector(
8569    state: &Arc<TuiState>,
8570    editor_container: &Arc<Container>,
8571    editor: &Arc<Editor>,
8572    tui: &Arc<TuiAltScreen>,
8573    lane: &Arc<dyn AgentLane>,
8574    chat: &Arc<Container>,
8575) {
8576    // Best-effort read of the current active set. The opener runs on the async
8577    // runtime (it's called from the main loop's channel dispatch or the submit
8578    // closure that lives on the blocking thread — but `handle.block_on` is safe
8579    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
8580    let mut active = match tokio::runtime::Handle::try_current() {
8581        Ok(h) => h
8582            .block_on(async { lane.get_active_tools().await })
8583            .unwrap_or_default(),
8584        Err(_) => Vec::new(),
8585    };
8586    // An empty active set is the harness sentinel for "all registered tools"
8587    // (the selector only exposes built-ins). Expand it before rendering and
8588    // toggling so the first `/tools` visit does not show every tool as off or
8589    // accidentally reduce the active set to the one item selected.
8590    if active.is_empty() {
8591        active = crate::session::BUILTIN_TOOL_NAMES
8592            .iter()
8593            .map(|name| (*name).to_string())
8594            .collect();
8595    }
8596    let mut items: Vec<SelectItem> = Vec::new();
8597    for name in crate::session::BUILTIN_TOOL_NAMES {
8598        let on = active.iter().any(|a| a == name);
8599        let label = if on {
8600            format!("{name} (on)")
8601        } else {
8602            (*name).to_string()
8603        };
8604        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8605    }
8606    let list = Arc::new(SelectList::new(items, 10));
8607
8608    // Capture the active set so on_select can toggle without re-reading.
8609    let active_captured = active.clone();
8610    let state_sel = state.clone();
8611    let ec_sel = editor_container.clone();
8612    let editor_sel = editor.clone();
8613    let tui_sel = tui.clone();
8614    let chat_sel = chat.clone();
8615    let lane_sel = lane.clone();
8616    list.on_select(Arc::new(move |item| {
8617        let mut next = active_captured.clone();
8618        if let Some(pos) = next.iter().position(|a| a == &item.value) {
8619            next.remove(pos);
8620        } else {
8621            next.push(item.value.clone());
8622        }
8623        let on = next.iter().any(|a| a == &item.value);
8624        let lane = lane_sel.clone();
8625        let next_clone = next.clone();
8626        tokio::spawn(async move {
8627            let _ = lane.set_active_tools(next_clone).await;
8628        });
8629        let list_str = if next.is_empty() {
8630            "(none)".to_string()
8631        } else {
8632            next.join(", ")
8633        };
8634        add_note_message(
8635            &chat_sel,
8636            &format!(
8637                "{} {} — active tools: {}",
8638                item.value,
8639                if on { "enabled" } else { "disabled" },
8640                list_str
8641            ),
8642        );
8643        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8644    }));
8645    let state_cancel = state.clone();
8646    let ec_cancel = editor_container.clone();
8647    let editor_cancel = editor.clone();
8648    let tui_cancel = tui.clone();
8649    list.on_cancel(Arc::new(move || {
8650        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8651    }));
8652
8653    open_selector(
8654        state,
8655        editor_container,
8656        editor,
8657        tui,
8658        list,
8659        SelectorKind::Tools,
8660    );
8661}
8662
8663/// Build + open the `/images` selector (Yes/No). Stores the choice in
8664/// `state.show_images` and notes it. Image wiring is minimal this pass — the
8665/// flag is consulted where images would be shown and echoed back here.
8666fn open_images_selector(
8667    state: &Arc<TuiState>,
8668    editor_container: &Arc<Container>,
8669    editor: &Arc<Editor>,
8670    tui: &Arc<TuiAltScreen>,
8671    chat: &Arc<Container>,
8672) {
8673    let current = *state.show_images.lock().unwrap();
8674    let items = vec![
8675        SelectItem::new("yes", "Yes").with_description(if current {
8676            "Inline images (current)"
8677        } else {
8678            "Inline images"
8679        }),
8680        SelectItem::new("no", "No").with_description(if current {
8681            "Placeholder only"
8682        } else {
8683            "Placeholder only (current)"
8684        }),
8685    ];
8686    let list = Arc::new(SelectList::new(items, 5));
8687
8688    let state_sel = state.clone();
8689    let ec_sel = editor_container.clone();
8690    let editor_sel = editor.clone();
8691    let tui_sel = tui.clone();
8692    let chat_sel = chat.clone();
8693    list.on_select(Arc::new(move |item| {
8694        let on = item.value == "yes";
8695        *state_sel.show_images.lock().unwrap() = on;
8696        add_note_message(
8697            &chat_sel,
8698            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8699        );
8700        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8701    }));
8702    let state_cancel = state.clone();
8703    let ec_cancel = editor_container.clone();
8704    let editor_cancel = editor.clone();
8705    let tui_cancel = tui.clone();
8706    list.on_cancel(Arc::new(move || {
8707        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8708    }));
8709
8710    open_selector(
8711        state,
8712        editor_container,
8713        editor,
8714        tui,
8715        list,
8716        SelectorKind::Images,
8717    );
8718}
8719
8720// ===========================================================================
8721// Autocomplete
8722// ===========================================================================
8723
8724/// Refresh the autocomplete suggestion list from the current editor text +
8725/// cursor. Renders the suggestions into `autocomplete_container` (above the
8726/// editor) or clears it when there are none.
8727fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8728    let text = editor.get_text();
8729    let cursor = editor_cursor_offset(editor, &text);
8730    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8731    render_autocomplete(state, suggestions);
8732}
8733
8734/// Convert the editor's logical `(row, byte-column)` caret into the absolute
8735/// byte offset expected by autocomplete providers.
8736fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8737    let (row, col) = editor.cursor_position();
8738    let mut offset = 0;
8739    for (index, line) in text.split('\n').enumerate() {
8740        if index == row {
8741            return (offset + col.min(line.len())).min(text.len());
8742        }
8743        offset = offset.saturating_add(line.len() + 1);
8744    }
8745    text.len()
8746}
8747
8748/// Restore an editor caret from an absolute byte offset after autocomplete
8749/// replaces a span in a multi-line draft.
8750fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8751    let offset = offset.min(text.len());
8752    let mut consumed = 0;
8753    for (row, line) in text.split('\n').enumerate() {
8754        let end = consumed + line.len();
8755        if offset <= end {
8756            editor.set_cursor(row, offset - consumed);
8757            return;
8758        }
8759        consumed = end + 1;
8760    }
8761    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8762    editor.set_cursor(
8763        last_row,
8764        text.rsplit('\n').next().map(str::len).unwrap_or(0),
8765    );
8766}
8767
8768/// Render (or clear) the autocomplete suggestion list into the container.
8769fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8770    state.autocomplete_container.clear();
8771    let Some(sugg) = suggestions else {
8772        return;
8773    };
8774    if sugg.items.is_empty() {
8775        return;
8776    }
8777    // Build a compact list: top item marked with `→`, rest with `  `.
8778    // Cap the list so the dock doesn't swallow the transcript.
8779    let accent = state.theme_manager.get().colors.accent;
8780    let muted = state.theme_manager.get().colors.muted;
8781    for (i, item) in sugg
8782        .items
8783        .iter()
8784        .take(state.autocomplete_max_visible)
8785        .enumerate()
8786    {
8787        let prefix = if i == 0 { "→ " } else { "  " };
8788        let label = item.display_text();
8789        let line = if i == 0 {
8790            format!(
8791                "{prefix}{} {}",
8792                accent.fg(label),
8793                muted.fg(item.description.as_deref().unwrap_or(""))
8794            )
8795        } else {
8796            format!(
8797                "{prefix}{} {}",
8798                muted.fg(label),
8799                muted.fg(item.description.as_deref().unwrap_or(""))
8800            )
8801        };
8802        state
8803            .autocomplete_container
8804            .add_child(Arc::new(Text::new(line, 1, 0)));
8805    }
8806}
8807
8808/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
8809/// suggestion text, reposition the caret, and clear the suggestion list.
8810/// Returns `true` if a suggestion was accepted.
8811fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8812    let text = editor.get_text();
8813    let cursor = editor_cursor_offset(editor, &text);
8814    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8815        return false;
8816    };
8817    let Some(top) = sugg.items.first() else {
8818        return false;
8819    };
8820    // Replace the [start, end) span with the suggestion text. `start`/`end`
8821    // are byte offsets emitted by the providers on char boundaries, so the
8822    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
8823    let start = sugg.start.min(text.len());
8824    let end = sugg.end.min(text.len());
8825    let mut replaced = String::with_capacity(text.len() + top.text.len());
8826    replaced.push_str(&text[..start]);
8827    replaced.push_str(&top.text);
8828    // Keep the text AFTER the replaced span (mid-line completion: replacing
8829    // `[start, end)` must not drop the rest of the line).
8830    replaced.push_str(&text[end..]);
8831    if top.insert_space && !replaced.ends_with('/') {
8832        replaced.push(' ');
8833    }
8834    // New caret position: after the inserted text (byte offset; the editor
8835    // snaps `set_cursor` to a char boundary as a safety net).
8836    let new_cursor = replaced.len().min(
8837        start
8838            + top.text.len()
8839            + if top.insert_space && !top.text.ends_with('/') {
8840                1
8841            } else {
8842                0
8843            },
8844    );
8845    editor.set_text(&replaced);
8846    set_editor_cursor_offset(editor, &replaced, new_cursor);
8847    state.autocomplete_container.clear();
8848    true
8849}
8850
8851// ===========================================================================
8852// Transcript message helpers
8853// ===========================================================================
8854
8855/// Add the welcome header to the chat container.
8856fn add_welcome_message(container: &Arc<Container>) {
8857    add_welcome_message_with_capabilities(container, &[], &[]);
8858}
8859
8860/// Add the startup welcome header and a compact snapshot of active tools and
8861/// discovered skills. The snapshot reflects the harness configuration used by
8862/// the first turn, including tools contributed by extensions.
8863fn add_welcome_message_with_capabilities(
8864    container: &Arc<Container>,
8865    active_tools: &[String],
8866    skills: &[String],
8867) {
8868    let c = current_theme().colors;
8869    // The mark, wordmark and language mark live in `crate::brand`. See that
8870    // module for why the layout is built from single-width characters only.
8871    container.add_child(Arc::new(crate::brand::BrandLockup::new()));
8872    container.add_child(Arc::new(Spacer::new(1)));
8873    // The mark carries the product name, so the line under it is the tagline
8874    // rather than a second logotype.
8875    let title = c.muted.fg("interactive TUI · library-first agent runtime");
8876    container.add_child(Arc::new(Text::new(title, 1, 0)));
8877    container.add_child(Arc::new(Spacer::new(1)));
8878    container.add_child(Arc::new(Text::new(
8879        c.dim.fg("Type your message and press Enter to send."),
8880        1,
8881        0,
8882    )));
8883    let hint = c
8884        .dim
8885        .fg("Enter send · Shift+Enter newline · Ctrl+C clear · Esc abort · /help");
8886    container.add_child(Arc::new(Text::new(hint, 1, 0)));
8887    container.add_child(Arc::new(Spacer::new(1)));
8888    container.add_child(Arc::new(Text::new(
8889        welcome_capability_line("Tools", active_tools),
8890        1,
8891        0,
8892    )));
8893    container.add_child(Arc::new(Text::new(
8894        welcome_capability_line("Skills", skills),
8895        1,
8896        0,
8897    )));
8898    container.add_child(Arc::new(DynamicBorder::new()));
8899}
8900
8901/// Append update notices inside the live transcript. Fullscreen mode clears
8902/// pre-TUI stdout/stderr, so update state must be represented by components.
8903fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8904    let colors = current_theme().colors;
8905    let group = Arc::new(Container::new());
8906
8907    for warning in &report.warnings {
8908        let body = format!(
8909            "{}\n{} {}{}",
8910            colors.error.fg(&warning.message),
8911            colors.muted.fg("Run"),
8912            colors.accent.fg(&warning.command),
8913            colors.muted.fg(" to retry.")
8914        );
8915        add_update_panel(&group, "Update Failed", &body, colors.error);
8916    }
8917
8918    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8919        let body = format!(
8920            "{} {}{}",
8921            colors
8922                .muted
8923                .fg(&format!("New version {} is available. Run", notice.latest)),
8924            colors.accent.fg(&notice.command),
8925            colors.muted.fg(".")
8926        );
8927        add_update_panel(&group, "Update Available", &body, colors.warning);
8928    }
8929
8930    // Other transcript producers append concurrently. Add the fully built
8931    // group in one operation so card borders and content cannot interleave
8932    // with user, assistant, tool, or extension messages.
8933    if group.child_count() > 0 {
8934        container.add_child(group);
8935    }
8936}
8937
8938fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8939    container.add_child(Arc::new(Spacer::new(1)));
8940    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8941    container.add_child(Arc::new(Text::new(
8942        format!("{}\n{body}", color.fg(&tui_bold(title))),
8943        1,
8944        0,
8945    )));
8946    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8947}
8948
8949fn welcome_capability_line(label: &str, names: &[String]) -> String {
8950    let c = current_theme().colors;
8951    let value = if names.is_empty() {
8952        "none".to_string()
8953    } else {
8954        names.join(" · ")
8955    };
8956    format!(
8957        "{} {}",
8958        c.accent.fg(&format!("{label} ({})", names.len())),
8959        c.muted.fg(&value)
8960    )
8961}
8962
8963/// Add the `/help` command listing to the chat container.
8964fn add_help_message(container: &Arc<Container>) {
8965    let c = current_theme().colors;
8966    // Section header + a thin themed rule, then a two-column command table:
8967    // `cmd` in accent, `— desc` in muted. The old single-space layout made
8968    // the description column wander depending on command length.
8969    container.add_child(Arc::new(Text::new(
8970        c.md_heading.fg(&tui_bold("📚 Available Commands")),
8971        1,
8972        0,
8973    )));
8974    container.add_child(Arc::new(Spacer::new(1)));
8975
8976    let cmds: &[(&str, &str)] = &[
8977        ("/help, /?", "Show this help message"),
8978        ("/clear, /new", "Clear the conversation"),
8979        ("/exit, /quit, /q", "Exit the application"),
8980        ("/version, /v", "Show version information"),
8981        ("/changelog", "Show recent release changes"),
8982        ("/model, /m", "Choose a model (live switch)"),
8983        ("/thinking, /think", "Set reasoning depth (selector)"),
8984        ("/tools", "Toggle built-in tools on/off"),
8985        ("/images", "Toggle inline image rendering"),
8986        ("/session", "List saved sessions"),
8987        ("/theme", "Choose a theme (selector)"),
8988        ("/compact", "Compact the conversation"),
8989        ("/copy", "Copy last reply to clipboard"),
8990        ("/hotkeys", "Show keyboard shortcuts"),
8991    ];
8992    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8993    for (cmd, desc) in cmds {
8994        let row = format!(
8995            "  {:<cmd_w$}  {}  {}",
8996            c.accent.fg(cmd),
8997            c.dim.fg("—"),
8998            c.muted.fg(desc)
8999        );
9000        container.add_child(Arc::new(Text::new(row, 1, 0)));
9001    }
9002    container.add_child(Arc::new(Spacer::new(1)));
9003}
9004
9005/// Add the `/version` block to the chat container.
9006fn add_version_message(container: &Arc<Container>) {
9007    let c = current_theme().colors;
9008    container.add_child(Arc::new(Text::new(
9009        c.md_heading.fg(&tui_bold("📦 Version Information")),
9010        1,
9011        0,
9012    )));
9013    container.add_child(Arc::new(Spacer::new(1)));
9014    // Use the crate version (kept in sync via `version.workspace = true`)
9015    // instead of the stale hardcoded "v0.1.2".
9016    container.add_child(Arc::new(Text::new(
9017        format!(
9018            "  {} {}",
9019            c.muted.fg("rpi-cli"),
9020            c.text.fg(&format!("v{}", crate::VERSION))
9021        ),
9022        1,
9023        0,
9024    )));
9025    container.add_child(Arc::new(Text::new(
9026        format!(
9027            "  {}",
9028            c.dim.fg("Rust implementation of pi coding agent TUI")
9029        ),
9030        1,
9031        0,
9032    )));
9033    container.add_child(Arc::new(Spacer::new(1)));
9034}
9035
9036/// Add a compact `/changelog` block to the chat container. Keep this local to
9037/// the binary so the command remains useful in installed builds without a
9038/// source checkout or a network request.
9039fn add_changelog_message(container: &Arc<Container>) {
9040    let c = current_theme().colors;
9041    container.add_child(Arc::new(Text::new(
9042        c.md_heading.fg(&tui_bold("Recent Changes")),
9043        1,
9044        0,
9045    )));
9046    container.add_child(Arc::new(Spacer::new(1)));
9047    let entries = [
9048        (
9049            "Native parity phase 1",
9050            "models, images, trust, export, and JSON events",
9051        ),
9052        (
9053            "TUI controls",
9054            "external editor, thinking levels, and tool output toggles",
9055        ),
9056        (
9057            "Provider auth",
9058            "OpenAI-compatible API key aliases and gateway headers",
9059        ),
9060    ];
9061    for (release, summary) in entries {
9062        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
9063        container.add_child(Arc::new(Text::new(row, 1, 0)));
9064    }
9065    container.add_child(Arc::new(Text::new(
9066        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
9067        1,
9068        0,
9069    )));
9070    container.add_child(Arc::new(Spacer::new(1)));
9071}
9072
9073/// Add the `/hotkeys` block to the chat container.
9074fn add_hotkeys_message(container: &Arc<Container>) {
9075    let c = current_theme().colors;
9076    container.add_child(Arc::new(Text::new(
9077        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
9078        1,
9079        0,
9080    )));
9081    container.add_child(Arc::new(Spacer::new(1)));
9082    let keys: &[(&str, &str)] = &[
9083        ("Enter", "Send message"),
9084        ("Shift+Enter", "New line"),
9085        ("Tab", "Accept autocomplete suggestion"),
9086        ("Ctrl+A / Ctrl+E", "Line start / end"),
9087        (
9088            "Ctrl+K / Ctrl+U",
9089            "Kill to end / start of line (Ctrl+Y yanks)",
9090        ),
9091        ("Ctrl+- / Ctrl+R", "Undo / redo"),
9092        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
9093        ("Alt+Backspace", "Kill previous word"),
9094        ("Ctrl+C", "Clear the editor (press twice quickly to exit)"),
9095        ("Ctrl+X", "Copy the editor selection or the last reply"),
9096        ("Esc", "Abort a running prompt"),
9097        ("Ctrl+L", "Open model selector"),
9098        ("Ctrl+P", "Cycle to the next model (live)"),
9099        ("Ctrl+O", "Expand/collapse all tool output"),
9100        ("Ctrl+T", "Show/hide reasoning blocks"),
9101        ("PageUp/Down", "Scroll transcript by one page"),
9102        ("Home / End", "Jump to transcript start / latest output"),
9103    ];
9104    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
9105    for (key, desc) in keys {
9106        let row = format!(
9107            "  {:<key_w$}  {}  {}",
9108            c.accent.fg(key),
9109            c.dim.fg("—"),
9110            c.muted.fg(desc)
9111        );
9112        container.add_child(Arc::new(Text::new(row, 1, 0)));
9113    }
9114    container.add_child(Arc::new(Spacer::new(1)));
9115}
9116
9117/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
9118/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
9119/// plain `> text` echo. A trailing Spacer(1) separates it from the next
9120// transcript entry (every entry contributes one trailing spacer so
9121// consecutive turns are separated by exactly one blank line).
9122fn add_user_message(container: &Arc<Container>, text: &str) {
9123    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
9124    container.add_child(Arc::new(Spacer::new(1)));
9125}
9126
9127/// Add an error message to the chat container.
9128/// Collect all rendered lines from the chat container for search.
9129fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
9130    let width = 80; // Default width for search; actual width varies by terminal
9131    chat_container.render(width)
9132}
9133
9134/// Build the shell command that resumes `session_id` in this project, or
9135/// `None` when there is nothing to resume. Mirrors upstream's
9136/// `formatResumeCommand`: `rpi --session <id>`, prefixing
9137/// `--session-dir <dir>` only when a non-default directory was requested.
9138fn format_resume_command(
9139    session_id: &str,
9140    cwd: &std::path::Path,
9141    session_dir: Option<&std::path::Path>,
9142) -> Option<String> {
9143    if session_id.is_empty() {
9144        return None;
9145    }
9146    let mut args = vec![crate::APP_NAME.to_string()];
9147    if let Some(dir) = session_dir {
9148        let default = crate::session::default_session_dir(cwd);
9149        if dir != default.as_path() {
9150            args.push("--session-dir".to_string());
9151            args.push(quote_shell_arg(&dir.to_string_lossy()));
9152        }
9153    }
9154    args.push("--session".to_string());
9155    args.push(session_id.to_string());
9156    Some(args.join(" "))
9157}
9158
9159/// Quote a shell argument when it contains whitespace or shell metacharacters
9160/// (mirrors upstream's `quoteIfNeeded`).
9161fn quote_shell_arg(value: &str) -> String {
9162    let needs_quoting = value.is_empty()
9163        || value
9164            .chars()
9165            .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
9166    if needs_quoting {
9167        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
9168    } else {
9169        value.to_string()
9170    }
9171}
9172
9173fn add_error_message(container: &Arc<Container>, text: &str) {
9174    let c = current_theme().colors;
9175    let text = sanitize_error_message(text);
9176    container.add_child(Arc::new(Text::new(
9177        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
9178        1,
9179        0,
9180    )));
9181    container.add_child(Arc::new(Spacer::new(1)));
9182}
9183
9184/// Keep provider diagnostics printable in the transcript. HTTP error bodies
9185/// may contain carriage returns, terminal escapes, or an unexpectedly large
9186/// JSON payload; letting those bytes reach the renderer can corrupt the input
9187/// row or make the whole frame exceed terminal limits.
9188fn sanitize_error_message(text: &str) -> String {
9189    const MAX_ERROR_CHARS: usize = 16 * 1024;
9190    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
9191    let mut count = 0;
9192    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
9193    let mut escape_mode = 0u8;
9194    for ch in text.chars() {
9195        if escape_mode != 0 {
9196            match escape_mode {
9197                1 if ch == '[' => escape_mode = 2,
9198                1 if ch == ']' => escape_mode = 3,
9199                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
9200                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
9201                3 if ch == '\x07' => escape_mode = 0,
9202                _ => {}
9203            }
9204            continue;
9205        }
9206        if ch == '\x1b' {
9207            escape_mode = 1;
9208            continue;
9209        }
9210        if count >= MAX_ERROR_CHARS {
9211            result.push_str("…");
9212            break;
9213        }
9214        match ch {
9215            '\n' | '\t' => {
9216                result.push(ch);
9217                count += 1;
9218            }
9219            '\r' => {}
9220            c if c.is_control() => {}
9221            c => {
9222                result.push(c);
9223                count += 1;
9224            }
9225        }
9226    }
9227    if result.trim().is_empty() {
9228        "Provider request failed.".to_string()
9229    } else {
9230        result
9231    }
9232}
9233
9234/// Add a neutral note (e.g. unsupported-command message) to the chat container.
9235fn add_note_message(container: &Arc<Container>, text: &str) {
9236    let c = current_theme().colors;
9237    container.add_child(Arc::new(Text::new(
9238        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
9239        1,
9240        0,
9241    )));
9242    container.add_child(Arc::new(Spacer::new(1)));
9243}
9244
9245/// Render the `/context` panel: a transcript message listing the discovered
9246/// context files, skills, and prompt templates loaded for this session
9247/// (Part A resource discovery). Reads the harness resources snapshot captured
9248/// at TUI startup (the blocking submit handler can't `await get_resources()`.
9249///
9250/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
9251/// via `/reload`); here it's a transcript note rather than an overlay since the
9252/// resource set is session-static between `/reload`s (deferred).
9253fn show_context_panel(
9254    chat: &Arc<Container>,
9255    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
9256) {
9257    let skills = resources.skills.as_deref().unwrap_or(&[]);
9258    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
9259    let mut lines: Vec<String> = Vec::new();
9260    lines.push("📂 Discovered resources for this session:".into());
9261
9262    if skills.is_empty() {
9263        lines.push(
9264            "  Skills: (none discovered — create .rpi/skills/ or ~/.rpi/agent/skills/)".into(),
9265        );
9266    } else {
9267        lines.push(format!("  Skills ({}):", skills.len()));
9268        for s in skills {
9269            let marker = if s.disable_model_invocation == Some(true) {
9270                " [hidden]"
9271            } else {
9272                ""
9273            };
9274            let desc: String = s.description.chars().take(72).collect();
9275            lines.push(format!("    • {}{marker} — {desc}", s.name));
9276        }
9277    }
9278
9279    if templates.is_empty() {
9280        lines.push(
9281            "  Prompt templates: (none — create .rpi/prompts/ or ~/.rpi/agent/prompts/)".into(),
9282        );
9283    } else {
9284        lines.push(format!("  Prompt templates ({}):", templates.len()));
9285        for t in templates {
9286            let desc = t
9287                .description
9288                .as_deref()
9289                .unwrap_or("(no description)")
9290                .chars()
9291                .take(72)
9292                .collect::<String>();
9293            lines.push(format!("    • /{} — {desc}", t.name));
9294        }
9295    }
9296    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
9297    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
9298    lines.push(
9299        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
9300            .into(),
9301    );
9302    let body = lines.join("\n");
9303    container_note_block(chat, &body);
9304}
9305
9306/// Append a multi-line neutral note (header line + body) to the chat container.
9307fn container_note_block(container: &Arc<Container>, body: &str) {
9308    for line in body.lines() {
9309        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
9310    }
9311    container.add_child(Arc::new(Spacer::new(1)));
9312}
9313
9314// ===========================================================================
9315// TUI support + entry detection
9316// ===========================================================================
9317
9318/// Check if the terminal supports TUI mode.
9319pub fn is_tui_supported() -> bool {
9320    std::io::stdout().is_terminal()
9321}
9322
9323// Keep the `Color` import used (theme accent rendering in autocomplete).
9324#[allow(unused_imports)]
9325use rpi_tui::Color as _Color;
9326
9327#[cfg(test)]
9328mod tests {
9329    use super::*;
9330    use rpi_tui::Component;
9331
9332    /// A draft injected from a voice transcription is submitted only when it is
9333    /// still exactly what we put in the editor and no run has started; any
9334    /// user edit (or a cleared editor) hands it back for editing instead.
9335    #[test]
9336    fn auto_send_cancels_when_the_user_edits_the_draft() {
9337        let now = std::time::Instant::now();
9338        let pending = AutoSendPending {
9339            deadline: now + std::time::Duration::from_secs(2),
9340            text: "hello world".to_string(),
9341        };
9342        // Untouched → still counting down.
9343        assert!(matches!(
9344            auto_send_step(Some(&pending), "hello world", true, now),
9345            AutoSendStep::Wait(_)
9346        ));
9347        // One added character is the user's correction: never send a draft
9348        // they are actively editing.
9349        assert_eq!(
9350            auto_send_step(Some(&pending), "hello worlds", true, now),
9351            AutoSendStep::Cancel
9352        );
9353        // Cleared out from under us (a slash command, Esc).
9354        assert_eq!(
9355            auto_send_step(Some(&pending), "", true, now),
9356            AutoSendStep::Cancel
9357        );
9358        // Whitespace is not content.
9359        assert_eq!(
9360            auto_send_step(Some(&pending), "   ", true, now),
9361            AutoSendStep::Cancel
9362        );
9363    }
9364
9365    /// The countdown fires exactly at the deadline, and never injects a prompt
9366    /// into a run that started while it was ticking.
9367    #[test]
9368    fn auto_send_submits_at_the_deadline_only_while_idle() {
9369        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2000);
9370        let pending = AutoSendPending {
9371            deadline,
9372            text: "go".to_string(),
9373        };
9374        assert!(matches!(
9375            auto_send_step(
9376                Some(&pending),
9377                "go",
9378                true,
9379                deadline - std::time::Duration::from_millis(1)
9380            ),
9381            AutoSendStep::Wait(_)
9382        ));
9383        assert_eq!(
9384            auto_send_step(Some(&pending), "go", true, deadline),
9385            AutoSendStep::Submit
9386        );
9387        // A turn started underneath the countdown — keep the draft, do not
9388        // queue a prompt mid-turn.
9389        assert_eq!(
9390            auto_send_step(Some(&pending), "go", false, deadline),
9391            AutoSendStep::Cancel
9392        );
9393        // Nothing pending.
9394        assert_eq!(
9395            auto_send_step(None, "go", true, deadline),
9396            AutoSendStep::Idle
9397        );
9398    }
9399
9400    /// The footer text the countdown writes is prefixed so `clear_auto_send`
9401    /// can tell its own message from an unrelated run status.
9402    #[test]
9403    fn auto_send_footer_label_is_recognisable() {
9404        let label = format!("{AUTO_SEND_PREFIX}2.0s · any key to edit");
9405        assert!(label.starts_with(AUTO_SEND_PREFIX));
9406        assert!(!"Working…".starts_with(AUTO_SEND_PREFIX));
9407    }
9408
9409    #[test]
9410    fn markdown_flag_only_comes_from_an_explicit_true() {
9411        // Tools opt in by setting `details.markdown = true` (rpi-todo does).
9412        assert!(tool_result_requests_markdown(Some(
9413            &serde_json::json!({"kind": "todo", "markdown": true})
9414        )));
9415        // Absence, a false value, or a non-bool value keeps plain rendering,
9416        // so every other tool is unaffected.
9417        assert!(!tool_result_requests_markdown(None));
9418        assert!(!tool_result_requests_markdown(Some(
9419            &serde_json::json!({"kind": "todo"})
9420        )));
9421        assert!(!tool_result_requests_markdown(Some(
9422            &serde_json::json!({"markdown": false})
9423        )));
9424        assert!(!tool_result_requests_markdown(Some(
9425            &serde_json::json!({"markdown": "true"})
9426        )));
9427    }
9428
9429    /// Minimal `TuiState` for unit tests that only touch state flags. Mirrors
9430    /// the explicit literals other tests build, but keeps one copy in sync.
9431    fn test_tui_state() -> Arc<TuiState> {
9432        Arc::new(TuiState {
9433            current_assistant: Arc::new(std::sync::Mutex::new(None)),
9434            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
9435            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
9436            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
9437            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
9438            show_terminal_progress: true,
9439            status: std::sync::Mutex::new(RunStatus::Idle),
9440            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
9441            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
9442            editor_text: rpi_extensions::EditorTextMailbox::new(),
9443            auto_send: std::sync::Mutex::new(None),
9444            last_editor_text: std::sync::Mutex::new(String::new()),
9445            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
9446            js_preparation_cancel: std::sync::Mutex::new(None),
9447            user_bash_cancel: std::sync::Mutex::new(None),
9448            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
9449            footer: Arc::new(FooterComponent::new()),
9450            status_container: Arc::new(Container::new()),
9451            chat_container: Arc::new(Container::new()),
9452            loader: Arc::new(Loader::new()),
9453            editor: Arc::new(Editor::simple()),
9454            last_assistant_text: std::sync::Mutex::new(String::new()),
9455            active_selector: std::sync::Mutex::new(None),
9456            active_extension_editor: std::sync::Mutex::new(None),
9457            active_extension_input: std::sync::Mutex::new(None),
9458            active_extension_cancel: std::sync::Mutex::new(None),
9459            autocomplete: AutocompleteManager::new(),
9460            autocomplete_container: Arc::new(Container::new()),
9461            autocomplete_max_visible: 5,
9462            pending_images: std::sync::Mutex::new(Vec::new()),
9463            theme_manager: Arc::new(ThemeManager::new()),
9464            tui: None,
9465            current_model_id: std::sync::Mutex::new(String::new()),
9466            show_images: std::sync::Mutex::new(true),
9467            cache_miss_notices: std::sync::Mutex::new(false),
9468            history: std::sync::Mutex::new(Vec::new()),
9469            history_index: std::sync::Mutex::new(-1),
9470            history_draft: std::sync::Mutex::new(None),
9471            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
9472            scoped_edit: std::sync::Mutex::new(None),
9473            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
9474            extension_session: Arc::new(std::sync::Mutex::new(
9475                rpi_extensions::ExtensionSession::none(),
9476            )),
9477            search: Arc::new(AltScreenSearch::new()),
9478            search_bar: Arc::new(SearchBar::new()),
9479            pending_container: Arc::new(Container::new()),
9480            dequeue_hint: String::new(),
9481            pending_snapshot: std::sync::Mutex::new(
9482                rpi_harness::agent_harness::QueuedMessages::default(),
9483            ),
9484            selection_start: std::sync::Mutex::new(None),
9485            selection_end: std::sync::Mutex::new(None),
9486        })
9487    }
9488
9489    /// A `SetEditorText` injection — and the host's own auto-send clear — must be
9490    /// attributed to `extension`, not to the user.
9491    ///
9492    /// A hands-free voice extension stands down whenever `source == "user"`
9493    /// (the human took the keyboard), so mislabelling the plugin's own injected
9494    /// transcription would cancel the whole conversation on its first turn.
9495    #[test]
9496    fn editor_change_source_separates_tui_writes_from_typing() {
9497        use std::sync::atomic::Ordering;
9498        let state = test_tui_state();
9499
9500        // Nothing changed yet → nothing to report.
9501        assert!(state.take_editor_change().is_none());
9502
9503        // The render tick applies an extension's draft…
9504        state.editor_text.push(rpi_extensions::EditorTextEdit {
9505            text: "你好".to_string(),
9506            mode: rpi_extensions::EditorTextMode::Replace,
9507            auto_send_ms: Some(2000),
9508        });
9509        assert!(state.drain_editor_text(), "the queued draft must land");
9510        assert!(
9511            state.programmatic_editor_write.load(Ordering::SeqCst),
9512            "a mailbox-applied edit must be marked programmatic"
9513        );
9514
9515        // …so its change is ours, not the user's.
9516        let payload = state
9517            .take_editor_change()
9518            .expect("draft applied ⇒ a change");
9519        assert_eq!(payload["source"], serde_json::json!("extension"));
9520        assert_eq!(payload["chars"], serde_json::json!(2));
9521        assert_eq!(payload["empty"], serde_json::json!(false));
9522
9523        // The mark was consumed: the same text twice is not re-reported, and a
9524        // genuine human edit is attributed to the user.
9525        assert!(state.take_editor_change().is_none());
9526        state.editor.set_text("你好啊");
9527        let payload = state.take_editor_change().expect("typing ⇒ a change");
9528        assert_eq!(payload["source"], serde_json::json!("user"));
9529        assert_eq!(payload["chars"], serde_json::json!(3));
9530    }
9531
9532    /// The attribution mark must not survive unrelated changes, or one injected
9533    /// draft would make the *next* keystroke look programmatic too.
9534    #[test]
9535    fn programmatic_mark_is_consumed_even_with_no_subscriber() {
9536        use std::sync::atomic::Ordering;
9537        let state = test_tui_state();
9538        assert!(
9539            state
9540                .extension_session
9541                .lock()
9542                .unwrap()
9543                .snapshot_arc()
9544                .is_none(),
9545            "this test relies on nobody subscribing"
9546        );
9547
9548        state.editor_text.push(rpi_extensions::EditorTextEdit {
9549            text: "draft".to_string(),
9550            mode: rpi_extensions::EditorTextMode::Replace,
9551            auto_send_ms: None,
9552        });
9553        state.drain_editor_text();
9554        // `sync_editor_change` bails out early (no subscriber)…
9555        state.sync_editor_change();
9556        // …but the mark is gone, so the next real edit is the user's.
9557        assert!(!state.programmatic_editor_write.load(Ordering::SeqCst));
9558        state.editor.set_text("draft +");
9559        let payload = state.take_editor_change().expect("typing ⇒ a change");
9560        assert_eq!(payload["source"], serde_json::json!("user"));
9561    }
9562
9563    /// Regression: the editor clear that follows *every* submit is host
9564    /// housekeeping, not the user typing.
9565    ///
9566    /// Reporting it as `source:"user"` switched a hands-free voice session off
9567    /// the instant it was switched on: `/voice auto` turns the mode on, then the
9568    /// render tick notices the cleared editor ~80ms later, stands the mode down
9569    /// and aborts the listen it just started — so the microphone never stayed
9570    /// open and the session looked like it "didn't hear anything".
9571    #[test]
9572    fn submit_clear_is_attributed_to_the_host_not_the_user() {
9573        let state = test_tui_state();
9574
9575        // Typing the command is a genuine user edit.
9576        state.editor.set_text("/voice auto");
9577        assert_eq!(
9578            state.take_editor_change().unwrap()["source"],
9579            serde_json::json!("user")
9580        );
9581
9582        // The submit path's clear is not.
9583        state.clear_editor_programmatically();
9584        let payload = state.take_editor_change().expect("clearing is a change");
9585        assert_eq!(payload["empty"], serde_json::json!(true));
9586        assert_eq!(
9587            payload["source"],
9588            serde_json::json!("extension"),
9589            "a submit-clear must not be reported as the user typing"
9590        );
9591    }
9592
9593    /// A user-initiated clear (Esc, select-all + delete) still goes through the
9594    /// editor itself and must stay `user` — only host writes are reattributed.
9595    #[test]
9596    fn user_initiated_clear_stays_a_user_change() {
9597        let state = test_tui_state();
9598        state.editor.set_text("draft");
9599        assert_eq!(
9600            state.take_editor_change().unwrap()["source"],
9601            serde_json::json!("user")
9602        );
9603        state.editor.clear();
9604        let payload = state.take_editor_change().expect("clearing is a change");
9605        assert_eq!(payload["source"], serde_json::json!("user"));
9606    }
9607
9608    #[test]
9609    fn live_panel_repaints_while_a_tool_or_bash_is_running() {
9610        // No panels → only the dock loader animates (reuse the cached
9611        // transcript instead of rebuilding a long history).
9612        let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
9613        assert!(!transcript_has_live_panel(false, &empty));
9614
9615        // A running tool call is a live panel: its elapsed readout is computed
9616        // at render time, so it must force a transcript rebuild every tick.
9617        let mut tools = HashMap::new();
9618        let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
9619        tool.set_running();
9620        tools.insert("tc1".to_string(), tool);
9621        assert!(transcript_has_live_panel(false, &tools));
9622
9623        // A finished tool no longer needs the repaint, but a running bash
9624        // command still does.
9625        tools.get("tc1").unwrap().set_result("done", false);
9626        assert!(!transcript_has_live_panel(false, &tools));
9627        assert!(transcript_has_live_panel(true, &tools));
9628    }
9629
9630    #[test]
9631    fn dequeue_merge_preserves_a_typed_draft() {
9632        assert_eq!(merge_queued_into_draft("queued", ""), "queued");
9633        assert_eq!(merge_queued_into_draft("queued", "   "), "queued");
9634        assert_eq!(
9635            merge_queued_into_draft("queued", "typed"),
9636            "queued\n\ntyped"
9637        );
9638    }
9639
9640    #[test]
9641    fn dequeue_places_the_caret_at_the_end_of_the_last_line_in_bytes() {
9642        // Restoring queued messages followed by the old `(0, char_count)` call
9643        // parked the caret at the end of the FIRST line, and treated a char
9644        // count as a byte offset (so any CJK draft landed mid-character).
9645        let editor = Arc::new(Editor::simple());
9646        // Multi-line + multibyte: the caret must be on the LAST row, after the
9647        // final byte of that row.
9648        let text = "排队\n第二行";
9649        set_editor_text_caret_at_end(&editor, text);
9650        assert_eq!(editor.get_text(), text);
9651        assert_eq!(
9652            editor.cursor_position(),
9653            (1, "第二行".len()),
9654            "caret must sit at the end of the last line, in bytes"
9655        );
9656    }
9657
9658    #[test]
9659    fn settings_menu_covers_the_wired_settings_surface() {
9660        let state = test_tui_state();
9661        let settings = crate::settings::Settings::default();
9662        let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
9663        let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
9664        assert_eq!(
9665            keys,
9666            [
9667                "theme",
9668                "model",
9669                "thinking",
9670                "scoped-models",
9671                "hide-thinking",
9672                "show-images",
9673                "cache-miss-notices",
9674                "quiet-startup",
9675                "terminal-progress",
9676                "fullscreen-copy-on-select",
9677                "double-escape-action",
9678                "editor-padding",
9679                "autocomplete-max-items",
9680                "http-idle-timeout",
9681            ]
9682        );
9683        // Submenu rows must not cycle their displayed value.
9684        for item in items.iter().filter(|item| item.has_submenu) {
9685            assert!(
9686                item.values.is_empty(),
9687                "{} should be submenu-only",
9688                item.key
9689            );
9690        }
9691        // Value rows advertise a cycle that contains the current value.
9692        for item in items.iter().filter(|item| !item.has_submenu) {
9693            assert!(!item.values.is_empty(), "{} has no values", item.key);
9694            assert!(
9695                item.values.iter().any(|value| value == &item.value),
9696                "{} current value {} not in {:?}",
9697                item.key,
9698                item.value,
9699                item.values
9700            );
9701        }
9702        // The default model row falls back to the running lane's model.
9703        assert_eq!(items[1].value, "claude-sonnet-5");
9704        // Native defaults: cache-miss notices off, terminal progress on.
9705        assert_eq!(items[6].value, "false");
9706        assert_eq!(items[8].value, "true");
9707    }
9708
9709    #[test]
9710    fn apply_setting_change_updates_settings_and_live_state() {
9711        let state = test_tui_state();
9712        let mut settings = crate::settings::Settings::default();
9713
9714        // Live: hide-thinking flips both the runtime flag and the persisted one.
9715        let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
9716        assert!(state.hide_thinking());
9717        assert_eq!(settings.hide_thinking_block, Some(true));
9718        assert!(note.expect("user-visible note").contains("hidden"));
9719
9720        // Live: show-images writes the runtime mutex.
9721        apply_setting_change(&state, "show-images", "false", &mut settings);
9722        assert!(!*state.show_images.lock().unwrap());
9723        assert_eq!(settings.show_images, Some(false));
9724
9725        // Live: cache-miss notices.
9726        apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
9727        assert!(*state.cache_miss_notices.lock().unwrap());
9728        assert_eq!(settings.show_cache_miss_notices, Some(true));
9729
9730        // Persist-only rows change settings without a transcript note.
9731        assert_eq!(
9732            apply_setting_change(&state, "editor-padding", "3", &mut settings),
9733            None
9734        );
9735        assert_eq!(settings.editor_padding_x, Some(3));
9736        apply_setting_change(&state, "double-escape-action", "none", &mut settings);
9737        assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
9738        apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
9739        assert_eq!(settings.autocomplete_max_visible, Some(20));
9740
9741        // HTTP idle timeout maps labels onto the numeric/`disabled` forms.
9742        apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
9743        assert_eq!(settings.http_idle_timeout_ms(), Some(0));
9744        apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
9745        assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
9746
9747        // Unknown keys are ignored rather than panicking.
9748        assert_eq!(
9749            apply_setting_change(&state, "nope", "x", &mut settings),
9750            None
9751        );
9752    }
9753
9754    #[test]
9755    fn add_user_message_renders_a_visible_user_bubble() {
9756        // The submit handler renders directly-sent prompts through this helper
9757        // (the harness emits no user `message_start` for them), so a silent
9758        // failure here would make every prompt vanish from the transcript.
9759        let chat = Arc::new(Container::new());
9760        add_user_message(&chat, "hello from the user");
9761        let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
9762        let plain = strip_ansi(&rendered.join("\n"));
9763        assert!(
9764            plain.contains("hello from the user"),
9765            "user bubble must show the prompt text; got: {plain:?}"
9766        );
9767    }
9768
9769    #[test]
9770    fn verbose_overrides_quiet_startup_listing() {
9771        assert!(should_show_startup_listing(false, false));
9772        assert!(should_show_startup_listing(true, false));
9773        assert!(should_show_startup_listing(true, true));
9774        assert!(!should_show_startup_listing(false, true));
9775    }
9776
9777    #[test]
9778    fn resume_command_omits_the_default_session_dir() {
9779        let cwd = std::path::Path::new("/proj/demo");
9780        assert_eq!(format_resume_command("", cwd, None), None);
9781        assert_eq!(
9782            format_resume_command("abc-123", cwd, None).as_deref(),
9783            Some("rpi --session abc-123")
9784        );
9785        // The default dir carries no flag (mirrors upstream).
9786        let default = crate::session::default_session_dir(cwd);
9787        assert_eq!(
9788            format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
9789            Some("rpi --session abc-123")
9790        );
9791        // A custom dir is echoed, quoting when needed.
9792        let custom = std::path::Path::new("/tmp/my sessions");
9793        assert_eq!(
9794            format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
9795            Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
9796        );
9797    }
9798
9799    #[test]
9800    fn quote_shell_arg_quotes_only_when_needed() {
9801        assert_eq!(quote_shell_arg("plain"), "plain");
9802        assert_eq!(quote_shell_arg("with space"), "\"with space\"");
9803        assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
9804    }
9805
9806    #[test]
9807    fn trust_command_uses_the_session_cwd_after_process_chdir() {
9808        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
9809        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
9810        const TEST_NAME: &str =
9811            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
9812
9813        if std::env::var_os(CHILD_ENV).is_some() {
9814            let session_cwd = std::path::PathBuf::from(
9815                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
9816            );
9817            let changed_cwd = std::env::current_dir().unwrap();
9818
9819            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
9820
9821            assert_eq!(
9822                crate::config::project_trust_decision(&session_cwd).unwrap(),
9823                Some(true)
9824            );
9825            assert_eq!(
9826                crate::config::project_trust_decision(&changed_cwd).unwrap(),
9827                None
9828            );
9829            return;
9830        }
9831
9832        let tmp = tempfile::tempdir().unwrap();
9833        let session_cwd = tmp.path().join("session-project");
9834        let changed_cwd = tmp.path().join("extension-cwd");
9835        let agent_dir = tmp.path().join("agent");
9836        std::fs::create_dir_all(&session_cwd).unwrap();
9837        std::fs::create_dir_all(&changed_cwd).unwrap();
9838        std::fs::create_dir_all(&agent_dir).unwrap();
9839
9840        let status = std::process::Command::new(std::env::current_exe().unwrap())
9841            .arg("--exact")
9842            .arg(TEST_NAME)
9843            .arg("--nocapture")
9844            .env(CHILD_ENV, "1")
9845            .env(SESSION_CWD_ENV, &session_cwd)
9846            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
9847            .current_dir(&changed_cwd)
9848            .status()
9849            .unwrap();
9850
9851        assert!(status.success(), "child test process failed: {status}");
9852    }
9853
9854    #[test]
9855    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
9856        let global = crate::settings::Settings {
9857            editor_padding_x: Some(3),
9858            autocomplete_max_visible: Some(6),
9859            hide_thinking_block: Some(false),
9860            quiet_startup: Some(true),
9861            show_terminal_progress: Some(true),
9862            ..Default::default()
9863        };
9864        let rpi_project = crate::settings::Settings {
9865            editor_padding_x: Some(0),
9866            hide_thinking_block: Some(true),
9867            quiet_startup: Some(false),
9868            show_terminal_progress: Some(false),
9869            ..Default::default()
9870        };
9871        let pi_project = crate::settings::Settings {
9872            editor_padding_x: Some(9),
9873            autocomplete_max_visible: Some(12),
9874            quiet_startup: Some(true),
9875            ..Default::default()
9876        };
9877
9878        assert_eq!(
9879            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
9880            TuiStartupSettings {
9881                editor_padding_x: 0,
9882                autocomplete_max_visible: 12,
9883                hide_thinking: true,
9884                quiet_startup: false,
9885                show_terminal_progress: false,
9886                show_images: true,
9887                cache_miss_notices: false,
9888            }
9889        );
9890    }
9891
9892    #[test]
9893    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
9894        let global = crate::settings::Settings {
9895            quiet_startup: Some(false),
9896            ..Default::default()
9897        };
9898        let rpi_project = crate::settings::Settings::default();
9899        let pi_project = crate::settings::Settings {
9900            quiet_startup: Some(true),
9901            ..Default::default()
9902        };
9903
9904        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
9905
9906        assert!(resolved.quiet_startup);
9907    }
9908
9909    #[test]
9910    fn tui_startup_settings_use_global_values_without_project_fields() {
9911        let global = crate::settings::Settings {
9912            editor_padding_x: Some(7),
9913            autocomplete_max_visible: Some(8),
9914            hide_thinking_block: Some(true),
9915            quiet_startup: Some(true),
9916            show_terminal_progress: Some(false),
9917            show_images: Some(false),
9918            show_cache_miss_notices: Some(true),
9919            ..Default::default()
9920        };
9921
9922        assert_eq!(
9923            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
9924            TuiStartupSettings {
9925                editor_padding_x: 7,
9926                autocomplete_max_visible: 8,
9927                hide_thinking: true,
9928                quiet_startup: true,
9929                show_terminal_progress: false,
9930                show_images: false,
9931                cache_miss_notices: true,
9932            }
9933        );
9934    }
9935
9936    #[test]
9937    fn tui_startup_settings_read_native_nested_terminal_block() {
9938        // upstream nests these under `terminal`; rpi's flat keys stay
9939        // compatible and the nested block is the fallback.
9940        let global = crate::settings::Settings {
9941            terminal: Some(crate::settings::TerminalSettings {
9942                show_images: Some(false),
9943                show_terminal_progress: Some(false),
9944                clear_on_shrink: None,
9945            }),
9946            ..Default::default()
9947        };
9948        let resolved =
9949            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
9950        assert!(!resolved.show_images);
9951        assert!(!resolved.show_terminal_progress);
9952    }
9953
9954    #[test]
9955    fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
9956        let global = crate::settings::Settings {
9957            show_images: Some(true),
9958            terminal: Some(crate::settings::TerminalSettings {
9959                show_images: Some(false),
9960                show_terminal_progress: None,
9961                clear_on_shrink: None,
9962            }),
9963            ..Default::default()
9964        };
9965        let resolved =
9966            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
9967        assert!(resolved.show_images);
9968    }
9969
9970    #[test]
9971    fn tui_startup_settings_ignore_untrusted_project_values() {
9972        let global = crate::settings::Settings {
9973            quiet_startup: Some(false),
9974            show_terminal_progress: Some(true),
9975            ..Default::default()
9976        };
9977        let project = crate::settings::Settings {
9978            quiet_startup: Some(true),
9979            show_terminal_progress: Some(false),
9980            ..Default::default()
9981        };
9982
9983        let resolved = resolve_tui_startup_settings(&global, &[project], false);
9984
9985        assert!(!resolved.quiet_startup);
9986        assert!(resolved.show_terminal_progress);
9987    }
9988
9989    #[test]
9990    fn transcript_page_uses_viewport_with_overlap() {
9991        assert_eq!(transcript_page_size(24), 20);
9992        assert_eq!(transcript_page_size(4), 1);
9993        assert_eq!(transcript_page_size(0), 1);
9994    }
9995
9996    #[test]
9997    fn parse_user_bash_distinguishes_single_and_double_bang() {
9998        // `!cmd` runs and stays in context.
9999        assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
10000        // `!!cmd` runs but is excluded from context.
10001        assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
10002        // Surrounding whitespace on the command is trimmed, the `!` prefix is not.
10003        assert_eq!(parse_user_bash("!  echo hi  "), Some(("echo hi", false)));
10004        assert_eq!(parse_user_bash("!!  echo hi"), Some(("echo hi", true)));
10005    }
10006
10007    #[test]
10008    fn parse_user_bash_ignores_bare_bang_and_plain_text() {
10009        // A bare `!` / `!!` has no command: fall through to the prompt path
10010        // instead of executing an empty shell line.
10011        assert_eq!(parse_user_bash("!"), None);
10012        assert_eq!(parse_user_bash("!!"), None);
10013        assert_eq!(parse_user_bash("!   "), None);
10014        assert_eq!(parse_user_bash("hello"), None);
10015        assert_eq!(parse_user_bash(""), None);
10016        // `/` keeps routing to the slash-command registry.
10017        assert_eq!(parse_user_bash("/model"), None);
10018    }
10019
10020    #[test]
10021    fn user_bash_run_slot_guards_and_cancels() {
10022        let state = test_tui_state();
10023        assert!(!state.user_bash_running());
10024        let cancel = state.begin_user_bash();
10025        assert!(state.user_bash_running());
10026        // Esc cancels without freeing the slot; the spawned task clears it when
10027        // the capture resolves (upstream's `isBashRunning`).
10028        assert!(state.cancel_user_bash());
10029        assert!(state.user_bash_running());
10030        assert!(cancel.is_cancelled());
10031        state.finish_user_bash();
10032        assert!(!state.user_bash_running());
10033        // Nothing to cancel once idle.
10034        assert!(!state.cancel_user_bash());
10035    }
10036
10037    #[test]
10038    fn key_repeat_is_dispatched_but_release_is_not() {
10039        assert!(should_dispatch_key(KeyEventKind::Press));
10040        assert!(should_dispatch_key(KeyEventKind::Repeat));
10041        assert!(!should_dispatch_key(KeyEventKind::Release));
10042    }
10043
10044    #[test]
10045    fn test_layout_renders_welcome_message() {
10046        let chat = Arc::new(Container::new());
10047        add_welcome_message(&chat);
10048
10049        let scroll = Arc::new(ScrollView::new(
10050            chat.clone(),
10051            ScrollViewOptions {
10052                follow: FollowMode::End,
10053                primary: true,
10054                ..Default::default()
10055            },
10056        ));
10057
10058        let editor = Arc::new(Editor::new(
10059            EditorOptions {
10060                padding_x: 1,
10061                ..Default::default()
10062            },
10063            EditorStyle::default(),
10064            Arc::new(rpi_tui::Keybindings::new()),
10065        ));
10066        let dock = Arc::new(Container::new());
10067        dock.add_child(editor);
10068
10069        let footer = Arc::new(FooterComponent::new());
10070
10071        let root = VStack::from_children(vec![
10072            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
10073            StackChild::Entry(StackEntry::new(dock)),
10074            StackChild::Entry(StackEntry::new(footer)),
10075        ]);
10076
10077        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
10078
10079        let all: String = frame.lines.join("\n");
10080        assert!(
10081            all.contains("rpi"),
10082            "Welcome message not found. Rendered: {}",
10083            all
10084        );
10085        assert!(
10086            all.contains("Type your message"),
10087            "Help text not found. Rendered: {}",
10088            all
10089        );
10090    }
10091
10092    #[test]
10093    fn test_chat_container_has_welcome_content() {
10094        let chat = Arc::new(Container::new());
10095        add_welcome_message_with_capabilities(
10096            &chat,
10097            &["read".into(), "bash".into(), "web_fetch".into()],
10098            &["rust-review".into(), "release".into()],
10099        );
10100
10101        let lines = chat.render(80);
10102        let all: String = lines.join("\n");
10103        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
10104        // joined by an ANSI reset — strip ANSI before checking the substring.
10105        let plain = strip_ansi(&all);
10106        assert!(
10107            plain.contains("rpi"),
10108            "Welcome message not in chat container: {:?}",
10109            lines
10110        );
10111        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
10112        assert!(
10113            plain.contains("read · bash · web_fetch"),
10114            "Tool names missing: {plain}"
10115        );
10116        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
10117        assert!(
10118            plain.contains("rust-review · release"),
10119            "Skill names missing: {plain}"
10120        );
10121    }
10122
10123    #[test]
10124    fn welcome_header_renders_the_brand_mark() {
10125        let chat = Arc::new(Container::new());
10126        add_welcome_message(&chat);
10127        let lines: Vec<String> = chat.render(80).iter().map(|l| strip_ansi(l)).collect();
10128        let all = lines.join("\n");
10129
10130        // The three-bar mark: the bottom row carries all three bars, and the
10131        // wordmark sits on its baseline next to the middle bar.
10132        assert!(
10133            lines.iter().any(|l| l.contains("██ ██ ██")),
10134            "brand mark missing: {all}"
10135        );
10136        assert!(
10137            all.contains("rpi"),
10138            "the mark must carry the product name: {all}"
10139        );
10140
10141        // Regression guard for the layout bug this replaced: `π` is East-Asian
10142        // Ambiguous — width 1 normally, width 2 under a CJK locale — which
10143        // walked the old box borders out of alignment depending on the user's
10144        // system. The crab is deliberately wide but unambiguous, and `crate::brand`
10145        // tests pin its width so the padding cannot silently drift.
10146        assert!(
10147            !all.contains('π'),
10148            "the header must not use east-asian-ambiguous glyphs: {all}"
10149        );
10150    }
10151
10152    #[test]
10153    fn update_notices_render_inside_the_transcript() {
10154        let chat = Arc::new(Container::new());
10155        let report = crate::updates::UpdateReport {
10156            notices: vec![crate::updates::UpdateNotice {
10157                name: "rpi".into(),
10158                current: "0.1.10".into(),
10159                latest: "0.1.11".into(),
10160                command: "rpi update".into(),
10161            }],
10162            warnings: vec![crate::updates::UpdateWarning {
10163                message: "The previously scheduled rpi update failed: access denied".into(),
10164                command: "rpi update".into(),
10165            }],
10166        };
10167
10168        add_update_notices(&chat, &report);
10169
10170        assert_eq!(chat.child_count(), 1);
10171        let plain = strip_ansi(&chat.render(80).join("\n"));
10172        assert!(plain.contains("Update Failed"), "{plain}");
10173        assert!(
10174            plain.contains("rpi update failed: access denied"),
10175            "{plain}"
10176        );
10177        assert!(plain.contains("Update Available"), "{plain}");
10178        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
10179        assert!(plain.contains("rpi update"), "{plain}");
10180    }
10181
10182    #[test]
10183    fn empty_update_report_does_not_add_transcript_content() {
10184        let chat = Arc::new(Container::new());
10185
10186        add_update_notices(&chat, &crate::updates::UpdateReport::default());
10187
10188        assert_eq!(chat.child_count(), 0);
10189    }
10190
10191    #[test]
10192    fn welcome_capabilities_show_empty_state() {
10193        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
10194        assert_eq!(plain, "Skills (0) none");
10195    }
10196
10197    #[test]
10198    fn completed_stream_reconciles_the_final_tail_before_detaching() {
10199        let component = Arc::new(AssistantMessageComponent::new(
10200            AssistantMessageOptions::default(),
10201        ));
10202        component.set_streaming(true);
10203        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
10204
10205        let current = Arc::new(Mutex::new(Some(component.clone())));
10206        let cached = Mutex::new("partial response".to_string());
10207        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
10208        final_message.content = vec![Content::text(
10209            "partial response with the previously missing final tail",
10210        )];
10211        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
10212
10213        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
10214
10215        assert!(current.lock().unwrap().is_none());
10216        assert_eq!(
10217            cached.lock().unwrap().as_str(),
10218            "partial response with the previously missing final tail"
10219        );
10220        let rendered = strip_ansi(&component.render(100).join("\n"));
10221        assert!(
10222            rendered.contains("previously missing final tail"),
10223            "{rendered}"
10224        );
10225    }
10226
10227    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
10228    /// replaces the editor text, the NEXT rendered frame must show the
10229    /// completed text (" /model " with the caret after it), not the old
10230    /// prefix. Mirrors the real dock layout (autocomplete_container above the
10231    /// bordered editor) and drives the same accept path the Tab handler uses.
10232    #[test]
10233    fn tab_accept_suggestion_reflects_in_next_render() {
10234        use rpi_tui::render_layout_frame;
10235
10236        let editor = Arc::new(Editor::new(
10237            EditorOptions {
10238                padding_x: 1,
10239                ..Default::default()
10240            },
10241            EditorStyle::default(),
10242            Arc::new(rpi_tui::Keybindings::new()),
10243        ));
10244        editor.set_focused(true);
10245        let editor_container = Arc::new(Container::new());
10246        editor_container.add_child(editor.clone());
10247        let autocomplete_container = Arc::new(Container::new());
10248        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
10249        let dock = Arc::new(VStack::from_children(vec![
10250            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
10251            StackChild::Entry(
10252                StackEntry::new(editor_container.clone())
10253                    .shrink(0)
10254                    .min_size(3),
10255            ),
10256            StackChild::Entry(StackEntry::new(footer)),
10257        ]));
10258
10259        // Simulate the user typing "/mo" (the popup shows suggestions).
10260        let manager = AutocompleteManager::new();
10261        let mut combined = CombinedAutocompleteProvider::new();
10262        combined.add_provider(Arc::new(
10263            SlashCommandAutocompleteProvider::with_default_commands(),
10264        ));
10265        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
10266        manager.set_provider(Arc::new(combined));
10267        // Simulate typing "/mo" via the real insert path (advances the caret
10268        // by char length, like `handle_key` does).
10269        editor.insert("/mo");
10270        assert_eq!(editor.cursor_position(), (0, 3));
10271
10272        let frame_before = render_layout_frame(dock.clone(), 80, 10);
10273        assert!(
10274            frame_before.lines.iter().any(|l| l.contains("/mo")),
10275            "precondition: editor shows the typed prefix. Frame rows:\n{}",
10276            frame_before
10277                .lines
10278                .iter()
10279                .map(|l| format!("  [{l}]"))
10280                .collect::<Vec<_>>()
10281                .join("\n")
10282        );
10283
10284        // Tab: accept the top suggestion (the same code path as the key loop).
10285        let text = editor.get_text();
10286        let cursor = editor_cursor_offset(&editor, &text);
10287        let sugg = manager
10288            .get_suggestions(&text, cursor)
10289            .expect("slash suggestions for /mo");
10290        let top = sugg.items.first().expect("at least one suggestion");
10291        let start = sugg.start.min(text.len());
10292        let end = sugg.end.min(text.len());
10293        let mut replaced = String::new();
10294        replaced.push_str(&text[..start]);
10295        replaced.push_str(&top.text);
10296        replaced.push_str(&text[end..]);
10297        if top.insert_space && !replaced.ends_with('/') {
10298            replaced.push(' ');
10299        }
10300        let new_cursor = start + top.text.len();
10301        editor.set_text(&replaced);
10302        set_editor_cursor_offset(&editor, &replaced, new_cursor);
10303        autocomplete_container.clear();
10304        assert_eq!(editor.get_text(), "/model");
10305
10306        // The next render MUST display the completed text.
10307        let frame_after = render_layout_frame(dock, 80, 10);
10308        let all: String = frame_after.lines.join("\n");
10309        assert!(
10310            all.contains("/model"),
10311            "completed text missing from next render. Got:\n{all}"
10312        );
10313        // The caret must sit AFTER the completed command (the snap_boundary
10314        // regression put it one char early: "/mode|l" with the final char
10315        // dangling past the caret).
10316        let editor_line = frame_after
10317            .lines
10318            .iter()
10319            .find(|l| l.contains("/model"))
10320            .expect("editor row with completed text");
10321        assert!(
10322            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
10323            "caret must follow the full completed text. Got: {editor_line:?}"
10324        );
10325    }
10326
10327    #[test]
10328    fn multiline_autocomplete_preserves_row_and_column() {
10329        let editor = Arc::new(Editor::simple());
10330        editor.set_text("first\n/mo");
10331        editor.set_cursor(1, 3);
10332
10333        let text = editor.get_text();
10334        assert_eq!(editor_cursor_offset(&editor, &text), 9);
10335
10336        let replaced = "first\n/model";
10337        editor.set_text(replaced);
10338        set_editor_cursor_offset(&editor, replaced, 12);
10339        assert_eq!(editor.cursor_position(), (1, 6));
10340    }
10341
10342    #[test]
10343    fn test_slash_command_dispatch() {
10344        // The registry is the single source of truth for dispatch: `find(token)`
10345        // returns the command (by name or alias) whose `name()` is the canonical
10346        // form, or `None` for an unknown token. This replaces the old enum-based
10347        // `handle_slash_command` assertions with equivalent registry lookups.
10348        let registry = build_builtin_registry();
10349
10350        // Helper: a token resolves to the command with this canonical name.
10351        let resolves_to = |token: &str, canonical: &str| {
10352            let found = registry.find(token).expect("{token} should resolve");
10353            assert_eq!(
10354                found.name(),
10355                canonical,
10356                "{token} resolved to {} (expected {canonical})",
10357                found.name()
10358            );
10359        };
10360
10361        resolves_to("/help", "/help");
10362        resolves_to("/?", "/help"); // alias → canonical
10363        resolves_to("/clear", "/clear");
10364        resolves_to("/new", "/clear"); // alias
10365        resolves_to("/q", "/exit"); // alias
10366        resolves_to("/quit", "/exit"); // alias
10367        resolves_to("/version", "/version");
10368        resolves_to("/v", "/version"); // alias
10369        resolves_to("/changelog", "/changelog");
10370        resolves_to("/hotkeys", "/hotkeys");
10371        resolves_to("/model", "/model");
10372        resolves_to("/m", "/model"); // alias
10373        resolves_to("/theme", "/theme");
10374        resolves_to("/session", "/session");
10375        resolves_to("/resume", "/session"); // alias
10376        resolves_to("/compact", "/compact");
10377        resolves_to("/copy", "/copy");
10378        resolves_to("/thinking", "/thinking");
10379        resolves_to("/think", "/thinking"); // alias
10380        resolves_to("/tools", "/tools");
10381        resolves_to("/images", "/images");
10382        resolves_to("/context", "/context");
10383        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
10384        resolves_to("/settings", "/settings");
10385        resolves_to("/name", "/name");
10386        resolves_to("/export", "/export");
10387
10388        // Unknown token → not found.
10389        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
10390    }
10391
10392    #[test]
10393
10394    fn test_registry_visible_entries_cover_dispatch() {
10395        // The autocomplete list is derived from the registry, so every visible
10396        // command the dispatcher recognizes must appear in it — by construction,
10397        // but this guards against a future command being registered with
10398        // `visible()` / a non-empty description that the builder drops.
10399        let registry = build_builtin_registry();
10400        let names: Vec<String> = registry
10401            .visible_entries()
10402            .iter()
10403            .map(|c| c.name.clone())
10404            .collect();
10405        for recognized in [
10406            "/help",
10407            "/clear",
10408            "/new",
10409            "/exit",
10410            "/quit",
10411            "/version",
10412            "/changelog",
10413            "/model",
10414            "/session",
10415            "/theme",
10416            "/compact",
10417            "/copy",
10418            "/hotkeys",
10419            "/tools",
10420            "/images",
10421            "/thinking",
10422            "/usage",
10423        ] {
10424            assert!(
10425                names.contains(&recognized.to_string()),
10426                "{recognized} missing from autocomplete list"
10427            );
10428        }
10429        // Hidden commands stay off the list.
10430        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
10431            assert!(
10432                !names.contains(&hidden.to_string()),
10433                "{hidden} should be hidden from autocomplete"
10434            );
10435        }
10436    }
10437
10438    #[test]
10439    fn test_agent_event_mapping_creates_assistant_and_tool() {
10440        // Synthetic AgentEvent sequence → UI mutations, exercised against the
10441        // real drain handler with a no-op TUI stand-in.
10442        use rpi_ai::types::{
10443            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
10444            ToolCall, ToolCallType, Usage,
10445        };
10446
10447        let state = Arc::new(TuiState {
10448            current_assistant: Arc::new(std::sync::Mutex::new(None)),
10449            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10450            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10451            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
10452            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
10453            show_terminal_progress: true,
10454            status: std::sync::Mutex::new(RunStatus::Idle),
10455            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10456            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10457            editor_text: rpi_extensions::EditorTextMailbox::new(),
10458            auto_send: std::sync::Mutex::new(None),
10459            last_editor_text: std::sync::Mutex::new(String::new()),
10460            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
10461            js_preparation_cancel: std::sync::Mutex::new(None),
10462            user_bash_cancel: std::sync::Mutex::new(None),
10463            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10464            footer: Arc::new(FooterComponent::new()),
10465            status_container: Arc::new(Container::new()),
10466            chat_container: Arc::new(Container::new()),
10467            loader: Arc::new(Loader::new()),
10468            editor: Arc::new(Editor::simple()),
10469            last_assistant_text: std::sync::Mutex::new(String::new()),
10470            active_selector: std::sync::Mutex::new(None),
10471            active_extension_editor: std::sync::Mutex::new(None),
10472            active_extension_input: std::sync::Mutex::new(None),
10473            active_extension_cancel: std::sync::Mutex::new(None),
10474            autocomplete: AutocompleteManager::new(),
10475            autocomplete_container: Arc::new(Container::new()),
10476            autocomplete_max_visible: 5,
10477            pending_images: std::sync::Mutex::new(Vec::new()),
10478            theme_manager: Arc::new(ThemeManager::new()),
10479            tui: None,
10480            current_model_id: std::sync::Mutex::new(String::new()),
10481            show_images: std::sync::Mutex::new(true),
10482            cache_miss_notices: std::sync::Mutex::new(false),
10483            history: std::sync::Mutex::new(Vec::new()),
10484            history_index: std::sync::Mutex::new(-1),
10485            history_draft: std::sync::Mutex::new(None),
10486            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10487            scoped_edit: std::sync::Mutex::new(None),
10488            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
10489            extension_session: Arc::new(std::sync::Mutex::new(
10490                rpi_extensions::ExtensionSession::none(),
10491            )),
10492            search: Arc::new(AltScreenSearch::new()),
10493            search_bar: Arc::new(SearchBar::new()),
10494            pending_container: Arc::new(Container::new()),
10495            dequeue_hint: String::new(),
10496            pending_snapshot: std::sync::Mutex::new(
10497                rpi_harness::agent_harness::QueuedMessages::default(),
10498            ),
10499            selection_start: std::sync::Mutex::new(None),
10500            selection_end: std::sync::Mutex::new(None),
10501        });
10502
10503        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
10504        // terminal; instead, exercise the *mutation* half directly against a
10505        // captured chat container via a synthetic message-start event's data.
10506        let assistant = AssistantMessage {
10507            role: rpi_ai::types::AssistantRole,
10508            content: vec![
10509                Content::Thinking(ThinkingContent {
10510                    kind: ThinkingContentType,
10511                    thinking: "Reasoning about the reply.".into(),
10512                    thinking_signature: None,
10513                    redacted: false,
10514                }),
10515                Content::Text(TextContent {
10516                    kind: TextContentType,
10517                    text: "Hello.".into(),
10518                    text_signature: None,
10519                }),
10520                Content::ToolCall(ToolCall {
10521                    kind: ToolCallType,
10522                    id: "tc1".into(),
10523                    name: "bash".into(),
10524                    arguments: serde_json::json!({"command": "echo hi"}),
10525                    thought_signature: None,
10526                    namespace: None,
10527                }),
10528            ],
10529            api: rpi_ai::Api::AnthropicMessages,
10530            provider: "anthropic".into(),
10531            model: "claude-sonnet-5".into(),
10532            response_model: None,
10533            response_id: None,
10534            usage: Usage::zero(),
10535            stop_reason: StopReason::Stop,
10536            deferred: None,
10537            error_message: None,
10538            raw_stop_reason: None,
10539            end_turn: None,
10540            timestamp: 0,
10541        };
10542
10543        // Manually apply the MessageStart assistant branch logic (mirrors the
10544        // drain handler, without needing a TuiAltScreen).
10545        let comp = Arc::new(AssistantMessageComponent::new(
10546            AssistantMessageOptions::default(),
10547        ));
10548        comp.set_streaming(true);
10549        comp.update_blocks(&assistant_blocks(&assistant));
10550        let chat = Arc::new(Container::new());
10551        chat.add_child(comp.clone());
10552        *state.current_assistant.lock().unwrap() = Some(comp);
10553
10554        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
10555        for c in &assistant.content {
10556            if let Content::ToolCall(tc) = c {
10557                let mut tools = state.tool_components.lock().unwrap();
10558                if !tools.contains_key(&tc.id) {
10559                    let tc_comp = Arc::new(ToolExecutionComponent::new(
10560                        &tc.name,
10561                        &tc.arguments.to_string(),
10562                    ));
10563                    tc_comp.set_running();
10564                    chat.add_child(tc_comp.clone());
10565                    tools.insert(tc.id.clone(), tc_comp);
10566                }
10567            }
10568        }
10569
10570        // Assert: the assistant component rendered the text + the thinking
10571        // block (the update_blocks path keeps thinking visible), and a tool
10572        // component was registered.
10573        let rendered = chat.render(80);
10574        let joined: String = rendered.join("\n");
10575        assert!(
10576            joined.contains("Hello."),
10577            "assistant text not rendered: {joined}"
10578        );
10579        assert!(
10580            joined.contains("Reasoning about the reply."),
10581            "thinking block not rendered: {joined}"
10582        );
10583        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
10584        assert!(state.current_assistant.lock().unwrap().is_some());
10585
10586        // Manually apply ToolExecutionEnd (mirrors drain).
10587        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
10588        ended.set_result("hi", false);
10589        assert!(state.tool_components.lock().unwrap().is_empty());
10590
10591        // A running bash panel owns the visible spinner. The global loader is
10592        // hidden until the last concurrent bash tool completes, then restored
10593        // while the agent remains in the Working state.
10594        assert!(state.try_start_working());
10595        assert!(
10596            !state.try_start_working(),
10597            "a second submit must be rejected"
10598        );
10599        state.set_status(RunStatus::Idle);
10600        state.set_status(RunStatus::Working);
10601        // The active loader now renders inside the editor's top border.
10602        assert!(state.editor.working().is_some());
10603        assert_eq!(state.status_container.child_count(), 0);
10604        assert!(state.footer.get_status().is_empty());
10605        state.show_retry(3, 10, 8_000);
10606        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
10607        assert!(retry_status.contains("Retrying (3/10)"));
10608        state.set_status(RunStatus::Working);
10609        {
10610            let mut bash = state.bash_components.lock().unwrap();
10611            bash.insert(
10612                "bash-1".into(),
10613                Arc::new(BashExecutionComponent::new("one")),
10614            );
10615            bash.insert(
10616                "bash-2".into(),
10617                Arc::new(BashExecutionComponent::new("two")),
10618            );
10619        }
10620        state.sync_working_loader_with_bash();
10621        assert_eq!(state.status_container.child_count(), 0);
10622        state.bash_components.lock().unwrap().remove("bash-1");
10623        state.sync_working_loader_with_bash();
10624        assert_eq!(state.status_container.child_count(), 0);
10625        state.bash_components.lock().unwrap().remove("bash-2");
10626        state.sync_working_loader_with_bash();
10627        // The editor-border indicator stays visible across bash panels, so the
10628        // status container stays empty.
10629        assert_eq!(state.status_container.child_count(), 0);
10630        assert!(state.editor.working().is_some());
10631
10632        state.set_status(RunStatus::Aborting);
10633        assert_eq!(state.status_container.child_count(), 0);
10634        assert!(!state.loader.is_running());
10635    }
10636
10637    #[test]
10638    fn fresh_launch_does_not_restore_old_history() {
10639        let fresh = Args::default();
10640        assert!(!launch_restores_history(&fresh));
10641
10642        let continued = Args {
10643            continue_session: true,
10644            ..Args::default()
10645        };
10646        assert!(launch_restores_history(&continued));
10647
10648        let selected = Args {
10649            session: Some("session-id".into()),
10650            ..Args::default()
10651        };
10652        assert!(launch_restores_history(&selected));
10653    }
10654
10655    #[test]
10656    fn context_badge_ignores_aborted_and_error_turns() {
10657        use rpi_ai::types::{Api, StopReason, Usage};
10658
10659        let footer = Arc::new(FooterComponent::new());
10660        footer.set_context_window(100_000);
10661
10662        // A real response fills in the percent (`?` -> `25.0%/100k`).
10663        let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
10664        ok.stop_reason = StopReason::Stop;
10665        ok.usage = Usage {
10666            input: 25_000,
10667            ..Usage::zero()
10668        };
10669        record_context_usage(&footer, &ok);
10670        let stats = strip_ansi(&footer.render(120).join("\n"));
10671        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
10672
10673        // An aborted turn must not clobber the last good value.
10674        let mut aborted =
10675            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
10676        aborted.stop_reason = StopReason::Aborted;
10677        aborted.usage = Usage {
10678            input: 90_000,
10679            ..Usage::zero()
10680        };
10681        record_context_usage(&footer, &aborted);
10682        let stats = strip_ansi(&footer.render(120).join("\n"));
10683        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
10684
10685        // An errored turn likewise (its usage is not a real measurement).
10686        let mut errored =
10687            rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
10688        errored.stop_reason = StopReason::Error;
10689        errored.usage = Usage {
10690            input: 90_000,
10691            ..Usage::zero()
10692        };
10693        record_context_usage(&footer, &errored);
10694        let stats = strip_ansi(&footer.render(120).join("\n"));
10695        assert!(stats.contains("25.0%/100k"), "stats: {stats}");
10696    }
10697
10698    #[test]
10699    fn test_short_model_name() {
10700        assert_eq!(
10701            short_model_name("anthropic:claude-sonnet-5"),
10702            "claude-sonnet-5"
10703        );
10704        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
10705    }
10706
10707    #[test]
10708    fn model_selector_items_are_deduplicated_and_provider_qualified() {
10709        use rpi_ai::{Api, Model};
10710
10711        let mut gateway = Model::new(
10712            "gpt-5.6-sol",
10713            "GPT 5.6 Sol",
10714            Api::OpenaiCompletions,
10715            "routeryo-copy",
10716            "https://gateway.example.com",
10717        );
10718        let duplicate = gateway.clone();
10719        let anthropic = Model::new(
10720            "claude-sonnet-5",
10721            "Claude Sonnet 5",
10722            Api::AnthropicMessages,
10723            "anthropic",
10724            "https://api.anthropic.com",
10725        );
10726        gateway.headers = Some(std::collections::BTreeMap::from([(
10727            "authorization".into(),
10728            "Bearer test".into(),
10729        )]));
10730
10731        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
10732        assert_eq!(items.len(), 2);
10733        assert_eq!(items[0].value, "gpt-5.6-sol");
10734        assert_eq!(items[0].label, "GPT 5.6 Sol");
10735        assert_eq!(
10736            items[0].description.as_deref(),
10737            Some("routeryo-copy/gpt-5.6-sol (current)")
10738        );
10739        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
10740    }
10741
10742    #[test]
10743    fn model_selector_search_text_covers_provider_name_and_qualified_id() {
10744        use rpi_ai::{Api, Model};
10745
10746        let anthropic = Model::new(
10747            "claude-sonnet-5",
10748            "Claude Sonnet 5",
10749            Api::AnthropicMessages,
10750            "anthropic",
10751            "https://api.anthropic.com",
10752        );
10753        let items = model_selector_items(&[anthropic], "");
10754        let search = items[0].search_key();
10755        // Mirrors native `getModelSelectorSearchText`: provider-prefixed first,
10756        // then the display name.
10757        assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
10758        assert!(search.contains("Claude Sonnet 5"), "{search}");
10759
10760        // A provider query and a display-name query both match through the
10761        // real fuzzy filter used by the searchable selector.
10762        use rpi_tui::fuzzy_filter;
10763        for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
10764            let matched = fuzzy_filter(&items, query, |item| item.search_key());
10765            assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
10766        }
10767    }
10768
10769    #[test]
10770    fn model_selector_match_accepts_bare_and_qualified_ids() {
10771        use rpi_ai::{Api, Model};
10772
10773        let gateway = Model::new(
10774            "gpt-5.6-sol",
10775            "GPT 5.6 Sol",
10776            Api::OpenaiCompletions,
10777            "routeryo-copy",
10778            "https://gateway.example.com",
10779        );
10780        let anthropic = Model::new(
10781            "claude-sonnet-5",
10782            "Claude Sonnet 5",
10783            Api::AnthropicMessages,
10784            "anthropic",
10785            "https://api.anthropic.com",
10786        );
10787        let catalog = [gateway, anthropic];
10788        assert_eq!(
10789            find_model_selector_match(&catalog, "gpt-5.6-sol")
10790                .unwrap()
10791                .provider,
10792            "routeryo-copy"
10793        );
10794        assert_eq!(
10795            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
10796                .unwrap()
10797                .id,
10798            "gpt-5.6-sol"
10799        );
10800        assert_eq!(
10801            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
10802                .unwrap()
10803                .id,
10804            "claude-sonnet-5"
10805        );
10806        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
10807    }
10808
10809    #[test]
10810    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
10811        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
10812
10813        let failed = AssistantMessage {
10814            role: AssistantRole,
10815            content: Vec::new(),
10816            api: rpi_ai::Api::AnthropicMessages,
10817            provider: "anthropic".into(),
10818            model: "claude-sonnet-5".into(),
10819            response_model: None,
10820            response_id: None,
10821            usage: Usage::zero(),
10822            stop_reason: StopReason::Error,
10823            deferred: None,
10824            error_message: Some("upstream returned 401".into()),
10825            raw_stop_reason: None,
10826            end_turn: None,
10827            timestamp: 0,
10828        };
10829        assert_eq!(
10830            assistant_error_text(&failed).as_deref(),
10831            Some("upstream returned 401")
10832        );
10833
10834        let mut no_detail = failed;
10835        no_detail.error_message = Some("  ".into());
10836        assert_eq!(
10837            assistant_error_text(&no_detail).as_deref(),
10838            Some("Provider request failed.")
10839        );
10840
10841        let mut aborted = no_detail;
10842        aborted.stop_reason = StopReason::Aborted;
10843        aborted.error_message = Some("abort error: Request aborted".into());
10844        assert_eq!(
10845            assistant_error_text(&aborted).as_deref(),
10846            Some("abort error: Request aborted")
10847        );
10848
10849        aborted.error_message = None;
10850        assert_eq!(
10851            assistant_error_text(&aborted).as_deref(),
10852            Some("Request aborted.")
10853        );
10854    }
10855
10856    #[test]
10857    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
10858        assert_eq!(
10859            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
10860            "405\nMethod Not Allowed"
10861        );
10862        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
10863        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
10864        let long = "x".repeat(20_000);
10865        let cleaned = sanitize_error_message(&long);
10866        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
10867        assert!(cleaned.ends_with('…'));
10868    }
10869
10870    #[test]
10871    fn test_cycle_next_model_wraps_around() {
10872        use rpi_ai::{Api, Model};
10873        let mk = |id: &str| {
10874            Model::new(
10875                id,
10876                id,
10877                Api::AnthropicMessages,
10878                "anthropic",
10879                "https://api.anthropic.com",
10880            )
10881        };
10882        let catalog = [mk("a"), mk("b"), mk("c")];
10883        // Next after "a" is "b"; after "c" wraps to "a".
10884        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
10885        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
10886        // An unknown current id falls back to the first model.
10887        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
10888        // Empty catalog yields None.
10889        let empty: Vec<Model> = vec![];
10890        assert!(cycle_next_model(&empty, "a").is_none());
10891    }
10892
10893    #[test]
10894    fn test_cycle_prev_model_wraps_around() {
10895        use rpi_ai::{Api, Model};
10896        let mk = |id: &str| {
10897            Model::new(
10898                id,
10899                id,
10900                Api::AnthropicMessages,
10901                "anthropic",
10902                "https://api.anthropic.com",
10903            )
10904        };
10905        let catalog = [mk("a"), mk("b"), mk("c")];
10906        // Previous before "b" is "a"; before "a" wraps to "c".
10907        assert_eq!(cycle_prev_model(&catalog, "b").unwrap().id, "a");
10908        assert_eq!(cycle_prev_model(&catalog, "a").unwrap().id, "c");
10909        // An unknown current id falls back to the first model.
10910        assert_eq!(cycle_prev_model(&catalog, "zzz").unwrap().id, "a");
10911        // Empty catalog yields None.
10912        let empty: Vec<Model> = vec![];
10913        assert!(cycle_prev_model(&empty, "a").is_none());
10914    }
10915
10916    #[test]
10917    fn test_autocomplete_slash_suggestions_render() {
10918        // The autocomplete container should render at least one suggestion
10919        // line when the editor holds a `/` prefix, and clear when it doesn't.
10920        let state = Arc::new(TuiState {
10921            current_assistant: Arc::new(std::sync::Mutex::new(None)),
10922            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10923            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10924            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
10925            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
10926            show_terminal_progress: true,
10927            status: std::sync::Mutex::new(RunStatus::Idle),
10928            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10929            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10930            editor_text: rpi_extensions::EditorTextMailbox::new(),
10931            auto_send: std::sync::Mutex::new(None),
10932            last_editor_text: std::sync::Mutex::new(String::new()),
10933            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
10934            js_preparation_cancel: std::sync::Mutex::new(None),
10935            user_bash_cancel: std::sync::Mutex::new(None),
10936            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10937            footer: Arc::new(FooterComponent::new()),
10938            status_container: Arc::new(Container::new()),
10939            chat_container: Arc::new(Container::new()),
10940            loader: Arc::new(Loader::new()),
10941            editor: Arc::new(Editor::simple()),
10942            last_assistant_text: std::sync::Mutex::new(String::new()),
10943            active_selector: std::sync::Mutex::new(None),
10944            active_extension_editor: std::sync::Mutex::new(None),
10945            active_extension_input: std::sync::Mutex::new(None),
10946            active_extension_cancel: std::sync::Mutex::new(None),
10947            autocomplete: AutocompleteManager::new(),
10948            autocomplete_container: Arc::new(Container::new()),
10949            autocomplete_max_visible: 5,
10950            pending_images: std::sync::Mutex::new(Vec::new()),
10951            theme_manager: Arc::new(ThemeManager::new()),
10952            tui: None,
10953            current_model_id: std::sync::Mutex::new(String::new()),
10954            show_images: std::sync::Mutex::new(true),
10955            cache_miss_notices: std::sync::Mutex::new(false),
10956            history: std::sync::Mutex::new(Vec::new()),
10957            history_index: std::sync::Mutex::new(-1),
10958            history_draft: std::sync::Mutex::new(None),
10959            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10960            scoped_edit: std::sync::Mutex::new(None),
10961            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
10962            extension_session: Arc::new(std::sync::Mutex::new(
10963                rpi_extensions::ExtensionSession::none(),
10964            )),
10965            search: Arc::new(AltScreenSearch::new()),
10966            search_bar: Arc::new(SearchBar::new()),
10967            pending_container: Arc::new(Container::new()),
10968            dequeue_hint: String::new(),
10969            pending_snapshot: std::sync::Mutex::new(
10970                rpi_harness::agent_harness::QueuedMessages::default(),
10971            ),
10972            selection_start: std::sync::Mutex::new(None),
10973            selection_end: std::sync::Mutex::new(None),
10974        });
10975        {
10976            let mut combined = CombinedAutocompleteProvider::new();
10977            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10978                build_builtin_registry().visible_entries(),
10979            )));
10980            state.autocomplete.set_provider(Arc::new(combined));
10981        }
10982
10983        let editor = Arc::new(Editor::simple());
10984        editor.set_text("/he");
10985        editor.set_cursor(0, 3);
10986        refresh_autocomplete(&state, &editor);
10987        let lines = state.autocomplete_container.render(80);
10988        let joined: String = lines.join("\n");
10989        assert!(
10990            joined.contains("/help"),
10991            "slash suggestions not rendered: {joined}"
10992        );
10993
10994        // Clear: no suggestions for plain text.
10995        editor.set_text("hello");
10996        editor.set_cursor(0, 5);
10997        refresh_autocomplete(&state, &editor);
10998        assert!(state.autocomplete_container.render(80).is_empty());
10999    }
11000
11001    #[test]
11002    fn test_select_list_swap_restores_editor() {
11003        // The editor-container swap: opening a selector replaces the editor
11004        // child; closing restores it. Verify the container child count + the
11005        // active_selector flag round-trip.
11006        let state = Arc::new(TuiState {
11007            current_assistant: Arc::new(std::sync::Mutex::new(None)),
11008            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11009            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11010            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11011            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11012            show_terminal_progress: true,
11013            status: std::sync::Mutex::new(RunStatus::Idle),
11014            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11015            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11016            editor_text: rpi_extensions::EditorTextMailbox::new(),
11017            auto_send: std::sync::Mutex::new(None),
11018            last_editor_text: std::sync::Mutex::new(String::new()),
11019            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11020            js_preparation_cancel: std::sync::Mutex::new(None),
11021            user_bash_cancel: std::sync::Mutex::new(None),
11022            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11023            footer: Arc::new(FooterComponent::new()),
11024            status_container: Arc::new(Container::new()),
11025            chat_container: Arc::new(Container::new()),
11026            loader: Arc::new(Loader::new()),
11027            editor: Arc::new(Editor::simple()),
11028            last_assistant_text: std::sync::Mutex::new(String::new()),
11029            active_selector: std::sync::Mutex::new(None),
11030            active_extension_editor: std::sync::Mutex::new(None),
11031            active_extension_input: std::sync::Mutex::new(None),
11032            active_extension_cancel: std::sync::Mutex::new(None),
11033            autocomplete: AutocompleteManager::new(),
11034            autocomplete_container: Arc::new(Container::new()),
11035            autocomplete_max_visible: 5,
11036            pending_images: std::sync::Mutex::new(Vec::new()),
11037            theme_manager: Arc::new(ThemeManager::new()),
11038            tui: None,
11039            current_model_id: std::sync::Mutex::new(String::new()),
11040            show_images: std::sync::Mutex::new(true),
11041            cache_miss_notices: std::sync::Mutex::new(false),
11042            history: std::sync::Mutex::new(Vec::new()),
11043            history_index: std::sync::Mutex::new(-1),
11044            history_draft: std::sync::Mutex::new(None),
11045            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11046            scoped_edit: std::sync::Mutex::new(None),
11047            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11048            extension_session: Arc::new(std::sync::Mutex::new(
11049                rpi_extensions::ExtensionSession::none(),
11050            )),
11051            search: Arc::new(AltScreenSearch::new()),
11052            search_bar: Arc::new(SearchBar::new()),
11053            pending_container: Arc::new(Container::new()),
11054            dequeue_hint: String::new(),
11055            pending_snapshot: std::sync::Mutex::new(
11056                rpi_harness::agent_harness::QueuedMessages::default(),
11057            ),
11058            selection_start: std::sync::Mutex::new(None),
11059            selection_end: std::sync::Mutex::new(None),
11060        });
11061        let editor_container = Arc::new(Container::new());
11062        let editor = Arc::new(Editor::simple());
11063        editor_container.add_child(editor.clone());
11064        assert!(!state.selector_open());
11065
11066        let tui_terminal = Box::new(ProcessTerminal::new());
11067        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
11068        let list = Arc::new(SelectList::new(
11069            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
11070            5,
11071        ));
11072        open_selector(
11073            &state,
11074            &editor_container,
11075            &editor,
11076            &tui,
11077            list,
11078            SelectorKind::Theme,
11079        );
11080        assert!(state.selector_open());
11081        // list only (editor swapped out).
11082        assert_eq!(editor_container.child_count(), 1);
11083
11084        close_selector(&state, &editor_container, &editor, &tui);
11085        assert!(!state.selector_open());
11086        // editor restored.
11087        assert_eq!(editor_container.child_count(), 1);
11088    }
11089
11090    #[test]
11091    fn test_message_history_browse_restores_draft() {
11092        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
11093        // messages, browse older → newer → back past the newest restores the
11094        // draft the user was typing.
11095        let state = Arc::new(TuiState {
11096            current_assistant: Arc::new(std::sync::Mutex::new(None)),
11097            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11098            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11099            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11100            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11101            show_terminal_progress: true,
11102            status: std::sync::Mutex::new(RunStatus::Idle),
11103            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11104            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11105            editor_text: rpi_extensions::EditorTextMailbox::new(),
11106            auto_send: std::sync::Mutex::new(None),
11107            last_editor_text: std::sync::Mutex::new(String::new()),
11108            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11109            js_preparation_cancel: std::sync::Mutex::new(None),
11110            user_bash_cancel: std::sync::Mutex::new(None),
11111            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11112            footer: Arc::new(FooterComponent::new()),
11113            status_container: Arc::new(Container::new()),
11114            chat_container: Arc::new(Container::new()),
11115            loader: Arc::new(Loader::new()),
11116            editor: Arc::new(Editor::simple()),
11117            last_assistant_text: std::sync::Mutex::new(String::new()),
11118            active_selector: std::sync::Mutex::new(None),
11119            active_extension_editor: std::sync::Mutex::new(None),
11120            active_extension_input: std::sync::Mutex::new(None),
11121            active_extension_cancel: std::sync::Mutex::new(None),
11122            autocomplete: AutocompleteManager::new(),
11123            autocomplete_container: Arc::new(Container::new()),
11124            autocomplete_max_visible: 5,
11125            pending_images: std::sync::Mutex::new(Vec::new()),
11126            theme_manager: Arc::new(ThemeManager::new()),
11127            tui: None,
11128            current_model_id: std::sync::Mutex::new(String::new()),
11129            show_images: std::sync::Mutex::new(true),
11130            cache_miss_notices: std::sync::Mutex::new(false),
11131            history: std::sync::Mutex::new(Vec::new()),
11132            history_index: std::sync::Mutex::new(-1),
11133            history_draft: std::sync::Mutex::new(None),
11134            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11135            scoped_edit: std::sync::Mutex::new(None),
11136            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11137            extension_session: Arc::new(std::sync::Mutex::new(
11138                rpi_extensions::ExtensionSession::none(),
11139            )),
11140            search: Arc::new(AltScreenSearch::new()),
11141            search_bar: Arc::new(SearchBar::new()),
11142            pending_container: Arc::new(Container::new()),
11143            dequeue_hint: String::new(),
11144            pending_snapshot: std::sync::Mutex::new(
11145                rpi_harness::agent_harness::QueuedMessages::default(),
11146            ),
11147            selection_start: std::sync::Mutex::new(None),
11148            selection_end: std::sync::Mutex::new(None),
11149        });
11150        let editor = Arc::new(Editor::simple());
11151
11152        push_history(&state, "first message");
11153        push_history(&state, "second message");
11154        // Consecutive duplicate is skipped.
11155        push_history(&state, "second message");
11156        push_history(&state, "   "); // empty → skipped
11157        assert_eq!(state.history.lock().unwrap().len(), 2);
11158        assert_eq!(state.history.lock().unwrap()[0], "second message");
11159
11160        // User starts typing a fresh prompt.
11161        editor.set_text("half-typed");
11162        editor.set_cursor(0, 11);
11163
11164        // ↑ → most recent.
11165        navigate_history(&state, &editor, -1);
11166        assert_eq!(editor.get_text(), "second message");
11167        assert_eq!(*state.history_index.lock().unwrap(), 0);
11168        // ↑ → older.
11169        navigate_history(&state, &editor, -1);
11170        assert_eq!(editor.get_text(), "first message");
11171        assert_eq!(*state.history_index.lock().unwrap(), 1);
11172        // ↑ past the oldest → stays (no wrap).
11173        navigate_history(&state, &editor, -1);
11174        assert_eq!(editor.get_text(), "first message");
11175        // ↓ → newer.
11176        navigate_history(&state, &editor, 1);
11177        assert_eq!(editor.get_text(), "second message");
11178        // ↓ past the newest → restores the draft.
11179        navigate_history(&state, &editor, 1);
11180        assert_eq!(editor.get_text(), "half-typed");
11181        assert_eq!(*state.history_index.lock().unwrap(), -1);
11182    }
11183
11184    #[test]
11185    fn test_accept_top_suggestion_replaces_prefix() {
11186        // `/he` + Tab → `/help ` (slash command provider inserts a space).
11187        let state = Arc::new(TuiState {
11188            current_assistant: Arc::new(std::sync::Mutex::new(None)),
11189            tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11190            bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11191            hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11192            tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11193            show_terminal_progress: true,
11194            status: std::sync::Mutex::new(RunStatus::Idle),
11195            ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11196            ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11197            editor_text: rpi_extensions::EditorTextMailbox::new(),
11198            auto_send: std::sync::Mutex::new(None),
11199            last_editor_text: std::sync::Mutex::new(String::new()),
11200            programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11201            js_preparation_cancel: std::sync::Mutex::new(None),
11202            user_bash_cancel: std::sync::Mutex::new(None),
11203            pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11204            footer: Arc::new(FooterComponent::new()),
11205            status_container: Arc::new(Container::new()),
11206            chat_container: Arc::new(Container::new()),
11207            loader: Arc::new(Loader::new()),
11208            editor: Arc::new(Editor::simple()),
11209            last_assistant_text: std::sync::Mutex::new(String::new()),
11210            active_selector: std::sync::Mutex::new(None),
11211            active_extension_editor: std::sync::Mutex::new(None),
11212            active_extension_input: std::sync::Mutex::new(None),
11213            active_extension_cancel: std::sync::Mutex::new(None),
11214            autocomplete: AutocompleteManager::new(),
11215            autocomplete_container: Arc::new(Container::new()),
11216            autocomplete_max_visible: 5,
11217            pending_images: std::sync::Mutex::new(Vec::new()),
11218            theme_manager: Arc::new(ThemeManager::new()),
11219            tui: None,
11220            current_model_id: std::sync::Mutex::new(String::new()),
11221            show_images: std::sync::Mutex::new(true),
11222            cache_miss_notices: std::sync::Mutex::new(false),
11223            history: std::sync::Mutex::new(Vec::new()),
11224            history_index: std::sync::Mutex::new(-1),
11225            history_draft: std::sync::Mutex::new(None),
11226            cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11227            scoped_edit: std::sync::Mutex::new(None),
11228            markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11229            extension_session: Arc::new(std::sync::Mutex::new(
11230                rpi_extensions::ExtensionSession::none(),
11231            )),
11232            search: Arc::new(AltScreenSearch::new()),
11233            search_bar: Arc::new(SearchBar::new()),
11234            pending_container: Arc::new(Container::new()),
11235            dequeue_hint: String::new(),
11236            pending_snapshot: std::sync::Mutex::new(
11237                rpi_harness::agent_harness::QueuedMessages::default(),
11238            ),
11239            selection_start: std::sync::Mutex::new(None),
11240            selection_end: std::sync::Mutex::new(None),
11241        });
11242        {
11243            let mut combined = CombinedAutocompleteProvider::new();
11244            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
11245                build_builtin_registry().visible_entries(),
11246            )));
11247            state.autocomplete.set_provider(Arc::new(combined));
11248        }
11249        let editor = Arc::new(Editor::simple());
11250        editor.set_text("/he");
11251        editor.set_cursor(0, 3);
11252        refresh_autocomplete(&state, &editor);
11253        let accepted = accept_top_suggestion(&state, &editor);
11254        assert!(accepted, "should accept the top suggestion");
11255        let text = editor.get_text();
11256        assert!(
11257            text.starts_with("/help"),
11258            "editor text should start with /help, got {text}"
11259        );
11260    }
11261
11262    #[test]
11263    fn configured_key_parser_supports_native_notation() {
11264        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
11265        assert_eq!(combo.code, KeyCode::Char('g'));
11266        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
11267        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
11268        assert_eq!(combo.code, KeyCode::BackTab);
11269    }
11270
11271    #[test]
11272    fn double_escape_trigger_has_half_second_window() {
11273        let now = std::time::Instant::now();
11274        assert!(!double_escape_trigger(None, now));
11275        assert!(double_escape_trigger(
11276            Some(now - std::time::Duration::from_millis(500)),
11277            now
11278        ));
11279        assert!(!double_escape_trigger(
11280            Some(now - std::time::Duration::from_millis(501)),
11281            now
11282        ));
11283    }
11284
11285    #[test]
11286    fn ctrl_c_clears_first_and_only_exits_inside_the_window() {
11287        // upstream's `handleCtrlC`: a lone press clears the editor and never
11288        // quits by itself; only a second press within 500ms exits. It must not
11289        // abort — Esc owns that.
11290        let now = std::time::Instant::now();
11291        assert_eq!(ctrl_c_action(None, now), CtrlCAction::Clear);
11292        assert_eq!(
11293            ctrl_c_action(Some(now - std::time::Duration::from_millis(500)), now),
11294            CtrlCAction::Exit
11295        );
11296        // A slower second press is a fresh "clear", not a quit.
11297        assert_eq!(
11298            ctrl_c_action(Some(now - std::time::Duration::from_millis(501)), now),
11299            CtrlCAction::Clear
11300        );
11301    }
11302
11303    #[test]
11304    fn pasted_multi_line_block_becomes_one_prompt_not_many() {
11305        // End-to-end shape of the fix: replay the key stream a Windows paste
11306        // produces ("l1\nl2\nl3" + a trailing Enter) through the same pieces
11307        // the key loop uses — the burst discriminator for each Enter and the
11308        // editor for the text — and assert that *no* submit fired while the
11309        // block arrived, then exactly one submit on the user's own Enter.
11310        use std::sync::atomic::{AtomicUsize, Ordering};
11311
11312        let editor = rpi_tui::Editor::simple();
11313        let submits = Arc::new(AtomicUsize::new(0));
11314        let submits_for_cb = submits.clone();
11315        editor.on_submit(Arc::new(move |_text: &str| {
11316            submits_for_cb.fetch_add(1, Ordering::SeqCst);
11317        }));
11318
11319        // Every pasted key is delivered back-to-back, so `now` is unchanged and
11320        // the console queue is never empty until the paste is drained.
11321        let now = std::time::Instant::now();
11322        let mut last_text_key_at: Option<std::time::Instant> = None;
11323        let block = ["l1", "l2", "l3"];
11324        for (index, line) in block.iter().enumerate() {
11325            for ch in line.chars() {
11326                editor.insert(&ch.to_string());
11327                last_text_key_at = Some(now);
11328            }
11329            if index + 1 < block.len() {
11330                // The pasted newline: more input is still queued behind it.
11331                let more_queued = true;
11332                assert!(
11333                    enter_is_paste_burst(last_text_key_at, now, more_queued),
11334                    "pasted newline {index} must not submit"
11335                );
11336                editor.insert("\n");
11337                last_text_key_at = Some(now);
11338            }
11339        }
11340        assert_eq!(editor.get_text(), "l1\nl2\nl3");
11341        assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
11342
11343        // The user's own Enter: a real gap, nothing queued.
11344        let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
11345        assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
11346        editor.handle_key(crossterm::event::KeyEvent::new(
11347            KeyCode::Enter,
11348            KeyModifiers::NONE,
11349        ));
11350        assert_eq!(
11351            submits.load(Ordering::SeqCst),
11352            1,
11353            "typed Enter submits once"
11354        );
11355    }
11356
11357    #[test]
11358    fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
11359        // A Windows paste delivers CRLF; the editor must fold it to LF so the
11360        // rendered block is clean and the cursor arithmetic stays sound.
11361        let editor = rpi_tui::Editor::simple();
11362        editor.insert("alpha\r\nbeta\r\n");
11363        assert_eq!(editor.get_text(), "alpha\nbeta\n");
11364        assert!(!editor.get_text().contains('\r'));
11365    }
11366
11367    #[test]
11368    fn pasted_enter_is_not_a_submit() {
11369        let now = std::time::Instant::now();
11370
11371        // Queued input behind the Enter => paste, regardless of history.
11372        assert!(enter_is_paste_burst(None, now, true));
11373
11374        // Enter immediately after pasted characters => paste (also covers a
11375        // paste whose final line ends with a newline, where nothing is queued).
11376        assert!(enter_is_paste_burst(
11377            Some(now - std::time::Duration::from_millis(1)),
11378            now,
11379            false
11380        ));
11381        assert!(enter_is_paste_burst(
11382            Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
11383            now,
11384            false
11385        ));
11386
11387        // A typed Enter: nothing queued and a real gap since the last key.
11388        assert!(!enter_is_paste_burst(None, now, false));
11389        assert!(!enter_is_paste_burst(
11390            Some(now - PASTE_BURST_GAP),
11391            now,
11392            false
11393        ));
11394        assert!(!enter_is_paste_burst(
11395            Some(now - std::time::Duration::from_millis(120)),
11396            now,
11397            false
11398        ));
11399    }
11400
11401    #[test]
11402    fn paste_probe_ignores_the_enter_keys_own_release() {
11403        use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
11404        let release =
11405            KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
11406
11407        // Windows queues press+release together over RDP: the release behind a
11408        // lone Enter must NOT count as queued paste content.
11409        let mut queued = vec![Event::Key(release)].into_iter();
11410        let (more, stashed) = probe_paste_input(None, || queued.next());
11411        assert!(!more, "a key release alone is not queued paste input");
11412        assert!(stashed.is_none());
11413
11414        // A real follow-up key (the next pasted line) IS queued input and is
11415        // stashed for the caller instead of being lost.
11416        let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
11417        let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
11418        let (more, stashed) = probe_paste_input(None, || queued.next());
11419        assert!(more, "a queued key press is paste continuation");
11420        assert!(matches!(stashed, Some(Event::Key(_))));
11421
11422        // Nothing queued at all.
11423        let (more, stashed) = probe_paste_input(None, || None);
11424        assert!(!more);
11425        assert!(stashed.is_none());
11426
11427        // An already-stashed event counts as queued input, without reading more.
11428        let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
11429            panic!("must not read when an event is already stashed")
11430        });
11431        assert!(more);
11432        assert!(matches!(stashed, Some(Event::Key(_))));
11433    }
11434
11435    // ---- ask_user TUI bridge ----
11436
11437    #[test]
11438    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
11439        let flat = rpi_extensions::UiDialogRequest {
11440            request_id: "r1".into(),
11441            tool_call_id: Some("c1".into()),
11442            ui: serde_json::json!({
11443                "kind": "selector",
11444                "id": "proxy_port",
11445                "header": "Proxy",
11446                "question": "端口?",
11447                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
11448                "allowMultiple": true,
11449                "allowFreeform": false,
11450                "suggest": "1080"
11451            }),
11452            raw: serde_json::json!({}),
11453        };
11454        let prompt = parse_ask_user_prompt(&flat);
11455        assert_eq!(prompt.question_id, "proxy_port");
11456        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
11457        assert_eq!(prompt.options.len(), 2);
11458        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
11459        assert!(prompt.allow_multiple);
11460        assert!(!prompt.allow_freeform);
11461        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
11462
11463        let alias = rpi_extensions::UiDialogRequest {
11464            ui: serde_json::json!({
11465                "questions": [{"id": "q", "question": "Q?", "options": []}]
11466            }),
11467            ..flat.clone()
11468        };
11469        let prompt = parse_ask_user_prompt(&alias);
11470        assert_eq!(prompt.question_id, "q");
11471        assert!(prompt.allow_freeform, "no options defaults to freeform");
11472
11473        let confirm = rpi_extensions::UiDialogRequest {
11474            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
11475            ..flat.clone()
11476        };
11477        let prompt = parse_ask_user_prompt(&confirm);
11478        assert_eq!(prompt.kind, "confirm");
11479        assert_eq!(prompt.options.len(), 2);
11480        assert_eq!(prompt.options[0].0, "Yes");
11481    }
11482
11483    #[test]
11484    fn ask_user_bridge_answers_and_cancels_by_request_id() {
11485        let mailbox = rpi_extensions::UiDialogMailbox::new();
11486        mailbox.attach();
11487        let bridge = AskUserBridge::new(mailbox.clone());
11488
11489        // Two concurrent requests must not cross answers.
11490        mailbox
11491            .handle(serde_json::json!({
11492                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
11493                "ui": {"kind": "input", "question": "Port?"}
11494            }))
11495            .unwrap();
11496        mailbox
11497            .handle(serde_json::json!({
11498                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
11499                "ui": {"kind": "input", "question": "Level?"}
11500            }))
11501            .unwrap();
11502
11503        let first = bridge.take_pending().expect("r1 visible");
11504        assert_eq!(first.request_id, "r1");
11505        assert!(
11506            bridge.take_pending().is_none(),
11507            "only one request occupies the input slot"
11508        );
11509        bridge.respond("r1", serde_json::json!({"text": "7897"}));
11510        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
11511
11512        let second = bridge.take_pending().expect("r2 visible");
11513        assert_eq!(second.request_id, "r2");
11514        bridge.cancel("r2");
11515        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
11516
11517        // cancel_all drops anything still queued.
11518        mailbox
11519            .handle(serde_json::json!({
11520                "op": "open", "requestId": "r3",
11521                "ui": {"kind": "input", "question": "X?"}
11522            }))
11523            .unwrap();
11524        bridge.cancel_all();
11525        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
11526
11527        // A detached mailbox rejects new prompts (headless contract).
11528        bridge.shutdown();
11529        assert!(mailbox
11530            .handle(serde_json::json!({
11531                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
11532            }))
11533            .is_err());
11534    }
11535}