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