Skip to main content

rpi_cli/
interactive_tui.rs

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