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