Skip to main content

rpi_cli/
interactive_tui.rs

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