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