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 if let Some(extension_editor) = state_for_key
1919                    .active_extension_editor
1920                    .lock()
1921                    .unwrap()
1922                    .clone()
1923                {
1924                    extension_editor.handle_key(key);
1925                } else if let Some(extension_input) =
1926                    state_for_key.active_extension_input.lock().unwrap().clone()
1927                {
1928                    extension_input.handle_key(key);
1929                }
1930                tui_for_key.request_render_reusing_scroll_content();
1931                continue;
1932            }
1933
1934            // Pet Esc pauses audio; an active run still reaches normal abort
1935            // routing below. Dialogs and selectors retain their own Escape.
1936            if key.code == KeyCode::Esc
1937                && key.modifiers == KeyModifiers::NONE
1938                && !state_for_key.selector_open()
1939                && pet::active(&state_for_key.ext_status)
1940            {
1941                let _ = invoke_extension_command(&state_for_key.extension_session, "pet", "quiet");
1942                tui_for_key.request_render(false);
1943                if *state_for_key.status.lock().unwrap() == RunStatus::Idle
1944                    && editor_for_key.get_text().is_empty()
1945                {
1946                    continue;
1947                }
1948            }
1949
1950            // 0. Ctrl+C: upstream's `handleCtrlC`. A second press inside the
1951            //    double-press window quits; any other press only clears the
1952            //    editor (text *and* selection — native `clearEditor`). It never
1953            //    aborts — `app.interrupt` (Esc) owns cancelling a run — so a
1954            //    stray Ctrl+C while streaming can no longer kill the turn.
1955            //    Open selectors and extension dialogs are handled above so
1956            //    their cancellation callbacks get first chance.
1957            if keybinding_matches(
1958                &keybindings_for_key,
1959                &key,
1960                rpi_tui::keybindings::keys::CLEAR,
1961            ) {
1962                // A held key auto-repeats: only a fresh Press may arm or
1963                // consume the window, so holding Ctrl+C cannot turn one tap
1964                // into the quit.
1965                if key.kind != KeyEventKind::Repeat {
1966                    let now = std::time::Instant::now();
1967                    match ctrl_c_action(last_sigint_time, now) {
1968                        CtrlCAction::Exit => {
1969                            last_sigint_time = None;
1970                            // The async loop parks inside
1971                            // `run_prompt_streaming(..).await` for the whole run,
1972                            // so a queued `Exit` would not be read until the run
1973                            // returns — force the quit while one is active. Either
1974                            // way the TUI stays quittable.
1975                            if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
1976                                let _ = tx_for_key.send(TuiMessage::Exit);
1977                            } else {
1978                                emergency_exit(&tui_for_key);
1979                            }
1980                        }
1981                        CtrlCAction::Clear => {
1982                            editor_for_key.clear();
1983                            state_for_key.take_pending_images();
1984                            refresh_autocomplete(&state_for_key, &editor_for_key);
1985                            last_sigint_time = Some(now);
1986                            tui_for_key.request_render(false);
1987                        }
1988                    }
1989                }
1990                continue;
1991            }
1992
1993            // 0b. Ctrl+X: upstream's `app.message.copy` — copy the editor
1994            //     selection, else the last assistant reply. Native's Ctrl+C no
1995            //     longer copies, so selection-copy lives here now.
1996            if !state_for_key.selector_open()
1997                && keybinding_matches(
1998                    &keybindings_for_key,
1999                    &key,
2000                    rpi_tui::keybindings::keys::MESSAGE_COPY,
2001                )
2002            {
2003                if editor_for_key.has_selection() && editor_for_key.copy_selection() {
2004                    add_note_message(&state_for_key.chat_container, "Copied!");
2005                } else {
2006                    copy_last_assistant(&state_for_key, &state_for_key.chat_container);
2007                }
2008                tui_for_key.request_render(false);
2009                continue;
2010            }
2011
2012            // 0c. Ctrl+Z (Unix only): upstream's `app.suspend` — hand the
2013            //     terminal back to the shell and stop ourselves; `SIGTSTP`
2014            //     returns when the user resumes with `fg`. Windows binds no key
2015            //     here (native's `defaultKeys` is empty), so this never fires.
2016            if keybinding_matches(
2017                &keybindings_for_key,
2018                &key,
2019                rpi_tui::keybindings::keys::SUSPEND,
2020            ) {
2021                handle_suspend(&tui_for_key);
2022                continue;
2023            }
2024
2025            // 1. A selector overlay is open → route to it first. Only Esc
2026            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
2027            //    escape to the selector; on done/cancel the selector callbacks
2028            //    restore the editor and clear `active_selector`.
2029            if state_for_key.selector_open() {
2030                // Esc always cancels the selector (even with modifiers off).
2031                // Route through `SelectList::handle_key(Esc)` so the list's
2032                // `on_cancel` fires (the `/scoped-models` toggle selector saves
2033                // its edits there) — the old shortcut called `close_selector`
2034                // directly and skipped the callback.
2035                if key.code == KeyCode::Esc {
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                    continue;
2044                }
2045                let (selector, _kind) = state_for_key
2046                    .active_selector
2047                    .lock()
2048                    .unwrap()
2049                    .clone()
2050                    .expect("selector_open guaranteed Some");
2051                selector.handle_key(key);
2052                tui_for_key.request_render_reusing_scroll_content();
2053                continue;
2054            }
2055
2056            // 2a. Ctrl+D: upstream's `app.exit`. `handleCtrlD` only fires when
2057            //     the editor is empty; with text present the key falls through to
2058            //     the editor's deleteCharForward (`tui.editor.deleteCharForward`).
2059            //     It never aborts — that is `app.interrupt` (Esc)'s job.
2060            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
2061                if !editor_for_key.get_text().is_empty() {
2062                    // Editor holds text — delete the char forward (pi parity).
2063                    editor_for_key.handle_key(key);
2064                    refresh_autocomplete(&state_for_key, &editor_for_key);
2065                    tui_for_key.request_render_reusing_scroll_content();
2066                    continue;
2067                }
2068                // Empty editor → quit. A parked run needs the forced path: a
2069                // queued `Exit` is unread until the run returns.
2070                if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
2071                    let _ = tx_for_key.send(TuiMessage::Exit);
2072                } else {
2073                    emergency_exit(&tui_for_key);
2074                }
2075                continue;
2076            }
2077
2078            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
2079            //     selector is open Esc already cancelled it above; when idle,
2080            //     Esc falls through to the editor (no-op-ish). Only fire while
2081            //     Working so an idle Esc doesn't abort a non-existent run.
2082            if keybinding_matches(
2083                &keybindings_for_key,
2084                &key,
2085                rpi_tui::keybindings::keys::INTERRUPT,
2086            ) {
2087                let status = *state_for_key.status.lock().unwrap();
2088                if status == RunStatus::Working {
2089                    state_for_key.set_status(RunStatus::Aborting);
2090                    ask_user_for_key.cancel_all();
2091                    if !run_extension_cancel(&state_for_key)
2092                        && state_for_key.extension_dialog_open()
2093                    {
2094                        close_extension_editor(
2095                            &state_for_key,
2096                            &ctx_for_key.editor_container,
2097                            &editor_for_key,
2098                            &tui_for_key,
2099                        );
2100                    }
2101                    // upstream's `onEscape` restores the queue before it
2102                    // aborts, so an interrupt hands the staged messages back.
2103                    abort_run_restoring_queue(
2104                        lane_for_key.clone(),
2105                        editor_for_key.clone(),
2106                        state_for_key.clone(),
2107                        tui_for_key.clone(),
2108                    );
2109                    continue;
2110                }
2111                // A running `!command` takes Esc next (upstream's `onEscape`
2112                // precedence: streaming → bash → bash-mode editor →
2113                // double-escape). The run slot stays occupied until the capture
2114                // resolves, so a second Esc is a no-op rather than a
2115                // double-escape trigger.
2116                if state_for_key.user_bash_running() {
2117                    state_for_key.cancel_user_bash();
2118                    tui_for_key.request_render(false);
2119                    continue;
2120                }
2121                if status == RunStatus::Idle
2122                    && editor_for_key.get_text().trim().is_empty()
2123                    && double_escape_action != "none"
2124                {
2125                    let now = std::time::Instant::now();
2126                    if double_escape_trigger(last_escape_time, now) {
2127                        last_escape_time = None;
2128                        match double_escape_action.as_str() {
2129                            "tree" => {
2130                                let _ = tx_for_key.send(TuiMessage::OpenTree);
2131                            }
2132                            "fork" => {
2133                                let _ = tx_for_key.send(TuiMessage::ForkSession);
2134                            }
2135                            _ => {}
2136                        }
2137                    } else {
2138                        last_escape_time = Some(now);
2139                    }
2140                }
2141                continue;
2142            }
2143
2144            // 2c. Ctrl+G: edit the current draft in the user's external
2145            // editor, matching upstream's VISUAL/EDITOR fallback chain.
2146            if keybinding_matches(
2147                &keybindings_for_key,
2148                &key,
2149                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
2150            ) {
2151                launch_external_editor(editor_for_key.get_expanded_text(), tx_for_key.clone());
2152                continue;
2153            }
2154
2155            // 2d. Ctrl+O: toggle all tool output panels between compact and
2156            // expanded rendering (upstream's global output toggle).
2157            if keybinding_matches(
2158                &keybindings_for_key,
2159                &key,
2160                rpi_tui::keybindings::keys::TOOLS_EXPAND,
2161            ) {
2162                let expanded = state_for_key.toggle_tool_outputs();
2163                // Persist per-session so a later `-c`/`-r` restores it.
2164                let session = ctx_for_key.session.clone();
2165                tokio::spawn(async move {
2166                    let _ = session
2167                        .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
2168                        .await;
2169                });
2170                tui_for_key.request_render(false);
2171                continue;
2172            }
2173
2174            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
2175            if keybinding_matches(
2176                &keybindings_for_key,
2177                &key,
2178                rpi_tui::keybindings::keys::THINKING_TOGGLE,
2179            ) {
2180                let hide = state_for_key.toggle_thinking();
2181                // Persist per-session so a later `-c`/`-r` restores it.
2182                let session = ctx_for_key.session.clone();
2183                tokio::spawn(async move {
2184                    let _ = session
2185                        .set_value("display.hide_thinking", serde_json::json!(hide))
2186                        .await;
2187                });
2188                tui_for_key.request_render(false);
2189                continue;
2190            }
2191
2192            // 2f. Ctrl+P: cycle to the next model in the catalog after the one
2193            //     currently tracked in `current_model_id`, apply it live via
2194            //     `lane.set_model` (takes effect on the next user message — the
2195            //     in-flight run's config is already snapshotted), and update the
2196            //     footer. `set_model` is async so it runs on a spawned task.
2197            if keybinding_matches(
2198                &keybindings_for_key,
2199                &key,
2200                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
2201            ) {
2202                let current = state_for_key.current_model_id();
2203                // Cycle within the `/scoped-models` set (settings.json) when
2204                // configured; otherwise the full catalog, restricted to the
2205                // current provider (the lane cannot route another provider).
2206                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
2207                let scope = same_provider_catalog(&scope, &current);
2208                if let Some(next) = cycle_next_model(&scope, &current) {
2209                    state_for_key.set_current_model(&next);
2210                    let lane = lane_for_key.clone();
2211                    tokio::spawn(async move {
2212                        let _ = lane.set_model(next).await;
2213                    });
2214                    tui_for_key.request_render_reusing_scroll_content();
2215                }
2216                continue;
2217            }
2218
2219            // 2f-bis. Shift+Ctrl+P (Alt+P on Windows): cycle to the *previous*
2220            //     model, the mirror of the Ctrl+P hotkey above.
2221            if keybinding_matches(
2222                &keybindings_for_key,
2223                &key,
2224                rpi_tui::keybindings::keys::MODEL_CYCLE_BACKWARD,
2225            ) {
2226                let current = state_for_key.current_model_id();
2227                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
2228                let scope = same_provider_catalog(&scope, &current);
2229                if let Some(prev) = cycle_prev_model(&scope, &current) {
2230                    state_for_key.set_current_model(&prev);
2231                    let lane = lane_for_key.clone();
2232                    tokio::spawn(async move {
2233                        let _ = lane.set_model(prev).await;
2234                    });
2235                    tui_for_key.request_render_reusing_scroll_content();
2236                }
2237                continue;
2238            }
2239
2240            // 2f. Shift+Tab / BackTab: cycle the current model's supported
2241            // thinking levels, matching upstream's thinking-level shortcut.
2242            if keybinding_matches(
2243                &keybindings_for_key,
2244                &key,
2245                rpi_tui::keybindings::keys::THINKING_CYCLE,
2246            ) {
2247                let lane = lane_for_key.clone();
2248                let catalog = model_catalog_for_key.clone();
2249                let state = state_for_key.clone();
2250                tokio::spawn(async move {
2251                    let Ok(current_model) = lane.get_model().await else {
2252                        return;
2253                    };
2254                    let levels = catalog
2255                        .iter()
2256                        .find(|model| {
2257                            model.provider == current_model.provider && model.id == current_model.id
2258                        })
2259                        .map(|model| model.supported_thinking_levels())
2260                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
2261                    if levels.is_empty() {
2262                        return;
2263                    }
2264                    let current = lane
2265                        .get_thinking_level()
2266                        .await
2267                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
2268                    let next = levels
2269                        .iter()
2270                        .position(|level| *level == current)
2271                        .map(|index| levels[(index + 1) % levels.len()])
2272                        .unwrap_or(levels[0]);
2273                    if lane.set_thinking_level(next).await.is_ok() {
2274                        state
2275                            .footer
2276                            .set_thinking_level(Some(thinking_level_name(next)));
2277                        state.tui.as_ref().map(|tui| tui.request_render(false));
2278                    }
2279                });
2280                continue;
2281            }
2282
2283            // Ctrl+V (or a configured paste-image key): if the system
2284            // clipboard holds an image, queue it for the next prompt;
2285            // otherwise paste the clipboard TEXT into the editor. Previously
2286            // a text-only clipboard fell through to the editor's Ctrl+V,
2287            // which yanks from the internal KILL RING — not the system
2288            // clipboard. On platforms without bracketed-paste `Event::Paste`
2289            // (Windows console, some remote clients) that meant Ctrl+V pasted
2290            // stale/other content instead of the user's real clipboard
2291            // ("不是系统的粘贴的内容").
2292            if keybinding_matches(
2293                &keybindings_for_key,
2294                &key,
2295                rpi_tui::keybindings::keys::PASTE_IMAGE,
2296            ) {
2297                if let Some(paths) = read_clipboard_file_paths() {
2298                    let text = paths.join("\n");
2299                    if let Some(images) = images_from_pasted_paths(&text) {
2300                        for image in images {
2301                            state_for_key.queue_image(image);
2302                        }
2303                        add_note_message(
2304                            &state_for_key.chat_container,
2305                            "Clipboard images attached to the next prompt.",
2306                        );
2307                    } else {
2308                        editor_for_key.handle_paste(&text);
2309                    }
2310                    tui_for_key.request_render(false);
2311                    continue;
2312                }
2313                match read_clipboard_image() {
2314                    Ok(Some(image)) => {
2315                        state_for_key.queue_image(image);
2316                        add_note_message(
2317                            &state_for_key.chat_container,
2318                            "Clipboard image attached to the next prompt.",
2319                        );
2320                        tui_for_key.request_render(false);
2321                        continue;
2322                    }
2323                    Ok(None) | Err(_) => {}
2324                }
2325                // Local Ctrl+V can paste clipboard text as Pi does. Under SSH,
2326                // text must arrive from the client via bracketed paste rather
2327                // than reading the remote host's unrelated clipboard.
2328                if cfg!(windows) || std::env::var_os("SSH_CONNECTION").is_none() {
2329                    if let Some(text) = read_clipboard_text() {
2330                        if !text.is_empty() {
2331                            if !attach_pasted_images(&state_for_key, &text) {
2332                                editor_for_key.handle_paste(&text);
2333                            }
2334                            refresh_autocomplete(&state_for_key, &editor_for_key);
2335                            tui_for_key.request_render_reusing_scroll_content();
2336                            continue;
2337                        }
2338                    }
2339                }
2340            }
2341
2342            // 3. Ctrl+Shift+F: open transcript search
2343            if key
2344                .modifiers
2345                .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
2346                && key.code == KeyCode::Char('F')
2347            {
2348                state_for_key.search.activate();
2349                state_for_key.search_bar.set_visible(true);
2350                state_for_key.search_bar.set_query("");
2351                state_for_key.search_bar.set_match_info(0, 0);
2352                tui_for_key.request_render(false);
2353                continue;
2354            }
2355
2356            // Handle search mode input
2357            if state_for_key.search.is_active() {
2358                let search_bar = state_for_key.search_bar.clone();
2359                match key.code {
2360                    KeyCode::Esc => {
2361                        state_for_key.search.deactivate();
2362                        search_bar.set_visible(false);
2363                        tui_for_key.request_render(false);
2364                        continue;
2365                    }
2366                    KeyCode::Enter => {
2367                        if key.modifiers.contains(KeyModifiers::SHIFT) {
2368                            state_for_key.search.previous_match();
2369                        } else {
2370                            state_for_key.search.next_match();
2371                        }
2372                        // Update search bar with current match info
2373                        let match_index = state_for_key.search.get_match_index();
2374                        let match_count = state_for_key.search.get_match_count();
2375                        search_bar.set_match_info(match_index, match_count);
2376                        tui_for_key.request_render(false);
2377                        continue;
2378                    }
2379                    KeyCode::Backspace => {
2380                        state_for_key.search.backspace();
2381                        // Re-search with updated query
2382                        let lines = collect_transcript_lines(&state_for_key.chat_container);
2383                        state_for_key.search.find_matches(&lines);
2384                        // Update search bar
2385                        let query = state_for_key.search.get_query();
2386                        let match_count = state_for_key.search.get_match_count();
2387                        search_bar.set_query(&query);
2388                        search_bar.set_match_info(0, match_count);
2389                        tui_for_key.request_render(false);
2390                        continue;
2391                    }
2392                    KeyCode::Char(c) => {
2393                        state_for_key.search.append_char(c);
2394                        // Re-search with updated query
2395                        let lines = collect_transcript_lines(&state_for_key.chat_container);
2396                        state_for_key.search.find_matches(&lines);
2397                        // Update search bar
2398                        let query = state_for_key.search.get_query();
2399                        let match_count = state_for_key.search.get_match_count();
2400                        search_bar.set_query(&query);
2401                        search_bar.set_match_info(0, match_count);
2402                        tui_for_key.request_render(false);
2403                        continue;
2404                    }
2405                    _ => continue,
2406                }
2407            }
2408
2409            // 3. Ctrl+L: open the model selector. Routed through the `/model`
2410            //    command so the hotkey and the slash command share one path
2411            //    (TS binds Ctrl+L to model-select).
2412            if keybinding_matches(
2413                &keybindings_for_key,
2414                &key,
2415                rpi_tui::keybindings::keys::MODEL_SELECT,
2416            ) {
2417                if let Some(cmd) = registry_for_key.find("/model") {
2418                    cmd.execute(&ctx_for_key, "");
2419                }
2420                continue;
2421            }
2422
2423            // 4. Tab: accept the top autocomplete suggestion (if any).
2424            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
2425                if accept_top_suggestion(&state_for_key, &editor_for_key) {
2426                    tui_for_key.request_render_reusing_scroll_content();
2427                }
2428                continue;
2429            }
2430
2431            // 5. Global transcript scroll. PageUp/PageDown use the actual
2432            // viewport height with four rows of overlap (upstream behavior),
2433            // while Home/End jump to the transcript boundaries.
2434            if crate::transcript_navigation::handle_key(&tui_for_key, &keybindings_for_key, &key) {
2435                continue;
2436            }
2437
2438            // 5a-bis. ↑/↓ navigate the autocomplete list while it is open
2439            //     (typed `/` or `@`), then Tab accepts the highlighted row.
2440            if key.modifiers == KeyModifiers::NONE
2441                && (key.code == KeyCode::Up || key.code == KeyCode::Down)
2442            {
2443                let direction = if key.code == KeyCode::Up { -1 } else { 1 };
2444                if navigate_autocomplete(&state_for_key, &editor_for_key, direction) {
2445                    tui_for_key.request_render_reusing_scroll_content();
2446                    continue;
2447                }
2448            }
2449
2450            // 5b. ↑/↓ browse submitted-message history when the editor is
2451            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
2452            //     without the surprise of replacing typed text. When the
2453            //     editor holds content, ↑/↓ fall through to cursor movement
2454            //     (typing "hello", pressing ↑ at the start, must never swap
2455            //     the draft for a history entry — reported as "text
2456            //     disappeared"). Once browsing, ↓ walks back and restores the
2457            //     draft.
2458            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
2459                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
2460                if editor_for_key.get_text().is_empty() || browsing {
2461                    navigate_history(&state_for_key, &editor_for_key, -1);
2462                    tui_for_key.request_render_reusing_scroll_content();
2463                    continue;
2464                }
2465            }
2466            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
2467                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
2468                if editor_for_key.get_text().is_empty() || browsing {
2469                    navigate_history(&state_for_key, &editor_for_key, 1);
2470                    tui_for_key.request_render_reusing_scroll_content();
2471                    continue;
2472                }
2473            }
2474
2475            // `app.message.dequeue` (Alt+Q on Windows, Alt+Up elsewhere):
2476            // restore every queued steering/follow-up message into the editor
2477            // so it can be edited before resending. Mirrors upstream's
2478            // `handleDequeue`; the pending-messages display shows the hint.
2479            if keybinding_matches(
2480                &keybindings_for_key,
2481                &key,
2482                rpi_tui::keybindings::keys::DEQUEUE,
2483            ) {
2484                // Handled here, on the key worker — NOT via the mailbox. The
2485                // async main loop parks inside `run_prompt_streaming(..).await`
2486                // for the whole run, and a queue only exists *while* a run is
2487                // in flight, so a `TuiMessage::Dequeue` sat unread until the
2488                // run finished (after the lane had already consumed the queue).
2489                // Alt+Q therefore looked like a no-op while it was most needed.
2490                // Spawn the drain so it lands while the user is still looking at
2491                // the queue, exactly like the Alt+Enter `follow_up` path above.
2492                // Routed through `handle_dequeue` (not the raw restore) so the
2493                // `Restored N queued message(s)` status note fires, mirroring
2494                // upstream's `handleDequeue`.
2495                tokio::spawn(handle_dequeue(
2496                    lane_for_key.clone(),
2497                    editor_for_key.clone(),
2498                    state_for_key.clone(),
2499                    tui_for_key.clone(),
2500                ));
2501                continue;
2502            }
2503
2504            // `app.message.followUp` (Alt+Enter; Ctrl+Q and Alt+Enter on
2505            // Windows) queues a follow-up while a run is active. It is handled
2506            // here because Editor treats only a bare Enter as submit; when idle
2507            // the same key keeps the normal prompt behavior.
2508            if keybinding_matches(
2509                &keybindings_for_key,
2510                &key,
2511                rpi_tui::keybindings::keys::MESSAGE_FOLLOW_UP,
2512            ) {
2513                let prompt = editor_for_key.get_expanded_text().trim().to_string();
2514                if prompt.is_empty() && state_for_key.pending_images.lock().unwrap().is_empty() {
2515                    continue;
2516                }
2517                editor_for_key.clear();
2518                let status = *state_for_key.status.lock().unwrap();
2519                if status == RunStatus::Idle {
2520                    if state_for_key.try_start_working() {
2521                        // Direct send: the harness emits no user `message_start`
2522                        // for it, so render the bubble here (see the submit
2523                        // handler in `interactive_tui`).
2524                        add_user_message(&state_for_key.chat_container, &prompt);
2525                        push_history(&state_for_key, &prompt);
2526                        let _ = tx_for_key.send(TuiMessage::UserInput(
2527                            prompt,
2528                            state_for_key.take_pending_images(),
2529                        ));
2530                    }
2531                } else {
2532                    // Follow-ups are echoed by the loop's
2533                    // `AgentEvent::MessageStart` once they are consumed; while
2534                    // they wait, the pending-messages display shows them.
2535                    let images = state_for_key.take_pending_images();
2536                    let message = user_message_with_images(&prompt, images.clone());
2537                    let lane = lane_for_key.clone();
2538                    let chat = state_for_key.chat_container.clone();
2539                    let tui = tui_for_key.clone();
2540                    let state = state_for_key.clone();
2541                    tokio::spawn(async move {
2542                        if let Err(error) = lane.follow_up(message).await {
2543                            state.restore_pending_images(images);
2544                            add_error_message(&chat, &format!("Could not queue message: {error}"));
2545                            tui.request_render(false);
2546                            return;
2547                        }
2548                        refresh_pending_messages(&state, &lane).await;
2549                        tui.request_render(false);
2550                    });
2551                }
2552                tui_for_key.request_render(false);
2553                continue;
2554            }
2555
2556            // Windows' console backend has no Event::Paste. Assemble text keys
2557            // into one payload; only absorb Enter when another key is queued
2558            // or the clipboard confirms a trailing pasted newline. A standalone
2559            // Enter must reach Editor::handle_key so it submits normally.
2560            if cfg!(windows) {
2561                let text_key = match key.code {
2562                    KeyCode::Char(c)
2563                        if !key
2564                            .modifiers
2565                            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
2566                    {
2567                        Some(c)
2568                    }
2569                    _ => None,
2570                };
2571                if let Some(ch) = text_key {
2572                    if ch == '\r' || ch == '\n' {
2573                        // Treat these the same as KeyCode::Enter below.
2574                    } else {
2575                        windows_paste_run.feed(ch, std::time::Instant::now());
2576                        #[cfg(windows)]
2577                        if windows_paste_run.burst
2578                            && windows_paste_run.text.len() >= 64
2579                            && pending_event.is_none()
2580                            && state_for_key
2581                                .extension_claimed_keys
2582                                .lock()
2583                                .unwrap()
2584                                .is_empty()
2585                        {
2586                            windows_paste_run.read_queued_text();
2587                        }
2588                        continue;
2589                    }
2590                }
2591                if key.modifiers.is_empty()
2592                    && matches!(
2593                        key.code,
2594                        KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
2595                    )
2596                {
2597                    let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
2598                        if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
2599                            crossterm::event::read().ok()
2600                        } else {
2601                            None
2602                        }
2603                    });
2604                    pending_event = stashed;
2605                    let clipboard = if !more_queued && windows_paste_run.burst {
2606                        read_clipboard_text()
2607                    } else {
2608                        None
2609                    };
2610                    if windows_enter_in_paste(&windows_paste_run, more_queued, clipboard.as_deref())
2611                    {
2612                        windows_paste_run.feed('\n', std::time::Instant::now());
2613                        crate::key_trace::note("windows paste run -> newline");
2614                        continue;
2615                    }
2616                    crate::key_trace::note("windows paste run -> flush + submit");
2617                    windows_paste_run.flush(&editor_for_key);
2618                } else {
2619                    windows_paste_run.flush(&editor_for_key);
2620                }
2621            }
2622
2623            // 6. Otherwise forward to the editor + refresh autocomplete. The
2624            //    editor maps unmodified Enter and the encodings a remote client
2625            //    may send for it (`Char('\n')`/`Char('\r')`, and the raw LF
2626            //    crossterm reports as `Ctrl+J`) to submit, so a remote Enter
2627            //    sends instead of inserting a newline.
2628            editor_for_key.handle_key(key);
2629            refresh_autocomplete(&state_for_key, &editor_for_key);
2630            tui_for_key.request_render_reusing_scroll_content();
2631        }
2632    });
2633
2634    // ---- Session lifecycle (P1): SessionStart ----
2635    // The TUI + key worker are up and the session is ready to accept input.
2636    // Notify subscribed extensions now so they can initialize session-scoped
2637    // state. A veto here is **advisory** (the session is already up) — warn and
2638    // continue. No-op when no extension subscribes.
2639    if let Some(reason) = crate::session::dispatch_session_event_async(
2640        reload_context,
2641        rpi_plugin_sdk::EventTag::SessionStart,
2642    )
2643    .await
2644    {
2645        tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
2646    }
2647
2648    // ---- Initial prompts (run before reading from the channel) ----
2649    let mut prompts: Vec<String> = Vec::new();
2650    if let Some(init) = initial {
2651        prompts.push(init);
2652    }
2653    for m in extra_messages {
2654        prompts.push(m.clone());
2655    }
2656    // ---- Continue a run that recovery repaired, the way upstream does ----
2657    // This happens before any user input, and only when nothing was passed on the
2658    // command line: an explicit `-p` means the user has already said what they
2659    // want next. Driven here rather than inside the harness so the run's output
2660    // goes through the same streaming/render path as any other run, and so
2661    // startup is never blocked by a run the user cannot see.
2662    if prompts.is_empty() && lane.has_pending_resume().await {
2663        run_prompt_streaming(
2664            &lane,
2665            "",
2666            true,
2667            &tui,
2668            &state,
2669            drain_handle.is_some(),
2670            Vec::new(),
2671        )
2672        .await;
2673    }
2674
2675    let mut images = initial_images;
2676    for prompt in prompts {
2677        if !*running.lock().unwrap() {
2678            break;
2679        }
2680        // The harness emits no user `message_start` for a directly-sent prompt
2681        // (it drives the loop with an empty prompts vec), so the initial
2682        // `-p` / `--prompt` bubbles are rendered here.
2683        add_user_message(&chat_container, &prompt);
2684        add_image_previews(&chat_container, &images, show_images);
2685        tui.request_render(false);
2686        run_prompt_streaming(
2687            &lane,
2688            &prompt,
2689            false,
2690            &tui,
2691            &state,
2692            drain_handle.is_some(),
2693            std::mem::take(&mut images),
2694        )
2695        .await;
2696    }
2697
2698    // ---- Main loop: process submitted input + lifecycle messages ----
2699    loop {
2700        if !*running.lock().unwrap() {
2701            break;
2702        }
2703        match rx.recv().await {
2704            Some(TuiMessage::UserInput(prompt, prompt_images)) => {
2705                // Clear the editor so the next prompt starts fresh (the submit
2706                // handler runs on the blocking key thread and can't mutate the
2707                // editor state safely there; clearing here, on the async loop,
2708                // keeps it on one thread). Marked programmatic: this is the host
2709                // tidying up after a submit, not the user typing — reporting it
2710                // as a user edit made a hands-free voice session switch itself
2711                // off the moment it was started.
2712                state.clear_editor_programmatically();
2713                add_image_previews(&chat_container, &prompt_images, state.images_visible());
2714                if !prompt_images.is_empty() {
2715                    add_note_message(
2716                        &chat_container,
2717                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
2718                    );
2719                }
2720                run_prompt_streaming(
2721                    &lane,
2722                    &prompt,
2723                    false,
2724                    &tui,
2725                    &state,
2726                    drain_handle.is_some(),
2727                    prompt_images,
2728                )
2729                .await;
2730            }
2731            Some(TuiMessage::ExternalEditorResult(result)) => {
2732                match result {
2733                    Ok(text) => {
2734                        let cursor = text.chars().count();
2735                        editor.set_text(&text);
2736                        editor.set_cursor(0, cursor);
2737                        add_note_message(&chat_container, "Draft updated from external editor.");
2738                    }
2739                    Err(error) => add_error_message(&chat_container, &error),
2740                }
2741                tui.request_render(false);
2742            }
2743            Some(TuiMessage::UserBashFinished(report)) => {
2744                // Free the run slot first: the guard and the Esc router both
2745                // read it, so a finished command must never block the next one.
2746                state.finish_user_bash();
2747                let message = rpi_harness::messages::bash_execution_message(
2748                    report.command,
2749                    report.output,
2750                    report.exit_code,
2751                    report.cancelled,
2752                    report.truncated,
2753                    report.full_output_path,
2754                    Some(report.exclude_from_context),
2755                    now_ms(),
2756                );
2757                // While a run is in flight the message is deferred so it is not
2758                // spliced into the middle of a turn's tool sequence; it is
2759                // flushed at `AgentEnd` (upstream `_pendingBashMessages`).
2760                if *state.status.lock().unwrap() == RunStatus::Idle {
2761                    if let Err(error) = lane.append_message(message).await {
2762                        add_error_message(
2763                            &chat_container,
2764                            &format!("Could not record bash result: {error}"),
2765                        );
2766                        tui.request_render(false);
2767                    }
2768                } else {
2769                    state.pending_bash_messages.lock().unwrap().push(message);
2770                }
2771            }
2772            Some(TuiMessage::OpenTree) => {
2773                if *state.status.lock().unwrap() != RunStatus::Idle {
2774                    add_note_message(
2775                        &chat_container,
2776                        "Wait for the current run to finish before opening the tree.",
2777                    );
2778                    tui.request_render(false);
2779                } else {
2780                    open_tree_selector(
2781                        &harness,
2782                        &state,
2783                        &editor_container,
2784                        &editor,
2785                        &tui,
2786                        &chat_container,
2787                        &tx,
2788                    )
2789                    .await;
2790                }
2791            }
2792            Some(TuiMessage::NavigateTree(entry_id)) => {
2793                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
2794                    Ok(result) => match result.outcome {
2795                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
2796                            chat_container.clear();
2797                            add_welcome_message(&chat_container);
2798                            render_session_history(
2799                                &harness,
2800                                &chat_container,
2801                                state.markdown_transformer(),
2802                                Some(state.extension_session.clone()),
2803                                state.images_visible(),
2804                            )
2805                            .await;
2806                            add_note_message(
2807                                &chat_container,
2808                                "Moved to the selected session entry.",
2809                            );
2810                        }
2811                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
2812                            add_error_message(&chat_container, &error.message);
2813                        }
2814                        _ => add_note_message(
2815                            &chat_container,
2816                            "The selected entry could not be opened.",
2817                        ),
2818                    },
2819                    Err(error) => add_error_message(
2820                        &chat_container,
2821                        &format!("Could not navigate session tree: {error}"),
2822                    ),
2823                }
2824                tui.request_render(false);
2825            }
2826            Some(TuiMessage::ClearChat) => {
2827                chat_container.clear();
2828                add_welcome_message(&chat_container);
2829                tui.request_render(false);
2830            }
2831            Some(TuiMessage::Compact) => {
2832                run_compact(&lane, &tui, &state).await;
2833            }
2834            Some(TuiMessage::Copy) => {
2835                copy_last_assistant(&state, &chat_container);
2836                tui.request_render(false);
2837            }
2838            Some(TuiMessage::Exit) => {
2839                *running.lock().unwrap() = false;
2840                break;
2841            }
2842            Some(TuiMessage::SwitchSession(id)) => {
2843                switch_to_session(
2844                    &harness,
2845                    &lane,
2846                    &id,
2847                    &cwd,
2848                    &chat_container,
2849                    &state,
2850                    &reload_context.tool_context,
2851                )
2852                .await;
2853                tui.request_render(false);
2854            }
2855            Some(TuiMessage::ImportSession(path)) => {
2856                import_session(
2857                    &harness,
2858                    &lane,
2859                    &path,
2860                    &cwd,
2861                    &chat_container,
2862                    &state,
2863                    &reload_context.tool_context,
2864                )
2865                .await;
2866                tui.request_render(false);
2867            }
2868            Some(TuiMessage::ShareSession) => {
2869                share_session(&harness, &chat_container).await;
2870                tui.request_render(false);
2871            }
2872            Some(TuiMessage::SetSessionName(name)) => {
2873                let outcome = harness.session().set_name(Some(&name)).await;
2874                match outcome {
2875                    Ok(_) => add_note_message(
2876                        &chat_container,
2877                        &format!("Session renamed to \"{name}\"."),
2878                    ),
2879                    Err(e) => add_error_message(
2880                        &chat_container,
2881                        &format!("Could not rename session: {e}"),
2882                    ),
2883                }
2884                tui.request_render(false);
2885            }
2886            Some(TuiMessage::ExportSession) => {
2887                let file_name = agent_session
2888                    .default_export_file_name(crate::export::ExportFormat::Markdown)
2889                    .await;
2890                let path = cwd.join(&file_name);
2891                match agent_session.export_to_markdown(&path).await {
2892                    Ok(_) => add_note_message(
2893                        &chat_container,
2894                        &format!("Exported session to {}", path.display()),
2895                    ),
2896                    Err(e) => {
2897                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
2898                    }
2899                }
2900                tui.request_render(false);
2901            }
2902            Some(TuiMessage::ExportSessionWithFormat(format)) => {
2903                let file_name = agent_session.default_export_file_name(format.clone()).await;
2904                let path = cwd.join(&file_name);
2905                match agent_session.export(format, &path).await {
2906                    Ok(_) => add_note_message(
2907                        &chat_container,
2908                        &format!("Exported session to {}", path.display()),
2909                    ),
2910                    Err(e) => {
2911                        add_error_message(&chat_container, &format!("Could not write export: {e}"))
2912                    }
2913                }
2914                tui.request_render(false);
2915            }
2916            Some(TuiMessage::ShowUsage) => {
2917                let stats = agent_session.usage_stats().await.ok();
2918                let total = agent_session.total_usage().await.ok();
2919                let snapshot = agent_session.snapshot().await.ok();
2920                match stats {
2921                    Some(stats) => {
2922                        let mut lines = vec![
2923                            "## Usage".to_string(),
2924                            String::new(),
2925                            format!("- messages: {}", stats.message_count),
2926                            format!("- total tokens: {}", stats.total_tokens),
2927                            format!("- cached tokens: {}", stats.cached_tokens),
2928                            format!("- uncached tokens: {}", stats.uncached_tokens),
2929                            format!("- cost: ${:.4}", stats.cost_total),
2930                        ];
2931                        match stats.cache_hit_ratio() {
2932                            Some(ratio) => {
2933                                lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
2934                            }
2935                            None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
2936                        }
2937                        if let Some(total) = total {
2938                            lines.push(String::new());
2939                            lines.push(format!(
2940                                "- last cumulative input/output: {} / {}",
2941                                total.input, total.output
2942                            ));
2943                        }
2944                        if let Some(snapshot) = snapshot {
2945                            lines.push(String::new());
2946                            lines.push(format!("- lane: `{}`", snapshot.lane));
2947                            lines.push(format!(
2948                                "- leaf: `{}`",
2949                                snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
2950                            ));
2951                            if let Some(active) = &snapshot.active {
2952                                lines.push(format!(
2953                                    "- active operation: {} (`{}`)",
2954                                    active.kind.as_str(),
2955                                    active.run_id
2956                                ));
2957                            }
2958                        }
2959                        add_note_message(&chat_container, &lines.join("\n"));
2960                    }
2961                    None => {
2962                        add_error_message(&chat_container, "Could not read session usage stats.")
2963                    }
2964                }
2965                tui.request_render(false);
2966            }
2967            Some(TuiMessage::ForkSession) => {
2968                fork_session(
2969                    &harness,
2970                    &cwd,
2971                    &chat_container,
2972                    &state,
2973                    &reload_context.tool_context,
2974                )
2975                .await;
2976                tui.request_render(false);
2977            }
2978            Some(TuiMessage::ReloadExtensions) => {
2979                // B5d: drive the shared reload routine on the async runtime,
2980                // then surface the outcome. `reload_context` was passed into
2981                // `interactive_tui` and is the same `Arc<ReloadContext>` the
2982                // `ReloadCommand` + the plugin mailbox both route through —
2983                // clone the `Arc` out so the borrow of `harness` (the main
2984                // loop's `&AgentHarness`) lives across the await.
2985                let reload_ctx = ctx.reload_context.clone();
2986                add_note_message(&chat_container, "Reloading extensions + resources…");
2987                tui.request_render(false);
2988                let outcome =
2989                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
2990                if !outcome.had_errors {
2991                    if let Ok(resources) = harness.get_resources().await {
2992                        update_resource_listing(
2993                            &ctx.resources,
2994                            ctx.startup_context.as_ref(),
2995                            resources,
2996                        );
2997                    }
2998                }
2999                // B5e: the reload swapped a fresh `ExtensionSession` into the
3000                // context's cell. Rebuild the markdown transformer from that
3001                // fresh snapshot and install it on the in-flight streaming
3002                // component (so a reloaded plugin's transformer takes effect on
3003                // the visible message immediately) + future components (they
3004                // read `state.markdown_transformer()` at construction). The old
3005                // closure no-ops once its snapshot's `active` flag flips false
3006                // (reload already did that before the swap).
3007                let fresh_transformer = build_markdown_transformer(
3008                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
3009                );
3010                state.set_markdown_transformer_with_reinstall(fresh_transformer);
3011                if outcome.had_errors {
3012                    // Hard failure: the summary itself carries the error detail
3013                    // (e.g. "Settings reload failed: …"), so render it as an
3014                    // error without an extra stderr pointer.
3015                    add_error_message(&chat_container, &outcome.summary);
3016                } else if outcome.had_warnings {
3017                    // Reload succeeded but produced soft diagnostics (skill
3018                    // shadowing, name validation, …). The details are printed
3019                    // to stderr; keep this as an informational note so a
3020                    // successful reload isn't shown as a red ✗ error.
3021                    add_note_message(
3022                        &chat_container,
3023                        &format!(
3024                            "{} (with warnings — see stderr for details).",
3025                            outcome.summary
3026                        ),
3027                    );
3028                } else {
3029                    add_note_message(&chat_container, &outcome.summary);
3030                }
3031                tui.request_render(false);
3032            }
3033            None => break,
3034        }
3035    }
3036
3037    // ---- Shutdown ----
3038    ask_user_bridge.shutdown();
3039    *running.lock().unwrap() = false;
3040    for handle in update_check_handles {
3041        handle.abort();
3042        let _ = handle.await;
3043    }
3044    // The input worker checks `running` at least every 50ms. Join it before
3045    // restoring cooked mode so no late event read races terminal cleanup.
3046    let _ = key_handle.await;
3047    tick_handle.abort();
3048    if let Some(handle) = drain_handle {
3049        handle.abort();
3050    }
3051    // Drop the reload bridge: clearing the mailbox closes the signal channel,
3052    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
3053    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
3054    reload_context.mailbox.clear();
3055    reload_bridge_handle.abort();
3056    tui.stop(Default::default());
3057
3058    // upstream prints how to resume the session after an interactive exit so
3059    // a terminal that scrolled away can be recovered. Mirrors
3060    // `formatResumeCommand` (id + `--session-dir` only when non-default).
3061    let session_id = harness.session().storage().metadata().id;
3062    if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
3063        println!("\x1b[2mTo resume this session:\x1b[0m {command}");
3064    }
3065    println!("\nGoodbye!");
3066    let _ = args;
3067
3068    0
3069}
3070
3071// ===========================================================================
3072// TUI support + entry detection
3073// ===========================================================================
3074
3075/// Check if the terminal supports TUI mode.
3076pub fn is_tui_supported() -> bool {
3077    std::io::stdout().is_terminal()
3078}
3079
3080// Keep the `Color` import used (theme accent rendering in autocomplete).
3081#[allow(unused_imports)]
3082use rpi_tui::Color as _Color;