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