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