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//! This host module owns startup and input dispatch. Feature implementations live
10//! in `interactive_tui/`: commands, extensions, settings, sessions, state, run,
11//! events, selectors, autocomplete, and rendering. Regression tests live in tests.rs.
12//!
13//! Key architecture facts (see the initial port notes (retired)):
14//! - `TuiAltScreen::start()` still has a readerless companion, so this module
15//!   owns a `spawn_blocking` crossterm `read()` loop for key dispatch and a
16//!   `tokio::spawn` task that drains `broadcast::Receiver<AgentEvent>` into UI
17//!   mutations.
18//! - The layout root is built ONCE at startup (mirrors the TS
19//!   `fullscreenLayoutRoot`); per-message we mutate only `chat_container` /
20//!   `status_container` / `autocomplete_container` children and call
21//!   `request_render(false)` so the differential renderer repaints just the
22//!   changed rows.
23//! - Selectors (`/model` `/session` `/theme`) are implemented by **swapping the
24//!   `editor_container` child** (the TS `showSelector` swap pattern,
25//!   `interactive-mode.ts:4354-4377`) — the `show_overlay` stub is avoided
26//!   entirely. An `active_selector` state field holds the live `SelectList`;
27//!   while it is `Some` the key loop routes to it first and restores the editor
28//!   on done/cancel.
29
30// Feature modules share the host state; their helpers stay private to this TUI.
31mod extensions;
32use extensions::*;
33pub(crate) use extensions::{ask_user_frame, parse_ask_user_prompt, AskUserPrompt};
34mod commands;
35use commands::*;
36mod settings;
37use settings::*;
38mod sessions;
39use sessions::*;
40mod run;
41use run::*;
42mod events;
43use events::*;
44mod selectors;
45use selectors::*;
46mod autocomplete;
47use autocomplete::*;
48mod rendering;
49use rendering::*;
50mod panels;
51mod pet;
52
53#[cfg(test)]
54mod tests;
55
56mod state;
57pub(crate) use state::configured_keybindings;
58use state::*;
59
60use std::collections::HashMap;
61use std::io::IsTerminal;
62use std::sync::{Arc, Mutex};
63
64use base64::Engine;
65use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
66use tokio::sync::{broadcast, mpsc};
67use tokio_util::sync::CancellationToken;
68
69use rpi_agent::{AgentEvent, AgentMessage};
70use rpi_ai::types::{AssistantMessage, Content, UserMessage};
71use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
72use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
73use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
74#[cfg(test)]
75use rpi_tui::strip_ansi;
76use rpi_tui::{
77    apply_theme_preset, AltScreenSearch, AssistantBlock, AssistantMessageComponent,
78    AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
79    BashTruncation, CombinedAutocompleteProvider, Component, Container, DynamicBorder, Editor,
80    EditorOptions, EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode,
81    FooterComponent, Image, ImageOptions, Input, Loader, Markdown, ProcessTerminal, ScrollView,
82    ScrollViewOptions, SearchBar, SearchableSelectList, SelectItem, SelectList, SettingItem,
83    SettingsList, SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer,
84    StackChild, StackEntry, StatusIndicator, Text, ThemeManager, ThemePreset,
85    ToolExecutionComponent, ToolStatus, TuiAltScreen, UserMessageComponent, VStack, WorkingState,
86    TUI,
87};
88use rpi_tui::{bold as tui_bold, theme as current_theme};
89
90#[allow(unused_imports)]
91use rpi_tui::BashStatus;
92
93use crate::args::Args;
94use crate::session_driver::{assistant_blocks, assistant_tool_calls};
95use crate::transcript_view::UiEvent;
96
97/// B5e: the markdown-transformer trait object the assistant-message render path
98/// applies to raw text BEFORE the [`Markdown`] renderer styles it. A plain
99/// `Fn(&str) -> String` (NO `rpi-extensions` types) so `rpi-tui` stays free of
100/// an `rpi-extensions` dep — `rpi-cli` (which already depends on
101/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot` and
102/// hands the trait object to `AssistantMessageComponent::set_markdown_transformer`.
103type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
104
105/// A synchronous rendezvous between the Node runtime-request thread and the
106/// blocking TUI key loop. Node's `ctx.ui.*` methods are promises, so the host
107/// request must remain pending while the user interacts with the native
108/// B5e: build the `AssistantMessageComponent` markdown-transformer closure the
109/// render path applies to raw assistant text before styling. Wraps any plugin
110/// `register_markdown_transformer` handlers registered in `snapshot` (chained
111/// in registration order: each handler's output feeds the next). `None` when
112/// no markdown transformers are registered (the component defaults to the
113/// identity transform + this avoids a closure allocation on the hot render
114/// path).
115///
116/// The closure captures an `Arc<RegistrySnapshot>` clone so it outlives the
117/// borrow that built it (the snapshot's `active` flag guards dispatch in
118/// `emit_resources_discover`/event translation; a reloaded session's old
119/// snapshot flips false, so a stale closure no-ops rather than driving a
120/// half-swapped registry — the transformer falls back to the input unchanged
121/// on an inactive snapshot, matching the plugin's per-handler skip-on-error).
122///
123/// This is the cycle-free seam: `rpi-tui` takes a `Fn(&str) -> String` trait
124/// object (no `rpi-extensions` dep); `rpi-cli` (which already depends on
125/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot`. The
126/// calling pattern mirrors `plugin_stub_smoke.rs`'s direct `RenderFn` round-
127/// trip (input `{"markdown":…}` → `render_fn` → reclaim `out` via the plugin's
128/// `free_string` → parse `{"markdown":…}`).
129fn build_markdown_transformer(
130    snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
131) -> Option<MarkdownTransformer> {
132    let snapshot = snapshot?;
133    // Pre-check: if no markdown renderers are registered, return None so the
134    // component uses the identity path (no per-delta closure call). The
135    // renderers list is a per-call `renderers_of` clone; snapshotting it once
136    // here keeps the closure cheap on the hot path.
137    let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
138    if renderers.is_empty() {
139        return None;
140    }
141    Some(Arc::new(move |raw: &str| -> String {
142        transform_markdown_chain(&snapshot, &renderers, raw)
143    }))
144}
145
146/// Drive the markdown-transformer chain for one input string. Each registered
147/// handler receives the previous handler's output (or the raw input for the
148/// first), as a `{"markdown": <text>}` JSON envelope; its `RenderFn` returns
149/// `{"markdown": <transformed>}` (rc=0) or an error (rc!=0). On any failure —
150/// nonzero rc, a panic across the FFI (caught), a missing `markdown` field, or
151/// an inactive snapshot — the chain short-circuits to the current text
152/// unchanged (per-handler skip-on-error, mirroring pi's `runner.ts` fan-out).
153fn transform_markdown_chain(
154    snapshot: &rpi_extensions::RegistrySnapshot,
155    renderers: &[rpi_extensions::RegisteredRenderer],
156    raw: &str,
157) -> String {
158    // A stale snapshot (post-/reload) must not drive a swapped-out registry.
159    // The renderers were captured from this snapshot; if it has gone inactive,
160    // fall back to the raw input so the UI never renders stale-transformed text
161    // from a dead plugin.
162    if !snapshot.is_active() {
163        return raw.to_string();
164    }
165
166    let mut current = raw.to_string();
167    for renderer in renderers {
168        let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
169            Ok(s) => s,
170            Err(_) => return current, // serialize failure — keep current, stop chain
171        };
172        // SAFETY: `render_fn` is a plugin-provided `extern "C" fn` over a
173        // borrowed `StbStringRef` + an out-param. The plugin warrants
174        // `poll`/`render` are non-blocking + thread-safe (the same contract
175        // the tool adapter relies on). `user_data` is the plugin's opaque
176        // pointer, stable for the registry lifetime (the keepalive keeps the
177        // cdylib mapped). We reclaim `out` via the plugin's `free_string`
178        // exactly once. The whole call is `catch_unwind`-wrapped — a plugin
179        // panic must not unwind across the FFI boundary (same policy as the
180        // tool partial cb + the runtime_action trampoline).
181        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
182            let mut out = rpi_plugin_sdk::StbString::empty();
183            let rc = (renderer.render_fn)(
184                rpi_plugin_sdk::StbStringRef::from_str(&input),
185                &mut out as *mut rpi_plugin_sdk::StbString,
186                renderer.user_data,
187            );
188            // SAFETY: the callback transfers a live output to its producer.
189            let out = unsafe {
190                rpi_plugin_sdk::callbacks::OwnedString::from_raw(
191                    out,
192                    Some(renderer.plugin_free_string),
193                )
194            };
195            let text = if rc == 0 {
196                let s = out.to_string_lossy();
197                Some(s)
198            } else {
199                None
200            };
201            // The owner reclaims output regardless of rc, including an error
202            // response a plugin wrote before returning nonzero.
203            text
204        }));
205        let out_text = match outcome {
206            Ok(Some(s)) => s,
207            Ok(None) => return current, // rc != 0 — skip this handler, keep current
208            Err(_) => return current,   // panic — skip, keep current (do not abort: the
209                                         // render path is not the action trampoline; a panicking transformer
210                                         // degrades to identity rather than killing the process. Logged via
211                                         // the `tracing` crate's panic hook.)
212        };
213        // Parse `{"markdown": <text>}`; lenient — a missing/non-string field
214        // keeps the current text (skip this handler).
215        let next = serde_json::from_str::<serde_json::Value>(&out_text)
216            .ok()
217            .and_then(|v| {
218                v.get("markdown")
219                    .and_then(|m| m.as_str())
220                    .map(|s| s.to_string())
221            })
222            .unwrap_or(current);
223        current = next;
224    }
225    current
226}
227
228// ===========================================================================
229// Slash commands — trait + registry
230// ===========================================================================
231//
232// Each built-in slash command is one `impl SlashCommand`. The commands are
233// registered at startup into a [`CommandRegistry`] (one source of truth) that
234// serves both dispatch ("given this token, run the command") and autocomplete
235// ("list the visible commands"). This replaces the old two-list + sync-test
236// arrangement, where `handle_slash_command` and `v1_slash_commands()` had to be
237// kept in lock-step by hand.
238//
239// `execute` runs on the blocking key/compose thread (the editor `on_submit`
240// callback and the Ctrl+L hotkey both land there), so it MUST stay synchronous:
241//   - commands needing async (`set_model`/`set_thinking_level`/`set_active_tools`)
242//     `tokio::spawn` the work and return immediately;
243//   - commands needing the main async loop (`compact`/`copy`/`exit`/`clear`/
244//     `user-input`) signal it via `ctx.tx.send(TuiMessage::…)`;
245//   - everything else mutates the chat container + requests a render directly.
246
247/// The borrowed world a slash command runs against. All fields are `Arc` (or a
248/// cheap `String` snapshot), so one `CommandContext` clones freely into each
249/// command without per-capture ceremony — this struct is exactly the set of
250/// `*_for_cb` clones the old submit closure used to make individually.
251#[derive(Clone)]
252struct CommandContext {
253    chat: Arc<Container>,
254    tui: Arc<TuiAltScreen>,
255    tx: mpsc::UnboundedSender<TuiMessage>,
256    state: Arc<TuiState>,
257    editor: Arc<Editor>,
258    editor_container: Arc<Container>,
259    lane: Arc<dyn AgentLane>,
260    model_catalog: Arc<Vec<rpi_ai::Model>>,
261    cwd: std::path::PathBuf,
262    /// Loaded resources for /context; refreshed after successful /reload.
263    resources: Arc<Mutex<rpi_harness::types::AgentHarnessResources>>,
264    /// Context file section retained in the startup header, if visible.
265    startup_context: Option<Arc<Container>>,
266    /// B5d: the reload context `/reload` drives. `Arc<ReloadContext>` so the
267    /// blocking submit thread can cheaply clone it into the `ReloadCommand`
268    /// without an `.await` (the command can't drive reload directly — it signals
269    /// the main loop via `TuiMessage::ReloadExtensions`, which awaits the shared
270    /// `reload_extension_resources` routine on the async runtime).
271    reload_context: Arc<crate::session::ReloadContext>,
272    /// The product-layer session (`AgentSession`). Commands that need durable
273    /// reads, export, or usage stats go through this instead of reaching into
274    /// the harness directly, so the product surface is one object.
275    session: Arc<crate::agent_session::AgentSession>,
276}
277
278/// One slash command.
279trait SlashCommand: Send + Sync {
280    /// Canonical name, with the leading `/` (e.g. "/model").
281    fn name(&self) -> &str;
282    /// Aliases, also `/`-prefixed. Matched alongside `name()` during dispatch.
283    /// Use [`SlashCommand::alias_visible`] to also surface an alias in the
284    /// `/`-autocomplete list (most aliases stay hidden).
285    fn aliases(&self) -> &'static [&'static str] {
286        &[]
287    }
288    /// Whether the canonical name appears in the `/` autocomplete list. Hidden
289    /// commands (`/context`, `/name`, …) return `false`.
290    fn visible(&self) -> bool {
291        true
292    }
293    /// Aliases that should also appear in the `/` autocomplete list. Defaults to
294    /// none — most aliases (`/q`, `/m`, `/think`, `/resume`, `/v`) are kept off
295    /// the list to keep it short. `/new` and `/quit` override this to surface.
296    fn alias_visible(&self) -> &'static [&'static str] {
297        &[]
298    }
299    /// Description shown in autocomplete and `/help`. A non-empty description is
300    /// required to surface in autocomplete even when `visible()` is true.
301    fn description(&self) -> &'static str {
302        ""
303    }
304    fn description_owned(&self) -> String {
305        self.description().to_string()
306    }
307    /// Execute the command. Only invoked for inputs starting with `/` whose
308    /// first token matches `name()` or an alias. `args` is the whitespace-
309    /// trimmed remainder after the command token ("" when none). Must stay
310    /// synchronous (see the module-level note) — async work goes through
311    /// `ctx.tx.send(TuiMessage::…)` or `tokio::spawn`.
312    fn execute(&self, ctx: &CommandContext, args: &str);
313}
314
315/// Holds all registered slash commands; the single source of truth for both
316/// dispatch and the autocomplete list.
317struct CommandRegistry {
318    commands: Vec<Arc<dyn SlashCommand>>,
319}
320
321impl CommandRegistry {
322    fn new() -> Self {
323        Self {
324            commands: Vec::new(),
325        }
326    }
327
328    fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
329        self.commands.push(cmd);
330    }
331
332    /// Find the command whose `name()` or an alias matches `token` (e.g. "/q").
333    /// `token` is the first whitespace-delimited word of the input, `/`-prefixed.
334    fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
335        self.commands
336            .iter()
337            .find(|c| c.name() == token || c.aliases().contains(&token))
338    }
339
340    /// The autocomplete entries, derived from the registry so it can never drift
341    /// from what dispatch recognizes. Surfaces the canonical name when
342    /// `visible()` + non-empty description, plus any `alias_visible()` entries.
343    /// Order = registration order; built-ins are registered before templates,
344    /// so they win on a fuzzy tie (unchanged).
345    fn visible_entries(&self) -> Vec<SlashCommandEntry> {
346        let mut out: Vec<SlashCommandEntry> = Vec::new();
347        for c in &self.commands {
348            let description = c.description_owned();
349            if c.visible() && !description.is_empty() {
350                out.push(SlashCommandEntry {
351                    name: c.name().into(),
352                    description: description.clone(),
353                });
354            }
355            // Surfaced aliases share the command's description.
356            for alias in c.alias_visible() {
357                out.push(SlashCommandEntry {
358                    name: (*alias).into(),
359                    description: description.clone(),
360                });
361            }
362        }
363        out
364    }
365}
366
367/// Dispatch a ui_prompt_start event to extensions.
368fn dispatch_ui_prompt_event(state: &TuiState) {
369    use rpi_plugin_sdk::EventTag;
370
371    if let Ok(session) = state.extension_session.lock() {
372        if let Some(snapshot) = session.snapshot_arc() {
373            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
374        }
375    }
376}
377
378/// Dispatch a ui_prompt_end event to extensions.
379fn dispatch_ui_prompt_event_end(state: &TuiState) {
380    use rpi_plugin_sdk::EventTag;
381
382    if let Ok(session) = state.extension_session.lock() {
383        if let Some(snapshot) = session.snapshot_arc() {
384            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
385        }
386    }
387}
388
389/// Canonical key name for a key event, matching the vocabulary
390/// [`parse_configured_key`] accepts (`"space"`, `"enter"`, `"f1"`, `"a"`).
391/// `None` for keys that cannot be named (media keys, unknown codes).
392fn key_shortcut_name(code: KeyCode) -> Option<&'static str> {
393    Some(match code {
394        KeyCode::Char(' ') => "space",
395        KeyCode::Enter => "enter",
396        KeyCode::Esc => "escape",
397        KeyCode::Tab => "tab",
398        KeyCode::BackTab => "backtab",
399        KeyCode::Backspace => "backspace",
400        KeyCode::Delete => "delete",
401        KeyCode::Up => "up",
402        KeyCode::Down => "down",
403        KeyCode::Left => "left",
404        KeyCode::Right => "right",
405        KeyCode::Home => "home",
406        KeyCode::End => "end",
407        KeyCode::PageUp => "pageup",
408        KeyCode::PageDown => "pagedown",
409        _ => return None,
410    })
411}
412
413/// Lower-case name for a printable ASCII key (`a`, `1`, `,`), or `None` when the
414/// key needs a dedicated name from [`key_shortcut_name`].
415fn char_shortcut_name(ch: char) -> Option<String> {
416    if ch.is_ascii_graphic() {
417        Some(ch.to_ascii_lowercase().to_string())
418    } else {
419        None
420    }
421}
422
423/// Route one key event to extension `Input` handlers when the key is claimed as
424/// a shortcut. Returns `true` when the key was claimed (the caller must then
425/// skip normal editor handling).
426///
427/// Only keys an extension explicitly claimed via `register_shortcut` are
428/// routed. Function keys also work with a draft; other shortcuts require an
429/// empty editor so they never steal ordinary typing. Press **and** Release (and Repeat) are dispatched, which is
430/// what lets a push-to-talk extension measure how long a key was held — the
431/// normal editor path deliberately drops Release.
432fn dispatch_key_event(state: &TuiState, key: &KeyEvent, editor: &Editor) -> bool {
433    use rpi_plugin_sdk::EventTag;
434
435    let owned_release = key.kind == KeyEventKind::Release
436        && state
437            .extension_claimed_keys
438            .lock()
439            .unwrap()
440            .remove(&key.code);
441    // Modal input owns its keys, including function keys and cancellation.
442    // The draft editor can be empty even while the dialog has text/focus.
443    if !owned_release
444        && (state.extension_dialog_open() || state.selector_open() || state.search.is_active())
445    {
446        return false;
447    }
448
449    let name = key_shortcut_name(key.code)
450        .map(str::to_string)
451        .or_else(|| match key.code {
452            KeyCode::Char(ch) => char_shortcut_name(ch),
453            KeyCode::F(n) => Some(format!("f{n}")),
454            _ => None,
455        });
456    let Some(name) = name else {
457        return false;
458    };
459
460    let Ok(session) = state.extension_session.lock() else {
461        return false;
462    };
463    let Some(snapshot) = session.snapshot_arc() else {
464        return false;
465    };
466    // Nothing claimed this key (or nobody subscribes to Input) — not ours.
467    if !snapshot.has_shortcut(&name) || snapshot.handlers_for(EventTag::Input).is_empty() {
468        return false;
469    }
470    // Function keys do not insert text. Other keys belong to the draft editor
471    // while composing (space especially).
472    if !owned_release && !editor.get_text().is_empty() && !matches!(key.code, KeyCode::F(_)) {
473        return false;
474    }
475
476    let kind = match key.kind {
477        KeyEventKind::Press => "press",
478        KeyEventKind::Repeat => "repeat",
479        KeyEventKind::Release => "release",
480    };
481    let payload = serde_json::json!({
482        "type": "key",
483        "key": name,
484        "kind": kind,
485        "ctrl": key.modifiers.contains(KeyModifiers::CONTROL),
486        "alt": key.modifiers.contains(KeyModifiers::ALT),
487        "shift": key.modifiers.contains(KeyModifiers::SHIFT),
488    })
489    .to_string();
490    // The handler decides whether it actually wants the key: it returns the
491    // `CLAIMED` code while its feature is active and `CONTINUE` otherwise, so a
492    // registered-but-disabled shortcut (push-to-talk toggled off) still lets the
493    // key reach the editor normally.
494    let claimed =
495        rpi_extensions::dispatch_data_event_claiming(&snapshot, EventTag::Input, &payload);
496    if claimed && key.kind == KeyEventKind::Press {
497        state
498            .extension_claimed_keys
499            .lock()
500            .unwrap()
501            .insert(key.code);
502    }
503    claimed
504}
505
506/// Resolve a command for a `/`-prefixed input and run it when one matches.
507///
508/// Unknown slash-prefixed input is deliberately left for the normal prompt
509/// path. In particular, the upstream skill-command behavior keeps an unknown
510/// `/skill:name` intact so the model can still handle it; it must not be
511/// converted into a local error before input handlers or the agent see it.
512/// Returns `true` only when a registered slash command handled the input.
513fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) -> bool {
514    let mut parts = text.split_whitespace();
515    let token = parts.next().unwrap_or("");
516    let args = parts.collect::<Vec<_>>().join(" ");
517    match registry.find(token) {
518        Some(cmd) => {
519            cmd.execute(ctx, &args);
520            true
521        }
522        None => false,
523    }
524}
525
526/// Current wall-clock time in milliseconds since the Unix epoch. Used for
527/// `bashExecution` message timestamps (agent-loop messages carry real times, so
528/// the transcript record must too).
529fn now_ms() -> i64 {
530    std::time::SystemTime::now()
531        .duration_since(std::time::UNIX_EPOCH)
532        .map(|d| d.as_millis() as i64)
533        .unwrap_or(0)
534}
535
536/// Parse a submitted line into a user shell command.
537///
538/// Mirrors upstream's `text.startsWith("!")` handling
539/// (`interactive-mode.ts:3225`): `!cmd` runs and is recorded in the session
540/// context, `!!cmd` is excluded from it. Returns `None` for a bare `!` / `!!`
541/// (or whitespace only), so the line falls through to the normal prompt path.
542fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
543    let rest = text.strip_prefix('!')?;
544    let (command, exclude_from_context) = match rest.strip_prefix('!') {
545        Some(command) => (command, true),
546        None => (rest, false),
547    };
548    let command = command.trim();
549    if command.is_empty() {
550        return None;
551    }
552    Some((command, exclude_from_context))
553}
554
555/// Run a `!command` submitted from the editor.
556///
557/// The command executes out-of-band — it never reaches the model. Output
558/// streams into a [`BashExecutionComponent`] in the transcript and the
559/// terminal result is handed to the main loop, which persists it as a
560/// `bashExecution` message (skipped from context for `!!`). Mirrors
561/// `handleBashCommand` (`interactive-mode.ts:6729`).
562fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
563    let comp = Arc::new(BashExecutionComponent::new_with_context(
564        command,
565        exclude_from_context,
566    ));
567    comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
568    ctx.chat.add_child(comp.clone());
569
570    // Register the cancellation slot before spawning so an Esc arriving on the
571    // key thread can never miss the run (upstream's `_bashAbortControllers`).
572    let cancel = ctx.state.begin_user_bash();
573    ctx.tui.request_render(false);
574
575    let env: Arc<dyn rpi_tools::ExecutionEnv> =
576        Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
577    let tui = ctx.tui.clone();
578    let tx = ctx.tx.clone();
579    let cwd = ctx.cwd.clone();
580    let command_owned = command.to_string();
581
582    tokio::spawn(async move {
583        let comp_chunk = comp.clone();
584        let tui_chunk = tui.clone();
585        let on_chunk: Box<
586            dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
587        > = Box::new(move |_chunk, get_progress| {
588            // The capture layer reports the complete tail captured so far, not
589            // a delta; `append_output` replaces its snapshot accordingly.
590            comp_chunk.append_output(&get_progress().output);
591            tui_chunk.request_render(false);
592        });
593        let options = rpi_tools::shell_output::ShellCaptureOptions {
594            cwd: Some(cwd),
595            cancel: Some(&cancel),
596            on_chunk: Some(on_chunk),
597            return_execution_errors: true,
598            ..Default::default()
599        };
600        let result =
601            rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
602                .await;
603
604        // `Ok` carries the real exit code/cancellation; `Err` is a transport
605        // level failure (spawn failure, invalid env) which we surface inline.
606        let (exit_code, cancelled, output, truncated, full_output_path) = match result {
607            Ok(capture) => (
608                capture.exit_code,
609                capture.cancelled,
610                capture.output.clone(),
611                capture.truncated,
612                capture
613                    .full_output_path
614                    .as_ref()
615                    .map(|path| path.to_string_lossy().into_owned()),
616            ),
617            Err(error) => (
618                None,
619                false,
620                format!("Bash command failed: {error}"),
621                false,
622                None,
623            ),
624        };
625        comp.append_output(&output);
626        comp.set_complete(
627            exit_code,
628            cancelled,
629            BashTruncation {
630                truncated,
631                full_output_path: full_output_path.clone(),
632            },
633        );
634        tui.render_now(false);
635
636        let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
637            command: command_owned,
638            output,
639            exit_code,
640            cancelled,
641            truncated,
642            full_output_path,
643            exclude_from_context,
644        }));
645    });
646}
647
648// ===========================================================================
649// Channel + helpers
650// ===========================================================================
651
652/// Message type for communication between the key/callback threads and the
653/// main async loop.
654enum TuiMessage {
655    UserInput(String, Vec<rpi_ai::types::ImageContent>),
656    OpenTree,
657    NavigateTree(String),
658    Exit,
659    /// Clear the transcript (from `/clear`).
660    ClearChat,
661    /// Compact the conversation (from `/compact`).
662    Compact,
663    /// Copy the last assistant reply to the clipboard (from `/copy`).
664    Copy,
665    /// Hot-switch to another saved session (from the `/session` selector):
666    /// the payload is the session id the selector's item value carried.
667    SwitchSession(String),
668    /// Export the current session to a markdown file (from `/export`).
669    ExportSession,
670    /// Export session with specific format
671    ExportSessionWithFormat(crate::export::ExportFormat),
672    /// Show live usage/context stats (from `/usage` and `/context`).
673    ShowUsage,
674    /// Fork the current session into a new one and switch to it (from `/fork`).
675    ForkSession,
676    /// Rename the current session (from `/name <name>`).
677    SetSessionName(String),
678    /// Import a JSONL session file into the session dir and switch to it
679    /// (from `/import <path>`).
680    ImportSession(String),
681    /// Share the current session (`/share`): `gh gist create` when the gh CLI
682    /// is available, otherwise copy the transcript to the clipboard.
683    ShareSession,
684    /// `/reload` — re-run extension + resource discovery into the live harness
685    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
686    /// mailbox) signal the main loop, which awaits
687    /// `reload_extension_resources` on the async runtime.
688    ReloadExtensions,
689    /// Result returned after Ctrl+G edits a temporary file in an external
690    /// editor. Handling it on the async loop keeps editor mutation single-
691    /// threaded with the rest of the TUI state.
692    ExternalEditorResult(Result<String, String>),
693    /// A user-initiated `!command` / `!!command` shell run finished. Routed to
694    /// the main loop so the `bashExecution` transcript record is persisted in
695    /// order relative to agent runs (upstream's `recordBashResult` /
696    /// `_pendingBashMessages`).
697    UserBashFinished(UserBashReport),
698}
699
700/// Terminal result of a user-initiated `!command` shell run.
701///
702/// Carries everything needed to build the persisted `bashExecution` message
703/// plus the `!!` context-exclusion flag. See `start_user_bash`.
704struct UserBashReport {
705    command: String,
706    output: String,
707    exit_code: Option<i32>,
708    cancelled: bool,
709    truncated: bool,
710    full_output_path: Option<String>,
711    exclude_from_context: bool,
712}
713
714/// Extract the concatenated text content from an assistant message (mirrors
715/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
716fn assistant_text(msg: &AssistantMessage) -> String {
717    msg.content
718        .iter()
719        .filter_map(|c| match c {
720            Content::Text(t) => Some(t.text.clone()),
721            _ => None,
722        })
723        .collect()
724}
725
726/// The user message's text (Text content or the text blocks of a Blocks
727/// payload — images are skipped, consistent with the v1 text-only prompt path).
728fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
729    msg.content
730        .iter()
731        .filter_map(|c| match c {
732            Content::Text(t) => Some(t.text.clone()),
733            _ => None,
734        })
735        .collect::<Vec<_>>()
736        .join("\n")
737}
738
739fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
740    match &msg.content {
741        rpi_ai::types::UserContent::Text(s) => s.clone(),
742        rpi_ai::types::UserContent::Blocks(blocks) => blocks
743            .iter()
744            .filter_map(|c| match c {
745                Content::Text(t) => Some(t.text.clone()),
746                _ => None,
747            })
748            .collect(),
749    }
750}
751
752/// The catalog allowed in the Ctrl+P cycle: the `/scoped-models` set from
753/// settings.json when present, otherwise every model. The current model is
754/// always included (fallback) so cycling can never strand the user off-scope.
755// ===========================================================================
756// interactive_tui — the entry point
757// ===========================================================================
758
759#[derive(Debug, PartialEq)]
760struct TuiStartupSettings {
761    editor_padding_x: usize,
762    autocomplete_max_visible: usize,
763    hide_thinking: bool,
764    quiet_startup: bool,
765    show_terminal_progress: bool,
766    /// Render images inline (`terminal.showImages` / flat `showImages`).
767    show_images: bool,
768    /// Transcript notices for prompt-cache costs (`showCacheMissNotices`).
769    cache_miss_notices: bool,
770}
771
772fn preferred_project_setting<T>(
773    project_settings: &[crate::settings::Settings],
774    field: impl Fn(&crate::settings::Settings) -> Option<T>,
775) -> Option<T> {
776    project_settings.iter().find_map(field)
777}
778
779fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
780    verbose || !quiet_startup
781}
782
783/// `project_settings` is ordered by precedence; an explicit `Some`
784/// wins even when the value is `false` or zero.
785fn resolve_tui_startup_settings(
786    global: &crate::settings::Settings,
787    project_settings: &[crate::settings::Settings],
788    project_trusted: bool,
789) -> TuiStartupSettings {
790    let project_settings = if project_trusted {
791        project_settings
792    } else {
793        &[]
794    };
795    let editor_padding_x =
796        preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
797            .or(global.editor_padding_x)
798            .unwrap_or(1)
799            .min(16);
800    let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
801        settings.autocomplete_max_visible
802    })
803    .or(global.autocomplete_max_visible)
804    .unwrap_or(5)
805    .clamp(1, 20);
806    let hide_thinking =
807        preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
808            .or(global.hide_thinking_block)
809            .unwrap_or(false);
810    let quiet_startup =
811        preferred_project_setting(project_settings, |settings| settings.quiet_startup)
812            .or(global.quiet_startup)
813            .unwrap_or(false);
814    let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
815        settings.show_terminal_progress()
816    })
817    .or_else(|| global.show_terminal_progress())
818    .unwrap_or(true);
819    let show_images =
820        preferred_project_setting(project_settings, |settings| settings.show_images())
821            .or_else(|| global.show_images())
822            .unwrap_or(true);
823    let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
824        settings.show_cache_miss_notices
825    })
826    .or(global.show_cache_miss_notices)
827    .unwrap_or(false);
828
829    TuiStartupSettings {
830        editor_padding_x,
831        autocomplete_max_visible,
832        hide_thinking,
833        quiet_startup,
834        show_terminal_progress,
835        show_images,
836        cache_miss_notices,
837    }
838}
839
840/// TUI-based interactive mode.
841///
842/// `event_rx` carries the live `AgentEvent` stream (installed by
843/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
844/// fn), it falls back to a blocking, await-final-text path.
845///
846/// `model_catalog` is the read-only catalog the `/model` selector displays.
847///
848/// This implementation mirrors the TypeScript `InteractiveMode` class:
849/// build the layout root once, drain `AgentEvent`s into UI mutations that
850/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
851/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
852/// autocomplete are layered on via the editor-container swap pattern.
853pub async fn interactive_tui(
854    harness: &AgentHarness,
855    event_rx: Option<broadcast::Receiver<AgentEvent>>,
856    args: &Args,
857    model_catalog: Vec<rpi_ai::Model>,
858    initial: Option<String>,
859    extra_messages: &[String],
860    initial_images: Vec<rpi_ai::types::ImageContent>,
861    theme: Option<&str>,
862    _no_themes: bool,
863    reload_context: &crate::session::ReloadContext,
864) -> i32 {
865    let lane: Arc<dyn AgentLane> = harness.lane("main");
866    // Reuse the startup snapshot captured before provider/session setup. An
867    // extension may mutate the process cwd while loading; that must not change
868    // which project settings or package update roots this TUI observes.
869    let cwd = reload_context.cwd.clone();
870    let saved_settings = crate::settings::load_settings().unwrap_or_default();
871    let project_trusted = reload_context.project_trusted;
872    let project_settings = if project_trusted {
873        crate::settings::load_project_settings(&cwd)
874    } else {
875        Vec::new()
876    };
877    let tui_settings =
878        resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
879    let editor_padding_x = tui_settings.editor_padding_x;
880    let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
881    let mut hide_thinking = tui_settings.hide_thinking;
882    let show_terminal_progress = tui_settings.show_terminal_progress;
883    let show_images = tui_settings.show_images;
884    let cache_miss_notices = tui_settings.cache_miss_notices;
885    let quiet_startup = tui_settings.quiet_startup;
886    // rpi only checks its own update channel.
887    // Session display preferences (durable, latest-wins) override the
888    // settings-file defaults. These are per-session facts stored as custom
889    // entries (see `rpi_harness::session::values`), so resuming a session with
890    // `-c`/`-r` restores the thinking/tool-output display the user last chose
891    // there — independent of global settings. Absent keys leave the settings
892    // value untouched.
893    let session_values = rpi_harness::session::SessionValues::load(harness.session())
894        .await
895        .unwrap_or_default();
896    if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
897        hide_thinking = persisted;
898    }
899
900    // Resolve the active model once, up front. The full id feeds the TuiState
901    // tracking field + the selectors/key loop (which run on a blocking thread
902    // and can't await `lane.get_model()`); the provider-qualified label feeds
903    // the footer, matching Pi's `(provider) model` display.
904    let lane_model = lane.get_model().await.ok();
905    let lane_model_id = lane_model
906        .as_ref()
907        .map(|model| model.id.clone())
908        .unwrap_or_default();
909    let model_name = lane_model
910        .as_ref()
911        .map(|model| format!("({}) {}", model.provider, short_model_name(&model.id)))
912        .unwrap_or_else(|| short_model_name(&lane_model_id));
913
914    // Snapshot startup capabilities for the welcome screen. Both accessors
915    // return defensive clones, so rendering this summary does not retain a
916    // harness lock or trigger a second resource scan.
917    let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
918    // AgentHarness uses an empty active-name list as the default "all tools"
919    // state. Do not expose that implementation sentinel as `Tools (0) none`
920    // in the welcome banner (it is especially visible on the first prompt).
921    if active_tool_names.is_empty() {
922        active_tool_names = harness
923            .get_tools()
924            .await
925            .unwrap_or_default()
926            .into_iter()
927            .map(|tool| tool.tool.schema().name.clone())
928            .collect();
929    }
930    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
931    let skill_names: Vec<String> = resources_snapshot
932        .skills
933        .as_deref()
934        .unwrap_or(&[])
935        .iter()
936        .map(|skill| skill.name.clone())
937        .collect();
938
939    // Channel between the key/callback threads and the main async loop.
940    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
941
942    // Apply the saved theme before constructing transcript components. Some
943    // components keep styled text, so doing this after the welcome banner left
944    // the first screen in the dark palette until it was rebuilt.
945    let theme_manager = Arc::new(ThemeManager::new());
946    if let Some(preset) = match theme {
947        Some("light") => Some(ThemePreset::Light),
948        Some("monochrome") => Some(ThemePreset::Monochrome),
949        Some("dark") => Some(ThemePreset::Dark),
950        _ => None,
951    } {
952        apply_theme_preset(preset);
953        theme_manager.apply_preset(preset);
954    }
955
956    // ---- TUI + containers ----
957    let terminal = Box::new(ProcessTerminal::new());
958    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
959    // Plugin `ask_user` prompts (runtime action 17). Attaching marks this
960    // session as interactive so a detached/headless host fails loudly instead
961    // of returning a fabricated answer. The mailbox is session-scoped (held in
962    // `ReloadContext`) so in-flight prompts survive a `/reload`.
963    let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
964    ask_user_bridge.attach();
965    tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
966
967    let chat_container = Arc::new(Container::new());
968    let startup_context = if should_show_startup_listing(args.verbose, quiet_startup) {
969        Some(add_welcome_message_with_capabilities(
970            &chat_container,
971            &active_tool_names,
972            &skill_names,
973            &resources_snapshot.context_files,
974        ))
975    } else {
976        None
977    };
978
979    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
980    // banner once, then write the sentinel. The banner covers the theme hint;
981    // the theme stays pickable via `/theme`. See `extras.rs`.
982    // Capture this before first-time setup writes its sentinel.
983    let existing_profile = crate::extras::setup_done()
984        || saved_settings.last_changelog_version.is_some()
985        || saved_settings.last_rpi_changelog_version.is_some();
986    crate::extras::maybe_first_time_setup(&chat_container);
987    let collapse_changelog =
988        preferred_project_setting(&project_settings, |settings| settings.collapse_changelog)
989            .or(saved_settings.collapse_changelog)
990            .unwrap_or(false);
991    maybe_add_startup_changelog(
992        &harness,
993        &chat_container,
994        &saved_settings,
995        collapse_changelog,
996        existing_profile,
997    )
998    .await;
999
1000    // A --continue/--resume/--session launch opens on an existing JSONL
1001    // session — render its prior user/assistant transcript so the user sees
1002    // where they left off (tool executions are skipped: their live display
1003    // belongs to the current run, and replaying old results would be noise).
1004    let initial_transformer = build_markdown_transformer(
1005        reload_context
1006            .extension_session
1007            .lock()
1008            .unwrap()
1009            .snapshot_arc(),
1010    );
1011    // A normal launch creates a fresh session and must not replay records from
1012    // another/project harness. Only explicit restore/fork modes render prior
1013    // conversation history. This fixes stale prompts appearing every startup.
1014    if launch_restores_history(args) {
1015        render_session_history(
1016            &harness,
1017            &chat_container,
1018            initial_transformer.clone(),
1019            Some(reload_context.extension_session.clone()),
1020            show_images,
1021        )
1022        .await;
1023    }
1024
1025    // `document_container` wraps the welcome header + chat so the scrollview
1026    // follows the whole transcript (mirrors TS `documentContainer`).
1027    let document_container = Arc::new(Container::new());
1028    document_container.add_child(chat_container.clone());
1029    // Add bottom padding to the output area for visual breathing room.
1030    document_container.add_child(Arc::new(Spacer::new(1)));
1031
1032    let pet_document = Arc::new(pet::PetDocument::new(
1033        document_container.clone(),
1034        reload_context.ext_status.clone(),
1035    ));
1036    let scroll_view = Arc::new(ScrollView::new(
1037        pet_document.clone(),
1038        ScrollViewOptions {
1039            follow: FollowMode::End,
1040            primary: true,
1041            overscroll: OverscrollMode::Chain,
1042            // upstream keeps transcript chrome out of the way. Our Auto mode
1043            // has no hide timer yet and therefore became effectively permanent
1044            // after the first wheel event, unlike the upstream experience.
1045            scrollbar: ScrollbarMode::Hidden,
1046            ..Default::default()
1047        },
1048    ));
1049
1050    // ---- Editor ----
1051    // Bordered box matching upstream: no `> ` prompt, no placeholder — the
1052    // editor renders full-width `─` top/bottom borders with padding-only lines
1053    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
1054    let keybindings = configured_keybindings();
1055    crate::transcript_navigation::install(&tui, keybindings.clone());
1056    let editor = Arc::new(Editor::new(
1057        EditorOptions {
1058            padding_x: editor_padding_x,
1059            autocomplete_max_visible,
1060            ..Default::default()
1061        },
1062        EditorStyle::default(),
1063        keybindings.clone(),
1064    ));
1065
1066    // Bash-mode border: a `!`-prefixed draft recolors the editor border to the
1067    // bash accent (upstream's `updateEditorBorderColor` / `isBashMode`). The
1068    // editor fires `on_change` for keystrokes and for programmatic
1069    // `clear`/`set_text`, so the color also resets after a `!command` submits.
1070    {
1071        let editor_for_change = editor.clone();
1072        editor.on_change(Arc::new(move |text: &str| {
1073            let colors = current_theme().colors;
1074            let is_bash = text.trim_start().starts_with('!');
1075            editor_for_change.set_border_color(if is_bash {
1076                Some(colors.bash_mode)
1077            } else {
1078                None
1079            });
1080        }));
1081    }
1082
1083    // ---- Footer + status ----
1084    let footer = Arc::new(FooterComponent::new());
1085    footer.set_model(&model_name);
1086    if let Ok(level) = lane.get_thinking_level().await {
1087        footer.set_thinking_level(Some(thinking_level_name(level)));
1088    }
1089    footer.set_cwd(&cwd.to_string_lossy());
1090    footer.set_git_branch(git_branch_for(&cwd).as_deref());
1091    if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
1092        footer.set_context_window(m.context_window as i64);
1093    }
1094    footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Clear/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+P: Cycle | Ctrl+O: Expand/collapse tools | Ctrl+T: Show/hide thinking | /help");
1095
1096    // Live git-branch refresh (native fs-watch on `.git/HEAD`): a checkout or
1097    // commit updates the footer's branch without a manual refresh.
1098    let _branch_watcher = find_git_head_path(&cwd).map(|head| {
1099        let footer_ref = footer.clone();
1100        let cwd_ref = cwd.clone();
1101        crate::fs_watch::watch_path(head, move || {
1102            footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
1103        })
1104    });
1105
1106    let status_container = Arc::new(Container::new());
1107    let loader = Arc::new(Loader::with_text("Working…"));
1108
1109    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
1110    // Prompt templates discovered at session build (Part A2) are surfaced as
1111    // `/`-prefixed entries alongside the built-in slash commands: typing
1112    // `/<name>` in the editor expands the template (mirrors pi
1113    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
1114    // the template's frontmatter description (or a fallback) so the autocomplete
1115    // popover shows what each template does.
1116    //
1117    // Build autocomplete from the initial templates. /context shares a resource
1118    // snapshot that the async reload handler refreshes, so its blocking command
1119    // can list the current files without awaiting the harness accessor.
1120    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
1121        .prompt_templates
1122        .clone()
1123        .unwrap_or_default()
1124        .iter()
1125        .map(|t| SlashCommandEntry {
1126            name: format!("/{}", t.name),
1127            description: t
1128                .description
1129                .clone()
1130                .unwrap_or_else(|| "Expand prompt template".to_string()),
1131        })
1132        .collect();
1133    let resources_arc = Arc::new(Mutex::new(resources_snapshot));
1134    // Build the built-in command registry once — the single source of truth for
1135    // both dispatch and the built-in autocomplete entries. The discovered
1136    // prompt-template commands are merged into the autocomplete list separately
1137    // (they dispatch via template expansion, not the registry); built-ins come
1138    // first so they win on a fuzzy tie.
1139    let mut command_registry = build_builtin_registry();
1140    register_extension_commands(
1141        &mut command_registry,
1142        reload_context.extension_session.clone(),
1143    );
1144    let registry = Arc::new(command_registry);
1145    let mut all_slash_commands = registry.visible_entries();
1146    all_slash_commands.extend(template_slash_commands);
1147    let autocomplete = AutocompleteManager::new();
1148    {
1149        let mut combined = CombinedAutocompleteProvider::new();
1150        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
1151            all_slash_commands,
1152        )));
1153        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
1154            cwd.clone(),
1155        )));
1156        autocomplete.set_provider(Arc::new(combined));
1157    }
1158    let autocomplete_container = Arc::new(Container::new());
1159
1160    let tool_outputs_expanded = session_values
1161        .get_as::<bool>("display.tool_outputs_expanded")
1162        .unwrap_or(false);
1163    let state = Arc::new(TuiState {
1164        current_assistant: Arc::new(std::sync::Mutex::new(None)),
1165        tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
1166        bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
1167        hide_thinking: Arc::new(std::sync::Mutex::new(hide_thinking)),
1168        tool_outputs_expanded: Arc::new(std::sync::Mutex::new(tool_outputs_expanded)),
1169        show_terminal_progress,
1170        status: std::sync::Mutex::new(RunStatus::Idle),
1171        ext_status: reload_context.ext_status.clone(),
1172        ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
1173        editor_text: reload_context.editor_text.clone(),
1174        auto_send: std::sync::Mutex::new(None),
1175        last_editor_text: std::sync::Mutex::new(String::new()),
1176        programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
1177        js_preparation_cancel: std::sync::Mutex::new(None),
1178        user_bash_cancel: std::sync::Mutex::new(None),
1179        pending_bash_messages: std::sync::Mutex::new(Vec::new()),
1180        footer: footer.clone(),
1181        status_container: status_container.clone(),
1182        chat_container: chat_container.clone(),
1183        loader: loader.clone(),
1184        editor: editor.clone(),
1185        last_assistant_text: std::sync::Mutex::new(String::new()),
1186        active_selector: std::sync::Mutex::new(None),
1187        provider_form: std::sync::Mutex::new(None),
1188        active_provider_input: std::sync::Mutex::new(None),
1189        active_provider_select: std::sync::Mutex::new(None),
1190        active_extension_editor: std::sync::Mutex::new(None),
1191        active_extension_input: std::sync::Mutex::new(None),
1192        active_extension_cancel: std::sync::Mutex::new(None),
1193        autocomplete,
1194        autocomplete_container: autocomplete_container.clone(),
1195        autocomplete_max_visible,
1196        autocomplete_selection: std::sync::Mutex::new(0),
1197        pending_images: std::sync::Mutex::new(Vec::new()),
1198        theme_manager,
1199        tui: Some(tui.clone()),
1200        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
1201        show_images: std::sync::Mutex::new(show_images),
1202        cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
1203        history: std::sync::Mutex::new(Vec::new()),
1204        history_index: std::sync::Mutex::new(-1),
1205        history_draft: std::sync::Mutex::new(None),
1206        cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
1207        scoped_edit: std::sync::Mutex::new(None),
1208        markdown_transformer: Arc::new(std::sync::Mutex::new(initial_transformer)),
1209        extension_claimed_keys: Mutex::new(Default::default()),
1210        extension_session: reload_context.extension_session.clone(),
1211        search: Arc::new(AltScreenSearch::new()),
1212        search_bar: Arc::new(SearchBar::new()),
1213        pending_container: Arc::new(Container::new()),
1214        dequeue_hint: pending_dequeue_hint(&keybindings),
1215        pending_snapshot: std::sync::Mutex::new(
1216            rpi_harness::agent_harness::QueuedMessages::default(),
1217        ),
1218        selection_start: std::sync::Mutex::new(None),
1219        selection_end: std::sync::Mutex::new(None),
1220    });
1221
1222    let _compaction_watcher = harness.watch({
1223        let state = Arc::clone(&state);
1224        let tui = Arc::clone(&tui);
1225        move |event| {
1226            if let rpi_harness::events::HarnessEvent::CompactionProgress(event) = event {
1227                if event.lane != "main" {
1228                    return;
1229                }
1230                if event.active {
1231                    if state.show_terminal_progress {
1232                        state.editor.set_working(Some(WorkingState {
1233                            frame: 0,
1234                            started_at: std::time::Instant::now(),
1235                            message: if event.manual {
1236                                "Compacting context… (Esc to cancel)"
1237                            } else {
1238                                "Auto-compacting… (Esc to cancel)"
1239                            }
1240                            .into(),
1241                        }));
1242                    }
1243                } else {
1244                    let status = *state.status.lock().unwrap();
1245                    state.apply_status(status);
1246                }
1247                tui.request_render(false);
1248            }
1249        }
1250    });
1251
1252    // Capture the model catalog + cwd for the selector builders + the key loop
1253    // (the callbacks fire on blocking threads and need owned data).
1254    let model_catalog_arc = Arc::new(model_catalog.clone());
1255
1256    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
1257    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
1258    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
1259    //
1260    // The scrollview gets `basis(0)` so the constrained stack allocator starts
1261    // it at zero height and grows it to fill the space the dock does not need
1262    // — this keeps the dock (editor borders + footer) pinned to the bottom and
1263    // never shrinks it below the editor's 3 rows (top + content + bottom). The
1264    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
1265    // never clip the input panel below its minimum.
1266    let editor_container = Arc::new(Container::new());
1267    editor_container.add_child(editor.clone());
1268
1269    let dock = Arc::new(VStack::from_children(vec![
1270        StackChild::Entry(StackEntry::new(Arc::new(panels::RegularPanels {
1271            mailbox: reload_context.ext_status.clone(),
1272            enabled: matches!(args.tui_mode, crate::args::TuiMode::Regular),
1273        }))),
1274        StackChild::Entry(StackEntry::new(state.pending_container().clone())),
1275        StackChild::Entry(StackEntry::new(status_container.clone())),
1276        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
1277        // NOTE: no Spacer here. The scroll content already ends with a blank line
1278        // (`document_container`), and a second one in the dock stacked into a
1279        // two-row gap between the last message and the input box. One row of
1280        // breathing room is the whole safe area now; the transcript keeps its
1281        // own padding so a long message never touches the editor border.
1282        StackChild::Entry(
1283            StackEntry::new(editor_container.clone())
1284                .shrink(0)
1285                .min_size(3),
1286        ),
1287        StackChild::Entry(StackEntry::new(state.search_bar.clone())),
1288        StackChild::Entry(StackEntry::new(footer.clone())),
1289        StackChild::Entry(StackEntry::new(Arc::new(panels::FooterPanels(
1290            reload_context.ext_status.clone(),
1291        )))),
1292    ]));
1293
1294    let transcript_stack = Arc::new(rpi_tui::HStack::from_entries(vec![StackEntry::new(
1295        scroll_view.clone(),
1296    )
1297    .basis(0)
1298    .grow(1)
1299    .shrink(1)
1300    .min_size(1)]));
1301    let root = VStack::from_children(vec![
1302        StackChild::Entry(
1303            StackEntry::new(transcript_stack.clone())
1304                .basis(0)
1305                .grow(1)
1306                .shrink(1)
1307                .min_size(1),
1308        ),
1309        StackChild::Entry(StackEntry::new(dock).shrink(1)),
1310    ]);
1311
1312    tui.set_layout_root(Some(Arc::new(root)));
1313    tui.set_focus(Some(editor.clone()));
1314    editor.set_focused(true);
1315
1316    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
1317    //
1318    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
1319    // the old version made individually) + the registry, then routes `/`-text
1320    // through `dispatch_slash` and sends plain text directly. Each command's
1321    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
1322    // chat mutation) — the handler itself stays a thin router.
1323    //
1324    // One `CommandContext` is built and cloned for both the submit handler and
1325    // the key loop (Ctrl+L routes `/model` through the same registry); all
1326    // fields are `Arc`/cheap, so the clones are free.
1327    //
1328    // The product-layer `AgentSession` wraps a clone of the harness (the
1329    // harness handle is cheap and shares the session/bus), so `/usage`,
1330    // `/export`, and future product commands go through one surface.
1331    let scoped_models = args
1332        .model
1333        .as_deref()
1334        .map(crate::agent_session::ScopedModel::parse)
1335        .into_iter()
1336        .collect();
1337    let agent_session = Arc::new(crate::agent_session::AgentSession::new(
1338        harness.clone(),
1339        model_catalog.clone(),
1340        scoped_models,
1341        cwd.clone(),
1342    ));
1343    let ctx = CommandContext {
1344        chat: chat_container.clone(),
1345        tui: tui.clone(),
1346        tx: tx.clone(),
1347        state: state.clone(),
1348        editor: editor.clone(),
1349        editor_container: editor_container.clone(),
1350        lane: lane.clone(),
1351        model_catalog: model_catalog_arc.clone(),
1352        cwd: cwd.clone(),
1353        resources: resources_arc.clone(),
1354        startup_context,
1355        reload_context: Arc::new(reload_context.clone()),
1356        session: agent_session.clone(),
1357    };
1358
1359    let ctx_for_cb = ctx.clone();
1360    let registry_for_cb = registry.clone();
1361    editor.on_submit(Arc::new(move |text: &str| {
1362        let text = text.trim();
1363        if text.is_empty() && ctx_for_cb.state.pending_images.lock().unwrap().is_empty() {
1364            return;
1365        }
1366
1367        if text.starts_with('/') && dispatch_slash(text, &ctx_for_cb, &registry_for_cb) {
1368            return;
1369        }
1370
1371        // User-initiated shell mode: `!command` runs in the background and is
1372        // recorded in the session context; `!!command` is excluded from it.
1373        // Handled before the agent prompt path so it never reaches the model.
1374        // Mirrors `interactive-mode.ts:3225`.
1375        if let Some((command, exclude_from_context)) = parse_user_bash(text) {
1376            if ctx_for_cb.state.user_bash_running() {
1377                add_error_message(
1378                    &ctx_for_cb.chat,
1379                    "A bash command is already running. Press Esc to cancel it first.",
1380                );
1381                ctx_for_cb.tui.request_render(false);
1382                return;
1383            }
1384            start_user_bash(&ctx_for_cb, command, exclude_from_context);
1385            return;
1386        }
1387
1388        let run_status = *ctx_for_cb.state.status.lock().unwrap();
1389        if run_status != RunStatus::Idle {
1390            let images = ctx_for_cb.state.take_pending_images();
1391            let message = user_message_with_images(text, images.clone());
1392            let lane = ctx_for_cb.lane.clone();
1393            let chat = ctx_for_cb.chat.clone();
1394            let tui = ctx_for_cb.tui.clone();
1395            let state = ctx_for_cb.state.clone();
1396            tokio::spawn(async move {
1397                // Queue immediately while the agent loop is still running.
1398                // Routing this through the TUI's main channel delayed it until
1399                // `prompt_text()` returned, after the loop's drain points had
1400                // passed, so the queued message appeared to disappear.
1401                //
1402                // upstream's `steer()` enqueues unconditionally, even after
1403                // the run ends, and the message stays in the queue for the
1404                // next run. This matches the TS design and avoids the race
1405                // between `activeRun` clearing and the status check.
1406                if let Err(error) = lane.steer(message).await {
1407                    state.restore_pending_images(images);
1408                    add_error_message(&chat, &format!("Could not queue message: {error}"));
1409                    tui.render_now(false);
1410                    return;
1411                }
1412                // Surface the queued entry in the pending dock immediately;
1413                // the drain-time refresh only fires on the next agent event.
1414                refresh_pending_messages(&state, &lane).await;
1415                tui.request_render(false);
1416            });
1417            // A queued prompt is echoed only once the loop actually consumes
1418            // it: the run's `AgentEvent::MessageStart` renders the user bubble
1419            // (upstream renders `message_start` for user messages). Until
1420            // then it is surfaced by the pending-messages display, so the
1421            // transcript never shows a message that is still waiting.
1422            ctx_for_cb.tui.request_render(false);
1423            return;
1424        }
1425
1426        if !ctx_for_cb.state.try_start_working() {
1427            return;
1428        }
1429
1430        // Render the user bubble here, NOT from `AgentEvent::MessageStart`.
1431        //
1432        // The harness persists the prompt itself and then drives
1433        // `run_agent_loop` with an EMPTY prompts vec (so the prompt is not
1434        // double-counted in the provider context), which means the loop emits
1435        // no `message_start` for it — see
1436        // `directly_sent_prompt_emits_no_user_message_start` in
1437        // `crates/rpi-harness/tests/harness_run_e2e.rs`. Queued steering /
1438        // follow-up prompts DO get a `message_start` (the loop drains those
1439        // itself), so those are deliberately rendered from the event instead.
1440        add_user_message(&ctx_for_cb.chat, text);
1441        // A new prompt starts a fresh interaction at the tail even when the
1442        // user had scrolled up to inspect older output.
1443        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
1444            scroll.scroll_to_end();
1445        }
1446        ctx_for_cb.tui.request_render(false);
1447        // Remember the message for ↑ recall (slash commands are not part of
1448        // the replayable message history).
1449        push_history(&ctx_for_cb.state, text);
1450        if ctx_for_cb
1451            .tx
1452            .send(TuiMessage::UserInput(
1453                text.to_string(),
1454                ctx_for_cb.state.take_pending_images(),
1455            ))
1456            .is_err()
1457        {
1458            ctx_for_cb.state.set_status(RunStatus::Idle);
1459        }
1460    }));
1461
1462    // Turn on the native-pi frame throttle before the first frame: from here on
1463    // `request_render(false)` parks a frame and the scheduler thread paints at
1464    // most one per `MIN_RENDER_INTERVAL_MS` (16 ms). A provider that streams one
1465    // `MessageUpdate` per delta would otherwise force a full transcript
1466    // re-render + full-viewport terminal write per token, which is what made
1467    // long output saturate the terminal (and its GPU compositor). Immediate
1468    // renders (selectors, the first frame, `render_now(..)`) are unaffected.
1469    tui.start_render_scheduler();
1470    tui.start_readerless();
1471
1472    // Consume local self-update results even when network checks are disabled.
1473    // Check rpi and installed registry packages in the background so startup
1474    // remains responsive while their latest versions are fetched.
1475    let mut update_check_handles = Vec::new();
1476    let rpi_chat = chat_container.clone();
1477    let rpi_tui = tui.clone();
1478    update_check_handles.push(tokio::spawn(async move {
1479        let report = crate::updates::check_rpi_startup().await;
1480        if !report.is_empty() {
1481            add_update_notices(&rpi_chat, &report);
1482        }
1483        rpi_tui.request_render(false);
1484    }));
1485
1486    // ---- Streaming drain task ----
1487    let drain_handle = if let Some(rx) = event_rx {
1488        let tui_drain = tui.clone();
1489        let state_drain = state.clone();
1490        let chat_drain = chat_container.clone();
1491        let lane_drain = lane.clone();
1492        Some(tokio::spawn(async move {
1493            drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
1494        }))
1495    } else {
1496        None
1497    };
1498
1499    // ---- B5d: plugin→TUI reload bridge ----
1500    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
1501    // (its cdylib would be unmapped while the call frame is still on the stack).
1502    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
1503    // (an `UnboundedSender<()>`); this task drains those signals and forwards
1504    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
1505    // `reload_extension_resources` routine asynchronously. The mailbox is the
1506    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
1507    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
1508    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
1509    reload_context.mailbox.install(reload_sig_tx);
1510    let reload_tx = tx.clone();
1511    let reload_bridge_handle = tokio::spawn(async move {
1512        while reload_sig_rx.recv().await.is_some() {
1513            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
1514                break; // main loop gone — stop forwarding
1515            }
1516        }
1517    });
1518
1519    // ---- Render-tick task (advances the loader spinner while Working) ----
1520    //
1521    // The `Loader` only advances its frame on render; without a periodic
1522    // `request_render` the spinner visibly freezes between events.
1523    let tui_tick = tui.clone();
1524    let state_tick = state.clone();
1525    // The tick, not the key loop, drives the voice-draft auto-send: it must
1526    // fire even when the user types nothing.
1527    let tx_tick = tx.clone();
1528    let pet_tick = pet_document.clone();
1529    let mut plugin_panels = panels::Panels::with_sidebar(
1530        transcript_stack,
1531        scroll_view.clone(),
1532        reload_context.ext_status.clone(),
1533    );
1534    plugin_panels.regular_mode = matches!(args.tui_mode, crate::args::TuiMode::Regular);
1535    plugin_panels.sync(tui.as_ref(), &reload_context.ext_status);
1536    let scroll_tick = scroll_view.clone();
1537    let tick_handle = tokio::spawn(async move {
1538        // 80ms per frame — upstream's `DEFAULT_INTERVAL_MS`, i.e. a full
1539        // 10-frame cycle every 800ms. The tick only *requests a repaint*; the
1540        // frame is advanced by `Editor::render` (mirroring `Loader::render`),
1541        // so streaming output — which schedules a frame per token — spins it
1542        // faster, and this interval is the floor when the model is silent.
1543        //
1544        // Advancing here as well would double-step every tick and make the idle
1545        // spinner run at 40ms/frame, twice upstream's rate.
1546        let mut interval = tokio::time::interval(std::time::Duration::from_millis(
1547            rpi_tui::loader::SPINNER_FRAME_MS,
1548        ));
1549        interval.tick().await; // discard immediate
1550        loop {
1551            interval.tick().await;
1552            // Extension status (langfuse ✓ …, …) — cheap revision check, and the
1553            // only reason an idle session repaints its footer.
1554            let panels_changed = plugin_panels.sync(tui_tick.as_ref(), &state_tick.ext_status);
1555            if state_tick.sync_extension_status() || panels_changed {
1556                // Status/panel updates leave chat content untouched. The scroll
1557                // cache regenerates itself if enabling a sidebar changes width.
1558                tui_tick.request_render_reusing_scroll_content();
1559            }
1560            if pet_tick.tick(scroll_tick.viewport_height()) {
1561                tui_tick.request_render(false);
1562            }
1563            // Extension editor-text injection (`SetEditorText`): a voice plugin
1564            // drops a transcription in as an editable draft.
1565            if state_tick.drain_editor_text() {
1566                tui_tick.request_render(false);
1567            }
1568            // Tell extensions the draft changed (voice barge-in). Runs after the
1569            // drain so an injected transcription also counts as a change.
1570            state_tick.sync_editor_change();
1571            // Advance a draft's auto-send countdown (submit / cancel / repaint).
1572            if poll_auto_send(&state_tick, &tx_tick) {
1573                tui_tick.request_render(false);
1574            }
1575            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
1576            if working {
1577                // A live transcript panel — a running bash command *or* a
1578                // running tool call — computes its elapsed readout from
1579                // `Instant::now()` at render time. A frame that reuses the
1580                // cached scroll content never rebuilds it, so the readout
1581                // froze at the second of the last full rebuild (reported: a
1582                // `search` call stuck at "2.0s" with no way to tell it was
1583                // still running). Rebuild while any such panel is live.
1584                let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
1585                let has_live_panel = {
1586                    let tools = state_tick.tool_components.lock().unwrap();
1587                    transcript_has_live_panel(bash_present, &tools)
1588                };
1589                if has_live_panel {
1590                    tui_tick.request_render(false);
1591                } else {
1592                    // Only the dock loader animates. Keep the already-rendered
1593                    // transcript instead of rebuilding a long history at 12.5
1594                    // frames per second.
1595                    tui_tick.request_render_reusing_scroll_content();
1596                }
1597            }
1598        }
1599    });
1600
1601    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
1602    let running = Arc::new(std::sync::Mutex::new(true));
1603    let running_key = running.clone();
1604    let tx_for_key = tx.clone();
1605    let tui_for_key = tui.clone();
1606    let editor_for_key = editor.clone();
1607    let scroll_for_key = scroll_view.clone();
1608    let lane_for_key = lane.clone();
1609    let state_for_key = state.clone();
1610    let model_catalog_for_key = model_catalog_arc.clone();
1611    let ask_user_for_key = ask_user_bridge.clone();
1612    // Ctrl+L routes through the same registry as `/model` (one path, not two),
1613    // so the key loop needs the same `CommandContext` + registry the submit
1614    // handler uses. All fields are `Arc`/cheap, so this clone is free.
1615    let ctx_for_key = ctx.clone();
1616    let registry_for_key = registry.clone();
1617    let keybindings_for_key = keybindings.clone();
1618    let double_escape_action = crate::settings::load_settings()
1619        .ok()
1620        .and_then(|settings| settings.double_escape_action)
1621        .unwrap_or_else(|| "tree".to_string())
1622        .to_ascii_lowercase();
1623
1624    let key_handle = tokio::task::spawn_blocking(move || {
1625        let mut last_escape_time = None;
1626        // upstream's `handleCtrlC` "press twice to exit" window: the instant of
1627        // the last lone Ctrl+C. Shares the 500ms with Esc's double-escape.
1628        let mut last_sigint_time: Option<std::time::Instant> = None;
1629        // One event peeked at by the paste-burst probe (which must discard a
1630        // key's Release but never lose a meaningful event it read). Restored
1631        // at the top of the next loop iteration.
1632        let mut pending_event: Option<Event> = None;
1633        let mut windows_paste_run = WindowsPasteRun::default();
1634        if crate::key_trace::enabled() {
1635            crate::key_trace::note(&format!(
1636                "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
1637                std::process::id(),
1638                std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
1639                crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
1640            ));
1641        }
1642        loop {
1643            if !*running_key.lock().unwrap() {
1644                break;
1645            }
1646            if !state_for_key.selector_open() && !state_for_key.extension_dialog_open() {
1647                if let Some(request) = ask_user_for_key.take_pending() {
1648                    open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
1649                }
1650            }
1651            let ev = if let Some(ev) = pending_event.take() {
1652                ev
1653            } else {
1654                // `event::read()` blocks indefinitely. Poll first so shutdown can
1655                // stop and join this worker even when no further key arrives.
1656                match crossterm::event::poll(
1657                    if cfg!(windows) && !windows_paste_run.text.is_empty() {
1658                        windows_paste_run.grace()
1659                    } else {
1660                        std::time::Duration::from_millis(50)
1661                    },
1662                ) {
1663                    Ok(true) => {}
1664                    Ok(false) => {
1665                        if cfg!(windows) && !windows_paste_run.text.is_empty() {
1666                            windows_paste_run.flush(&editor_for_key);
1667                            refresh_autocomplete(&state_for_key, &editor_for_key);
1668                            tui_for_key.request_render_reusing_scroll_content();
1669                        }
1670                        continue;
1671                    }
1672                    Err(_) => {
1673                        state_for_key.cancel_js_preparation();
1674                        let _ = tx_for_key.send(TuiMessage::Exit);
1675                        break;
1676                    }
1677                }
1678                let Ok(ev) = crossterm::event::read() else {
1679                    state_for_key.cancel_js_preparation();
1680                    let _ = tx_for_key.send(TuiMessage::Exit);
1681                    break;
1682                };
1683                ev
1684            };
1685            // `Event::Resize` is delivered as its own event (not a Key). With
1686            // `start_readerless` there is no competing terminal-reader thread to
1687            // handle it, so refresh the cached terminal size here and force a
1688            // full redraw so the constrained layout re-fits the new dimensions.
1689            if let Event::Resize(_cols, _rows) = ev {
1690                tui_for_key.refresh_size();
1691                continue;
1692            }
1693            // Mouse wheel scrolls the transcript (pi supports wheel
1694            // scrolling). Previously every non-Key event was dropped, so a
1695            // wheel had zero effect
1696            if let Event::Mouse(m) = ev {
1697                if tui_for_key.handle_scroll_to_end_indicator_mouse_event(&m) {
1698                    *state_for_key.selection_start.lock().unwrap() = None;
1699                    *state_for_key.selection_end.lock().unwrap() = None;
1700                    continue;
1701                }
1702                use crossterm::event::MouseEventKind;
1703                match m.kind {
1704                    MouseEventKind::ScrollUp => {
1705                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
1706                        if scroll_for_key.scroll_by(delta) != delta {
1707                            tui_for_key.request_render_reusing_scroll_content();
1708                        }
1709                    }
1710                    MouseEventKind::ScrollDown => {
1711                        let delta = MOUSE_WHEEL_SCROLL_LINES;
1712                        if scroll_for_key.scroll_by(delta) != delta {
1713                            tui_for_key.request_render_reusing_scroll_content();
1714                        }
1715                    }
1716                    MouseEventKind::Down(crossterm::event::MouseButton::Right) if cfg!(windows) => {
1717                        // upstream treats Windows right-click as a clipboard
1718                        // paste, rather than letting the console deliver the
1719                        // clipboard contents as individual key events. This is
1720                        // what preserves the complete payload for
1721                        // Editor::handle_paste and large-paste folding.
1722                        if let Some(text) = read_clipboard_text() {
1723                            if !text.is_empty() {
1724                                editor_for_key.handle_paste(&text);
1725                                refresh_autocomplete(&state_for_key, &editor_for_key);
1726                                tui_for_key.request_render_reusing_scroll_content();
1727                            }
1728                        }
1729                    }
1730                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
1731                        // Start selection tracking. `selection_end` doubles as the
1732                        // "a real drag happened" marker: a plain tap never sets
1733                        // it, so it can never overwrite the clipboard.
1734                        *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
1735                        *state_for_key.selection_end.lock().unwrap() = None;
1736                    }
1737                    MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
1738                        // Motion with the button held = a genuine selection drag.
1739                        // (Terminals only report this with button-event mouse
1740                        // tracking, which `ProcessTerminal` enables.)
1741                        if state_for_key.selection_start.lock().unwrap().is_some() {
1742                            *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
1743                        }
1744                    }
1745                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
1746                        // End selection and auto-copy if enabled
1747                        if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
1748                            let end = (m.column, m.row);
1749
1750                            // Only auto-copy when the pointer actually MOVED with
1751                            // the button held (`Drag` set `selection_end`). A plain
1752                            // click/tap — even one whose Down/Up cells differ by a
1753                            // pixel over RDP — must not touch the clipboard: it used
1754                            // to fire `extract_selected_text`, which copies whatever
1755                            // rendered TUI line sat under the pointer, silently
1756                            // clobbering the user's real clipboard so the next paste
1757                            // returned that stale text.
1758                            let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
1759                            *state_for_key.selection_end.lock().unwrap() = Some(end);
1760
1761                            // Check if auto-copy is enabled
1762                            let auto_copy = crate::settings::load_settings()
1763                                .ok()
1764                                .and_then(|s| s.fullscreen_copy_on_select)
1765                                .unwrap_or(true);
1766
1767                            if auto_copy && is_drag {
1768                                // Try to extract selected text from chat container
1769                                if let Some(selected_text) =
1770                                    extract_selected_text(&state_for_key.chat_container, start, end)
1771                                {
1772                                    if !selected_text.trim().is_empty() {
1773                                        let _ = copy_to_clipboard(&selected_text);
1774                                    }
1775                                }
1776                            }
1777                        }
1778                        *state_for_key.selection_start.lock().unwrap() = None;
1779                        *state_for_key.selection_end.lock().unwrap() = None;
1780                    }
1781                    _ => {}
1782                }
1783                continue;
1784            }
1785            let Event::Key(key) = ev else {
1786                if let Event::Paste(text) = ev {
1787                    crate::key_trace::note(&format!(
1788                        "paste event ({} bytes, bracketed paste supported)",
1789                        text.len()
1790                    ));
1791                    // A provider-form text step owns the input slot: paste into
1792                    // its single-line input instead of the chat editor.
1793                    if state_for_key.provider_form.lock().unwrap().is_some() {
1794                        let active = state_for_key.active_provider_input.lock().unwrap().clone();
1795                        if let Some(input) = active {
1796                            input.handle_paste(&text);
1797                            tui_for_key.request_render_reusing_scroll_content();
1798                            continue;
1799                        }
1800                    }
1801                    if let Some(images) = images_from_pasted_paths(&text) {
1802                        for image in images {
1803                            state_for_key.queue_image(image);
1804                        }
1805                        add_note_message(
1806                            &state_for_key.chat_container,
1807                            "Dropped images attached to the next prompt.",
1808                        );
1809                        tui_for_key.request_render(false);
1810                        continue;
1811                    }
1812                    editor_for_key.handle_paste(&text);
1813                    refresh_autocomplete(&state_for_key, &editor_for_key);
1814                    tui_for_key.request_render_reusing_scroll_content();
1815                }
1816                continue;
1817            };
1818            let key = rpi_tui::terminal::normalize_key_event(key);
1819            // Diagnostic trace (off unless RPI_DEBUG_KEYS is set): records what
1820            // actually arrived before any routing decision, so a client that
1821            // sends LF for Enter is visible as `Char('j') mods=CONTROL`.
1822            crate::key_trace::key(&key, "");
1823            // A key press during a voice-draft auto-send countdown cancels the
1824            // countdown — the user wants to edit, not send. The key still falls
1825            // through to the editor, so it doubles as their first correction.
1826            if key.kind != KeyEventKind::Release && state_for_key.cancel_auto_send_on_key() {
1827                tui_for_key.request_render(false);
1828            }
1829            // Extension shortcuts see Press *and* Release (this is what lets a
1830            // push-to-talk extension time a hold). Checked before the
1831            // release-drop below; a claimed key with an empty editor is routed
1832            // to the plugin's `Input` handler instead of the editor.
1833            if dispatch_key_event(&state_for_key, &key, &editor_for_key) {
1834                continue;
1835            }
1836            // Drop releases but preserve Repeat so holding arrows, Backspace,
1837            // PageUp, etc. behaves naturally. Windows emits Press + Release
1838            // for a tap; terminals with keyboard enhancement may additionally
1839            // emit Repeat while a key is held.
1840            if !should_dispatch_key(key.kind) {
1841                continue;
1842            }
1843
1844            // Prompt preparation runs on a blocking worker before the agent
1845            // lane owns the turn. Cancel it directly: an abort queued only to
1846            // the lane cannot wake a JS factory or lifecycle hook that never
1847            // resolves. This check precedes custom/dialog routing because
1848            // those components may themselves have been opened by the hook.
1849            let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
1850                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
1851                || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
1852            if prompt_abort && state_for_key.cancel_js_preparation() {
1853                state_for_key.set_status(RunStatus::Aborting);
1854                if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
1855                    close_extension_editor(
1856                        &state_for_key,
1857                        &ctx_for_key.editor_container,
1858                        &editor_for_key,
1859                        &tui_for_key,
1860                    );
1861                }
1862                tui_for_key.set_render_suspended(false);
1863                tui_for_key.request_render(false);
1864                continue;
1865            }
1866
1867            // Ctrl+C cancels an open selector before it reaches the global
1868            // abort/exit handler. Route through Esc so selector callbacks run.
1869            if key.modifiers == KeyModifiers::CONTROL
1870                && key.code == KeyCode::Char('c')
1871                && state_for_key.selector_open()
1872            {
1873                let selector = state_for_key
1874                    .active_selector
1875                    .lock()
1876                    .unwrap()
1877                    .clone()
1878                    .expect("selector_open guaranteed Some")
1879                    .0;
1880                selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1881                tui_for_key.request_render_reusing_scroll_content();
1882                continue;
1883            }
1884
1885            // The interactive "Add provider" form owns the input slot while it
1886            // collects a field: route every key to its single-line input (Esc
1887            // and Enter are handled by the Input's own callbacks). Clone the
1888            // input OUTSIDE `if let` so the `active_provider_input` mutex guard
1889            // drops before `handle_key` — the submit path re-enters that mutex
1890            // to swap in the next step's input and would deadlock on it.
1891            if state_for_key.provider_form.lock().unwrap().is_some() {
1892                let active_input = state_for_key.active_provider_input.lock().unwrap().clone();
1893                let active_select = state_for_key.active_provider_select.lock().unwrap().clone();
1894                if let Some(input) = active_input {
1895                    input.handle_key(key);
1896                } else if let Some(select) = active_select {
1897                    select.handle_key(key);
1898                }
1899                tui_for_key.request_render_reusing_scroll_content();
1900                continue;
1901            }
1902
1903            // Extension dialogs own the input slot while awaiting a result.
1904            // Esc and Ctrl+C both resolve the pending command with cancel;
1905            // all other keys go to the active native editor/input widget.
1906            if state_for_key.extension_dialog_open() {
1907                let cancel = key.code == KeyCode::Esc
1908                    || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
1909                if cancel {
1910                    if !run_extension_cancel(&state_for_key) {
1911                        close_extension_editor(
1912                            &state_for_key,
1913                            &ctx_for_key.editor_container,
1914                            &editor_for_key,
1915                            &tui_for_key,
1916                        );
1917                    }
1918                } else {
1919                    handle_extension_dialog_key(&state_for_key, key);
1920                }
1921                tui_for_key.request_render_reusing_scroll_content();
1922                continue;
1923            }
1924
1925            // Pet Esc pauses audio; an active run still reaches normal abort
1926            // routing below. Dialogs and selectors retain their own Escape.
1927            if key.code == KeyCode::Esc
1928                && key.modifiers == KeyModifiers::NONE
1929                && !state_for_key.selector_open()
1930                && pet::active(&state_for_key.ext_status)
1931            {
1932                let _ = invoke_extension_command(&state_for_key.extension_session, "pet", "quiet");
1933                tui_for_key.request_render(false);
1934                if *state_for_key.status.lock().unwrap() == RunStatus::Idle
1935                    && editor_for_key.get_text().is_empty()
1936                {
1937                    continue;
1938                }
1939            }
1940
1941            // 0. Ctrl+C: upstream's `handleCtrlC`. A second press inside the
1942            //    double-press window quits; any other press only clears the
1943            //    editor (text *and* selection — native `clearEditor`). It never
1944            //    aborts — `app.interrupt` (Esc) owns cancelling a run — so a
1945            //    stray Ctrl+C while streaming can no longer kill the turn.
1946            //    Open selectors and extension dialogs are handled above so
1947            //    their cancellation callbacks get first chance.
1948            if keybinding_matches(
1949                &keybindings_for_key,
1950                &key,
1951                rpi_tui::keybindings::keys::CLEAR,
1952            ) {
1953                // A held key auto-repeats: only a fresh Press may arm or
1954                // consume the window, so holding Ctrl+C cannot turn one tap
1955                // into the quit.
1956                if key.kind != KeyEventKind::Repeat {
1957                    let now = std::time::Instant::now();
1958                    match ctrl_c_action(last_sigint_time, now) {
1959                        CtrlCAction::Exit => {
1960                            last_sigint_time = None;
1961                            // The async loop parks inside
1962                            // `run_prompt_streaming(..).await` for the whole run,
1963                            // so a queued `Exit` would not be read until the run
1964                            // returns — force the quit while one is active. Either
1965                            // way the TUI stays quittable.
1966                            if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
1967                                let _ = tx_for_key.send(TuiMessage::Exit);
1968                            } else {
1969                                emergency_exit(&tui_for_key);
1970                            }
1971                        }
1972                        CtrlCAction::Clear => {
1973                            editor_for_key.clear();
1974                            state_for_key.take_pending_images();
1975                            refresh_autocomplete(&state_for_key, &editor_for_key);
1976                            last_sigint_time = Some(now);
1977                            tui_for_key.request_render(false);
1978                        }
1979                    }
1980                }
1981                continue;
1982            }
1983
1984            // 0b. Ctrl+X: upstream's `app.message.copy` — copy the editor
1985            //     selection, else the last assistant reply. Native's Ctrl+C no
1986            //     longer copies, so selection-copy lives here now.
1987            if !state_for_key.selector_open()
1988                && keybinding_matches(
1989                    &keybindings_for_key,
1990                    &key,
1991                    rpi_tui::keybindings::keys::MESSAGE_COPY,
1992                )
1993            {
1994                if editor_for_key.has_selection() && editor_for_key.copy_selection() {
1995                    add_note_message(&state_for_key.chat_container, "Copied!");
1996                } else {
1997                    copy_last_assistant(&state_for_key, &state_for_key.chat_container);
1998                }
1999                tui_for_key.request_render(false);
2000                continue;
2001            }
2002
2003            // 0c. Ctrl+Z (Unix only): upstream's `app.suspend` — hand the
2004            //     terminal back to the shell and stop ourselves; `SIGTSTP`
2005            //     returns when the user resumes with `fg`. Windows binds no key
2006            //     here (native's `defaultKeys` is empty), so this never fires.
2007            if keybinding_matches(
2008                &keybindings_for_key,
2009                &key,
2010                rpi_tui::keybindings::keys::SUSPEND,
2011            ) {
2012                handle_suspend(&tui_for_key);
2013                continue;
2014            }
2015
2016            // 1. A selector overlay is open → route to it first. Only Esc
2017            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
2018            //    escape to the selector; on done/cancel the selector callbacks
2019            //    restore the editor and clear `active_selector`.
2020            if state_for_key.selector_open() {
2021                // Esc always cancels the selector (even with modifiers off).
2022                // Route through `SelectList::handle_key(Esc)` so the list's
2023                // `on_cancel` fires (the `/scoped-models` toggle selector saves
2024                // its edits there) — the old shortcut called `close_selector`
2025                // directly and skipped the callback.
2026                if key.code == KeyCode::Esc {
2027                    let (selector, _kind) = state_for_key
2028                        .active_selector
2029                        .lock()
2030                        .unwrap()
2031                        .clone()
2032                        .expect("selector_open guaranteed Some");
2033                    selector.handle_key(key);
2034                    continue;
2035                }
2036                let (selector, _kind) = state_for_key
2037                    .active_selector
2038                    .lock()
2039                    .unwrap()
2040                    .clone()
2041                    .expect("selector_open guaranteed Some");
2042                selector.handle_key(key);
2043                tui_for_key.request_render_reusing_scroll_content();
2044                continue;
2045            }
2046
2047            // 2a. Ctrl+D: upstream's `app.exit`. `handleCtrlD` only fires when
2048            //     the editor is empty; with text present the key falls through to
2049            //     the editor's deleteCharForward (`tui.editor.deleteCharForward`).
2050            //     It never aborts — that is `app.interrupt` (Esc)'s job.
2051            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
2052                if !editor_for_key.get_text().is_empty() {
2053                    // Editor holds text — delete the char forward (pi parity).
2054                    editor_for_key.handle_key(key);
2055                    refresh_autocomplete(&state_for_key, &editor_for_key);
2056                    tui_for_key.request_render_reusing_scroll_content();
2057                    continue;
2058                }
2059                // Empty editor → quit. A parked run needs the forced path: a
2060                // queued `Exit` is unread until the run returns.
2061                if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
2062                    let _ = tx_for_key.send(TuiMessage::Exit);
2063                } else {
2064                    emergency_exit(&tui_for_key);
2065                }
2066                continue;
2067            }
2068
2069            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
2070            //     selector is open Esc already cancelled it above; when idle,
2071            //     Esc falls through to the editor (no-op-ish). Only fire while
2072            //     Working so an idle Esc doesn't abort a non-existent run.
2073            if keybinding_matches(
2074                &keybindings_for_key,
2075                &key,
2076                rpi_tui::keybindings::keys::INTERRUPT,
2077            ) {
2078                let status = *state_for_key.status.lock().unwrap();
2079                if status == RunStatus::Working {
2080                    state_for_key.set_status(RunStatus::Aborting);
2081                    ask_user_for_key.cancel_all();
2082                    if !run_extension_cancel(&state_for_key)
2083                        && state_for_key.extension_dialog_open()
2084                    {
2085                        close_extension_editor(
2086                            &state_for_key,
2087                            &ctx_for_key.editor_container,
2088                            &editor_for_key,
2089                            &tui_for_key,
2090                        );
2091                    }
2092                    // upstream's `onEscape` restores the queue before it
2093                    // aborts, so an interrupt hands the staged messages back.
2094                    abort_run_restoring_queue(
2095                        lane_for_key.clone(),
2096                        editor_for_key.clone(),
2097                        state_for_key.clone(),
2098                        tui_for_key.clone(),
2099                    );
2100                    continue;
2101                }
2102                // A running `!command` takes Esc next (upstream's `onEscape`
2103                // precedence: streaming → bash → bash-mode editor →
2104                // double-escape). The run slot stays occupied until the capture
2105                // resolves, so a second Esc is a no-op rather than a
2106                // double-escape trigger.
2107                if state_for_key.user_bash_running() {
2108                    state_for_key.cancel_user_bash();
2109                    tui_for_key.request_render(false);
2110                    continue;
2111                }
2112                if status == RunStatus::Idle
2113                    && editor_for_key.get_text().trim().is_empty()
2114                    && double_escape_action != "none"
2115                {
2116                    let now = std::time::Instant::now();
2117                    if double_escape_trigger(last_escape_time, now) {
2118                        last_escape_time = None;
2119                        match double_escape_action.as_str() {
2120                            "tree" => {
2121                                let _ = tx_for_key.send(TuiMessage::OpenTree);
2122                            }
2123                            "fork" => {
2124                                let _ = tx_for_key.send(TuiMessage::ForkSession);
2125                            }
2126                            _ => {}
2127                        }
2128                    } else {
2129                        last_escape_time = Some(now);
2130                    }
2131                }
2132                continue;
2133            }
2134
2135            // 2c. Ctrl+G: edit the current draft in the user's external
2136            // editor, matching upstream's VISUAL/EDITOR fallback chain.
2137            if keybinding_matches(
2138                &keybindings_for_key,
2139                &key,
2140                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
2141            ) {
2142                launch_external_editor(editor_for_key.get_expanded_text(), tx_for_key.clone());
2143                continue;
2144            }
2145
2146            // 2d. Ctrl+O: toggle all tool output panels between compact and
2147            // expanded rendering (upstream's global output toggle).
2148            if keybinding_matches(
2149                &keybindings_for_key,
2150                &key,
2151                rpi_tui::keybindings::keys::TOOLS_EXPAND,
2152            ) {
2153                let expanded = state_for_key.toggle_tool_outputs();
2154                // Persist per-session so a later `-c`/`-r` restores it.
2155                let session = ctx_for_key.session.clone();
2156                tokio::spawn(async move {
2157                    let _ = session
2158                        .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
2159                        .await;
2160                });
2161                tui_for_key.request_render(false);
2162                continue;
2163            }
2164
2165            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
2166            if keybinding_matches(
2167                &keybindings_for_key,
2168                &key,
2169                rpi_tui::keybindings::keys::THINKING_TOGGLE,
2170            ) {
2171                let hide = state_for_key.toggle_thinking();
2172                // Persist per-session so a later `-c`/`-r` restores it.
2173                let session = ctx_for_key.session.clone();
2174                tokio::spawn(async move {
2175                    let _ = session
2176                        .set_value("display.hide_thinking", serde_json::json!(hide))
2177                        .await;
2178                });
2179                tui_for_key.request_render(false);
2180                continue;
2181            }
2182
2183            // 2f. Ctrl+P: cycle to the next model in the catalog after the one
2184            //     currently tracked in `current_model_id`, apply it live via
2185            //     `lane.set_model` (takes effect on the next user message — the
2186            //     in-flight run's config is already snapshotted), and update the
2187            //     footer. `set_model` is async so it runs on a spawned task.
2188            if keybinding_matches(
2189                &keybindings_for_key,
2190                &key,
2191                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
2192            ) {
2193                let current = state_for_key.current_model_id();
2194                // Cycle within the `/scoped-models` set (settings.json) when
2195                // configured; otherwise the full catalog, restricted to the
2196                // current provider (the lane cannot route another provider).
2197                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
2198                let scope = same_provider_catalog(&scope, &current);
2199                if let Some(next) = cycle_next_model(&scope, &current) {
2200                    state_for_key.set_current_model(&next);
2201                    let lane = lane_for_key.clone();
2202                    tokio::spawn(async move {
2203                        let _ = lane.set_model(next).await;
2204                    });
2205                    tui_for_key.request_render_reusing_scroll_content();
2206                }
2207                continue;
2208            }
2209
2210            // 2f-bis. Shift+Ctrl+P (Alt+P on Windows): cycle to the *previous*
2211            //     model, the mirror of the Ctrl+P hotkey above.
2212            if keybinding_matches(
2213                &keybindings_for_key,
2214                &key,
2215                rpi_tui::keybindings::keys::MODEL_CYCLE_BACKWARD,
2216            ) {
2217                let current = state_for_key.current_model_id();
2218                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
2219                let scope = same_provider_catalog(&scope, &current);
2220                if let Some(prev) = cycle_prev_model(&scope, &current) {
2221                    state_for_key.set_current_model(&prev);
2222                    let lane = lane_for_key.clone();
2223                    tokio::spawn(async move {
2224                        let _ = lane.set_model(prev).await;
2225                    });
2226                    tui_for_key.request_render_reusing_scroll_content();
2227                }
2228                continue;
2229            }
2230
2231            // 2f. Shift+Tab / BackTab: cycle the current model's supported
2232            // thinking levels, matching upstream's thinking-level shortcut.
2233            if keybinding_matches(
2234                &keybindings_for_key,
2235                &key,
2236                rpi_tui::keybindings::keys::THINKING_CYCLE,
2237            ) {
2238                let lane = lane_for_key.clone();
2239                let catalog = model_catalog_for_key.clone();
2240                let state = state_for_key.clone();
2241                tokio::spawn(async move {
2242                    let Ok(current_model) = lane.get_model().await else {
2243                        return;
2244                    };
2245                    let levels = catalog
2246                        .iter()
2247                        .find(|model| {
2248                            model.provider == current_model.provider && model.id == current_model.id
2249                        })
2250                        .map(|model| model.supported_thinking_levels())
2251                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
2252                    if levels.is_empty() {
2253                        return;
2254                    }
2255                    let current = lane
2256                        .get_thinking_level()
2257                        .await
2258                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
2259                    let next = levels
2260                        .iter()
2261                        .position(|level| *level == current)
2262                        .map(|index| levels[(index + 1) % levels.len()])
2263                        .unwrap_or(levels[0]);
2264                    if lane.set_thinking_level(next).await.is_ok() {
2265                        state
2266                            .footer
2267                            .set_thinking_level(Some(thinking_level_name(next)));
2268                        state.tui.as_ref().map(|tui| tui.request_render(false));
2269                    }
2270                });
2271                continue;
2272            }
2273
2274            // Ctrl+V (or a configured paste-image key): if the system
2275            // clipboard holds an image, queue it for the next prompt;
2276            // otherwise paste the clipboard TEXT into the editor. Previously
2277            // a text-only clipboard fell through to the editor's Ctrl+V,
2278            // which yanks from the internal KILL RING — not the system
2279            // clipboard. On platforms without bracketed-paste `Event::Paste`
2280            // (Windows console, some remote clients) that meant Ctrl+V pasted
2281            // stale/other content instead of the user's real clipboard
2282            // ("不是系统的粘贴的内容").
2283            if keybinding_matches(
2284                &keybindings_for_key,
2285                &key,
2286                rpi_tui::keybindings::keys::PASTE_IMAGE,
2287            ) {
2288                if let Some(paths) = read_clipboard_file_paths() {
2289                    let text = paths.join("\n");
2290                    if let Some(images) = images_from_pasted_paths(&text) {
2291                        for image in images {
2292                            state_for_key.queue_image(image);
2293                        }
2294                        add_note_message(
2295                            &state_for_key.chat_container,
2296                            "Clipboard images attached to the next prompt.",
2297                        );
2298                    } else {
2299                        editor_for_key.handle_paste(&text);
2300                    }
2301                    tui_for_key.request_render(false);
2302                    continue;
2303                }
2304                match read_clipboard_image() {
2305                    Ok(Some(image)) => {
2306                        state_for_key.queue_image(image);
2307                        add_note_message(
2308                            &state_for_key.chat_container,
2309                            "Clipboard image attached to the next prompt.",
2310                        );
2311                        tui_for_key.request_render(false);
2312                        continue;
2313                    }
2314                    Ok(None) | Err(_) => {}
2315                }
2316                // Local Ctrl+V can paste clipboard text as Pi does. Under SSH,
2317                // text must arrive from the client via bracketed paste rather
2318                // than reading the remote host's unrelated clipboard.
2319                if cfg!(windows) || std::env::var_os("SSH_CONNECTION").is_none() {
2320                    if let Some(text) = read_clipboard_text() {
2321                        if !text.is_empty() {
2322                            if !attach_pasted_images(&state_for_key, &text) {
2323                                editor_for_key.handle_paste(&text);
2324                            }
2325                            refresh_autocomplete(&state_for_key, &editor_for_key);
2326                            tui_for_key.request_render_reusing_scroll_content();
2327                            continue;
2328                        }
2329                    }
2330                }
2331            }
2332
2333            // 3. Ctrl+Shift+F: open transcript search
2334            if key
2335                .modifiers
2336                .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
2337                && key.code == KeyCode::Char('F')
2338            {
2339                state_for_key.search.activate();
2340                state_for_key.search_bar.set_visible(true);
2341                state_for_key.search_bar.set_query("");
2342                state_for_key.search_bar.set_match_info(0, 0);
2343                tui_for_key.request_render(false);
2344                continue;
2345            }
2346
2347            // Handle search mode input
2348            if state_for_key.search.is_active() {
2349                let search_bar = state_for_key.search_bar.clone();
2350                match key.code {
2351                    KeyCode::Esc => {
2352                        state_for_key.search.deactivate();
2353                        search_bar.set_visible(false);
2354                        tui_for_key.request_render(false);
2355                        continue;
2356                    }
2357                    KeyCode::Enter => {
2358                        if key.modifiers.contains(KeyModifiers::SHIFT) {
2359                            state_for_key.search.previous_match();
2360                        } else {
2361                            state_for_key.search.next_match();
2362                        }
2363                        // Update search bar with current match info
2364                        let match_index = state_for_key.search.get_match_index();
2365                        let match_count = state_for_key.search.get_match_count();
2366                        search_bar.set_match_info(match_index, match_count);
2367                        tui_for_key.request_render(false);
2368                        continue;
2369                    }
2370                    KeyCode::Backspace => {
2371                        state_for_key.search.backspace();
2372                        // Re-search with updated query
2373                        let lines = collect_transcript_lines(&state_for_key.chat_container);
2374                        state_for_key.search.find_matches(&lines);
2375                        // Update search bar
2376                        let query = state_for_key.search.get_query();
2377                        let match_count = state_for_key.search.get_match_count();
2378                        search_bar.set_query(&query);
2379                        search_bar.set_match_info(0, match_count);
2380                        tui_for_key.request_render(false);
2381                        continue;
2382                    }
2383                    KeyCode::Char(c) => {
2384                        state_for_key.search.append_char(c);
2385                        // Re-search with updated query
2386                        let lines = collect_transcript_lines(&state_for_key.chat_container);
2387                        state_for_key.search.find_matches(&lines);
2388                        // Update search bar
2389                        let query = state_for_key.search.get_query();
2390                        let match_count = state_for_key.search.get_match_count();
2391                        search_bar.set_query(&query);
2392                        search_bar.set_match_info(0, match_count);
2393                        tui_for_key.request_render(false);
2394                        continue;
2395                    }
2396                    _ => continue,
2397                }
2398            }
2399
2400            // 3. Ctrl+L: open the model selector. Routed through the `/model`
2401            //    command so the hotkey and the slash command share one path
2402            //    (TS binds Ctrl+L to model-select).
2403            if keybinding_matches(
2404                &keybindings_for_key,
2405                &key,
2406                rpi_tui::keybindings::keys::MODEL_SELECT,
2407            ) {
2408                if let Some(cmd) = registry_for_key.find("/model") {
2409                    cmd.execute(&ctx_for_key, "");
2410                }
2411                continue;
2412            }
2413
2414            // 4. Tab: accept the top autocomplete suggestion (if any).
2415            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
2416                if accept_top_suggestion(&state_for_key, &editor_for_key) {
2417                    tui_for_key.request_render_reusing_scroll_content();
2418                }
2419                continue;
2420            }
2421
2422            // 5. Global transcript scroll. PageUp/PageDown use the actual
2423            // viewport height with four rows of overlap (upstream behavior),
2424            // while Home/End jump to the transcript boundaries.
2425            if crate::transcript_navigation::handle_key(&tui_for_key, &keybindings_for_key, &key) {
2426                continue;
2427            }
2428
2429            // 5a-bis. ↑/↓ navigate the autocomplete list while it is open
2430            //     (typed `/` or `@`), then Tab accepts the highlighted row.
2431            if key.modifiers == KeyModifiers::NONE
2432                && (key.code == KeyCode::Up || key.code == KeyCode::Down)
2433            {
2434                let direction = if key.code == KeyCode::Up { -1 } else { 1 };
2435                if navigate_autocomplete(&state_for_key, &editor_for_key, direction) {
2436                    tui_for_key.request_render_reusing_scroll_content();
2437                    continue;
2438                }
2439            }
2440
2441            // 5b. ↑/↓ browse submitted-message history when the editor is
2442            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
2443            //     without the surprise of replacing typed text. When the
2444            //     editor holds content, ↑/↓ fall through to cursor movement
2445            //     (typing "hello", pressing ↑ at the start, must never swap
2446            //     the draft for a history entry — reported as "text
2447            //     disappeared"). Once browsing, ↓ walks back and restores the
2448            //     draft.
2449            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
2450                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
2451                if editor_for_key.get_text().is_empty() || browsing {
2452                    navigate_history(&state_for_key, &editor_for_key, -1);
2453                    tui_for_key.request_render_reusing_scroll_content();
2454                    continue;
2455                }
2456            }
2457            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
2458                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
2459                if editor_for_key.get_text().is_empty() || browsing {
2460                    navigate_history(&state_for_key, &editor_for_key, 1);
2461                    tui_for_key.request_render_reusing_scroll_content();
2462                    continue;
2463                }
2464            }
2465
2466            // `app.message.dequeue` (Alt+Q on Windows, Alt+Up elsewhere):
2467            // restore every queued steering/follow-up message into the editor
2468            // so it can be edited before resending. Mirrors upstream's
2469            // `handleDequeue`; the pending-messages display shows the hint.
2470            if keybinding_matches(
2471                &keybindings_for_key,
2472                &key,
2473                rpi_tui::keybindings::keys::DEQUEUE,
2474            ) {
2475                // Handled here, on the key worker — NOT via the mailbox. The
2476                // async main loop parks inside `run_prompt_streaming(..).await`
2477                // for the whole run, and a queue only exists *while* a run is
2478                // in flight, so a `TuiMessage::Dequeue` sat unread until the
2479                // run finished (after the lane had already consumed the queue).
2480                // Alt+Q therefore looked like a no-op while it was most needed.
2481                // Spawn the drain so it lands while the user is still looking at
2482                // the queue, exactly like the Alt+Enter `follow_up` path above.
2483                // Routed through `handle_dequeue` (not the raw restore) so the
2484                // `Restored N queued message(s)` status note fires, mirroring
2485                // upstream's `handleDequeue`.
2486                tokio::spawn(handle_dequeue(
2487                    lane_for_key.clone(),
2488                    editor_for_key.clone(),
2489                    state_for_key.clone(),
2490                    tui_for_key.clone(),
2491                ));
2492                continue;
2493            }
2494
2495            // `app.message.followUp` (Alt+Enter; Ctrl+Q and Alt+Enter on
2496            // Windows) queues a follow-up while a run is active. It is handled
2497            // here because Editor treats only a bare Enter as submit; when idle
2498            // the same key keeps the normal prompt behavior.
2499            if keybinding_matches(
2500                &keybindings_for_key,
2501                &key,
2502                rpi_tui::keybindings::keys::MESSAGE_FOLLOW_UP,
2503            ) {
2504                let prompt = editor_for_key.get_expanded_text().trim().to_string();
2505                if prompt.is_empty() && state_for_key.pending_images.lock().unwrap().is_empty() {
2506                    continue;
2507                }
2508                editor_for_key.clear();
2509                let status = *state_for_key.status.lock().unwrap();
2510                if status == RunStatus::Idle {
2511                    if state_for_key.try_start_working() {
2512                        // Direct send: the harness emits no user `message_start`
2513                        // for it, so render the bubble here (see the submit
2514                        // handler in `interactive_tui`).
2515                        add_user_message(&state_for_key.chat_container, &prompt);
2516                        push_history(&state_for_key, &prompt);
2517                        let _ = tx_for_key.send(TuiMessage::UserInput(
2518                            prompt,
2519                            state_for_key.take_pending_images(),
2520                        ));
2521                    }
2522                } else {
2523                    // Follow-ups are echoed by the loop's
2524                    // `AgentEvent::MessageStart` once they are consumed; while
2525                    // they wait, the pending-messages display shows them.
2526                    let images = state_for_key.take_pending_images();
2527                    let message = user_message_with_images(&prompt, images.clone());
2528                    let lane = lane_for_key.clone();
2529                    let chat = state_for_key.chat_container.clone();
2530                    let tui = tui_for_key.clone();
2531                    let state = state_for_key.clone();
2532                    tokio::spawn(async move {
2533                        if let Err(error) = lane.follow_up(message).await {
2534                            state.restore_pending_images(images);
2535                            add_error_message(&chat, &format!("Could not queue message: {error}"));
2536                            tui.request_render(false);
2537                            return;
2538                        }
2539                        refresh_pending_messages(&state, &lane).await;
2540                        tui.request_render(false);
2541                    });
2542                }
2543                tui_for_key.request_render(false);
2544                continue;
2545            }
2546
2547            // Windows' console backend has no Event::Paste. Assemble text keys
2548            // into one payload; only absorb Enter when another key is queued
2549            // or the clipboard confirms a trailing pasted newline. A standalone
2550            // Enter must reach Editor::handle_key so it submits normally.
2551            if cfg!(windows) {
2552                let text_key = match key.code {
2553                    KeyCode::Char(c)
2554                        if !key
2555                            .modifiers
2556                            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
2557                    {
2558                        Some(c)
2559                    }
2560                    _ => None,
2561                };
2562                if let Some(ch) = text_key {
2563                    if ch == '\r' || ch == '\n' {
2564                        // Treat these the same as KeyCode::Enter below.
2565                    } else {
2566                        windows_paste_run.feed(ch, std::time::Instant::now());
2567                        #[cfg(windows)]
2568                        if windows_paste_run.burst
2569                            && windows_paste_run.text.len() >= 64
2570                            && pending_event.is_none()
2571                            && state_for_key
2572                                .extension_claimed_keys
2573                                .lock()
2574                                .unwrap()
2575                                .is_empty()
2576                        {
2577                            windows_paste_run.read_queued_text();
2578                        }
2579                        continue;
2580                    }
2581                }
2582                if key.modifiers.is_empty()
2583                    && matches!(
2584                        key.code,
2585                        KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
2586                    )
2587                {
2588                    let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
2589                        if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
2590                            crossterm::event::read().ok()
2591                        } else {
2592                            None
2593                        }
2594                    });
2595                    pending_event = stashed;
2596                    let clipboard = if !more_queued && windows_paste_run.burst {
2597                        read_clipboard_text()
2598                    } else {
2599                        None
2600                    };
2601                    if windows_enter_in_paste(&windows_paste_run, more_queued, clipboard.as_deref())
2602                    {
2603                        windows_paste_run.feed('\n', std::time::Instant::now());
2604                        crate::key_trace::note("windows paste run -> newline");
2605                        continue;
2606                    }
2607                    crate::key_trace::note("windows paste run -> flush + submit");
2608                    windows_paste_run.flush(&editor_for_key);
2609                } else {
2610                    windows_paste_run.flush(&editor_for_key);
2611                }
2612            }
2613
2614            // 6. Otherwise forward to the editor + refresh autocomplete. The
2615            //    editor maps unmodified Enter and the encodings a remote client
2616            //    may send for it (`Char('\n')`/`Char('\r')`, and the raw LF
2617            //    crossterm reports as `Ctrl+J`) to submit, so a remote Enter
2618            //    sends instead of inserting a newline.
2619            editor_for_key.handle_key(key);
2620            refresh_autocomplete(&state_for_key, &editor_for_key);
2621            tui_for_key.request_render_reusing_scroll_content();
2622        }
2623    });
2624
2625    // ---- Session lifecycle (P1): SessionStart ----
2626    // The TUI + key worker are up and the session is ready to accept input.
2627    // Notify subscribed extensions now so they can initialize session-scoped
2628    // state. A veto here is **advisory** (the session is already up) — warn and
2629    // continue. No-op when no extension subscribes.
2630    if let Some(reason) = crate::session::dispatch_session_event_async(
2631        reload_context,
2632        rpi_plugin_sdk::EventTag::SessionStart,
2633    )
2634    .await
2635    {
2636        tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
2637    }
2638
2639    // ---- Initial prompts (run before reading from the channel) ----
2640    let mut prompts: Vec<String> = Vec::new();
2641    if let Some(init) = initial {
2642        prompts.push(init);
2643    }
2644    for m in extra_messages {
2645        prompts.push(m.clone());
2646    }
2647    // ---- Continue a run that recovery repaired, the way upstream does ----
2648    // This happens before any user input, and only when nothing was passed on the
2649    // command line: an explicit `-p` means the user has already said what they
2650    // want next. Driven here rather than inside the harness so the run's output
2651    // goes through the same streaming/render path as any other run, and so
2652    // startup is never blocked by a run the user cannot see.
2653    if prompts.is_empty() && lane.has_pending_resume().await {
2654        run_prompt_streaming(
2655            &lane,
2656            "",
2657            true,
2658            &tui,
2659            &state,
2660            drain_handle.is_some(),
2661            Vec::new(),
2662        )
2663        .await;
2664    }
2665
2666    let mut images = initial_images;
2667    for prompt in prompts {
2668        if !*running.lock().unwrap() {
2669            break;
2670        }
2671        // The harness emits no user `message_start` for a directly-sent prompt
2672        // (it drives the loop with an empty prompts vec), so the initial
2673        // `-p` / `--prompt` bubbles are rendered here.
2674        add_user_message(&chat_container, &prompt);
2675        add_image_previews(&chat_container, &images, show_images);
2676        tui.request_render(false);
2677        run_prompt_streaming(
2678            &lane,
2679            &prompt,
2680            false,
2681            &tui,
2682            &state,
2683            drain_handle.is_some(),
2684            std::mem::take(&mut images),
2685        )
2686        .await;
2687    }
2688
2689    // ---- Main loop: process submitted input + lifecycle messages ----
2690    loop {
2691        if !*running.lock().unwrap() {
2692            break;
2693        }
2694        match rx.recv().await {
2695            Some(TuiMessage::UserInput(prompt, prompt_images)) => {
2696                // Clear the editor so the next prompt starts fresh (the submit
2697                // handler runs on the blocking key thread and can't mutate the
2698                // editor state safely there; clearing here, on the async loop,
2699                // keeps it on one thread). Marked programmatic: this is the host
2700                // tidying up after a submit, not the user typing — reporting it
2701                // as a user edit made a hands-free voice session switch itself
2702                // off the moment it was started.
2703                state.clear_editor_programmatically();
2704                add_image_previews(&chat_container, &prompt_images, state.images_visible());
2705                if !prompt_images.is_empty() {
2706                    add_note_message(
2707                        &chat_container,
2708                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
2709                    );
2710                }
2711                run_prompt_streaming(
2712                    &lane,
2713                    &prompt,
2714                    false,
2715                    &tui,
2716                    &state,
2717                    drain_handle.is_some(),
2718                    prompt_images,
2719                )
2720                .await;
2721            }
2722            Some(TuiMessage::ExternalEditorResult(result)) => {
2723                match result {
2724                    Ok(text) => {
2725                        let cursor = text.chars().count();
2726                        editor.set_text(&text);
2727                        editor.set_cursor(0, cursor);
2728                        add_note_message(&chat_container, "Draft updated from external editor.");
2729                    }
2730                    Err(error) => add_error_message(&chat_container, &error),
2731                }
2732                tui.request_render(false);
2733            }
2734            Some(TuiMessage::UserBashFinished(report)) => {
2735                // Free the run slot first: the guard and the Esc router both
2736                // read it, so a finished command must never block the next one.
2737                state.finish_user_bash();
2738                let message = rpi_harness::messages::bash_execution_message(
2739                    report.command,
2740                    report.output,
2741                    report.exit_code,
2742                    report.cancelled,
2743                    report.truncated,
2744                    report.full_output_path,
2745                    Some(report.exclude_from_context),
2746                    now_ms(),
2747                );
2748                // While a run is in flight the message is deferred so it is not
2749                // spliced into the middle of a turn's tool sequence; it is
2750                // flushed at `AgentEnd` (upstream `_pendingBashMessages`).
2751                if *state.status.lock().unwrap() == RunStatus::Idle {
2752                    if let Err(error) = lane.append_message(message).await {
2753                        add_error_message(
2754                            &chat_container,
2755                            &format!("Could not record bash result: {error}"),
2756                        );
2757                        tui.request_render(false);
2758                    }
2759                } else {
2760                    state.pending_bash_messages.lock().unwrap().push(message);
2761                }
2762            }
2763            Some(TuiMessage::OpenTree) => {
2764                if *state.status.lock().unwrap() != RunStatus::Idle {
2765                    add_note_message(
2766                        &chat_container,
2767                        "Wait for the current run to finish before opening the tree.",
2768                    );
2769                    tui.request_render(false);
2770                } else {
2771                    open_tree_selector(
2772                        &harness,
2773                        &state,
2774                        &editor_container,
2775                        &editor,
2776                        &tui,
2777                        &chat_container,
2778                        &tx,
2779                    )
2780                    .await;
2781                }
2782            }
2783            Some(TuiMessage::NavigateTree(entry_id)) => {
2784                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
2785                    Ok(result) => match result.outcome {
2786                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
2787                            chat_container.clear();
2788                            add_welcome_message(&chat_container);
2789                            render_session_history(
2790                                &harness,
2791                                &chat_container,
2792                                state.markdown_transformer(),
2793                                Some(state.extension_session.clone()),
2794                                state.images_visible(),
2795                            )
2796                            .await;
2797                            add_note_message(
2798                                &chat_container,
2799                                "Moved to the selected session entry.",
2800                            );
2801                        }
2802                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
2803                            add_error_message(&chat_container, &error.message);
2804                        }
2805                        _ => add_note_message(
2806                            &chat_container,
2807                            "The selected entry could not be opened.",
2808                        ),
2809                    },
2810                    Err(error) => add_error_message(
2811                        &chat_container,
2812                        &format!("Could not navigate session tree: {error}"),
2813                    ),
2814                }
2815                tui.request_render(false);
2816            }
2817            Some(TuiMessage::ClearChat) => {
2818                chat_container.clear();
2819                add_welcome_message(&chat_container);
2820                tui.request_render(false);
2821            }
2822            Some(TuiMessage::Compact) => {
2823                run_compact(&lane, &tui, &state).await;
2824            }
2825            Some(TuiMessage::Copy) => {
2826                copy_last_assistant(&state, &chat_container);
2827                tui.request_render(false);
2828            }
2829            Some(TuiMessage::Exit) => {
2830                *running.lock().unwrap() = false;
2831                break;
2832            }
2833            Some(TuiMessage::SwitchSession(id)) => {
2834                switch_to_session(
2835                    &harness,
2836                    &lane,
2837                    &id,
2838                    &cwd,
2839                    &chat_container,
2840                    &state,
2841                    &reload_context.tool_context,
2842                )
2843                .await;
2844                tui.request_render(false);
2845            }
2846            Some(TuiMessage::ImportSession(path)) => {
2847                import_session(
2848                    &harness,
2849                    &lane,
2850                    &path,
2851                    &cwd,
2852                    &chat_container,
2853                    &state,
2854                    &reload_context.tool_context,
2855                )
2856                .await;
2857                tui.request_render(false);
2858            }
2859            Some(TuiMessage::ShareSession) => {
2860                share_session(&harness, &chat_container).await;
2861                tui.request_render(false);
2862            }
2863            Some(TuiMessage::SetSessionName(name)) => {
2864                let outcome = harness.session().set_name(Some(&name)).await;
2865                match outcome {
2866                    Ok(_) => add_note_message(
2867                        &chat_container,
2868                        &format!("Session renamed to \"{name}\"."),
2869                    ),
2870                    Err(e) => add_error_message(
2871                        &chat_container,
2872                        &format!("Could not rename session: {e}"),
2873                    ),
2874                }
2875                tui.request_render(false);
2876            }
2877            Some(TuiMessage::ExportSession) => {
2878                let file_name = agent_session
2879                    .default_export_file_name(crate::export::ExportFormat::Markdown)
2880                    .await;
2881                let path = cwd.join(&file_name);
2882                match agent_session.export_to_markdown(&path).await {
2883                    Ok(_) => add_note_message(
2884                        &chat_container,
2885                        &format!("Exported session to {}", path.display()),
2886                    ),
2887                    Err(e) => {
2888                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
2889                    }
2890                }
2891                tui.request_render(false);
2892            }
2893            Some(TuiMessage::ExportSessionWithFormat(format)) => {
2894                let file_name = agent_session.default_export_file_name(format.clone()).await;
2895                let path = cwd.join(&file_name);
2896                match agent_session.export(format, &path).await {
2897                    Ok(_) => add_note_message(
2898                        &chat_container,
2899                        &format!("Exported session to {}", path.display()),
2900                    ),
2901                    Err(e) => {
2902                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
2903                    }
2904                }
2905                tui.request_render(false);
2906            }
2907            Some(TuiMessage::ShowUsage) => {
2908                let stats = agent_session.usage_stats().await.ok();
2909                let total = agent_session.total_usage().await.ok();
2910                let snapshot = agent_session.snapshot().await.ok();
2911                match stats {
2912                    Some(stats) => {
2913                        let mut lines = vec![
2914                            "## Usage".to_string(),
2915                            String::new(),
2916                            format!("- messages: {}", stats.message_count),
2917                            format!("- total tokens: {}", stats.total_tokens),
2918                            format!("- cached tokens: {}", stats.cached_tokens),
2919                            format!("- uncached tokens: {}", stats.uncached_tokens),
2920                            format!("- cost: ${:.4}", stats.cost_total),
2921                        ];
2922                        match stats.cache_hit_ratio() {
2923                            Some(ratio) => {
2924                                lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
2925                            }
2926                            None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
2927                        }
2928                        if let Some(total) = total {
2929                            lines.push(String::new());
2930                            lines.push(format!(
2931                                "- last cumulative input/output: {} / {}",
2932                                total.input, total.output
2933                            ));
2934                        }
2935                        if let Some(snapshot) = snapshot {
2936                            lines.push(String::new());
2937                            lines.push(format!("- lane: `{}`", snapshot.lane));
2938                            lines.push(format!(
2939                                "- leaf: `{}`",
2940                                snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
2941                            ));
2942                            if let Some(active) = &snapshot.active {
2943                                lines.push(format!(
2944                                    "- active operation: {} (`{}`)",
2945                                    active.kind.as_str(),
2946                                    active.run_id
2947                                ));
2948                            }
2949                        }
2950                        add_note_message(&chat_container, &lines.join("\n"));
2951                    }
2952                    None => {
2953                        add_error_message(&chat_container, "Could not read session usage stats.")
2954                    }
2955                }
2956                tui.request_render(false);
2957            }
2958            Some(TuiMessage::ForkSession) => {
2959                fork_session(
2960                    &harness,
2961                    &cwd,
2962                    &chat_container,
2963                    &state,
2964                    &reload_context.tool_context,
2965                )
2966                .await;
2967                tui.request_render(false);
2968            }
2969            Some(TuiMessage::ReloadExtensions) => {
2970                // B5d: drive the shared reload routine on the async runtime,
2971                // then surface the outcome. `reload_context` was passed into
2972                // `interactive_tui` and is the same `Arc<ReloadContext>` the
2973                // `ReloadCommand` + the plugin mailbox both route through —
2974                // clone the `Arc` out so the borrow of `harness` (the main
2975                // loop's `&AgentHarness`) lives across the await.
2976                let reload_ctx = ctx.reload_context.clone();
2977                add_note_message(&chat_container, "Reloading extensions + resources…");
2978                tui.request_render(false);
2979                let outcome =
2980                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
2981                if !outcome.had_errors {
2982                    if let Ok(resources) = harness.get_resources().await {
2983                        update_resource_listing(
2984                            &ctx.resources,
2985                            ctx.startup_context.as_ref(),
2986                            resources,
2987                        );
2988                    }
2989                }
2990                // B5e: the reload swapped a fresh `ExtensionSession` into the
2991                // context's cell. Rebuild the markdown transformer from that
2992                // fresh snapshot and install it on the in-flight streaming
2993                // component (so a reloaded plugin's transformer takes effect on
2994                // the visible message immediately) + future components (they
2995                // read `state.markdown_transformer()` at construction). The old
2996                // closure no-ops once its snapshot's `active` flag flips false
2997                // (reload already did that before the swap).
2998                let fresh_transformer = build_markdown_transformer(
2999                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
3000                );
3001                state.set_markdown_transformer_with_reinstall(fresh_transformer);
3002                if outcome.had_errors {
3003                    // Hard failure: the summary itself carries the error detail
3004                    // (e.g. "Settings reload failed: …"), so render it as an
3005                    // error without an extra stderr pointer.
3006                    add_error_message(&chat_container, &outcome.summary);
3007                } else if outcome.had_warnings {
3008                    // Reload succeeded but produced soft diagnostics (skill
3009                    // shadowing, name validation, …). The details are printed
3010                    // to stderr; keep this as an informational note so a
3011                    // successful reload isn't shown as a red ✗ error.
3012                    add_note_message(
3013                        &chat_container,
3014                        &format!(
3015                            "{} (with warnings — see stderr for details).",
3016                            outcome.summary
3017                        ),
3018                    );
3019                } else {
3020                    add_note_message(&chat_container, &outcome.summary);
3021                }
3022                tui.request_render(false);
3023            }
3024            None => break,
3025        }
3026    }
3027
3028    // ---- Shutdown ----
3029    ask_user_bridge.shutdown();
3030    *running.lock().unwrap() = false;
3031    for handle in update_check_handles {
3032        handle.abort();
3033        let _ = handle.await;
3034    }
3035    // The input worker checks `running` at least every 50ms. Join it before
3036    // restoring cooked mode so no late event read races terminal cleanup.
3037    let _ = key_handle.await;
3038    tick_handle.abort();
3039    if let Some(handle) = drain_handle {
3040        handle.abort();
3041    }
3042    // Drop the reload bridge: clearing the mailbox closes the signal channel,
3043    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
3044    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
3045    reload_context.mailbox.clear();
3046    reload_bridge_handle.abort();
3047    tui.stop(Default::default());
3048
3049    // upstream prints how to resume the session after an interactive exit so
3050    // a terminal that scrolled away can be recovered. Mirrors
3051    // `formatResumeCommand` (id + `--session-dir` only when non-default).
3052    let session_id = harness.session().storage().metadata().id;
3053    if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
3054        println!("\x1b[2mTo resume this session:\x1b[0m {command}");
3055    }
3056    println!("\nGoodbye!");
3057    let _ = args;
3058
3059    0
3060}
3061
3062// ===========================================================================
3063// TUI support + entry detection
3064// ===========================================================================
3065
3066/// Check if the terminal supports TUI mode.
3067pub fn is_tui_supported() -> bool {
3068    std::io::stdout().is_terminal()
3069}
3070
3071// Keep the `Color` import used (theme accent rendering in autocomplete).
3072#[allow(unused_imports)]
3073use rpi_tui::Color as _Color;