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