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