Skip to main content

rpi_cli/
interactive_tui.rs

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