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