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