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