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