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 tui_ref = tui.clone();
1383 let registry_snapshot = state.theme_registry.read().ok().map(|r| r.clone());
1384 crate::themes::restart_theme_watcher(
1385 &state.theme_watcher,
1386 state.active_theme_spec().as_deref(),
1387 registry_snapshot
1388 .as_ref()
1389 .unwrap_or(&crate::themes::ThemeRegistry::default()),
1390 &state.theme_manager,
1391 theme_watch_notify(&state, tui_ref),
1392 );
1393 }
1394
1395 // Capture the model catalog + cwd for the selector builders + the key loop
1396 // (the callbacks fire on blocking threads and need owned data).
1397 let model_catalog_arc = reload_context.catalog.clone();
1398
1399 // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
1400 // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
1401 // dock = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
1402 //
1403 // The scrollview gets `basis(0)` so the constrained stack allocator starts
1404 // it at zero height and grows it to fill the space the dock does not need
1405 // — this keeps the dock (editor borders + footer) pinned to the bottom and
1406 // never shrinks it below the editor's 3 rows (top + content + bottom). The
1407 // editor_container is `shrink(0).min_size(3)` so a tall transcript can
1408 // never clip the input panel below its minimum.
1409 let editor_container = Arc::new(Container::new());
1410 editor_container.add_child(editor.clone());
1411
1412 let footer_panels: Arc<dyn Component> =
1413 Arc::new(panels::FooterPanels(reload_context.ext_status.clone()));
1414 let dock = Arc::new(VStack::from_children(vec![
1415 StackChild::Entry(StackEntry::new(Arc::new(panels::RegularPanels {
1416 mailbox: reload_context.ext_status.clone(),
1417 enabled: matches!(args.tui_mode, crate::args::TuiMode::Regular),
1418 }))),
1419 StackChild::Entry(StackEntry::new(state.pending_container().clone())),
1420 StackChild::Entry(StackEntry::new(status_container.clone())),
1421 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
1422 // NOTE: no Spacer here. The scroll content already ends with a blank line
1423 // (`document_container`), and a second one in the dock stacked into a
1424 // two-row gap between the last message and the input box. One row of
1425 // breathing room is the whole safe area now; the transcript keeps its
1426 // own padding so a long message never touches the editor border.
1427 StackChild::Entry(
1428 StackEntry::new(editor_container.clone())
1429 .shrink(0)
1430 .min_size(3),
1431 ),
1432 StackChild::Entry(StackEntry::new(state.search_bar.clone())),
1433 StackChild::Entry(StackEntry::new(footer.clone())),
1434 StackChild::Entry(StackEntry::new(footer_panels.clone())),
1435 ]));
1436
1437 let transcript_stack = Arc::new(rpi_tui::HStack::from_entries(vec![StackEntry::new(
1438 scroll_view.clone(),
1439 )
1440 .basis(0)
1441 .grow(1)
1442 .shrink(1)
1443 .min_size(1)]));
1444 let root = VStack::from_children(vec![
1445 StackChild::Entry(
1446 StackEntry::new(transcript_stack.clone())
1447 .basis(0)
1448 .grow(1)
1449 .shrink(1)
1450 .min_size(1),
1451 ),
1452 StackChild::Entry(StackEntry::new(dock).shrink(1)),
1453 ]);
1454
1455 let side_ui = Arc::new(Mutex::new(side_questions::SideQuestionUi::new(
1456 Arc::new(root),
1457 editor.clone(),
1458 matches!(args.tui_mode, crate::args::TuiMode::Regular),
1459 show_terminal_progress,
1460 footer.clone(),
1461 footer_panels,
1462 )));
1463 tui.set_layout_root(Some(side_ui.lock().unwrap().root()));
1464 tui.set_focus(Some(editor.clone()));
1465 editor.set_focused(true);
1466
1467 // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
1468 //
1469 // The handler captures one `CommandContext` (the set of `*_for_cb` clones
1470 // the old version made individually) + the registry, then routes `/`-text
1471 // through `dispatch_slash` and sends plain text directly. Each command's
1472 // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
1473 // chat mutation) — the handler itself stays a thin router.
1474 //
1475 // One `CommandContext` is built and cloned for both the submit handler and
1476 // the key loop (Ctrl+L routes `/model` through the same registry); all
1477 // fields are `Arc`/cheap, so the clones are free.
1478 //
1479 // The product-layer `AgentSession` wraps a clone of the harness (the
1480 // harness handle is cheap and shares the session/bus), so `/usage`,
1481 // `/export`, and future product commands go through one surface.
1482 let scoped_models = args
1483 .model
1484 .as_deref()
1485 .map(crate::agent_session::ScopedModel::parse)
1486 .into_iter()
1487 .collect();
1488 let agent_session = Arc::new(crate::agent_session::AgentSession::new(
1489 harness.clone(),
1490 model_catalog.clone(),
1491 scoped_models,
1492 cwd.clone(),
1493 ));
1494 let streaming = event_rx.is_some();
1495 let ctx = CommandContext {
1496 chat: chat_container.clone(),
1497 tui: tui.clone(),
1498 tx: tx.clone(),
1499 state: state.clone(),
1500 editor: editor.clone(),
1501 editor_container: editor_container.clone(),
1502 lane: lane.clone(),
1503 model_catalog: model_catalog_arc.clone(),
1504 cwd: cwd.clone(),
1505 resources: resources_arc.clone(),
1506 startup_context,
1507 reload_context: Arc::new(reload_context.clone()),
1508 session: agent_session.clone(),
1509 streaming,
1510 };
1511
1512 let ctx_for_cb = ctx.clone();
1513 let registry_for_cb = registry.clone();
1514 editor.on_submit(Arc::new(move |text: &str| {
1515 let text = text.trim();
1516 if text.is_empty() && ctx_for_cb.state.pending_images.lock().unwrap().is_empty() {
1517 return;
1518 }
1519
1520 if text.starts_with('/') && dispatch_slash(text, &ctx_for_cb, ®istry_for_cb) {
1521 return;
1522 }
1523
1524 // User-initiated shell mode: `!command` runs in the background and is
1525 // recorded in the session context; `!!command` is excluded from it.
1526 // Handled before the agent prompt path so it never reaches the model.
1527 // Mirrors `interactive-mode.ts:3225`.
1528 if let Some((command, exclude_from_context)) = parse_user_bash(text) {
1529 if ctx_for_cb.state.user_bash_running() {
1530 add_error_message(
1531 &ctx_for_cb.state.notice_container(),
1532 "A bash command is already running. Press Esc to cancel it first.",
1533 );
1534 ctx_for_cb.tui.request_render(false);
1535 return;
1536 }
1537 start_user_bash(&ctx_for_cb, command, exclude_from_context);
1538 return;
1539 }
1540
1541 let run_status = *ctx_for_cb.state.status.lock().unwrap();
1542 if run_status != RunStatus::Idle {
1543 let images = ctx_for_cb.state.take_pending_images();
1544 let message = user_message_with_images(text, images.clone());
1545 let lane = ctx_for_cb.lane.clone();
1546 let chat = ctx_for_cb.state.notice_container();
1547 let tui = ctx_for_cb.tui.clone();
1548 let state = ctx_for_cb.state.clone();
1549 tokio::spawn(async move {
1550 // Queue immediately while the agent loop is still running.
1551 // Routing this through the TUI's main channel delayed it until
1552 // `prompt_text()` returned, after the loop's drain points had
1553 // passed, so the queued message appeared to disappear.
1554 //
1555 // upstream's `steer()` enqueues unconditionally, even after
1556 // the run ends, and the message stays in the queue for the
1557 // next run. This matches the TS design and avoids the race
1558 // between `activeRun` clearing and the status check.
1559 if let Err(error) = lane.steer(message).await {
1560 state.restore_pending_images(images);
1561 add_error_message(&chat, &format!("Could not queue message: {error}"));
1562 tui.render_now(false);
1563 return;
1564 }
1565 // Surface the queued entry in the pending dock immediately;
1566 // the drain-time refresh only fires on the next agent event.
1567 refresh_pending_messages(&state, &lane).await;
1568 tui.request_render(false);
1569 });
1570 // A queued prompt is echoed only once the loop actually consumes
1571 // it: the run's `AgentEvent::MessageStart` renders the user bubble
1572 // (upstream renders `message_start` for user messages). Until
1573 // then it is surfaced by the pending-messages display, so the
1574 // transcript never shows a message that is still waiting.
1575 ctx_for_cb.tui.request_render(false);
1576 return;
1577 }
1578
1579 if !ctx_for_cb.state.try_start_working() {
1580 return;
1581 }
1582
1583 // Render the user bubble here, NOT from `AgentEvent::MessageStart`.
1584 //
1585 // The harness persists the prompt itself and then drives
1586 // `run_agent_loop` with an EMPTY prompts vec (so the prompt is not
1587 // double-counted in the provider context), which means the loop emits
1588 // no `message_start` for it — see
1589 // `directly_sent_prompt_emits_no_user_message_start` in
1590 // `crates/rpi-harness/tests/harness_run_e2e.rs`. Queued steering /
1591 // follow-up prompts DO get a `message_start` (the loop drains those
1592 // itself), so those are deliberately rendered from the event instead.
1593 add_user_message(&ctx_for_cb.state.history_page.active, text);
1594 // A new prompt starts a fresh interaction at the tail even when the
1595 // user had scrolled up to inspect older output.
1596 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
1597 scroll.scroll_to_end();
1598 }
1599 ctx_for_cb.tui.request_render(false);
1600 // Remember the message for ↑ recall (slash commands are not part of
1601 // the replayable message history).
1602 push_history(&ctx_for_cb.state, text);
1603 if ctx_for_cb
1604 .tx
1605 .send(TuiMessage::UserInput(
1606 text.to_string(),
1607 ctx_for_cb.state.take_pending_images(),
1608 ))
1609 .is_err()
1610 {
1611 ctx_for_cb.state.set_status(RunStatus::Idle);
1612 }
1613 }));
1614
1615 // Turn on the native-pi frame throttle before the first frame: from here on
1616 // `request_render(false)` parks a frame and the scheduler thread paints at
1617 // most one per `MIN_RENDER_INTERVAL_MS` (16 ms). A provider that streams one
1618 // `MessageUpdate` per delta would otherwise force a full transcript
1619 // re-render + full-viewport terminal write per token, which is what made
1620 // long output saturate the terminal (and its GPU compositor). Immediate
1621 // renders (selectors, the first frame, `render_now(..)`) are unaffected.
1622 tui.start_render_scheduler();
1623 tui.start_readerless();
1624
1625 // Consume local self-update results even when network checks are disabled.
1626 // Check rpi and installed registry packages in the background so startup
1627 // remains responsive while their latest versions are fetched.
1628 let mut update_check_handles = Vec::new();
1629 let update_container = state.history_page.updates.clone();
1630 let rpi_tui = tui.clone();
1631 update_check_handles.push(tokio::spawn(async move {
1632 let report = crate::updates::check_rpi_startup().await;
1633 if !report.is_empty() {
1634 add_update_notices(&update_container, &report);
1635 }
1636 rpi_tui.request_render(false);
1637 }));
1638
1639 // ---- Streaming drain task ----
1640 let drain_handle = if let Some(rx) = event_rx {
1641 let tui_drain = tui.clone();
1642 let state_drain = state.clone();
1643 let chat_drain = chat_container.clone();
1644 let lane_drain = lane.clone();
1645 Some(tokio::spawn(async move {
1646 drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
1647 }))
1648 } else {
1649 None
1650 };
1651
1652 // ---- B5d: plugin→TUI reload bridge ----
1653 // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
1654 // (its cdylib would be unmapped while the call frame is still on the stack).
1655 // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
1656 // (an `UnboundedSender<()>`); this task drains those signals and forwards
1657 // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
1658 // `reload_extension_resources` routine asynchronously. The mailbox is the
1659 // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
1660 // leaf DAG preserved); the TUI owns the receiver + the reload routine.
1661 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
1662 reload_context.mailbox.install(reload_sig_tx);
1663 let reload_tx = tx.clone();
1664 let reload_bridge_handle = tokio::spawn(async move {
1665 while reload_sig_rx.recv().await.is_some() {
1666 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
1667 break; // main loop gone — stop forwarding
1668 }
1669 }
1670 });
1671
1672 // ---- plugin→TUI model-catalog refresh bridge (action id 31) ----
1673 //
1674 // `runtime_action(ReloadModels)` must run the refresh on the main loop
1675 // (it owns the harness), and the caller wants the answer — so this is a
1676 // request/reply channel, not the fire-and-forget `ReloadMailbox` above.
1677 let (reload_models_tx, mut reload_models_rx) =
1678 tokio::sync::mpsc::unbounded_channel::<rpi_extensions::ReloadModelsRequest>();
1679 reload_context
1680 .reload_models_mailbox
1681 .install(reload_models_tx);
1682 let reload_models_tx = tx.clone();
1683 let reload_models_bridge_handle = tokio::spawn(async move {
1684 while let Some(request) = reload_models_rx.recv().await {
1685 if reload_models_tx
1686 .send(TuiMessage::ReloadModels { request })
1687 .is_err()
1688 {
1689 break; // main loop gone — stop forwarding
1690 }
1691 }
1692 });
1693
1694 // ---- Render-tick task (keeps the spinner animating while Working) ----
1695 //
1696 // The spinner frame is derived from wall-clock time at render time (one
1697 // step per `SPINNER_FRAME_MS`, matching upstream's `setInterval` cadence),
1698 // but a frame only reaches the screen when something repaints. Without a
1699 // periodic `request_render` a quiet stretch (thinking, tool running) would
1700 // show a frozen glyph even though the frame index moved.
1701 let tui_tick = tui.clone();
1702 let state_tick = state.clone();
1703 // The tick, not the key loop, drives the voice-draft auto-send: it must
1704 // fire even when the user types nothing.
1705 let tx_tick = tx.clone();
1706 let pet_tick = pet_document.clone();
1707 let mut plugin_panels = panels::Panels::with_sidebar(
1708 transcript_stack,
1709 scroll_view.clone(),
1710 reload_context.ext_status.clone(),
1711 );
1712 plugin_panels.regular_mode = matches!(args.tui_mode, crate::args::TuiMode::Regular);
1713 plugin_panels.sync(tui.as_ref(), &reload_context.ext_status);
1714 let scroll_tick = scroll_view.clone();
1715 let side_tick = side_ui.clone();
1716 let side_context = reload_context.clone();
1717 let side_parent = harness.clone();
1718 let side_keys = keybindings.clone();
1719 let tick_handle = tokio::spawn(async move {
1720 // 80ms per frame — upstream's `DEFAULT_INTERVAL_MS`, i.e. a full
1721 // 10-frame cycle every 800ms. The tick only *requests a repaint*; the
1722 // frame itself comes from elapsed wall-clock time inside the render, so
1723 // this interval is only the repaint floor for quiet stretches and has
1724 // no effect on the visible speed (streaming repaints included).
1725 let mut interval = tokio::time::interval(std::time::Duration::from_millis(
1726 rpi_tui::loader::SPINNER_FRAME_MS,
1727 ));
1728 interval.tick().await; // discard immediate
1729 loop {
1730 interval.tick().await;
1731 // Size watchdog: re-read the window and repaint when it differs
1732 // from what is on screen. A resize event is only a hint (on Windows
1733 // its record can be delivered before the window rectangle moves),
1734 // and a lost event would otherwise leave the fullscreen frame at the
1735 // previous geometry for the rest of the session.
1736 tui_tick.ensure_size();
1737 let side_changed = side_tick.lock().unwrap().sync(
1738 &tui_tick,
1739 &side_context,
1740 &side_parent,
1741 side_keys.clone(),
1742 side_questions::input_allowed(&state_tick),
1743 );
1744 if side_changed {
1745 tui_tick.request_render(false);
1746 }
1747 // Extension status (langfuse ✓ …, …) — cheap revision check, and the
1748 // only reason an idle session repaints its footer.
1749 let panels_changed = plugin_panels.sync(tui_tick.as_ref(), &state_tick.ext_status);
1750 if state_tick.sync_extension_status() || panels_changed {
1751 // Status/panel updates leave chat content untouched. The scroll
1752 // cache regenerates itself if enabling a sidebar changes width.
1753 tui_tick.request_render_reusing_scroll_content();
1754 }
1755 if pet_tick.tick(scroll_tick.viewport_height()) {
1756 tui_tick.request_render(false);
1757 }
1758 // Extension editor-text injection (`SetEditorText`): a voice plugin
1759 // drops a transcription in as an editable draft.
1760 if state_tick.drain_editor_text() {
1761 tui_tick.request_render(false);
1762 }
1763 // Tell extensions the draft changed (voice barge-in). Runs after the
1764 // drain so an injected transcription also counts as a change.
1765 state_tick.sync_editor_change();
1766 // Advance a draft's auto-send countdown (submit / cancel / repaint).
1767 if poll_auto_send(&state_tick, &tx_tick) {
1768 tui_tick.request_render(false);
1769 }
1770 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
1771 if working {
1772 // A live transcript panel — a running bash command *or* a
1773 // running tool call — computes its elapsed readout from
1774 // `Instant::now()` at render time. A frame that reuses the
1775 // cached scroll content never rebuilds it, so the readout
1776 // froze at the second of the last full rebuild (reported: a
1777 // `search` call stuck at "2.0s" with no way to tell it was
1778 // still running). Rebuild while any such panel is live.
1779 let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
1780 let has_live_panel = {
1781 let tools = state_tick.tool_components.lock().unwrap();
1782 transcript_has_live_panel(bash_present, &tools)
1783 };
1784 if has_live_panel {
1785 tui_tick.request_render(false);
1786 } else {
1787 // Only the dock loader animates. Keep the already-rendered
1788 // transcript instead of rebuilding a long history at 12.5
1789 // frames per second.
1790 tui_tick.request_render_reusing_scroll_content();
1791 }
1792 }
1793 }
1794 });
1795
1796 // ---- Key dispatch loop (spawn_blocking crossterm read) ----
1797 let running = Arc::new(std::sync::Mutex::new(true));
1798 let running_key = running.clone();
1799 let tx_for_key = tx.clone();
1800 let tui_for_key = tui.clone();
1801 let editor_for_key = editor.clone();
1802 let scroll_for_key = scroll_view.clone();
1803 let side_for_key = side_ui.clone();
1804 let lane_for_key = lane.clone();
1805 let state_for_key = state.clone();
1806 let model_catalog_for_key = model_catalog_arc.clone();
1807 let ask_user_for_key = ask_user_bridge.clone();
1808 // Ctrl+L routes through the same registry as `/model` (one path, not two),
1809 // so the key loop needs the same `CommandContext` + registry the submit
1810 // handler uses. All fields are `Arc`/cheap, so this clone is free.
1811 let ctx_for_key = ctx.clone();
1812 let registry_for_key = registry.clone();
1813 let keybindings_for_key = keybindings.clone();
1814 let double_escape_action = crate::settings::load_settings()
1815 .ok()
1816 .and_then(|settings| settings.double_escape_action)
1817 .unwrap_or_else(|| "tree".to_string())
1818 .to_ascii_lowercase();
1819
1820 let key_handle = tokio::task::spawn_blocking(move || {
1821 let mut last_escape_time = None;
1822 // upstream's `handleCtrlC` "press twice to exit" window: the instant of
1823 // the last lone Ctrl+C. Shares the 500ms with Esc's double-escape.
1824 let mut last_sigint_time: Option<std::time::Instant> = None;
1825 // One event peeked at by the paste-burst probe (which must discard a
1826 // key's Release but never lose a meaningful event it read). Restored
1827 // at the top of the next loop iteration.
1828 let mut pending_event: Option<Event> = None;
1829 let mut windows_paste_run = WindowsPasteRun::default();
1830 if crate::key_trace::enabled() {
1831 crate::key_trace::note(&format!(
1832 "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
1833 std::process::id(),
1834 std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
1835 crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
1836 ));
1837 }
1838 loop {
1839 if !*running_key.lock().unwrap() {
1840 break;
1841 }
1842 // Console releases, mouse motion and resize must not postpone
1843 // the last text character's idle deadline indefinitely.
1844 if cfg!(windows)
1845 && windows_paste_run
1846 .remaining_delay(std::time::Instant::now())
1847 .is_some_and(|left| left.is_zero())
1848 {
1849 windows_paste_run.flush_with_clipboard(
1850 &editor_for_key,
1851 std::time::Instant::now(),
1852 read_clipboard_text,
1853 );
1854 refresh_autocomplete(&state_for_key, &editor_for_key);
1855 tui_for_key.request_render_reusing_scroll_content();
1856 }
1857 if !state_for_key.selector_open() && !state_for_key.extension_dialog_open() {
1858 if let Some(request) = ask_user_for_key.take_pending_for_tui() {
1859 if cfg!(windows) {
1860 windows_paste_run.flush_with_clipboard(
1861 &editor_for_key,
1862 std::time::Instant::now(),
1863 read_clipboard_text,
1864 );
1865 windows_paste_run.end_paste_session();
1866 }
1867 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
1868 }
1869 }
1870 let ev = if let Some(ev) = pending_event.take() {
1871 ev
1872 } else {
1873 // `event::read()` blocks indefinitely. Poll first so shutdown can
1874 // stop and join this worker even when no further key arrives. A
1875 // paste run waits only for the remainder of its idle window,
1876 // not another full 120ms after each unrelated event.
1877 let idle = if cfg!(windows) {
1878 windows_paste_run.remaining_delay(std::time::Instant::now())
1879 } else {
1880 None
1881 }
1882 .unwrap_or(std::time::Duration::from_millis(50));
1883 match crossterm::event::poll(idle) {
1884 Ok(true) => {}
1885 Ok(false) => {
1886 if cfg!(windows)
1887 && windows_paste_run
1888 .remaining_delay(std::time::Instant::now())
1889 .is_some_and(|left| left.is_zero())
1890 {
1891 windows_paste_run.flush_with_clipboard(
1892 &editor_for_key,
1893 std::time::Instant::now(),
1894 read_clipboard_text,
1895 );
1896 refresh_autocomplete(&state_for_key, &editor_for_key);
1897 tui_for_key.request_render_reusing_scroll_content();
1898 }
1899 continue;
1900 }
1901 Err(_) => {
1902 state_for_key.cancel_js_preparation();
1903 let _ = tx_for_key.send(TuiMessage::Exit);
1904 break;
1905 }
1906 }
1907 let Ok(ev) = crossterm::event::read() else {
1908 state_for_key.cancel_js_preparation();
1909 let _ = tx_for_key.send(TuiMessage::Exit);
1910 break;
1911 };
1912 normalize_decoded_event(ev)
1913 };
1914 // `Event::Resize` is delivered as its own event (not a Key). With
1915 // `start_readerless` there is no competing terminal-reader thread to
1916 // handle it: force one full-screen repaint. The renderer re-reads the
1917 // window size itself; the event's own columns/rows are not used (on
1918 // Windows they carry the console buffer size, not the window).
1919 if let Event::Resize(..) = ev {
1920 tui_for_key.note_resize();
1921 continue;
1922 }
1923 // Mouse wheel scrolls the transcript (pi supports wheel
1924 // scrolling). Previously every non-Key event was dropped, so a
1925 // wheel had zero effect
1926 if let Event::Mouse(m) = ev {
1927 if cfg!(windows) && m.kind != crossterm::event::MouseEventKind::Moved {
1928 if windows_paste_run.is_buffering() {
1929 windows_paste_run.flush_with_clipboard(
1930 &editor_for_key,
1931 std::time::Instant::now(),
1932 read_clipboard_text,
1933 );
1934 refresh_autocomplete(&state_for_key, &editor_for_key);
1935 tui_for_key.request_render_reusing_scroll_content();
1936 }
1937 windows_paste_run.end_paste_session();
1938 }
1939 if side_questions::input_allowed(&state_for_key)
1940 && side_for_key
1941 .lock()
1942 .unwrap()
1943 .mouse(&m, &tui_for_key, &scroll_for_key)
1944 {
1945 continue;
1946 }
1947 if tui_for_key.handle_scroll_to_end_indicator_mouse_event(&m) {
1948 tui_for_key.clear_selection();
1949 continue;
1950 }
1951 use crossterm::event::MouseEventKind;
1952 match m.kind {
1953 MouseEventKind::ScrollUp => {
1954 let delta = -MOUSE_WHEEL_SCROLL_LINES;
1955 if scroll_for_key.scroll_by(delta) != delta {
1956 tui_for_key.request_render_reusing_scroll_content();
1957 }
1958 }
1959 MouseEventKind::ScrollDown => {
1960 let delta = MOUSE_WHEEL_SCROLL_LINES;
1961 if scroll_for_key.scroll_by(delta) != delta {
1962 tui_for_key.request_render_reusing_scroll_content();
1963 }
1964 }
1965 MouseEventKind::Down(crossterm::event::MouseButton::Right) if cfg!(windows) => {
1966 windows_paste_run.flush_with_clipboard(
1967 &editor_for_key,
1968 std::time::Instant::now(),
1969 read_clipboard_text,
1970 );
1971 windows_paste_run.end_paste_session();
1972 // upstream treats Windows right-click as a clipboard
1973 // paste, rather than letting the console deliver the
1974 // clipboard contents as individual key events. This is
1975 // what preserves the complete payload for
1976 // Editor::handle_paste and large-paste folding.
1977 if let Some(text) = read_clipboard_text() {
1978 if !text.is_empty() {
1979 editor_for_key.handle_paste(&text);
1980 refresh_autocomplete(&state_for_key, &editor_for_key);
1981 tui_for_key.request_render_reusing_scroll_content();
1982 }
1983 }
1984 }
1985 MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
1986 // Begin fullscreen text selection (anchor + highlight).
1987 // The TUI owns the anchor/focus state in content
1988 // coordinates; a plain click later collapses it and
1989 // never touches the clipboard.
1990 tui_for_key.handle_selection_mouse_event(&m);
1991 }
1992 MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
1993 // Extend the selection; the re-render paints the
1994 // reverse-video highlight as the pointer moves.
1995 tui_for_key.handle_selection_mouse_event(&m);
1996 }
1997 MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
1998 // Finish the selection and auto-copy when enabled.
1999 tui_for_key.handle_selection_mouse_event(&m);
2000 if tui_for_key.selection_dragged() {
2001 let auto_copy = crate::settings::load_settings()
2002 .ok()
2003 .and_then(|s| s.fullscreen_copy_on_select)
2004 .unwrap_or(true);
2005 if auto_copy
2006 && copy_mouse_selection_with(&tui_for_key, copy_to_clipboard)
2007 == Some(false)
2008 {
2009 add_note_message(
2010 &state_for_key.notice_container(),
2011 "Clipboard unavailable. Selection kept; press Ctrl+X to retry.",
2012 );
2013 tui_for_key.request_render(false);
2014 }
2015 }
2016 }
2017 _ => {}
2018 }
2019 continue;
2020 }
2021 let Event::Key(key) = ev else {
2022 if let Event::Paste(text) = ev {
2023 if cfg!(windows) {
2024 windows_paste_run.flush_with_clipboard(
2025 &editor_for_key,
2026 std::time::Instant::now(),
2027 read_clipboard_text,
2028 );
2029 windows_paste_run.end_paste_session();
2030 }
2031 if side_questions::input_allowed(&state_for_key)
2032 && side_for_key.lock().unwrap().paste(&text, &tui_for_key)
2033 {
2034 continue;
2035 }
2036 crate::key_trace::note(&format!(
2037 "paste event ({} bytes, bracketed paste supported)",
2038 text.len()
2039 ));
2040 // A provider-form text step owns the input slot: paste into
2041 // its single-line input instead of the chat editor.
2042 if state_for_key.provider_form.lock().unwrap().is_some() {
2043 let active = state_for_key.active_provider_input.lock().unwrap().clone();
2044 if let Some(input) = active {
2045 input.handle_paste(&text);
2046 tui_for_key.request_render_reusing_scroll_content();
2047 continue;
2048 }
2049 }
2050 if let Some(input) = state_for_key.active_provider_input.lock().unwrap().clone()
2051 {
2052 input.handle_paste(&text);
2053 tui_for_key.request_render_reusing_scroll_content();
2054 continue;
2055 }
2056 if state_for_key
2057 .provider_auth_flow_id
2058 .load(std::sync::atomic::Ordering::Acquire)
2059 != 0
2060 {
2061 tui_for_key.request_render_reusing_scroll_content();
2062 continue;
2063 }
2064 if let Some(images) = images_from_pasted_paths(&text) {
2065 for image in images {
2066 state_for_key.queue_image(image);
2067 }
2068 add_note_message(
2069 &state_for_key.notice_container(),
2070 "Dropped images attached to the next prompt.",
2071 );
2072 tui_for_key.request_render(false);
2073 continue;
2074 }
2075 editor_for_key.handle_paste(&text);
2076 refresh_autocomplete(&state_for_key, &editor_for_key);
2077 tui_for_key.request_render_reusing_scroll_content();
2078 }
2079 continue;
2080 };
2081 let key = rpi_tui::terminal::normalize_key_event_with_protocol(
2082 key,
2083 tui_for_key.terminal().info().supports_kitty_keyboard,
2084 );
2085 if cfg!(windows) && windows_paste_key_barrier(&key) {
2086 if windows_paste_run.is_buffering() {
2087 windows_paste_run.flush_with_clipboard(
2088 &editor_for_key,
2089 std::time::Instant::now(),
2090 read_clipboard_text,
2091 );
2092 refresh_autocomplete(&state_for_key, &editor_for_key);
2093 tui_for_key.request_render_reusing_scroll_content();
2094 }
2095 windows_paste_run.end_paste_session();
2096 }
2097 // Diagnostic trace (off unless RPI_DEBUG_KEYS is set): records what
2098 // actually arrived before any routing decision, so a client that
2099 // sends LF for Enter is visible as `Char('j') mods=CONTROL`.
2100 crate::key_trace::key(&key, "");
2101 // Side input is independent of main-run abort/preparation and plugin
2102 // shortcuts. Modal/approval dialogs still own the input slot first.
2103 if side_questions::input_allowed(&state_for_key)
2104 && side_for_key.lock().unwrap().focused()
2105 {
2106 if !should_dispatch_key(key.kind) {
2107 continue;
2108 }
2109 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT)
2110 && side_for_key.lock().unwrap().empty_draft()
2111 {
2112 if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
2113 let _ = tx_for_key.send(TuiMessage::Exit);
2114 } else {
2115 emergency_exit(&tui_for_key);
2116 }
2117 continue;
2118 }
2119 if side_for_key.lock().unwrap().key(key, &tui_for_key) {
2120 continue;
2121 }
2122 }
2123 // A key press during a voice-draft auto-send countdown cancels the
2124 // countdown — the user wants to edit, not send. The key still falls
2125 // through to the editor, so it doubles as their first correction.
2126 if key.kind != KeyEventKind::Release && state_for_key.cancel_auto_send_on_key() {
2127 tui_for_key.request_render(false);
2128 }
2129 // Extension shortcuts see Press *and* Release (this is what lets a
2130 // push-to-talk extension time a hold). Checked before the
2131 // release-drop below; a claimed key with an empty editor is routed
2132 // to the plugin's `Input` handler instead of the editor.
2133 // Hidden, buffered text is still a draft. Do not let an
2134 // empty-editor shortcut (notably push-to-talk Space) steal a
2135 // printable key while that draft awaits its display deadline.
2136 // Releases owned by an extension must still reach that owner.
2137 let owned_release = key.kind == KeyEventKind::Release
2138 && state_for_key
2139 .extension_claimed_keys
2140 .lock()
2141 .unwrap()
2142 .contains(&key.code);
2143 let shortcut_allowed = !windows_paste_run.is_buffering()
2144 || matches!(key.code, KeyCode::F(_))
2145 || owned_release;
2146 if shortcut_allowed && dispatch_key_event(&state_for_key, &key, &editor_for_key) {
2147 continue;
2148 }
2149 // Drop releases but preserve Repeat so holding arrows, Backspace,
2150 // PageUp, etc. behaves naturally. Windows emits Press + Release
2151 // for a tap; terminals with keyboard enhancement may additionally
2152 // emit Repeat while a key is held.
2153 if !should_dispatch_key(key.kind) {
2154 continue;
2155 }
2156
2157 // Prompt preparation runs on a blocking worker before the agent
2158 // lane owns the turn. Cancel it directly: an abort queued only to
2159 // the lane cannot wake a JS factory or lifecycle hook that never
2160 // resolves. This check precedes custom/dialog routing because
2161 // those components may themselves have been opened by the hook.
2162 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
2163 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
2164 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
2165 if prompt_abort && state_for_key.cancel_js_preparation() {
2166 state_for_key.set_status(RunStatus::Aborting);
2167 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
2168 close_extension_editor(
2169 &state_for_key,
2170 &ctx_for_key.editor_container,
2171 &editor_for_key,
2172 &tui_for_key,
2173 );
2174 }
2175 tui_for_key.set_render_suspended(false);
2176 tui_for_key.request_render(false);
2177 continue;
2178 }
2179
2180 // Ctrl+C cancels an open selector before it reaches the global
2181 // abort/exit handler. Route through Esc so selector callbacks run.
2182 if key.modifiers == KeyModifiers::CONTROL
2183 && key.code == KeyCode::Char('c')
2184 && state_for_key.selector_open()
2185 {
2186 let selector = state_for_key
2187 .active_selector
2188 .lock()
2189 .unwrap()
2190 .clone()
2191 .expect("selector_open guaranteed Some")
2192 .0;
2193 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2194 tui_for_key.request_render_reusing_scroll_content();
2195 continue;
2196 }
2197
2198 // The interactive "Add provider" form owns the input slot while it
2199 // collects a field: route every key to its single-line input (Esc
2200 // and Enter are handled by the Input's own callbacks). Clone the
2201 // input OUTSIDE `if let` so the `active_provider_input` mutex guard
2202 // drops before `handle_key` — the submit path re-enters that mutex
2203 // to swap in the next step's input and would deadlock on it.
2204 if state_for_key.provider_form.lock().unwrap().is_some() {
2205 let active_input = state_for_key.active_provider_input.lock().unwrap().clone();
2206 let active_select = state_for_key.active_provider_select.lock().unwrap().clone();
2207 if let Some(input) = active_input {
2208 input.handle_key(key);
2209 } else if let Some(select) = active_select {
2210 select.handle_key(key);
2211 }
2212 tui_for_key.request_render_reusing_scroll_content();
2213 continue;
2214 }
2215
2216 // Provider login inputs use the same input slot without creating an
2217 // Add-provider form state. Route them before the normal editor so
2218 // masked API keys and OAuth exchange values actually receive keys.
2219 if handle_provider_login_key(&state_for_key, key) {
2220 tui_for_key.request_render_reusing_scroll_content();
2221 continue;
2222 }
2223
2224 // OAuth begin/exchange/revoke owns the input slot even while the
2225 // provider call is running. Do not let keys leak into the prompt.
2226 if state_for_key
2227 .provider_auth_flow_id
2228 .load(std::sync::atomic::Ordering::Acquire)
2229 != 0
2230 {
2231 // Only report a cancel that won the flow from a completing result.
2232 if key.code == KeyCode::Esc
2233 && key.modifiers == KeyModifiers::NONE
2234 && finish_provider_login(
2235 &state_for_key,
2236 &ctx_for_key.editor_container,
2237 &editor_for_key,
2238 &tui_for_key,
2239 )
2240 {
2241 add_note_message(&state_for_key.notice_container(), "OAuth flow cancelled.");
2242 }
2243 tui_for_key.request_render_reusing_scroll_content();
2244 continue;
2245 }
2246
2247 // Extension dialogs own the input slot while awaiting a result.
2248 // Esc and Ctrl+C both resolve the pending command with cancel;
2249 // all other keys go to the active native editor/input widget.
2250 if state_for_key.extension_dialog_open() {
2251 let cancel = key.code == KeyCode::Esc
2252 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
2253 if cancel {
2254 if !run_extension_cancel(&state_for_key) {
2255 close_extension_editor(
2256 &state_for_key,
2257 &ctx_for_key.editor_container,
2258 &editor_for_key,
2259 &tui_for_key,
2260 );
2261 }
2262 } else {
2263 handle_extension_dialog_key(&state_for_key, key);
2264 }
2265 tui_for_key.request_render_reusing_scroll_content();
2266 continue;
2267 }
2268
2269 // Pet Esc pauses audio; an active run still reaches normal abort
2270 // routing below. Dialogs and selectors retain their own Escape.
2271 if key.code == KeyCode::Esc
2272 && key.modifiers == KeyModifiers::NONE
2273 && !state_for_key.selector_open()
2274 && pet::active(&state_for_key.ext_status)
2275 {
2276 let _ = invoke_extension_command(&state_for_key.extension_session, "pet", "quiet");
2277 tui_for_key.request_render(false);
2278 if *state_for_key.status.lock().unwrap() == RunStatus::Idle
2279 && editor_for_key.get_text().is_empty()
2280 {
2281 continue;
2282 }
2283 }
2284
2285 // 0. Ctrl+C: upstream's `handleCtrlC`. A second press inside the
2286 // double-press window quits; any other press only clears the
2287 // editor (text *and* selection — native `clearEditor`). It never
2288 // aborts — `app.interrupt` (Esc) owns cancelling a run — so a
2289 // stray Ctrl+C while streaming can no longer kill the turn.
2290 // Open selectors and extension dialogs are handled above so
2291 // their cancellation callbacks get first chance.
2292 if keybinding_matches(
2293 &keybindings_for_key,
2294 &key,
2295 rpi_tui::keybindings::keys::CLEAR,
2296 ) {
2297 // A held key auto-repeats: only a fresh Press may arm or
2298 // consume the window, so holding Ctrl+C cannot turn one tap
2299 // into the quit.
2300 if key.kind != KeyEventKind::Repeat {
2301 let now = std::time::Instant::now();
2302 match ctrl_c_action(last_sigint_time, now) {
2303 CtrlCAction::Exit => {
2304 last_sigint_time = None;
2305 // The async loop parks inside
2306 // `run_prompt_streaming(..).await` for the whole run,
2307 // so a queued `Exit` would not be read until the run
2308 // returns — force the quit while one is active. Either
2309 // way the TUI stays quittable.
2310 if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
2311 let _ = tx_for_key.send(TuiMessage::Exit);
2312 } else {
2313 emergency_exit(&tui_for_key);
2314 }
2315 }
2316 CtrlCAction::Clear => {
2317 editor_for_key.clear();
2318 state_for_key.take_pending_images();
2319 refresh_autocomplete(&state_for_key, &editor_for_key);
2320 last_sigint_time = Some(now);
2321 tui_for_key.request_render(false);
2322 }
2323 }
2324 }
2325 continue;
2326 }
2327
2328 // 0b. Ctrl+X: copy the mouse selection first, then the editor
2329 // selection, else the last assistant reply. Ctrl+C retains its
2330 // clear/exit semantics. A failed write must not change sources.
2331 if !state_for_key.selector_open()
2332 && keybinding_matches(
2333 &keybindings_for_key,
2334 &key,
2335 rpi_tui::keybindings::keys::MESSAGE_COPY,
2336 )
2337 {
2338 match copy_mouse_selection_with(&tui_for_key, copy_to_clipboard) {
2339 Some(copied) => add_note_message(
2340 &state_for_key.notice_container(),
2341 if copied {
2342 "Copied selection to the clipboard."
2343 } else {
2344 "Clipboard unavailable. Selection kept; press Ctrl+X to retry."
2345 },
2346 ),
2347 None => {
2348 if editor_for_key.has_selection() && editor_for_key.copy_selection() {
2349 add_note_message(&state_for_key.notice_container(), "Copied!");
2350 } else {
2351 copy_last_assistant(&state_for_key);
2352 }
2353 }
2354 }
2355 tui_for_key.request_render(false);
2356 continue;
2357 }
2358
2359 // 0c. Ctrl+Z (Unix only): upstream's `app.suspend` — hand the
2360 // terminal back to the shell and stop ourselves; `SIGTSTP`
2361 // returns when the user resumes with `fg`. Windows binds no key
2362 // here (native's `defaultKeys` is empty), so this never fires.
2363 if keybinding_matches(
2364 &keybindings_for_key,
2365 &key,
2366 rpi_tui::keybindings::keys::SUSPEND,
2367 ) {
2368 handle_suspend(&tui_for_key);
2369 continue;
2370 }
2371
2372 // 1. A selector overlay is open → route to it first. Only Esc
2373 // (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
2374 // escape to the selector; on done/cancel the selector callbacks
2375 // restore the editor and clear `active_selector`.
2376 if state_for_key.selector_open() {
2377 // Esc always cancels the selector (even with modifiers off).
2378 // Route through `SelectList::handle_key(Esc)` so the list's
2379 // `on_cancel` fires (the `/scoped-models` toggle selector saves
2380 // its edits there) — the old shortcut called `close_selector`
2381 // directly and skipped the callback.
2382 if key.code == KeyCode::Esc {
2383 let (selector, _kind) = state_for_key
2384 .active_selector
2385 .lock()
2386 .unwrap()
2387 .clone()
2388 .expect("selector_open guaranteed Some");
2389 selector.handle_key(key);
2390 continue;
2391 }
2392 let (selector, _kind) = state_for_key
2393 .active_selector
2394 .lock()
2395 .unwrap()
2396 .clone()
2397 .expect("selector_open guaranteed Some");
2398 selector.handle_key(key);
2399 tui_for_key.request_render_reusing_scroll_content();
2400 continue;
2401 }
2402
2403 // 2a. Ctrl+D: upstream's `app.exit`. `handleCtrlD` only fires when
2404 // the editor is empty; with text present the key falls through to
2405 // the editor's deleteCharForward (`tui.editor.deleteCharForward`).
2406 // It never aborts — that is `app.interrupt` (Esc)'s job.
2407 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
2408 if !editor_for_key.get_text().is_empty() {
2409 // Editor holds text — delete the char forward (pi parity).
2410 editor_for_key.handle_key(key);
2411 refresh_autocomplete(&state_for_key, &editor_for_key);
2412 tui_for_key.request_render_reusing_scroll_content();
2413 continue;
2414 }
2415 // Empty editor → quit. A parked run needs the forced path: a
2416 // queued `Exit` is unread until the run returns.
2417 if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
2418 let _ = tx_for_key.send(TuiMessage::Exit);
2419 } else {
2420 emergency_exit(&tui_for_key);
2421 }
2422 continue;
2423 }
2424
2425 // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
2426 // selector is open Esc already cancelled it above; when idle,
2427 // Esc falls through to the editor (no-op-ish). Only fire while
2428 // Working so an idle Esc doesn't abort a non-existent run.
2429 if keybinding_matches(
2430 &keybindings_for_key,
2431 &key,
2432 rpi_tui::keybindings::keys::INTERRUPT,
2433 ) {
2434 let status = *state_for_key.status.lock().unwrap();
2435 if status == RunStatus::Working {
2436 state_for_key.set_status(RunStatus::Aborting);
2437 ask_user_for_key.cancel_all();
2438 if !run_extension_cancel(&state_for_key)
2439 && state_for_key.extension_dialog_open()
2440 {
2441 close_extension_editor(
2442 &state_for_key,
2443 &ctx_for_key.editor_container,
2444 &editor_for_key,
2445 &tui_for_key,
2446 );
2447 }
2448 // upstream's `onEscape` restores the queue before it
2449 // aborts, so an interrupt hands the staged messages back.
2450 abort_run_restoring_queue(
2451 lane_for_key.clone(),
2452 editor_for_key.clone(),
2453 state_for_key.clone(),
2454 tui_for_key.clone(),
2455 );
2456 continue;
2457 }
2458 // A running `!command` takes Esc next (upstream's `onEscape`
2459 // precedence: streaming → bash → bash-mode editor →
2460 // double-escape). The run slot stays occupied until the capture
2461 // resolves, so a second Esc is a no-op rather than a
2462 // double-escape trigger.
2463 if state_for_key.user_bash_running() {
2464 state_for_key.cancel_user_bash();
2465 tui_for_key.request_render(false);
2466 continue;
2467 }
2468 if status == RunStatus::Idle
2469 && editor_for_key.get_text().trim().is_empty()
2470 && double_escape_action != "none"
2471 {
2472 let now = std::time::Instant::now();
2473 if double_escape_trigger(last_escape_time, now) {
2474 last_escape_time = None;
2475 match double_escape_action.as_str() {
2476 "tree" => {
2477 let _ = tx_for_key.send(TuiMessage::OpenTree);
2478 }
2479 "fork" => {
2480 let _ = tx_for_key.send(TuiMessage::ForkSession);
2481 }
2482 _ => {}
2483 }
2484 } else {
2485 last_escape_time = Some(now);
2486 }
2487 }
2488 continue;
2489 }
2490
2491 // 2c. Ctrl+G: edit the current draft in the user's external
2492 // editor, matching upstream's VISUAL/EDITOR fallback chain.
2493 if keybinding_matches(
2494 &keybindings_for_key,
2495 &key,
2496 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
2497 ) {
2498 launch_external_editor(editor_for_key.get_expanded_text(), tx_for_key.clone());
2499 continue;
2500 }
2501
2502 // 2d. Ctrl+O: toggle all tool output panels between compact and
2503 // expanded rendering (upstream's global output toggle).
2504 if keybinding_matches(
2505 &keybindings_for_key,
2506 &key,
2507 rpi_tui::keybindings::keys::TOOLS_EXPAND,
2508 ) {
2509 let expanded = state_for_key.toggle_tool_outputs();
2510 // Persist per-session so a later `-c`/`-r` restores it.
2511 let session = ctx_for_key.session.clone();
2512 tokio::spawn(async move {
2513 let _ = session
2514 .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
2515 .await;
2516 });
2517 tui_for_key.request_render(false);
2518 continue;
2519 }
2520
2521 // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
2522 if keybinding_matches(
2523 &keybindings_for_key,
2524 &key,
2525 rpi_tui::keybindings::keys::THINKING_TOGGLE,
2526 ) {
2527 let hide = state_for_key.toggle_thinking();
2528 // Persist per-session so a later `-c`/`-r` restores it.
2529 let session = ctx_for_key.session.clone();
2530 tokio::spawn(async move {
2531 let _ = session
2532 .set_value("display.hide_thinking", serde_json::json!(hide))
2533 .await;
2534 });
2535 tui_for_key.request_render(false);
2536 continue;
2537 }
2538
2539 // Cycle the authenticated catalog, including other providers. Resolve
2540 // the anchor from the live lane so duplicate model ids stay distinct.
2541 let cycle_forward = keybinding_matches(
2542 &keybindings_for_key,
2543 &key,
2544 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
2545 );
2546 let cycle_backward = keybinding_matches(
2547 &keybindings_for_key,
2548 &key,
2549 rpi_tui::keybindings::keys::MODEL_CYCLE_BACKWARD,
2550 );
2551 if cycle_forward || cycle_backward {
2552 let catalog = ctx_for_key
2553 .model_catalog
2554 .read()
2555 .unwrap_or_else(|error| error.into_inner())
2556 .clone();
2557 let lane = lane_for_key.clone();
2558 let state = state_for_key.clone();
2559 let tui = tui_for_key.clone();
2560 tokio::spawn(async move {
2561 match cycle_model(&lane, &catalog, cycle_backward).await {
2562 Ok(Some(model)) => {
2563 report_model_selection(&lane, &state, &tui, model, false).await;
2564 }
2565 Ok(None) => {}
2566 Err(error) => {
2567 add_error_message(&state.notice_container(), &error);
2568 tui.request_render(false);
2569 }
2570 }
2571 });
2572 continue;
2573 }
2574
2575 // 2f. Shift+Tab / BackTab: cycle the current model's supported
2576 // thinking levels, matching upstream's thinking-level shortcut.
2577 if keybinding_matches(
2578 &keybindings_for_key,
2579 &key,
2580 rpi_tui::keybindings::keys::THINKING_CYCLE,
2581 ) {
2582 let lane = lane_for_key.clone();
2583 let catalog = model_catalog_for_key
2584 .read()
2585 .unwrap_or_else(|error| error.into_inner())
2586 .clone();
2587 let state = state_for_key.clone();
2588 tokio::spawn(async move {
2589 let Ok(current_model) = lane.get_model().await else {
2590 return;
2591 };
2592 let levels = catalog
2593 .iter()
2594 .find(|model| {
2595 model.provider == current_model.provider && model.id == current_model.id
2596 })
2597 .map(|model| model.supported_thinking_levels())
2598 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
2599 if levels.is_empty() {
2600 return;
2601 }
2602 let current = lane
2603 .get_thinking_level()
2604 .await
2605 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
2606 let next = levels
2607 .iter()
2608 .position(|level| *level == current)
2609 .map(|index| levels[(index + 1) % levels.len()])
2610 .unwrap_or(levels[0]);
2611 if lane.set_thinking_level(next).await.is_ok() {
2612 state
2613 .footer
2614 .set_thinking_level(Some(thinking_level_name(next)));
2615 if let Some(tui) = state.tui.as_ref() {
2616 tui.request_render(false)
2617 }
2618 }
2619 });
2620 continue;
2621 }
2622
2623 // Ctrl+V (or a configured paste-image key): if the system
2624 // clipboard holds an image, queue it for the next prompt;
2625 // otherwise paste the clipboard TEXT into the editor. Previously
2626 // a text-only clipboard fell through to the editor's Ctrl+V,
2627 // which yanks from the internal KILL RING — not the system
2628 // clipboard. On platforms without bracketed-paste `Event::Paste`
2629 // (Windows console, some remote clients) that meant Ctrl+V pasted
2630 // stale/other content instead of the user's real clipboard
2631 // ("不是系统的粘贴的内容").
2632 if keybinding_matches(
2633 &keybindings_for_key,
2634 &key,
2635 rpi_tui::keybindings::keys::PASTE_IMAGE,
2636 ) {
2637 if cfg!(windows) {
2638 windows_paste_run.flush_with_clipboard(
2639 &editor_for_key,
2640 std::time::Instant::now(),
2641 read_clipboard_text,
2642 );
2643 windows_paste_run.end_paste_session();
2644 }
2645 if let Some(paths) = read_clipboard_file_paths() {
2646 let text = paths.join("\n");
2647 if let Some(images) = images_from_pasted_paths(&text) {
2648 for image in images {
2649 state_for_key.queue_image(image);
2650 }
2651 add_note_message(
2652 &state_for_key.notice_container(),
2653 "Clipboard images attached to the next prompt.",
2654 );
2655 } else {
2656 editor_for_key.handle_paste(&text);
2657 }
2658 tui_for_key.request_render(false);
2659 continue;
2660 }
2661 if let Ok(Some(image)) = read_clipboard_image() {
2662 state_for_key.queue_image(image);
2663 add_note_message(
2664 &state_for_key.notice_container(),
2665 "Clipboard image attached to the next prompt.",
2666 );
2667 tui_for_key.request_render(false);
2668 continue;
2669 }
2670 // Local Ctrl+V can paste clipboard text as Pi does. Under SSH,
2671 // text must arrive from the client via bracketed paste rather
2672 // than reading the remote host's unrelated clipboard.
2673 if cfg!(windows) || std::env::var_os("SSH_CONNECTION").is_none() {
2674 if let Some(text) = read_clipboard_text() {
2675 if !text.is_empty() {
2676 if !attach_pasted_images(&state_for_key, &text) {
2677 editor_for_key.handle_paste(&text);
2678 }
2679 refresh_autocomplete(&state_for_key, &editor_for_key);
2680 tui_for_key.request_render_reusing_scroll_content();
2681 continue;
2682 }
2683 }
2684 }
2685 }
2686
2687 // 3. Ctrl+Shift+F: open transcript search
2688 if key
2689 .modifiers
2690 .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
2691 && key.code == KeyCode::Char('F')
2692 {
2693 state_for_key.search.activate();
2694 state_for_key.search_bar.set_visible(true);
2695 state_for_key.search_bar.set_query("");
2696 state_for_key.search_bar.set_match_info(0, 0);
2697 tui_for_key.request_render(false);
2698 continue;
2699 }
2700
2701 // Handle search mode input
2702 if state_for_key.search.is_active() {
2703 let search_bar = state_for_key.search_bar.clone();
2704 match key.code {
2705 KeyCode::Esc => {
2706 state_for_key.search.deactivate();
2707 search_bar.set_visible(false);
2708 tui_for_key.request_render(false);
2709 continue;
2710 }
2711 KeyCode::Enter => {
2712 if key.modifiers.contains(KeyModifiers::SHIFT) {
2713 state_for_key.search.previous_match();
2714 } else {
2715 state_for_key.search.next_match();
2716 }
2717 // Update search bar with current match info
2718 let match_index = state_for_key.search.get_match_index();
2719 let match_count = state_for_key.search.get_match_count();
2720 search_bar.set_match_info(match_index, match_count);
2721 tui_for_key.request_render(false);
2722 continue;
2723 }
2724 KeyCode::Backspace => {
2725 state_for_key.search.backspace();
2726 // Re-search with updated query
2727 let lines = collect_transcript_lines(&state_for_key.chat_container);
2728 state_for_key.search.find_matches(&lines);
2729 // Update search bar
2730 let query = state_for_key.search.get_query();
2731 let match_count = state_for_key.search.get_match_count();
2732 search_bar.set_query(&query);
2733 search_bar.set_match_info(0, match_count);
2734 tui_for_key.request_render(false);
2735 continue;
2736 }
2737 KeyCode::Char(c) => {
2738 state_for_key.search.append_char(c);
2739 // Re-search with updated query
2740 let lines = collect_transcript_lines(&state_for_key.chat_container);
2741 state_for_key.search.find_matches(&lines);
2742 // Update search bar
2743 let query = state_for_key.search.get_query();
2744 let match_count = state_for_key.search.get_match_count();
2745 search_bar.set_query(&query);
2746 search_bar.set_match_info(0, match_count);
2747 tui_for_key.request_render(false);
2748 continue;
2749 }
2750 _ => continue,
2751 }
2752 }
2753
2754 // 3. Ctrl+L: open the model selector. Routed through the `/model`
2755 // command so the hotkey and the slash command share one path
2756 // (TS binds Ctrl+L to model-select).
2757 if keybinding_matches(
2758 &keybindings_for_key,
2759 &key,
2760 rpi_tui::keybindings::keys::MODEL_SELECT,
2761 ) {
2762 if let Some(cmd) = registry_for_key.find("/model") {
2763 cmd.execute(&ctx_for_key, "");
2764 }
2765 continue;
2766 }
2767
2768 // 4. Tab: accept the top autocomplete suggestion (if any).
2769 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
2770 if accept_top_suggestion(&state_for_key, &editor_for_key) {
2771 tui_for_key.request_render_reusing_scroll_content();
2772 }
2773 continue;
2774 }
2775
2776 // 5. Global transcript scroll. PageUp/PageDown use the actual
2777 // viewport height with four rows of overlap (upstream behavior),
2778 // while Home/End jump to the transcript boundaries.
2779 if history::handle_key(&state_for_key, &tui_for_key, &keybindings_for_key, &key) {
2780 continue;
2781 }
2782 if editor_for_key.get_text().is_empty()
2783 && crate::transcript_navigation::handle_key(
2784 &tui_for_key,
2785 &keybindings_for_key,
2786 &key,
2787 )
2788 {
2789 continue;
2790 }
2791
2792 // 5a-bis. ↑/↓ navigate the autocomplete list while it is open
2793 // (typed `/` or `@`), then Tab accepts the highlighted row.
2794 if key.modifiers == KeyModifiers::NONE
2795 && (key.code == KeyCode::Up || key.code == KeyCode::Down)
2796 {
2797 let direction = if key.code == KeyCode::Up { -1 } else { 1 };
2798 if navigate_autocomplete(&state_for_key, &editor_for_key, direction) {
2799 tui_for_key.request_render_reusing_scroll_content();
2800 continue;
2801 }
2802 }
2803
2804 // 5b. ↑/↓ move the caret through a multi-line draft, and only
2805 // leave for prompt history once the caret cannot move any
2806 // further (first line for ↑, last line for ↓) — mirrors TS
2807 // historyPrevious/Next, which only fire at the editor's edge.
2808 // A fresh prompt (no caret row to move to) recalls
2809 // immediately. A single-line draft keeps the older contract
2810 // and never swaps itself for a history entry: that was
2811 // reported as "text disappeared", and one line has no second
2812 // row to move to — but once a browse session is active, ↑/↓
2813 // keep walking history (a one-line recall must not strand it).
2814 if key.modifiers == KeyModifiers::NONE
2815 && matches!(key.code, KeyCode::Up | KeyCode::Down)
2816 {
2817 let direction = if key.code == KeyCode::Up { -1 } else { 1 };
2818 if arrow_belongs_to_history(&state_for_key, &editor_for_key, direction) {
2819 navigate_history(&state_for_key, &editor_for_key, direction);
2820 tui_for_key.request_render_reusing_scroll_content();
2821 continue;
2822 }
2823 }
2824
2825 // `app.message.dequeue` (Alt+Q on Windows, Alt+Up elsewhere):
2826 // restore every queued steering/follow-up message into the editor
2827 // so it can be edited before resending. Mirrors upstream's
2828 // `handleDequeue`; the pending-messages display shows the hint.
2829 if keybinding_matches(
2830 &keybindings_for_key,
2831 &key,
2832 rpi_tui::keybindings::keys::DEQUEUE,
2833 ) {
2834 // Handled here, on the key worker — NOT via the mailbox. The
2835 // async main loop parks inside `run_prompt_streaming(..).await`
2836 // for the whole run, and a queue only exists *while* a run is
2837 // in flight, so a `TuiMessage::Dequeue` sat unread until the
2838 // run finished (after the lane had already consumed the queue).
2839 // Alt+Q therefore looked like a no-op while it was most needed.
2840 // Spawn the drain so it lands while the user is still looking at
2841 // the queue, exactly like the Alt+Enter `follow_up` path above.
2842 // Routed through `handle_dequeue` (not the raw restore) so the
2843 // `Restored N queued message(s)` status note fires, mirroring
2844 // upstream's `handleDequeue`.
2845 tokio::spawn(handle_dequeue(
2846 lane_for_key.clone(),
2847 editor_for_key.clone(),
2848 state_for_key.clone(),
2849 tui_for_key.clone(),
2850 ));
2851 continue;
2852 }
2853
2854 // `app.message.followUp` (Alt+Enter; Ctrl+Q and Alt+Enter on
2855 // Windows) queues a follow-up while a run is active. It is handled
2856 // here because Editor treats only a bare Enter as submit; when idle
2857 // the same key keeps the normal prompt behavior.
2858 if keybinding_matches(
2859 &keybindings_for_key,
2860 &key,
2861 rpi_tui::keybindings::keys::MESSAGE_FOLLOW_UP,
2862 ) {
2863 let prompt = editor_for_key.get_expanded_text().trim().to_string();
2864 if prompt.is_empty() && state_for_key.pending_images.lock().unwrap().is_empty() {
2865 continue;
2866 }
2867 editor_for_key.clear();
2868 let status = *state_for_key.status.lock().unwrap();
2869 if status == RunStatus::Idle {
2870 if state_for_key.try_start_working() {
2871 // Direct send: the harness emits no user `message_start`
2872 // for it, so render the bubble here (see the submit
2873 // handler in `interactive_tui`).
2874 add_user_message(&state_for_key.history_page.active, &prompt);
2875 push_history(&state_for_key, &prompt);
2876 let _ = tx_for_key.send(TuiMessage::UserInput(
2877 prompt,
2878 state_for_key.take_pending_images(),
2879 ));
2880 }
2881 } else {
2882 // Follow-ups are echoed by the loop's
2883 // `AgentEvent::MessageStart` once they are consumed; while
2884 // they wait, the pending-messages display shows them.
2885 let images = state_for_key.take_pending_images();
2886 let message = user_message_with_images(&prompt, images.clone());
2887 let lane = lane_for_key.clone();
2888 let chat = state_for_key.notice_container();
2889 let tui = tui_for_key.clone();
2890 let state = state_for_key.clone();
2891 tokio::spawn(async move {
2892 if let Err(error) = lane.follow_up(message).await {
2893 state.restore_pending_images(images);
2894 add_error_message(&chat, &format!("Could not queue message: {error}"));
2895 tui.request_render(false);
2896 return;
2897 }
2898 refresh_pending_messages(&state, &lane).await;
2899 tui.request_render(false);
2900 });
2901 }
2902 tui_for_key.request_render(false);
2903 continue;
2904 }
2905
2906 // Windows' console backend has no Event::Paste. Assemble text keys
2907 // into one payload; only absorb Enter when another key is queued
2908 // or the clipboard confirms a trailing pasted newline. A standalone
2909 // Enter must reach Editor::handle_key so it submits normally.
2910 if cfg!(windows) {
2911 let text_key = match key.code {
2912 KeyCode::Char(c)
2913 if !key
2914 .modifiers
2915 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
2916 {
2917 Some(c)
2918 }
2919 _ => None,
2920 };
2921 if let Some(ch) = text_key {
2922 if ch == '\r' || ch == '\n' {
2923 // Treat these the same as KeyCode::Enter below.
2924 } else {
2925 let now = std::time::Instant::now();
2926 // Feed first: `feed` is what detects the burst, and the
2927 // fold threshold is what decides whether this run is an
2928 // established paste.
2929 windows_paste_run.feed(ch, now);
2930 // An established paste is drained straight out of the
2931 // console. Probing here would stash the next event, which
2932 // is what made that drain unreachable. `pending_event` is
2933 // `None` at this point (the loop took it above), so
2934 // reading the console cannot reorder input.
2935 if !windows_paste_run.probe_needed() {
2936 #[cfg(windows)]
2937 if should_drain_queued_text(
2938 &windows_paste_run,
2939 state_for_key
2940 .extension_claimed_keys
2941 .lock()
2942 .unwrap()
2943 .is_empty(),
2944 ) {
2945 windows_paste_run.read_queued_text();
2946 }
2947 continue;
2948 }
2949 // Queue-empty is not a paste boundary. Leave the run
2950 // buffered for its idle window; probe only to retain
2951 // newline evidence. Releases are not queued text.
2952 let (more_queued, stashed) =
2953 probe_paste_input(pending_event.take(), || {
2954 if crossterm::event::poll(std::time::Duration::ZERO)
2955 .unwrap_or(false)
2956 {
2957 crossterm::event::read().ok().map(normalize_decoded_event)
2958 } else {
2959 None
2960 }
2961 });
2962 pending_event = stashed;
2963 if more_queued {
2964 windows_paste_run.mark_collected();
2965 }
2966 continue;
2967 }
2968 }
2969 if key.modifiers.is_empty()
2970 && matches!(
2971 key.code,
2972 KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
2973 )
2974 {
2975 let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
2976 if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
2977 crossterm::event::read().ok().map(normalize_decoded_event)
2978 } else {
2979 None
2980 }
2981 });
2982 pending_event = stashed;
2983 let now = std::time::Instant::now();
2984 // Read the clipboard only when some text here could be a
2985 // paste still waiting for its trailing newline — the live
2986 // run or a batch flushed within `PASTE_CARRY_GAP`.
2987 let clipboard = if !more_queued && windows_paste_run.has_paste_evidence(now) {
2988 read_clipboard_text()
2989 } else {
2990 None
2991 };
2992 if windows_enter_in_paste(
2993 &windows_paste_run,
2994 more_queued,
2995 clipboard.as_deref(),
2996 now,
2997 ) {
2998 windows_paste_run.feed('\n', now);
2999 crate::key_trace::note("windows paste run -> newline");
3000 continue;
3001 }
3002 crate::key_trace::note("windows paste run -> flush + submit");
3003 windows_paste_run.flush_with_clipboard(
3004 &editor_for_key,
3005 now,
3006 read_clipboard_text,
3007 );
3008 windows_paste_run.end_paste_session();
3009 // Enter can beat the idle deadline: autocomplete must see
3010 // the newly delivered draft before its confirm/submit path.
3011 refresh_autocomplete(&state_for_key, &editor_for_key);
3012 } else {
3013 windows_paste_run.flush_with_clipboard(
3014 &editor_for_key,
3015 std::time::Instant::now(),
3016 read_clipboard_text,
3017 );
3018 windows_paste_run.end_paste_session();
3019 }
3020 }
3021
3022 // 5a-ter. A bare Enter in a completion context (`/` or `@`)
3023 // resolves the completion before submitting, mirroring the
3024 // TS editor's `tui.select.confirm` (`editor.ts:757-815`):
3025 // apply the highlighted item — the panel's first choice on a
3026 // fresh popup — then let a `/` completion fall through to
3027 // submit while an `@`-path one consumes the Enter (a path
3028 // popup therefore still needs a sole candidate or an explicit
3029 // ↑/↓). Unknown `/skill:name` has no popup and still reaches
3030 // the model untouched.
3031 //
3032 // Placement is load-bearing: this runs AFTER the Windows
3033 // paste classification and flush above, immediately before
3034 // the confirmed submit Enter is forwarded to the editor, so
3035 // an Enter that belongs to a paste never reaches it — the
3036 // paste paths have already `continue`d. At this point the
3037 // editor also holds any buffered typed prefix the Windows
3038 // handler just flushed, so the completion sees the full
3039 // token.
3040 if key.modifiers == KeyModifiers::NONE
3041 && (key.code == KeyCode::Enter
3042 || key.code == KeyCode::Char('\n')
3043 || key.code == KeyCode::Char('\r'))
3044 {
3045 // One resolution, one replacement: `route_enter_completion`
3046 // applies the same snapshot it decided on and reports whether
3047 // this Enter is consumed by an `@`-path completion.
3048 if route_enter_completion(&state_for_key, &editor_for_key) {
3049 refresh_autocomplete(&state_for_key, &editor_for_key);
3050 tui_for_key.request_render_reusing_scroll_content();
3051 continue;
3052 }
3053 }
3054
3055 // 6. Otherwise forward to the editor + refresh autocomplete. The
3056 // editor maps unmodified Enter and the encodings a remote client
3057 // may send for it (`Char('\n')`/`Char('\r')`, and the raw LF
3058 // crossterm reports as `Ctrl+J`) to submit, so a remote Enter
3059 // sends instead of inserting a newline.
3060 editor_for_key.handle_key(key);
3061 refresh_autocomplete(&state_for_key, &editor_for_key);
3062 tui_for_key.request_render_reusing_scroll_content();
3063 }
3064 });
3065
3066 // ---- Session lifecycle (P1): SessionStart ----
3067 // The TUI + key worker are up and the session is ready to accept input.
3068 // Notify subscribed extensions now so they can initialize session-scoped
3069 // state. A veto here is **advisory** (the session is already up) — warn and
3070 // continue. No-op when no extension subscribes.
3071 if let Some(reason) = crate::session::dispatch_session_event_async(
3072 reload_context,
3073 rpi_plugin_sdk::EventTag::SessionStart,
3074 )
3075 .await
3076 {
3077 tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
3078 }
3079 if let Ok(model) = lane.get_model().await {
3080 selectors::dispatch_model_select(&state, &model);
3081 }
3082
3083 // ---- Initial prompts (run before reading from the channel) ----
3084 let mut prompts: Vec<String> = Vec::new();
3085 if let Some(init) = initial {
3086 prompts.push(init);
3087 }
3088 for m in extra_messages {
3089 prompts.push(m.clone());
3090 }
3091 // ---- Continue a run that recovery repaired, the way upstream does ----
3092 // This happens before any user input, and only when nothing was passed on the
3093 // command line: an explicit `-p` means the user has already said what they
3094 // want next. Driven here rather than inside the harness so the run's output
3095 // goes through the same streaming/render path as any other run, and so
3096 // startup is never blocked by a run the user cannot see.
3097 if prompts.is_empty() && lane.has_pending_resume().await {
3098 run_prompt_streaming(
3099 &lane,
3100 "",
3101 true,
3102 &tui,
3103 &state,
3104 drain_handle.is_some(),
3105 Vec::new(),
3106 )
3107 .await;
3108 }
3109
3110 let mut images = initial_images;
3111 for prompt in prompts {
3112 if !*running.lock().unwrap() {
3113 break;
3114 }
3115 // The harness emits no user `message_start` for a directly-sent prompt
3116 // (it drives the loop with an empty prompts vec), so the initial
3117 // `-p` / `--prompt` bubbles are rendered here.
3118 add_user_message(&state.history_page.active, &prompt);
3119 add_image_previews(&state.history_page.active, &images, show_images);
3120 tui.request_render(false);
3121 run_prompt_streaming(
3122 &lane,
3123 &prompt,
3124 false,
3125 &tui,
3126 &state,
3127 drain_handle.is_some(),
3128 std::mem::take(&mut images),
3129 )
3130 .await;
3131 }
3132
3133 // ---- Main loop: process submitted input + lifecycle messages ----
3134 loop {
3135 if !*running.lock().unwrap() {
3136 break;
3137 }
3138 match rx.recv().await {
3139 Some(TuiMessage::OpenProviderSelector) => {
3140 let current = lane.get_model().await.ok();
3141 open_provider_selector(
3142 &state,
3143 &editor_container,
3144 &editor,
3145 &tui,
3146 &ctx.reload_context.extension_session,
3147 &ctx.tx,
3148 current.as_ref().map(|model| model.provider.as_str()),
3149 );
3150 }
3151 Some(TuiMessage::SwitchProvider(input)) => {
3152 let manifests = current_provider_manifests(&ctx.reload_context.extension_session);
3153 let available = ctx
3154 .model_catalog
3155 .read()
3156 .unwrap_or_else(|error| error.into_inner())
3157 .clone();
3158 match switch_provider(&input, &manifests, &available, &lane).await {
3159 Ok((model, note)) => {
3160 state.set_current_model(&model);
3161 add_note_message(&state.notice_container(), ¬e);
3162 }
3163 Err(error) => add_error_message(&state.notice_container(), &error),
3164 }
3165 tui.request_render(false);
3166 }
3167 Some(TuiMessage::UserInput(prompt, prompt_images)) => {
3168 // Clear the editor so the next prompt starts fresh (the submit
3169 // handler runs on the blocking key thread and can't mutate the
3170 // editor state safely there; clearing here, on the async loop,
3171 // keeps it on one thread). Marked programmatic: this is the host
3172 // tidying up after a submit, not the user typing — reporting it
3173 // as a user edit made a hands-free voice session switch itself
3174 // off the moment it was started.
3175 state.clear_editor_programmatically();
3176 add_image_previews(
3177 &state.history_page.active,
3178 &prompt_images,
3179 state.images_visible(),
3180 );
3181 if !prompt_images.is_empty() {
3182 add_note_message(
3183 &state.notice_container(),
3184 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
3185 );
3186 }
3187 run_prompt_streaming(
3188 &lane,
3189 &prompt,
3190 false,
3191 &tui,
3192 &state,
3193 drain_handle.is_some(),
3194 prompt_images,
3195 )
3196 .await;
3197 }
3198 Some(TuiMessage::ExternalEditorResult(result)) => {
3199 match result {
3200 Ok(text) => {
3201 let cursor = text.chars().count();
3202 editor.set_text(&text);
3203 editor.set_cursor(0, cursor);
3204 add_note_message(
3205 &state.notice_container(),
3206 "Draft updated from external editor.",
3207 );
3208 }
3209 Err(error) => add_error_message(&state.notice_container(), &error),
3210 }
3211 tui.request_render(false);
3212 }
3213 Some(TuiMessage::UserBashFinished(report)) => {
3214 // Free the run slot first: the guard and the Esc router both
3215 // read it, so a finished command must never block the next one.
3216 state.finish_user_bash();
3217 let message = rpi_harness::messages::bash_execution_message(
3218 report.command,
3219 report.output,
3220 report.exit_code,
3221 report.cancelled,
3222 report.truncated,
3223 report.full_output_path,
3224 Some(report.exclude_from_context),
3225 now_ms(),
3226 );
3227 // While a run is in flight the message is deferred so it is not
3228 // spliced into the middle of a turn's tool sequence; it is
3229 // flushed at `AgentEnd` (upstream `_pendingBashMessages`).
3230 if *state.status.lock().unwrap() == RunStatus::Idle {
3231 if let Err(error) = lane.append_message(message).await {
3232 state
3233 .history_page
3234 .unsaved
3235 .store(true, std::sync::atomic::Ordering::SeqCst);
3236 add_error_message(
3237 &state.notice_container(),
3238 &format!("Could not record bash result: {error}"),
3239 );
3240 tui.request_render(false);
3241 }
3242 history::settle(&state, &tui);
3243 } else {
3244 state.pending_bash_messages.lock().unwrap().push(message);
3245 }
3246 }
3247 Some(TuiMessage::OpenTree) => {
3248 if *state.status.lock().unwrap() != RunStatus::Idle {
3249 add_note_message(
3250 &state.notice_container(),
3251 "Wait for the current run to finish before opening the tree.",
3252 );
3253 tui.request_render(false);
3254 } else {
3255 open_tree_selector(harness, &state, &editor_container, &editor, &tui, &tx)
3256 .await;
3257 }
3258 }
3259 Some(TuiMessage::NavigateTree(entry_id)) => {
3260 state.history_page.invalidate();
3261 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
3262 Ok(result) => match result.outcome {
3263 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
3264 state.reset_session_state();
3265 chat_container.clear();
3266 let (tools, skills, context_files) =
3267 current_capabilities(harness, &lane).await;
3268 add_welcome_message_with_capabilities(
3269 &state.history_page.header,
3270 &tools,
3271 &skills,
3272 &context_files,
3273 );
3274 history::restore(harness, &state, &chat_container).await;
3275 add_note_message(
3276 &state.notice_container(),
3277 "Moved to the selected session entry.",
3278 );
3279 }
3280 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
3281 add_error_message(&state.notice_container(), &error.message);
3282 }
3283 _ => add_note_message(
3284 &state.notice_container(),
3285 "The selected entry could not be opened.",
3286 ),
3287 },
3288 Err(error) => add_error_message(
3289 &state.notice_container(),
3290 &format!("Could not navigate session tree: {error}"),
3291 ),
3292 }
3293 tui.request_render(false);
3294 }
3295 Some(TuiMessage::ClearChat) => {
3296 // `/new` starts a genuinely new session instead of only wiping
3297 // the transcript: the harness swaps its JSONL backing, the
3298 // banner is rebuilt from the real capability snapshot, and the
3299 // footer counters are reset.
3300 start_new_session(
3301 harness,
3302 &lane,
3303 &cwd,
3304 &chat_container,
3305 &state,
3306 &reload_context.tool_context,
3307 )
3308 .await;
3309 tui.request_render(false);
3310 }
3311 Some(TuiMessage::Compact) => {
3312 run_compact(&lane, &tui, &state).await;
3313 }
3314 Some(TuiMessage::Copy) => {
3315 copy_last_assistant(&state);
3316 tui.request_render(false);
3317 }
3318 Some(TuiMessage::Exit) => {
3319 *running.lock().unwrap() = false;
3320 break;
3321 }
3322 Some(TuiMessage::SwitchSession(id)) => {
3323 switch_to_session(
3324 harness,
3325 &lane,
3326 &id,
3327 &cwd,
3328 &chat_container,
3329 &state,
3330 &reload_context.tool_context,
3331 &reload_context.catalog,
3332 )
3333 .await;
3334 tui.request_render(false);
3335 }
3336 Some(TuiMessage::ImportSession(path)) => {
3337 import_session(
3338 harness,
3339 &lane,
3340 &path,
3341 &cwd,
3342 &chat_container,
3343 &state,
3344 &reload_context.tool_context,
3345 &reload_context.catalog,
3346 )
3347 .await;
3348 tui.request_render(false);
3349 }
3350 Some(TuiMessage::ShareSession) => {
3351 share_session(harness, &state.notice_container()).await;
3352 tui.request_render(false);
3353 }
3354 Some(TuiMessage::SetSessionName(name)) => {
3355 let outcome = harness.session().set_name(Some(&name)).await;
3356 match outcome {
3357 Ok(_) => {
3358 // Line 1 of the footer renders `session_name`, but nothing
3359 // in production ever wrote it — the slot was always empty
3360 // and `/name` looked like a no-op on screen.
3361 state.footer.set_session_name(Some(&name));
3362 add_note_message(
3363 &state.notice_container(),
3364 &format!("Session renamed to \"{name}\"."),
3365 );
3366 }
3367 Err(e) => add_error_message(
3368 &state.notice_container(),
3369 &format!("Could not rename session: {e}"),
3370 ),
3371 }
3372 tui.request_render(false);
3373 }
3374 Some(TuiMessage::ExportSession) => {
3375 let file_name = agent_session
3376 .default_export_file_name(crate::export::ExportFormat::Markdown)
3377 .await;
3378 let path = cwd.join(&file_name);
3379 match agent_session.export_to_markdown(&path).await {
3380 Ok(_) => add_note_message(
3381 &state.notice_container(),
3382 &format!("Exported session to {}", path.display()),
3383 ),
3384 Err(e) => add_error_message(
3385 &state.notice_container(),
3386 &format!("Could not write export: {e}"),
3387 ),
3388 }
3389 tui.request_render(false);
3390 }
3391 Some(TuiMessage::ExportSessionWithFormat(format)) => {
3392 let file_name = agent_session.default_export_file_name(format.clone()).await;
3393 let path = cwd.join(&file_name);
3394 match agent_session.export(format, &path).await {
3395 Ok(_) => add_note_message(
3396 &state.notice_container(),
3397 &format!("Exported session to {}", path.display()),
3398 ),
3399 Err(e) => add_error_message(
3400 &state.notice_container(),
3401 &format!("Could not write export: {e}"),
3402 ),
3403 }
3404 tui.request_render(false);
3405 }
3406 Some(TuiMessage::ShowUsage) => {
3407 let stats = agent_session.usage_stats().await.ok();
3408 let total = agent_session.total_usage().await.ok();
3409 let snapshot = agent_session.snapshot().await.ok();
3410 match stats {
3411 Some(stats) => {
3412 let mut lines = vec![
3413 "## Usage".to_string(),
3414 String::new(),
3415 format!("- messages: {}", stats.message_count),
3416 format!("- total tokens: {}", stats.total_tokens),
3417 format!("- cached tokens: {}", stats.cached_tokens),
3418 format!("- uncached tokens: {}", stats.uncached_tokens),
3419 format!("- cost: ${:.4}", stats.cost_total),
3420 ];
3421 match stats.cache_hit_ratio() {
3422 Some(ratio) => {
3423 lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
3424 }
3425 None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
3426 }
3427 if let Some(total) = total {
3428 lines.push(String::new());
3429 lines.push(format!(
3430 "- last cumulative input/output: {} / {}",
3431 total.input, total.output
3432 ));
3433 }
3434 if let Some(snapshot) = snapshot {
3435 lines.push(String::new());
3436 lines.push(format!("- lane: `{}`", snapshot.lane));
3437 lines.push(format!(
3438 "- leaf: `{}`",
3439 snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
3440 ));
3441 if let Some(active) = &snapshot.active {
3442 lines.push(format!(
3443 "- active operation: {} (`{}`)",
3444 active.kind.as_str(),
3445 active.run_id
3446 ));
3447 }
3448 }
3449 add_note_message(&state.notice_container(), &lines.join("\n"));
3450 }
3451 None => add_error_message(
3452 &state.notice_container(),
3453 "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 &state.notice_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 &state.notice_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 &state.notice_container(),
3586 &format!("No saved credentials found for {provider}."),
3587 ),
3588 Err(error) => add_error_message(
3589 &state.notice_container(),
3590 &format!("Could not remove {provider} credentials: {error}"),
3591 ),
3592 }
3593 tui.request_render(false);
3594 }
3595 None => {
3596 add_note_message(
3597 &state.notice_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(
3625 &state.notice_container(),
3626 "Reloading extensions + resources…",
3627 );
3628 tui.request_render(false);
3629 let outcome =
3630 crate::session::reload_extension_resources(harness, &reload_ctx).await;
3631 if !outcome.had_errors {
3632 if let Ok(resources) = harness.get_resources().await {
3633 let global = crate::settings::load_settings().unwrap_or_default();
3634 let project = if outcome.project_trusted.unwrap_or(project_trusted) {
3635 crate::settings::load_project_settings(&cwd)
3636 } else {
3637 Vec::new()
3638 };
3639 configure_resource_autocomplete(
3640 &state.autocomplete,
3641 registry.visible_entries(),
3642 &resources,
3643 &cwd,
3644 skill_commands_enabled(&global, &project),
3645 );
3646 state.autocomplete_container.clear();
3647 update_resource_listing(
3648 &ctx.resources,
3649 ctx.startup_context.as_ref(),
3650 resources,
3651 );
3652 }
3653 }
3654 // B5e: the reload swapped a fresh `ExtensionSession` into the
3655 // context's cell. Rebuild the markdown transformer from that
3656 // fresh snapshot and install it on the in-flight streaming
3657 // component (so a reloaded plugin's transformer takes effect on
3658 // the visible message immediately) + future components (they
3659 // read `state.markdown_transformer()` at construction). The old
3660 // closure no-ops once its snapshot's `active` flag flips false
3661 // (reload already did that before the swap).
3662 let fresh_transformer = build_markdown_transformer(
3663 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
3664 );
3665 state.set_markdown_transformer_with_reinstall(fresh_transformer);
3666 // Rebuild the custom-theme registry: extension reloads can
3667 // change project trust (`/trust` flow) and the session cwd is
3668 // fixed per TUI, so re-resolve both. Use the policy this
3669 // reload actually resolved (`resolve_project_trust` over the
3670 // effective args), not the immutable startup snapshot;
3671 // after a hard failure the previous resources were kept, so
3672 // the previous registry stays untouched too. A broken theme
3673 // dir surfaces as a warning, never a reload failure.
3674 if !reload_ctx.args.no_themes {
3675 let mut rebuilt = None;
3676 if let Some(reload_trusted) = outcome.project_trusted {
3677 let (mut registry, diagnostics) =
3678 crate::themes::ThemeRegistry::discover(&reload_ctx.cwd, reload_trusted);
3679 let discovered = reload_ctx
3680 .discovered_theme_paths
3681 .lock()
3682 .map(|paths| paths.clone())
3683 .unwrap_or_default();
3684 registry.merge_discovered(&discovered);
3685 for diagnostic in &diagnostics {
3686 eprintln!("warning: {diagnostic}");
3687 }
3688 // A reload can remove the active file theme (its package
3689 // went away or no longer returns the path); fall back to
3690 // the default preset with a notice instead of rendering
3691 // with a palette nothing can re-resolve.
3692 let active = state.active_theme_spec.lock().ok().and_then(|g| g.clone());
3693 if let Ok(mut guard) = state.theme_registry.write() {
3694 *guard = registry.clone();
3695 }
3696 if let Some(notice) = crate::themes::reconcile_after_reload(
3697 active.as_deref(),
3698 ®istry,
3699 &state.theme_manager,
3700 ) {
3701 add_note_message(&state.notice_container(), ¬ice);
3702 state.set_active_theme_spec(Some("dark".to_string()));
3703 }
3704 rebuilt = Some(registry);
3705 }
3706 // The registry may now resolve a different file for the
3707 // active name; re-target the watcher either way (with the
3708 // rebuilt registry when the reload completed, the previous
3709 // one after a hard failure kept the old resources).
3710 let registry = rebuilt.unwrap_or_else(|| {
3711 state
3712 .theme_registry
3713 .read()
3714 .map(|guard| guard.clone())
3715 .unwrap_or_default()
3716 });
3717 crate::themes::restart_theme_watcher(
3718 &state.theme_watcher,
3719 state.active_theme_spec().as_deref(),
3720 ®istry,
3721 &state.theme_manager,
3722 theme_watch_notify(&state, tui.clone()),
3723 );
3724 }
3725 if outcome.had_errors {
3726 // Hard failure: the summary itself carries the error detail
3727 // (e.g. "Settings reload failed: …"), so render it as an
3728 // error without an extra stderr pointer.
3729 add_error_message(&state.notice_container(), &outcome.summary);
3730 } else if outcome.had_warnings {
3731 // Reload succeeded but produced soft diagnostics (skill
3732 // shadowing, name validation, …). The details are printed
3733 // to stderr; keep this as an informational note so a
3734 // successful reload isn't shown as a red ✗ error.
3735 add_note_message(
3736 &state.notice_container(),
3737 &format!(
3738 "{} (with warnings — see stderr for details).",
3739 outcome.summary
3740 ),
3741 );
3742 } else {
3743 add_note_message(&state.notice_container(), &outcome.summary);
3744 }
3745 tui.request_render(false);
3746 }
3747 None => break,
3748 }
3749 }
3750
3751 // ---- Shutdown ----
3752 state.history_page.invalidate();
3753 // Let anything the lane has already committed to writing reach the disk
3754 // before the tasks that carry those writes are aborted. A turn's entries are
3755 // appended by the harness rather than by the drain task, but the drain task
3756 // is what acknowledges them to the UI, so aborting it first can leave a turn
3757 // the user saw with no durable counterpart.
3758 let _ = lane.wait_for_idle().await;
3759 lane.drain_session().await;
3760
3761 ask_user_bridge.shutdown();
3762 *running.lock().unwrap() = false;
3763 for handle in update_check_handles {
3764 handle.abort();
3765 let _ = handle.await;
3766 }
3767 // The input worker checks `running` at least every 50ms. Join it before
3768 // restoring cooked mode so no late event read races terminal cleanup.
3769 let _ = key_handle.await;
3770 tick_handle.abort();
3771 if let Some(handle) = drain_handle {
3772 handle.abort();
3773 }
3774 // Drop the reload bridge: clearing the mailbox closes the signal channel,
3775 // the drain task's `recv` returns `None`, and the task exits. (Aborting is
3776 // redundant — the recv terminates — but cheap + makes shutdown explicit.)
3777 reload_context.mailbox.clear();
3778 reload_bridge_handle.abort();
3779 // Same for the model-catalog refresh bridge: closing the mailbox ends the
3780 // request channel, so a pending caller gets "TUI gone" instead of hanging.
3781 reload_context.reload_models_mailbox.clear();
3782 reload_models_bridge_handle.abort();
3783 tui.stop(Default::default());
3784
3785 // upstream prints how to resume the session after an interactive exit so
3786 // a terminal that scrolled away can be recovered. Mirrors
3787 // `formatResumeCommand` (id + `--session-dir` only when non-default).
3788 let session_id = harness.session().storage().metadata().id;
3789 if let Some(command) = format_resume_command(&session_id, &cwd, Some(&state.session_dir)) {
3790 println!("\x1b[2mTo resume this session:\x1b[0m {command}");
3791 }
3792 println!("\nGoodbye!");
3793 let _ = args;
3794
3795 0
3796}
3797
3798// ===========================================================================
3799// TUI support + entry detection
3800// ===========================================================================
3801
3802/// Check if the terminal supports TUI mode.
3803pub fn is_tui_supported() -> bool {
3804 std::io::stdout().is_terminal()
3805}
3806
3807// Keep the `Color` import used (theme accent rendering in autocomplete).
3808#[allow(unused_imports)]
3809use rpi_tui::Color as _Color;