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