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