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