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