Skip to main content

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//! Key architecture facts (see `docs/tui-gap-analysis.md`):
10//! - `TuiAltScreen::start()` and `show_overlay` are stubs, so this module owns
11//!   a `spawn_blocking` crossterm `read()` loop for key dispatch and a
12//!   `tokio::spawn` task that drains `broadcast::Receiver<AgentEvent>` into UI
13//!   mutations.
14//! - The layout root is built ONCE at startup (mirrors the TS
15//!   `fullscreenLayoutRoot`); per-message we mutate only `chat_container` /
16//!   `status_container` / `autocomplete_container` children and call
17//!   `request_render(false)` so the differential renderer repaints just the
18//!   changed rows.
19//! - Selectors (`/model` `/session` `/theme`) are implemented by **swapping the
20//!   `editor_container` child** (the TS `showSelector` swap pattern,
21//!   `interactive-mode.ts:4354-4377`) — the `show_overlay` stub is avoided
22//!   entirely. An `active_selector` state field holds the live `SelectList`;
23//!   while it is `Some` the key loop routes to it first and restores the editor
24//!   on done/cancel.
25
26use std::collections::HashMap;
27use std::io::IsTerminal;
28use std::sync::Arc;
29use std::sync::mpsc::{self, channel};
30
31use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
32use tokio::sync::broadcast;
33
34use rpi_agent::{AgentEvent, AgentMessage};
35use rpi_harness::session::types::{Entry, EntryQuery};
36use rpi_ai::types::{AssistantMessage, Content};
37use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
38use rpi_tui::{
39    AutocompleteManager, CombinedAutocompleteProvider, Container, Editor, EditorOptions,
40    EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode, Loader, ProcessTerminal,
41    ScrollView, ScrollViewOptions, SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer,
42    StackChild, StackEntry, Text, TuiAltScreen, TUI, VStack, AssistantBlock,
43    AssistantMessageComponent, AssistantMessageOptions, AutocompleteSuggestions,
44    FooterComponent, SelectList, SelectItem, ThemeManager, ThemePreset,
45    ToolExecutionComponent, render_diff,
46    BashExecutionComponent, BashTruncation, UserMessageComponent,
47};
48
49#[allow(unused_imports)]
50use rpi_tui::BashStatus;
51
52use crate::args::Args;
53
54/// B5e: the markdown-transformer trait object the assistant-message render path
55/// applies to raw text BEFORE the [`Markdown`] renderer styles it. A plain
56/// `Fn(&str) -> String` (NO `rpi-extensions` types) so `rpi-tui` stays free of
57/// an `rpi-extensions` dep — `rpi-cli` (which already depends on
58/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot` and
59/// hands the trait object to `AssistantMessageComponent::set_markdown_transformer`.
60type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
61
62/// B5e: build the `AssistantMessageComponent` markdown-transformer closure the
63/// render path applies to raw assistant text before styling. Wraps any plugin
64/// `register_markdown_transformer` handlers registered in `snapshot` (chained
65/// in registration order: each handler's output feeds the next). `None` when
66/// no markdown transformers are registered (the component defaults to the
67/// identity transform + this avoids a closure allocation on the hot render
68/// path).
69///
70/// The closure captures an `Arc<RegistrySnapshot>` clone so it outlives the
71/// borrow that built it (the snapshot's `active` flag guards dispatch in
72/// `emit_resources_discover`/event translation; a reloaded session's old
73/// snapshot flips false, so a stale closure no-ops rather than driving a
74/// half-swapped registry — the transformer falls back to the input unchanged
75/// on an inactive snapshot, matching the plugin's per-handler skip-on-error).
76///
77/// This is the cycle-free seam: `rpi-tui` takes a `Fn(&str) -> String` trait
78/// object (no `rpi-extensions` dep); `rpi-cli` (which already depends on
79/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot`. The
80/// calling pattern mirrors `plugin_stub_smoke.rs`'s direct `RenderFn` round-
81/// trip (input `{"markdown":…}` → `render_fn` → reclaim `out` via the plugin's
82/// `free_string` → parse `{"markdown":…}`).
83fn build_markdown_transformer(
84    snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
85) -> Option<MarkdownTransformer> {
86    let snapshot = snapshot?;
87    // Pre-check: if no markdown renderers are registered, return None so the
88    // component uses the identity path (no per-delta closure call). The
89    // renderers list is a per-call `renderers_of` clone; snapshotting it once
90    // here keeps the closure cheap on the hot path.
91    let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
92    if renderers.is_empty() {
93        return None;
94    }
95    Some(Arc::new(move |raw: &str| -> String {
96        transform_markdown_chain(&snapshot, &renderers, raw)
97    }))
98}
99
100/// Drive the markdown-transformer chain for one input string. Each registered
101/// handler receives the previous handler's output (or the raw input for the
102/// first), as a `{"markdown": <text>}` JSON envelope; its `RenderFn` returns
103/// `{"markdown": <transformed>}` (rc=0) or an error (rc!=0). On any failure —
104/// nonzero rc, a panic across the FFI (caught), a missing `markdown` field, or
105/// an inactive snapshot — the chain short-circuits to the current text
106/// unchanged (per-handler skip-on-error, mirroring pi's `runner.ts` fan-out).
107fn transform_markdown_chain(
108    snapshot: &rpi_extensions::RegistrySnapshot,
109    renderers: &[rpi_extensions::RegisteredRenderer],
110    raw: &str,
111) -> String {
112    // A stale snapshot (post-/reload) must not drive a swapped-out registry.
113    // The renderers were captured from this snapshot; if it has gone inactive,
114    // fall back to the raw input so the UI never renders stale-transformed text
115    // from a dead plugin.
116    if !snapshot.is_active() {
117        return raw.to_string();
118    }
119
120    let mut current = raw.to_string();
121    for renderer in renderers {
122        let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
123            Ok(s) => s,
124            Err(_) => return current, // serialize failure — keep current, stop chain
125        };
126        // SAFETY: `render_fn` is a plugin-provided `extern "C" fn` over a
127        // borrowed `StbStringRef` + an out-param. The plugin warrants
128        // `poll`/`render` are non-blocking + thread-safe (the same contract
129        // the tool adapter relies on). `user_data` is the plugin's opaque
130        // pointer, stable for the registry lifetime (the keepalive keeps the
131        // cdylib mapped). We reclaim `out` via the plugin's `free_string`
132        // exactly once. The whole call is `catch_unwind`-wrapped — a plugin
133        // panic must not unwind across the FFI boundary (same policy as the
134        // tool partial cb + the runtime_action trampoline).
135        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
136            let mut out = rpi_plugin_sdk::StbString::empty();
137            let rc = (renderer.render_fn)(
138                rpi_plugin_sdk::StbStringRef::from_str(&input),
139                &mut out as *mut rpi_plugin_sdk::StbString,
140                renderer.user_data,
141            );
142            let text = if rc == 0 {
143                let s = out.to_string_lossy();
144                Some(s)
145            } else {
146                None
147            };
148            // Reclaim the plugin-owned `out` regardless of rc (rc!=0 may still
149            // have written an error JSON the plugin allocated). `free_with` is
150            // idempotent on an empty `StbString`.
151            out.free_with(Some(renderer.plugin_free_string));
152            text
153        }));
154        let out_text = match outcome {
155            Ok(Some(s)) => s,
156            Ok(None) => return current, // rc != 0 — skip this handler, keep current
157            Err(_) => return current,  // panic — skip, keep current (do not abort: the
158            // render path is not the action trampoline; a panicking transformer
159            // degrades to identity rather than killing the process. Logged via
160            // the `tracing` crate's panic hook.)
161        };
162        // Parse `{"markdown": <text>}`; lenient — a missing/non-string field
163        // keeps the current text (skip this handler).
164        let next = serde_json::from_str::<serde_json::Value>(&out_text)
165            .ok()
166            .and_then(|v| v.get("markdown").and_then(|m| m.as_str()).map(|s| s.to_string()))
167            .unwrap_or(current);
168        current = next;
169    }
170    current
171}
172
173
174
175// ===========================================================================
176// Slash commands — trait + registry
177// ===========================================================================
178//
179// Each built-in slash command is one `impl SlashCommand`. The commands are
180// registered at startup into a [`CommandRegistry`] (one source of truth) that
181// serves both dispatch ("given this token, run the command") and autocomplete
182// ("list the visible commands"). This replaces the old two-list + sync-test
183// arrangement, where `handle_slash_command` and `v1_slash_commands()` had to be
184// kept in lock-step by hand.
185//
186// `execute` runs on the blocking key/compose thread (the editor `on_submit`
187// callback and the Ctrl+L hotkey both land there), so it MUST stay synchronous:
188//   - commands needing async (`set_model`/`set_thinking_level`/`set_active_tools`)
189//     `tokio::spawn` the work and return immediately;
190//   - commands needing the main async loop (`compact`/`copy`/`exit`/`clear`/
191//     `user-input`) signal it via `ctx.tx.send(TuiMessage::…)`;
192//   - everything else mutates the chat container + requests a render directly.
193
194/// The borrowed world a slash command runs against. All fields are `Arc` (or a
195/// cheap `String` snapshot), so one `CommandContext` clones freely into each
196/// command without per-capture ceremony — this struct is exactly the set of
197/// `*_for_cb` clones the old submit closure used to make individually.
198#[derive(Clone)]
199struct CommandContext {
200    chat: Arc<Container>,
201    tui: Arc<TuiAltScreen>,
202    tx: mpsc::Sender<TuiMessage>,
203    state: Arc<TuiState>,
204    editor: Arc<Editor>,
205    editor_container: Arc<Container>,
206    lane: Arc<dyn AgentLane>,
207    model_catalog: Arc<Vec<rpi_ai::Model>>,
208    /// Lane model id snapshot, read once via `lane.get_model().await` BEFORE the
209    /// blocking key loop starts. Selectors/key loop can't await, so they read
210    /// this owned string instead. Semantically unchanged from pre-refactor.
211    lane_model_id: String,
212    cwd: std::path::PathBuf,
213    /// Harness resources snapshot (skills + prompt templates) for `/context`.
214    /// Captured once at TUI startup because the blocking submit thread can't
215    /// `.await get_resources()`.
216    resources: Arc<rpi_harness::types::AgentHarnessResources>,
217    /// B5d: the reload context `/reload` drives. `Arc<ReloadContext>` so the
218    /// blocking submit thread can cheaply clone it into the `ReloadCommand`
219    /// without an `.await` (the command can't drive reload directly — it signals
220    /// the main loop via `TuiMessage::ReloadExtensions`, which awaits the shared
221    /// `reload_extension_resources` routine on the async runtime).
222    reload_context: Arc<crate::session::ReloadContext>,
223}
224
225/// One slash command.
226trait SlashCommand: Send + Sync {
227    /// Canonical name, with the leading `/` (e.g. "/model").
228    fn name(&self) -> &'static str;
229    /// Aliases, also `/`-prefixed. Matched alongside `name()` during dispatch.
230    /// Use [`SlashCommand::alias_visible`] to also surface an alias in the
231    /// `/`-autocomplete list (most aliases stay hidden).
232    fn aliases(&self) -> &'static [&'static str] {
233        &[]
234    }
235    /// Whether the canonical name appears in the `/` autocomplete list. Hidden
236    /// commands (`/context`, `/name`, …) return `false`.
237    fn visible(&self) -> bool {
238        true
239    }
240    /// Aliases that should also appear in the `/` autocomplete list. Defaults to
241    /// none — most aliases (`/q`, `/m`, `/think`, `/resume`, `/v`) are kept off
242    /// the list to keep it short. `/new` and `/quit` override this to surface.
243    fn alias_visible(&self) -> &'static [&'static str] {
244        &[]
245    }
246    /// Description shown in autocomplete and `/help`. A non-empty description is
247    /// required to surface in autocomplete even when `visible()` is true.
248    fn description(&self) -> &'static str {
249        ""
250    }
251    /// Execute the command. Only invoked for inputs starting with `/` whose
252    /// first token matches `name()` or an alias. `args` is the whitespace-
253    /// trimmed remainder after the command token ("" when none). Must stay
254    /// synchronous (see the module-level note) — async work goes through
255    /// `ctx.tx.send(TuiMessage::…)` or `tokio::spawn`.
256    fn execute(&self, ctx: &CommandContext, args: &str);
257}
258
259/// Holds all registered slash commands; the single source of truth for both
260/// dispatch and the autocomplete list.
261struct CommandRegistry {
262    commands: Vec<Arc<dyn SlashCommand>>,
263}
264
265impl CommandRegistry {
266    fn new() -> Self {
267        Self { commands: Vec::new() }
268    }
269
270    fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
271        self.commands.push(cmd);
272    }
273
274    /// Find the command whose `name()` or an alias matches `token` (e.g. "/q").
275    /// `token` is the first whitespace-delimited word of the input, `/`-prefixed.
276    fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
277        self.commands
278            .iter()
279            .find(|c| c.name() == token || c.aliases().contains(&token))
280    }
281
282    /// The autocomplete entries, derived from the registry so it can never drift
283    /// from what dispatch recognizes. Surfaces the canonical name when
284    /// `visible()` + non-empty description, plus any `alias_visible()` entries.
285    /// Order = registration order; built-ins are registered before templates,
286    /// so they win on a fuzzy tie (unchanged).
287    fn visible_entries(&self) -> Vec<SlashCommandEntry> {
288        let mut out: Vec<SlashCommandEntry> = Vec::new();
289        for c in &self.commands {
290            if c.visible() && !c.description().is_empty() {
291                out.push(SlashCommandEntry {
292                    name: c.name().into(),
293                    description: c.description().into(),
294                });
295            }
296            // Surfaced aliases share the command's description.
297            for alias in c.alias_visible() {
298                out.push(SlashCommandEntry {
299                    name: (*alias).into(),
300                    description: c.description().into(),
301                });
302            }
303        }
304        out
305    }
306}
307
308/// Resolve the command for a `/`-prefixed input and run it, or emit the
309/// unknown-command error if nothing matches. Non-slash text never reaches here
310/// — callers route only `/`-prefixed inputs and send plain text directly.
311fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
312    let mut parts = text.split_whitespace();
313    let token = parts.next().unwrap_or("");
314    let args = parts.collect::<Vec<_>>().join(" ");
315    match registry.find(token) {
316        Some(cmd) => cmd.execute(ctx, &args),
317        None => {
318            add_error_message(
319                &ctx.chat,
320                &format!("Unknown command: {text}. Type /help for available commands."),
321            );
322            ctx.tui.request_render(false);
323        }
324    }
325}
326
327/// A slash command that is recognized but not implemented in this v1 build.
328/// One struct feeds every `/settings`/`/export`/… entry — no per-command
329/// boilerplate.
330struct UnsupportedCommand {
331    name: &'static str,
332    desc: &'static str,
333}
334
335impl UnsupportedCommand {
336    fn new(name: &'static str, desc: &'static str) -> Self {
337        Self { name, desc }
338    }
339}
340
341impl SlashCommand for UnsupportedCommand {
342    fn name(&self) -> &'static str {
343        self.name
344    }
345    /// Visible with a description so autocomplete lists it (the user discovers
346    /// the command exists) even though running it reports "not supported".
347    fn description(&self) -> &'static str {
348        self.desc
349    }
350    fn execute(&self, ctx: &CommandContext, _args: &str) {
351        add_note_message(&ctx.chat, &format!("{} is not supported in v1.", self.name));
352        ctx.tui.request_render(false);
353    }
354}
355
356// ---- Built-in command implementations ----
357
358struct HelpCommand;
359impl SlashCommand for HelpCommand {
360    fn name(&self) -> &'static str {
361        "/help"
362    }
363    fn aliases(&self) -> &'static [&'static str] {
364        &["/?"]
365    }
366    fn description(&self) -> &'static str {
367        "Show available commands"
368    }
369    fn execute(&self, ctx: &CommandContext, _args: &str) {
370        add_help_message(&ctx.chat);
371        ctx.tui.request_render(false);
372    }
373}
374
375struct ClearChatCommand;
376impl SlashCommand for ClearChatCommand {
377    fn name(&self) -> &'static str {
378        "/clear"
379    }
380    fn aliases(&self) -> &'static [&'static str] {
381        &["/new"]
382    }
383    // `/new` carries its own weight as a discoverable entry, so surface it.
384    fn alias_visible(&self) -> &'static [&'static str] {
385        &["/new"]
386    }
387    fn description(&self) -> &'static str {
388        "Clear the conversation"
389    }
390    fn execute(&self, ctx: &CommandContext, _args: &str) {
391        let _ = ctx.tx.send(TuiMessage::ClearChat);
392    }
393}
394
395struct ExitCommand;
396impl SlashCommand for ExitCommand {
397    fn name(&self) -> &'static str {
398        "/exit"
399    }
400    fn aliases(&self) -> &'static [&'static str] {
401        &["/quit", "/q"]
402    }
403    // `/quit` is surfaced (matches pi's BUILTIN list); `/q` stays a hidden alias.
404    fn alias_visible(&self) -> &'static [&'static str] {
405        &["/quit"]
406    }
407    fn description(&self) -> &'static str {
408        "Exit the application"
409    }
410    fn execute(&self, ctx: &CommandContext, _args: &str) {
411        let _ = ctx.tx.send(TuiMessage::Exit);
412    }
413}
414
415struct VersionCommand;
416impl SlashCommand for VersionCommand {
417    fn name(&self) -> &'static str {
418        "/version"
419    }
420    fn aliases(&self) -> &'static [&'static str] {
421        &["/v"]
422    }
423    fn description(&self) -> &'static str {
424        "Show version information"
425    }
426    fn execute(&self, ctx: &CommandContext, _args: &str) {
427        add_version_message(&ctx.chat);
428        ctx.tui.request_render(false);
429    }
430}
431
432struct HotkeysCommand;
433impl SlashCommand for HotkeysCommand {
434    fn name(&self) -> &'static str {
435        "/hotkeys"
436    }
437    fn description(&self) -> &'static str {
438        "Show keyboard shortcuts"
439    }
440    fn execute(&self, ctx: &CommandContext, _args: &str) {
441        add_hotkeys_message(&ctx.chat);
442        ctx.tui.request_render(false);
443    }
444}
445
446struct ModelCommand;
447impl SlashCommand for ModelCommand {
448    fn name(&self) -> &'static str {
449        "/model"
450    }
451    fn aliases(&self) -> &'static [&'static str] {
452        &["/m"]
453    }
454    fn description(&self) -> &'static str {
455        "Choose a model (selector)"
456    }
457    fn execute(&self, ctx: &CommandContext, _args: &str) {
458        open_model_selector(
459            &ctx.state,
460            &ctx.editor_container,
461            &ctx.editor,
462            &ctx.tui,
463            &ctx.model_catalog,
464            &ctx.lane,
465            &ctx.lane_model_id,
466            &ctx.chat,
467        );
468    }
469}
470
471struct ThinkingCommand;
472impl SlashCommand for ThinkingCommand {
473    fn name(&self) -> &'static str {
474        "/thinking"
475    }
476    fn aliases(&self) -> &'static [&'static str] {
477        &["/think"]
478    }
479    fn description(&self) -> &'static str {
480        "Set thinking level (selector)"
481    }
482    fn execute(&self, ctx: &CommandContext, _args: &str) {
483        open_thinking_selector(
484            &ctx.state,
485            &ctx.editor_container,
486            &ctx.editor,
487            &ctx.tui,
488            &ctx.lane,
489            &ctx.model_catalog,
490            &ctx.lane_model_id,
491            &ctx.chat,
492        );
493    }
494}
495
496struct ToolsCommand;
497impl SlashCommand for ToolsCommand {
498    fn name(&self) -> &'static str {
499        "/tools"
500    }
501    fn description(&self) -> &'static str {
502        "Toggle tools on/off"
503    }
504    fn execute(&self, ctx: &CommandContext, _args: &str) {
505        open_tools_selector(
506            &ctx.state,
507            &ctx.editor_container,
508            &ctx.editor,
509            &ctx.tui,
510            &ctx.lane,
511            &ctx.chat,
512        );
513    }
514}
515
516struct ImagesCommand;
517impl SlashCommand for ImagesCommand {
518    fn name(&self) -> &'static str {
519        "/images"
520    }
521    fn description(&self) -> &'static str {
522        "Toggle inline images"
523    }
524    fn execute(&self, ctx: &CommandContext, _args: &str) {
525        open_images_selector(
526            &ctx.state,
527            &ctx.editor_container,
528            &ctx.editor,
529            &ctx.tui,
530            &ctx.chat,
531        );
532    }
533}
534
535struct SessionCommand;
536impl SlashCommand for SessionCommand {
537    fn name(&self) -> &'static str {
538        "/session"
539    }
540    fn aliases(&self) -> &'static [&'static str] {
541        &["/resume"]
542    }
543    fn description(&self) -> &'static str {
544        "List saved sessions"
545    }
546    fn execute(&self, ctx: &CommandContext, _args: &str) {
547        open_session_selector(
548            &ctx.state,
549            &ctx.editor_container,
550            &ctx.editor,
551            &ctx.tui,
552            &ctx.cwd,
553            &ctx.tx,
554        );
555    }
556}
557
558struct ThemeCommand;
559impl SlashCommand for ThemeCommand {
560    fn name(&self) -> &'static str {
561        "/theme"
562    }
563    fn description(&self) -> &'static str {
564        "Choose a theme (selector)"
565    }
566    fn execute(&self, ctx: &CommandContext, _args: &str) {
567        open_theme_selector(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
568    }
569}
570
571struct CompactCommand;
572impl SlashCommand for CompactCommand {
573    fn name(&self) -> &'static str {
574        "/compact"
575    }
576    fn description(&self) -> &'static str {
577        "Compact the conversation"
578    }
579    fn execute(&self, ctx: &CommandContext, _args: &str) {
580        let _ = ctx.tx.send(TuiMessage::Compact);
581    }
582}
583
584struct CopyCommand;
585impl SlashCommand for CopyCommand {
586    fn name(&self) -> &'static str {
587        "/copy"
588    }
589    fn description(&self) -> &'static str {
590        "Copy last reply to clipboard"
591    }
592    fn execute(&self, ctx: &CommandContext, _args: &str) {
593        let _ = ctx.tx.send(TuiMessage::Copy);
594    }
595}
596
597struct ExportCommand;
598impl SlashCommand for ExportCommand {
599    fn name(&self) -> &'static str {
600        "/export"
601    }
602    fn description(&self) -> &'static str {
603        "Export session to a markdown file"
604    }
605    fn execute(&self, ctx: &CommandContext, _args: &str) {
606        let _ = ctx.tx.send(TuiMessage::ExportSession);
607    }
608}
609
610struct ForkCommand;
611impl SlashCommand for ForkCommand {
612    fn name(&self) -> &'static str {
613        "/fork"
614    }
615    fn description(&self) -> &'static str {
616        "Fork the session into a new one"
617    }
618    fn execute(&self, ctx: &CommandContext, _args: &str) {
619        let _ = ctx.tx.send(TuiMessage::ForkSession);
620    }
621}
622
623struct NameCommand;
624impl SlashCommand for NameCommand {
625    fn name(&self) -> &'static str {
626        "/name"
627    }
628    fn description(&self) -> &'static str {
629        "Set session display name"
630    }
631    fn execute(&self, ctx: &CommandContext, args: &str) {
632        let name = args.trim();
633        if name.is_empty() {
634            add_note_message(
635                &ctx.chat,
636                "Usage: /name <display name> — sets the current session's name.",
637            );
638            ctx.tui.request_render(false);
639            return;
640        }
641        let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
642    }
643}
644
645struct ImportCommand;
646impl SlashCommand for ImportCommand {
647    fn name(&self) -> &'static str {
648        "/import"
649    }
650    fn description(&self) -> &'static str {
651        "Import a session file (path)"
652    }
653    fn execute(&self, ctx: &CommandContext, args: &str) {
654        let path = args.trim();
655        if path.is_empty() {
656            add_note_message(
657                &ctx.chat,
658                "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
659            );
660            ctx.tui.request_render(false);
661            return;
662        }
663        let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
664    }
665}
666
667struct SettingsCommand;
668impl SlashCommand for SettingsCommand {
669    fn name(&self) -> &'static str {
670        "/settings"
671    }
672    fn description(&self) -> &'static str {
673        "Open settings menu"
674    }
675    fn execute(&self, ctx: &CommandContext, _args: &str) {
676        open_settings_selector(
677            &ctx.state,
678            &ctx.editor_container,
679            &ctx.editor,
680            &ctx.tui,
681            &ctx.lane,
682            &ctx.model_catalog,
683            &ctx.lane_model_id,
684            &ctx.chat,
685        );
686    }
687}
688
689struct ScopedModelsCommand;
690impl SlashCommand for ScopedModelsCommand {
691    fn name(&self) -> &'static str {
692        "/scoped-models"
693    }
694    fn description(&self) -> &'static str {
695        "Choose models for Ctrl+M cycling"
696    }
697    fn execute(&self, ctx: &CommandContext, _args: &str) {
698        open_scoped_models_selector(
699            &ctx.state,
700            &ctx.editor_container,
701            &ctx.editor,
702            &ctx.tui,
703            &ctx.model_catalog,
704            &ctx.chat,
705        );
706    }
707}
708
709struct ShareCommand;
710impl SlashCommand for ShareCommand {
711    fn name(&self) -> &'static str {
712        "/share"
713    }
714    fn description(&self) -> &'static str {
715        "Share session (gist via gh, or clipboard)"
716    }
717    fn execute(&self, ctx: &CommandContext, _args: &str) {
718        let _ = ctx.tx.send(TuiMessage::ShareSession);
719    }
720}
721
722struct ArminCommand;
723impl SlashCommand for ArminCommand {
724    fn name(&self) -> &'static str {
725        "/armin"
726    }
727    fn description(&self) -> &'static str {
728        "??? (easter egg)"
729    }
730    fn execute(&self, ctx: &CommandContext, _args: &str) {
731        crate::extras::add_armin(&ctx.chat);
732        ctx.tui.request_render(false);
733    }
734}
735
736struct EarendilCommand;
737impl SlashCommand for EarendilCommand {
738    fn name(&self) -> &'static str {
739        "/earendil"
740    }
741    fn description(&self) -> &'static str {
742        "Announcement"
743    }
744    fn execute(&self, ctx: &CommandContext, _args: &str) {
745        crate::extras::add_earendil(&ctx.chat);
746        ctx.tui.request_render(false);
747    }
748}
749
750/// `/context` — lists discovered context files, skills, and prompt templates.
751/// Hidden from autocomplete (needs the resources snapshot to be meaningful as a
752/// discovery surface; like `/name`, it's recognized-v1 but kept off the list).
753struct ContextCommand;
754impl SlashCommand for ContextCommand {
755    fn name(&self) -> &'static str {
756        "/context"
757    }
758    fn visible(&self) -> bool {
759        false
760    }
761    fn execute(&self, ctx: &CommandContext, _args: &str) {
762        show_context_panel(&ctx.chat, &ctx.resources);
763        ctx.tui.request_render(false);
764    }
765}
766
767/// `/reload` — re-run extension + resource discovery into the LIVE harness
768/// (B5d): reload the cdylib plugins, invalidate the old `ActionBridge` +
769/// registry snapshot, rebuild skills/prompts/context/SYSTEM.md/APPEND_SYSTEM.md
770/// + the `TeeEmitter`, and push the rebuilt state via the B5d harness setters.
771/// The command itself runs on the blocking submit thread, so it can't drive
772/// the async `reload_extension_resources` routine directly — it signals the main
773/// loop via `TuiMessage::ReloadExtensions`, which awaits it on the async runtime.
774/// (A plugin's `runtime_action(Reload)` signals the same loop via the
775/// `ReloadMailbox` the TUI installs — the B5d async-reload design avoids the
776/// self-unmapping race a synchronous plugin-initiated reload would have.)
777struct ReloadCommand;
778impl SlashCommand for ReloadCommand {
779    fn name(&self) -> &'static str {
780        "/reload"
781    }
782    fn description(&self) -> &'static str {
783        "Reload extensions, skills, prompts"
784    }
785    fn execute(&self, ctx: &CommandContext, _args: &str) {
786        // Signal the main loop. It owns the `&AgentHarness` borrow the
787        // `reload_extension_resources` routine needs (the blocking submit thread
788        // only has the context's `Arc<ReloadContext>` + the `Arc<dyn AgentLane>`).
789        add_note_message(
790            &ctx.chat,
791            "Reloading extensions + resources…",
792        );
793        ctx.tui.request_render(false);
794        let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
795    }
796}
797
798/// Build the full command registry: active built-ins first (so they win on a
799/// fuzzy autocomplete tie), then the v1-out-of-scope stubs. Prompt-template
800/// commands are merged in separately by the autocomplete builder (they dispatch
801/// via template expansion, not this registry).
802fn build_builtin_registry() -> CommandRegistry {
803    let mut r = CommandRegistry::new();
804    r.register(Arc::new(HelpCommand));
805    r.register(Arc::new(ClearChatCommand));
806    r.register(Arc::new(ExitCommand));
807    r.register(Arc::new(VersionCommand));
808    r.register(Arc::new(ModelCommand));
809    r.register(Arc::new(ThinkingCommand));
810    r.register(Arc::new(ToolsCommand));
811    r.register(Arc::new(ImagesCommand));
812    r.register(Arc::new(SessionCommand));
813    r.register(Arc::new(ThemeCommand));
814    r.register(Arc::new(CompactCommand));
815    r.register(Arc::new(CopyCommand));
816    r.register(Arc::new(HotkeysCommand));
817    r.register(Arc::new(ArminCommand));
818    r.register(Arc::new(EarendilCommand));
819    r.register(Arc::new(ContextCommand));
820    // Recognized but inert in v1 (one struct backs them all). The TS builtins
821    // out of v1 scope; each carries a description so autocomplete surfaces its
822    // existence even though running it reports "not supported".
823    r.register(Arc::new(NameCommand));
824    r.register(Arc::new(SettingsCommand));
825    r.register(Arc::new(ScopedModelsCommand));
826    r.register(Arc::new(ExportCommand));
827    r.register(Arc::new(ImportCommand));
828    r.register(Arc::new(ShareCommand));
829    r.register(Arc::new(ForkCommand));
830    r.register(Arc::new(UnsupportedCommand::new(
831        "/clone",
832        "Duplicate the current session",
833    )));
834    r.register(Arc::new(UnsupportedCommand::new(
835        "/tree",
836        "Navigate session tree",
837    )));
838    r.register(Arc::new(UnsupportedCommand::new(
839        "/trust",
840        "Save project trust decision",
841    )));
842    r.register(Arc::new(UnsupportedCommand::new(
843        "/login",
844        "Configure provider authentication",
845    )));
846    r.register(Arc::new(UnsupportedCommand::new(
847        "/logout",
848        "Remove provider authentication",
849    )));
850    r.register(Arc::new(ReloadCommand));
851    r
852}
853
854// ===========================================================================
855// Channel + helpers
856// ===========================================================================
857
858/// Message type for communication between the key/callback threads and the
859/// main async loop.
860enum TuiMessage {
861    UserInput(String),
862    Exit,
863    /// Clear the transcript (from `/clear`).
864    ClearChat,
865    /// Compact the conversation (from `/compact`).
866    Compact,
867    /// Copy the last assistant reply to the clipboard (from `/copy`).
868    Copy,
869    /// Hot-switch to another saved session (from the `/session` selector):
870    /// the payload is the session id the selector's item value carried.
871    SwitchSession(String),
872    /// Export the current session to a markdown file (from `/export`).
873    ExportSession,
874    /// Fork the current session into a new one and switch to it (from `/fork`).
875    ForkSession,
876    /// Rename the current session (from `/name <name>`).
877    SetSessionName(String),
878    /// Import a JSONL session file into the session dir and switch to it
879    /// (from `/import <path>`).
880    ImportSession(String),
881    /// Share the current session (`/share`): `gh gist create` when the gh CLI
882    /// is available, otherwise copy the transcript to the clipboard.
883    ShareSession,
884    /// `/reload` — re-run extension + resource discovery into the live harness
885    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
886    /// mailbox) signal the main loop, which awaits
887    /// `reload_extension_resources` on the async runtime.
888    ReloadExtensions,
889}
890
891/// Extract the concatenated text content from an assistant message (mirrors
892/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
893fn assistant_text(msg: &AssistantMessage) -> String {
894    msg.content
895        .iter()
896        .filter_map(|c| match c {
897            Content::Text(t) => Some(t.text.clone()),
898            _ => None,
899        })
900        .collect()
901}
902
903/// The user message's text (Text content or the text blocks of a Blocks
904/// payload — images are skipped, consistent with the v1 text-only prompt path).
905fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
906    match &msg.content {
907        rpi_ai::types::UserContent::Text(s) => s.clone(),
908        rpi_ai::types::UserContent::Blocks(blocks) => blocks
909            .iter()
910            .filter_map(|c| match c {
911                Content::Text(t) => Some(t.text.clone()),
912                _ => None,
913            })
914            .collect(),
915    }
916}
917
918/// Render the `/settings` panel: the saved settings.json values the session
919/// honors, plus pointers to the commands that edit them (theme via `/theme`,
920/// defaults via flags, cycle scope via `/scoped-models`). Kept for the
921/// read-only summary; the interactive menu is [`open_settings_selector`].
922fn show_settings_panel(chat: &Arc<Container>) {
923    let s = crate::settings::load_settings().unwrap_or_default();
924    let mut lines: Vec<String> = Vec::new();
925    lines.push("⚙️  Saved settings:".into());
926    lines.push(format!(
927        "  Theme: {} (edit with /theme)",
928        s.theme.as_deref().unwrap_or("(default)")
929    ));
930    lines.push(format!(
931        "  Default model: {} (set at launch with --model)",
932        s.default_model.as_deref().unwrap_or("(none)")
933    ));
934    lines.push(format!(
935        "  Default thinking: {} (set at launch with --thinking)",
936        s.default_thinking_level.as_deref().unwrap_or("(default)")
937    ));
938    match &s.scoped_models {
939        Some(list) if !list.is_empty() => lines.push(format!(
940            "  Ctrl+M cycle scope: {} (edit with /scoped-models)",
941            list.join(", ")
942        )),
943        _ => lines.push("  Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
944    }
945    let body = lines.join("\n");
946    container_note_block(chat, &body);
947}
948
949/// The catalog allowed in the Ctrl+M cycle: the `/scoped-models` set from
950/// settings.json when present, otherwise every model. The current model is
951/// always included (fallback) so cycling can never strand the user off-scope.
952fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
953    let scoped = crate::settings::load_settings()
954        .ok()
955        .and_then(|s| s.scoped_models)
956        .unwrap_or_default();
957    if scoped.is_empty() {
958        return catalog.to_vec();
959    }
960    let mut out: Vec<rpi_ai::Model> = catalog
961        .iter()
962        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
963        .cloned()
964        .collect();
965    // Never strand the user: if the current model isn't in scope, keep it.
966    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
967        if let Some(cur) = catalog.iter().find(|m| m.id.eq_ignore_ascii_case(current_id)) {
968            out.push(cur.clone());
969        }
970    }
971    out
972}
973
974/// Interactive `/settings` menu: a top-level selector over the editable
975/// settings, each opening a sub-selector that applies the choice AND persists
976/// it to settings.json (theme / default model / default thinking / cycle
977/// scope). Selecting a menu item swaps the current selector for the
978/// sub-selector (the `active_selector` slot is single, so each open replaces
979/// the previous list); the sub-selector's cancel restores the editor.
980fn open_settings_selector(
981    state: &Arc<TuiState>,
982    editor_container: &Arc<Container>,
983    editor: &Arc<Editor>,
984    tui: &Arc<TuiAltScreen>,
985    lane: &Arc<dyn AgentLane>,
986    catalog: &[rpi_ai::Model],
987    lane_model_id: &str,
988    chat: &Arc<Container>,
989) {
990    let settings = crate::settings::load_settings().unwrap_or_default();
991    let mut items: Vec<SelectItem> = Vec::new();
992    items.push(
993        SelectItem::new("theme", "Theme")
994            .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
995    );
996    items.push(
997        SelectItem::new("model", "Default model")
998            .with_description(&settings.default_model.clone().unwrap_or_else(|| "(none)".into())),
999    );
1000    items.push(
1001        SelectItem::new("thinking", "Default thinking")
1002            .with_description(&settings.default_thinking_level.clone().unwrap_or_else(|| "(default)".into())),
1003    );
1004    let scope_desc = match &settings.scoped_models {
1005        Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
1006        _ => "all models".to_string(),
1007    };
1008    items.push(
1009        SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc),
1010    );
1011    let list = Arc::new(SelectList::new(items, 10));
1012
1013    let state_sel = state.clone();
1014    let ec_sel = editor_container.clone();
1015    let editor_sel = editor.clone();
1016    let tui_sel = tui.clone();
1017    let lane_sel = lane.clone();
1018    let chat_sel = chat.clone();
1019    let catalog_sel = catalog.to_vec();
1020    let lane_model_sel = lane_model_id.to_string();
1021    list.on_select(Arc::new(move |item| {
1022        // Swap this menu for the sub-selector; each sub-selector saves its
1023        // choice to settings.json on select.
1024        match item.value.as_str() {
1025            "theme" => open_settings_theme_selector(
1026                &state_sel, &ec_sel, &editor_sel, &tui_sel, &chat_sel,
1027            ),
1028            "model" => open_settings_model_selector(
1029                &state_sel,
1030                &ec_sel,
1031                &editor_sel,
1032                &tui_sel,
1033                &lane_sel,
1034                &catalog_sel,
1035                &lane_model_sel,
1036                &chat_sel,
1037            ),
1038            "thinking" => open_settings_thinking_selector(
1039                &state_sel,
1040                &ec_sel,
1041                &editor_sel,
1042                &tui_sel,
1043                &lane_sel,
1044                &catalog_sel,
1045                &lane_model_sel,
1046                &chat_sel,
1047            ),
1048            "scoped-models" => open_scoped_models_selector(
1049                &state_sel, &ec_sel, &editor_sel, &tui_sel, &catalog_sel, &chat_sel,
1050            ),
1051            _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
1052        }
1053    }));
1054    let state_cancel = state.clone();
1055    let ec_cancel = editor_container.clone();
1056    let editor_cancel = editor.clone();
1057    let tui_cancel = tui.clone();
1058    list.on_cancel(Arc::new(move || {
1059        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1060    }));
1061
1062    open_selector(state, editor_container, editor, tui, list, SelectorKind::Settings);
1063}
1064
1065/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
1066fn open_settings_theme_selector(
1067    state: &Arc<TuiState>,
1068    editor_container: &Arc<Container>,
1069    editor: &Arc<Editor>,
1070    tui: &Arc<TuiAltScreen>,
1071    chat: &Arc<Container>,
1072) {
1073    let items = vec![
1074        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
1075        SelectItem::new("light", "Light").with_description("Light background"),
1076        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
1077    ];
1078    let list = Arc::new(SelectList::new(items, 10));
1079
1080    let state_sel = state.clone();
1081    let ec_sel = editor_container.clone();
1082    let editor_sel = editor.clone();
1083    let tui_sel = tui.clone();
1084    let chat_sel = chat.clone();
1085    list.on_select(Arc::new(move |item| {
1086        let preset = match item.value.as_str() {
1087            "light" => ThemePreset::Light,
1088            "monochrome" => ThemePreset::Monochrome,
1089            _ => ThemePreset::Dark,
1090        };
1091        state_sel.theme_manager.apply_preset(preset);
1092        let mut settings = crate::settings::load_settings().unwrap_or_default();
1093        settings.theme = Some(item.value.clone());
1094        let saved = crate::settings::save_settings(&settings);
1095        add_note_message(
1096            &chat_sel,
1097            &format!(
1098                "Theme set to {} (saved{})",
1099                item.label,
1100                if saved.is_ok() { "" } else { ", not saved" },
1101            ),
1102        );
1103        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1104        tui_sel.render_now(true);
1105    }));
1106    let state_cancel = state.clone();
1107    let ec_cancel = editor_container.clone();
1108    let editor_cancel = editor.clone();
1109    let tui_cancel = tui.clone();
1110    list.on_cancel(Arc::new(move || {
1111        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1112    }));
1113
1114    open_selector(state, editor_container, editor, tui, list, SelectorKind::Settings);
1115}
1116
1117/// Choose the default model AND persist it (`/settings` → Default model):
1118/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
1119/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
1120fn open_settings_model_selector(
1121    state: &Arc<TuiState>,
1122    editor_container: &Arc<Container>,
1123    editor: &Arc<Editor>,
1124    tui: &Arc<TuiAltScreen>,
1125    lane: &Arc<dyn AgentLane>,
1126    catalog: &[rpi_ai::Model],
1127    lane_model_id: &str,
1128    chat: &Arc<Container>,
1129) {
1130    let mut items: Vec<SelectItem> = Vec::new();
1131    for m in catalog {
1132        let label = if m.name.is_empty() { short_model_name(&m.id) } else { m.name.clone() };
1133        let marker = if m.id.eq_ignore_ascii_case(lane_model_id) { " (current)" } else { "" };
1134        items.push(SelectItem::new(&m.id, &label).with_description(&format!("{id}{marker}", id = m.id)));
1135    }
1136    if items.is_empty() {
1137        add_note_message(chat, "No models in the catalog.");
1138        tui.request_render(false);
1139        return;
1140    }
1141    let list = Arc::new(SelectList::new(items, 10));
1142
1143    let catalog_arc = catalog.to_vec();
1144    let state_sel = state.clone();
1145    let ec_sel = editor_container.clone();
1146    let editor_sel = editor.clone();
1147    let tui_sel = tui.clone();
1148    let chat_sel = chat.clone();
1149    let lane_sel = lane.clone();
1150    list.on_select(Arc::new(move |item| {
1151        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
1152            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
1153            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1154            return;
1155        };
1156        state_sel.set_current_model(&model);
1157        let lane = lane_sel.clone();
1158        tokio::spawn(async move {
1159            let _ = lane.set_model(model).await;
1160        });
1161        let mut settings = crate::settings::load_settings().unwrap_or_default();
1162        settings.default_model = Some(item.value.clone());
1163        let saved = crate::settings::save_settings(&settings);
1164        add_note_message(
1165            &chat_sel,
1166            &format!(
1167                "Default model set to {} (saved{}",
1168                short_model_name(&item.value),
1169                if saved.is_ok() { ")" } else { ", not saved)" },
1170            ),
1171        );
1172        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1173    }));
1174    let state_cancel = state.clone();
1175    let ec_cancel = editor_container.clone();
1176    let editor_cancel = editor.clone();
1177    let tui_cancel = tui.clone();
1178    list.on_cancel(Arc::new(move || {
1179        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1180    }));
1181
1182    open_selector(state, editor_container, editor, tui, list, SelectorKind::Settings);
1183}
1184
1185/// Choose the default thinking level AND persist it (`/settings` → Default
1186/// thinking): applies live via `lane.set_thinking_level` and saves
1187/// `defaultThinkingLevel` to settings.json.
1188fn open_settings_thinking_selector(
1189    state: &Arc<TuiState>,
1190    editor_container: &Arc<Container>,
1191    editor: &Arc<Editor>,
1192    tui: &Arc<TuiAltScreen>,
1193    lane: &Arc<dyn AgentLane>,
1194    catalog: &[rpi_ai::Model],
1195    lane_model_id: &str,
1196    chat: &Arc<Container>,
1197) {
1198    let model = catalog.iter().find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
1199    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
1200        .map(|m| m.supported_thinking_levels())
1201        .unwrap_or_else(|| {
1202            use rpi_ai::types::ThinkingLevel::*;
1203            vec![Off, Minimal, Low, Medium, High]
1204        });
1205    let mut items: Vec<SelectItem> = Vec::new();
1206    for lvl in &levels {
1207        let name = thinking_level_name(*lvl);
1208        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
1209    }
1210    if items.is_empty() {
1211        add_note_message(chat, "This model has no supported thinking levels.");
1212        tui.request_render(false);
1213        return;
1214    }
1215    let list = Arc::new(SelectList::new(items, 10));
1216
1217    let state_sel = state.clone();
1218    let ec_sel = editor_container.clone();
1219    let editor_sel = editor.clone();
1220    let tui_sel = tui.clone();
1221    let chat_sel = chat.clone();
1222    let lane_sel = lane.clone();
1223    list.on_select(Arc::new(move |item| {
1224        let Some(level) = thinking_level_from_name(&item.value) else {
1225            add_note_message(&chat_sel, &format!("Unknown thinking level: {}.", item.label));
1226            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1227            return;
1228        };
1229        let lane = lane_sel.clone();
1230        let footer_sel = state_sel.footer.clone();
1231        tokio::spawn(async move {
1232            let _ = lane.set_thinking_level(level).await;
1233        });
1234        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
1235        let mut settings = crate::settings::load_settings().unwrap_or_default();
1236        settings.default_thinking_level = Some(item.value.clone());
1237        let saved = crate::settings::save_settings(&settings);
1238        add_note_message(
1239            &chat_sel,
1240            &format!(
1241                "Default thinking set to {} (saved{}",
1242                item.label,
1243                if saved.is_ok() { ")" } else { ", not saved)" },
1244            ),
1245        );
1246        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1247    }));
1248    let state_cancel = state.clone();
1249    let ec_cancel = editor_container.clone();
1250    let editor_cancel = editor.clone();
1251    let tui_cancel = tui.clone();
1252    list.on_cancel(Arc::new(move || {
1253        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1254    }));
1255
1256    open_selector(state, editor_container, editor, tui, list, SelectorKind::Settings);
1257}
1258
1259/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
1260/// item toggles it in the in-progress set (the selector stays open); Esc saves
1261/// the set to settings.json and closes. The active scoped set is echoed after
1262/// each toggle so the user sees the current selection.
1263fn open_scoped_models_selector(
1264    state: &Arc<TuiState>,
1265    editor_container: &Arc<Container>,
1266    editor: &Arc<Editor>,
1267    tui: &Arc<TuiAltScreen>,
1268    catalog: &[rpi_ai::Model],
1269    chat: &Arc<Container>,
1270) {
1271    if catalog.is_empty() {
1272        add_note_message(chat, "No models in the catalog.");
1273        tui.request_render(false);
1274        return;
1275    }
1276    // Seed the edit set from the saved scoped models.
1277    let seed: Vec<String> = crate::settings::load_settings()
1278        .ok()
1279        .and_then(|s| s.scoped_models)
1280        .unwrap_or_default();
1281    *state.scoped_edit.lock().unwrap() = Some(seed);
1282
1283    let mut items: Vec<SelectItem> = Vec::new();
1284    for m in catalog {
1285        items.push(SelectItem::new(&m.id, &m.id));
1286    }
1287    let list = Arc::new(SelectList::new(items, 10));
1288
1289    let state_sel = state.clone();
1290    let chat_sel = chat.clone();
1291    let tui_sel = tui.clone();
1292    list.on_select(Arc::new(move |item| {
1293        // Toggle the model in the in-progress set; the selector stays open.
1294        let mut set = state_sel.scoped_edit.lock().unwrap();
1295        let set = set.get_or_insert_with(Vec::new);
1296        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
1297            set.remove(pos);
1298            add_note_message(
1299                &chat_sel,
1300                &format!("{} removed — Esc to save", item.label),
1301            );
1302        } else {
1303            set.push(item.value.clone());
1304            add_note_message(
1305                &chat_sel,
1306                &format!("{} added — Esc to save", item.label),
1307            );
1308        }
1309        tui_sel.request_render(false);
1310    }));
1311    let state_cancel = state.clone();
1312    let ec_cancel = editor_container.clone();
1313    let editor_cancel = editor.clone();
1314    let tui_cancel = tui.clone();
1315    let chat_cancel = chat.clone();
1316    list.on_cancel(Arc::new(move || {
1317        // Save the edited set to settings.json and close.
1318        let set = state_cancel.scoped_edit.lock().unwrap().take().unwrap_or_default();
1319        let mut settings = crate::settings::load_settings().unwrap_or_default();
1320        settings.scoped_models = if set.is_empty() { None } else { Some(set.clone()) };
1321        match crate::settings::save_settings(&settings) {
1322            Ok(()) => {
1323                if set.is_empty() {
1324                    add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
1325                } else {
1326                    add_note_message(
1327                        &chat_cancel,
1328                        &format!("Ctrl+M cycle scope: {}", set.join(", ")),
1329                    );
1330                }
1331            }
1332            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
1333        }
1334        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1335    }));
1336
1337    open_selector(state, editor_container, editor, tui, list, SelectorKind::ScopedModels);
1338}
1339
1340/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
1341/// PATH, create a gist of the exported markdown; otherwise fall back to the
1342/// clipboard (best-effort) and note the local path.
1343async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
1344    use std::process::Stdio;
1345
1346    // Reuse the export builder for the transcript text.
1347    let tree = harness.session().view("main");
1348    let entries = match tree.find_entries(&EntryQuery {
1349        entry_type: None,
1350        custom_type: None,
1351        order: None,
1352        limit: None,
1353        cursor: None,
1354    }).await {
1355        Ok(e) => e,
1356        Err(e) => {
1357            add_error_message(chat, &format!("Could not read session: {e}"));
1358            return;
1359        }
1360    };
1361    let mut md = String::from("# Session\n\n");
1362    for e in entries {
1363        let Entry::Message(me) = e else { continue };
1364        match &me.message {
1365            AgentMessage::User(u) => {
1366                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1367            }
1368            AgentMessage::Assistant(a) => {
1369                let text = assistant_text(a);
1370                if !text.is_empty() {
1371                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1372                }
1373            }
1374            _ => {}
1375        }
1376    }
1377
1378    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
1379    let gh = std::process::Command::new("gh")
1380        .arg("gist")
1381        .arg("create")
1382        .arg("--filename")
1383        .arg("session.md")
1384        .arg("-")
1385        .stdin(Stdio::piped())
1386        .stdout(Stdio::piped())
1387        .stderr(Stdio::null())
1388        .spawn();
1389    if let Ok(mut child) = gh {
1390        use std::io::Write;
1391        if let Some(mut stdin) = child.stdin.take() {
1392            let _ = stdin.write_all(md.as_bytes());
1393            let _ = stdin.flush();
1394        }
1395        let out = child.wait_with_output().ok();
1396        if let Some(out) = out {
1397            if out.status.success() {
1398                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
1399                add_note_message(chat, &format!("Shared session: {url}"));
1400                return;
1401            }
1402        }
1403        add_note_message(
1404            chat,
1405            "gh gist failed — falling back to the clipboard.",
1406        );
1407    } else {
1408        add_note_message(
1409            chat,
1410            "gh CLI not found — falling back to the clipboard.",
1411        );
1412    }
1413    // Clipboard fallback (or transcript echo when the clipboard feature is off).
1414    if copy_to_clipboard(&md) {
1415        add_note_message(chat, "Session transcript copied to the clipboard.");
1416    } else {
1417        add_note_message(
1418            chat,
1419            "Clipboard unavailable — use /export to write the transcript to a file.",
1420        );
1421    }
1422}
1423
1424/// Export the current session to a markdown transcript file. Writes
1425/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
1426/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
1427/// Best-effort: failures surface as a chat note.
1428/// Export the current session to a markdown transcript file. Writes
1429/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
1430/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
1431/// Best-effort: failures surface as a chat note.
1432async fn export_session(harness: &AgentHarness, chat: &Arc<Container>) {
1433    let tree = harness.session().view("main");
1434    let entries = match tree.find_entries(&EntryQuery {
1435        entry_type: None,
1436        custom_type: None,
1437        order: None,
1438        limit: None,
1439        cursor: None,
1440    }).await {
1441        Ok(e) => e,
1442        Err(e) => {
1443            add_error_message(chat, &format!("Could not read session: {e}"));
1444            return;
1445        }
1446    };
1447    let name = tree.get_name().await.ok().flatten().unwrap_or_default();
1448    let id = tree
1449        .get_leaf_id()
1450        .await
1451        .ok()
1452        .flatten()
1453        .unwrap_or_else(|| "session".to_string());
1454    let mut md = String::from("# Session\n\n");
1455    for e in entries {
1456        let Entry::Message(me) = e else { continue };
1457        match &me.message {
1458            AgentMessage::User(u) => {
1459                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1460            }
1461            AgentMessage::Assistant(a) => {
1462                let text = assistant_text(a);
1463                if !text.is_empty() {
1464                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1465                }
1466            }
1467            _ => {}
1468        }
1469    }
1470    let file_name = if name.is_empty() {
1471        format!("{id}.md")
1472    } else {
1473        format!("{name}.md")
1474    };
1475    let path = std::env::current_dir()
1476        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1477        .join(&file_name);
1478    match std::fs::write(&path, md) {
1479        Ok(_) => add_note_message(
1480            chat,
1481            &format!("Exported session to {}", path.display()),
1482        ),
1483        Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
1484    }
1485}
1486
1487/// Fork the current session into a new JSONL session and switch to it (TS
1488/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
1489/// session the user continues in). Uses the repo's `fork_typed`, then swaps
1490/// the harness backing and renders the (empty-ish) fork transcript.
1491/// Hot-switch the harness to another saved session: abort any in-flight run,
1492/// open the target session file, swap the durable backing, and re-render the
1493/// transcript from the new history (mirrors pi's `/session` resume-in-place).
1494/// Shared by the `/session` selector, `/import`, and `/fork`. The current
1495/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
1496async fn switch_to_session(
1497    harness: &AgentHarness,
1498    lane: &Arc<dyn AgentLane>,
1499    id: &str,
1500    cwd: &std::path::Path,
1501    chat: &Arc<Container>,
1502    state: &Arc<TuiState>,
1503) -> bool {
1504    if *state.status.lock().unwrap() == RunStatus::Working {
1505        state.set_status(RunStatus::Aborting);
1506        let _ = lane.abort().await;
1507    }
1508    let cwd_str = cwd.to_string_lossy().to_string();
1509    match crate::session::open_session_by_id(id, &cwd_str).await {
1510        Ok(new_session) => {
1511            let _ = harness.set_session(new_session).await;
1512            chat.clear();
1513            add_welcome_message(chat);
1514            render_session_history(harness, chat, state.markdown_transformer()).await;
1515            state.set_status(RunStatus::Idle);
1516            add_note_message(chat, &format!("Switched to session {id}."));
1517            true
1518        }
1519        Err(e) => {
1520            state.set_status(RunStatus::Idle);
1521            add_error_message(chat, &format!("Could not open session {id}: {e}"));
1522            false
1523        }
1524    }
1525}
1526
1527/// `/import <path>`: copy a JSONL session file into the default session dir,
1528/// then hot-switch to it (the file name becomes its id — matching the
1529/// selector/`open_session_by_id` containment rules).
1530async fn import_session(
1531    harness: &AgentHarness,
1532    lane: &Arc<dyn AgentLane>,
1533    path: &str,
1534    cwd: &std::path::Path,
1535    chat: &Arc<Container>,
1536    state: &Arc<TuiState>,
1537) {
1538    use std::path::Path as FsPath;
1539
1540    let src = FsPath::new(path);
1541    if !src.is_file() {
1542        add_error_message(chat, &format!("Import source not found: {path}"));
1543        return;
1544    }
1545    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
1546        add_error_message(chat, "Import source has no file name.");
1547        return;
1548    };
1549    if !fname.ends_with(".jsonl") {
1550        add_error_message(chat, "Import source must be a .jsonl session file.");
1551        return;
1552    }
1553    let dir = crate::session::default_session_dir(cwd);
1554    if let Err(e) = std::fs::create_dir_all(&dir) {
1555        add_error_message(chat, &format!("Could not create session dir: {e}"));
1556        return;
1557    }
1558    let dest = dir.join(fname);
1559    match std::fs::copy(src, &dest) {
1560        Ok(_) => {
1561            let id = fname
1562                .strip_suffix(".jsonl")
1563                .unwrap_or(fname)
1564                .to_string();
1565            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
1566                add_note_message(chat, &format!("Imported session from {path}"));
1567            }
1568        }
1569        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
1570    }
1571}
1572
1573async fn fork_session(
1574    harness: &AgentHarness,
1575    cwd: &std::path::Path,
1576    chat: &Arc<Container>,
1577    state: &Arc<TuiState>,
1578) {
1579    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1580    use rpi_tools::FileSystem;
1581
1582    let cwd_str = cwd.to_string_lossy().to_string();
1583    let dir = crate::session::default_session_dir(cwd);
1584    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
1585    let fs: Arc<dyn FileSystem> = env.clone();
1586    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1587        fs,
1588        sessions_root: dir.to_string_lossy().into_owned(),
1589        clock: Arc::new(rpi_harness::session::memory::SystemClock),
1590        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
1591    });
1592    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
1593    // it from the session list by the current session's id.
1594    let id = harness.session().storage().metadata().id.clone();
1595    let metas = match crate::session::list_session_metadata(&cwd_str).await {
1596        Ok(m) => m,
1597        Err(e) => {
1598            add_error_message(chat, &format!("Could not list sessions: {e}"));
1599            return;
1600        }
1601    };
1602    let Some(source) = metas.iter().find(|m| m.id == id) else {
1603        add_error_message(chat, &format!("Current session {id} not found on disk."));
1604        return;
1605    };
1606    let fork_storage = match repo
1607        .fork_typed(
1608            source,
1609            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
1610                id: None,
1611                parent_session_id: Some(source.id.clone()),
1612                cwd: cwd_str.clone(),
1613                metadata: None,
1614            },
1615            &rpi_harness::session::types::ForkOptions::default(),
1616        )
1617        .await
1618    {
1619        Ok(s) => s,
1620        Err(e) => {
1621            add_error_message(chat, &format!("Could not fork session: {e}"));
1622            return;
1623        }
1624    };
1625    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
1626    let _ = harness.set_session(new_session).await;
1627    chat.clear();
1628    add_welcome_message(chat);
1629    render_session_history(harness, chat, state.markdown_transformer()).await;
1630    state.set_status(RunStatus::Idle);
1631    add_note_message(chat, "Forked into a new session.");
1632}
1633
1634/// Render the restored session's prior transcript (user + assistant messages)
1635/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
1636/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
1637/// any session read failure just starts with an empty transcript.
1638///
1639/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
1640/// the identity path. Each restored assistant component installs it so replayed
1641/// history renders through the same `register_markdown_transformer` handlers
1642/// the live stream does.
1643async fn render_session_history(
1644    harness: &AgentHarness,
1645    chat: &Arc<Container>,
1646    transformer: Option<MarkdownTransformer>,
1647) {
1648    let tree = harness.session().view("main");
1649    let entries = match tree.find_entries(&EntryQuery {
1650        entry_type: None,
1651        custom_type: None,
1652        order: None,
1653        limit: None,
1654        cursor: None,
1655    }).await {
1656        Ok(e) => e,
1657        Err(_) => return,
1658    };
1659    let mut rendered_any = false;
1660    for e in entries {
1661        let Entry::Message(me) = e else { continue };
1662        match &me.message {
1663            AgentMessage::User(u) => {
1664                add_user_message(chat, &user_message_text(u));
1665                rendered_any = true;
1666            }
1667            AgentMessage::Assistant(a) => {
1668                let comp = Arc::new(AssistantMessageComponent::new(
1669                    AssistantMessageOptions::default(),
1670                ));
1671                if let Some(t) = &transformer {
1672                    comp.set_markdown_transformer(Some(t.clone()));
1673                }
1674                comp.update_blocks(&assistant_blocks(a));
1675                chat.add_child(comp);
1676                chat.add_child(Arc::new(Spacer::new(1)));
1677                rendered_any = true;
1678            }
1679            _ => {}
1680        }
1681    }
1682    if rendered_any {
1683        chat.add_child(Arc::new(Spacer::new(1)));
1684    }
1685}
1686
1687/// Project an assistant message's content into the provider-free
1688/// [`AssistantBlock`] list (text + thinking blocks, in document order) the
1689/// `AssistantMessageComponent` renders. Tool-call/image blocks are dropped —
1690/// they're rendered by their own components in the transcript. This keeps the
1691/// thinking blocks visible in the TUI (they previously vanished because the
1692/// stream path only fed the concatenated *text* into the component).
1693fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
1694    msg.content
1695        .iter()
1696        .filter_map(|c| match c {
1697            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
1698            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
1699            _ => None,
1700        })
1701        .collect()
1702}
1703
1704/// The name displayed for a model id (last path segment / after the final
1705/// `:`), to keep the footer compact.
1706fn short_model_name(id: &str) -> String {
1707    id.rsplit([':', '/'])
1708        .next()
1709        .filter(|s| !s.is_empty())
1710        .unwrap_or(id)
1711        .to_string()
1712}
1713
1714// ===========================================================================
1715// Streaming run status
1716// ===========================================================================
1717
1718/// The live status of the agent run, fed to the footer + status slot.
1719#[derive(Clone, Copy, PartialEq, Eq)]
1720enum RunStatus {
1721    Idle,
1722    Working,
1723    Aborting,
1724}
1725
1726/// Which selector overlay (if any) is currently swapped into the editor slot.
1727#[derive(Clone, Copy, PartialEq, Eq)]
1728enum SelectorKind {
1729    /// `/model` — available models (live switch via `lane.set_model`).
1730    Model,
1731    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
1732    Thinking,
1733    /// `/tools` — toggle builtin tools on/off.
1734    Tools,
1735    /// `/images` — toggle inline image rendering.
1736    Images,
1737    /// `/session` — saved JSONL sessions (restore not implemented in v1).
1738    Session,
1739    /// `/theme` — dark / light / monochrome presets applied live.
1740    Theme,
1741    /// `/scoped-models` — multi-toggle Ctrl+M cycle scope.
1742    ScopedModels,
1743    /// `/settings` — interactive settings menu (and its sub-selectors).
1744    Settings,
1745}
1746
1747/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
1748/// and the render-tick task.
1749struct TuiState {
1750    /// The in-flight streaming assistant message (cleared on finalize).
1751    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
1752    /// Tool-execution components keyed by `tool_call_id`.
1753    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
1754    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
1755    /// generic tool map so bash output streams into a `BashExecutionComponent`
1756    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
1757    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
1758    /// The most recently created tool component (bash or generic). Ctrl+T
1759    /// toggles `expanded` on this — a pragmatic "expand last tool" since the
1760    /// key loop has no per-line focus. Updated on every tool/bash Start.
1761    last_tool_comp: std::sync::Mutex<Option<Arc<ToolExecutionComponent>>>,
1762    /// Run status for the status indicator + interrupt routing.
1763    status: std::sync::Mutex<RunStatus>,
1764    /// The footer, updated live by the drain task.
1765    footer: Arc<FooterComponent>,
1766    /// The status-container (status slot in the dock) — cleared/filled with a
1767    /// loader while a run is active.
1768    status_container: Arc<Container>,
1769    /// The chat transcript container.
1770    chat_container: Arc<Container>,
1771    /// The active loader shown while `Working`.
1772    loader: Arc<Loader>,
1773    /// The last finalized assistant text (for `/copy`). Updated by the drain
1774    /// task on `MessageEnd` / `AgentEnd`.
1775    last_assistant_text: std::sync::Mutex<String>,
1776    /// The active selector overlay, swapped into the editor slot. `Some` while
1777    /// a selector is open; the key loop routes to it first and restores the
1778    /// editor on done/cancel.
1779    active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
1780    /// The autocomplete manager (slash + @file providers) consulted on every
1781    /// editor keystroke.
1782    autocomplete: AutocompleteManager,
1783    /// The container rendered above the editor holding the live autocomplete
1784    /// suggestion list (cleared when there are no suggestions).
1785    autocomplete_container: Arc<Container>,
1786    /// The owned theme manager — `/theme` applies presets here. The global
1787    /// `theme()` is read-only after OnceLock init, so per-instance state is the
1788    /// only way to apply a preset at runtime.
1789    theme_manager: Arc<ThemeManager>,
1790    /// The alt-screen handle, held so `set_status` can reflect run state in the
1791    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
1792    /// that never call `set_status` with a title.
1793    tui: Option<Arc<TuiAltScreen>>,
1794    /// The model id currently shown in the footer + used as the Ctrl+M
1795    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
1796    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
1797    current_model_id: std::sync::Mutex<String>,
1798    /// Whether inline image rendering is enabled (`/images` toggle). Stored
1799    /// even though image wiring is minimal this pass — the flag is consulted
1800    /// where images would be shown and echoed back by `/images`.
1801    show_images: std::sync::Mutex<bool>,
1802    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
1803    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
1804    history: std::sync::Mutex<Vec<String>>,
1805    /// Browse index while recalling history: -1 = not browsing, 0 = most
1806    /// recent, 1 = older, … Reset to -1 on every submit.
1807    history_index: std::sync::Mutex<isize>,
1808    /// The editor text captured when entering browse mode, restored when the
1809    /// user navigates back past the newest entry (TS `historyDraft`).
1810    history_draft: std::sync::Mutex<Option<String>>,
1811    /// The previous turn's input token count, used by the cache-miss notice:
1812    /// a large input that reads nothing from cache after an established prefix
1813    /// means the prefix was re-billed (simplified `maybeShowCacheMissNotice`).
1814    last_input_tokens: std::sync::Mutex<i64>,
1815    /// The in-progress scoped-models selection while the `/scoped-models`
1816    /// selector is open (toggle per item, Esc saves). `None` when not editing.
1817    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
1818    /// B5e: the live assistant-markdown transformer, built from the current
1819    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
1820    /// when no markdown transformers are registered (identity render path).
1821    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
1822    /// closure no-ops once its snapshot's `active` flag flips false) and
1823    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
1824    /// transform takes effect on the visible streaming message immediately.
1825    /// New assistant components pick up whatever closure is current at
1826    /// construction time via [`install_markdown_transformer`].
1827    markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
1828}
1829
1830/// How many submitted messages are kept for ↑ recall (mirrors the TS
1831/// editor's 100-entry cap).
1832const HISTORY_LIMIT: usize = 100;
1833
1834/// A turn with at least this many input tokens is worth a cache-miss notice
1835/// when nothing was read from cache (matches the TS 20k threshold).
1836const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
1837
1838/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
1839fn format_tokens(n: i64) -> String {
1840    if n >= 1_000_000 {
1841        format!("{:.1}M", n as f64 / 1_000_000.0)
1842    } else if n >= 1_000 {
1843        format!("{:.1}K", n as f64 / 1_000.0)
1844    } else {
1845        n.to_string()
1846    }
1847}
1848
1849/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
1850/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
1851/// resets the browse state so a fresh prompt never resumes mid-history.
1852fn push_history(state: &Arc<TuiState>, text: &str) {
1853    let trimmed = text.trim().to_string();
1854    if trimmed.is_empty() {
1855        return;
1856    }
1857    let mut history = state.history.lock().unwrap();
1858    if history.first() == Some(&trimmed) {
1859        return;
1860    }
1861    history.insert(0, trimmed);
1862    history.truncate(HISTORY_LIMIT);
1863    *state.history_index.lock().unwrap() = -1;
1864    *state.history_draft.lock().unwrap() = None;
1865}
1866
1867/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
1868/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
1869/// current editor text as the draft; navigating back past the newest entry
1870/// restores that draft.
1871fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
1872    let history = state.history.lock().unwrap();
1873    if history.is_empty() {
1874        return;
1875    }
1876    let mut index = state.history_index.lock().unwrap();
1877    let new_index = *index - direction as isize;
1878    if new_index < -1 || new_index >= history.len() as isize {
1879        return;
1880    }
1881    if *index == -1 && new_index >= 0 {
1882        // Entering browse mode: stash the current input.
1883        *state.history_draft.lock().unwrap() = Some(editor.get_text());
1884    }
1885    *index = new_index;
1886    if new_index == -1 {
1887        // Exited browse mode: restore the draft (or clear if there was none).
1888        let draft = state.history_draft.lock().unwrap().take();
1889        match draft {
1890            Some(d) => {
1891                let len = d.len();
1892                editor.set_text(&d);
1893                editor.set_cursor(0, len);
1894            }
1895            None => editor.set_text(""),
1896        }
1897    } else {
1898        let text = history[new_index as usize].clone();
1899        let len = text.len();
1900        editor.set_text(&text);
1901        editor.set_cursor(0, len);
1902    }
1903}
1904
1905impl TuiState {
1906    fn set_status(&self, status: RunStatus) {
1907        *self.status.lock().unwrap() = status;
1908        match status {
1909            RunStatus::Working => {
1910                self.footer.set_status("Working…");
1911                // Reflect the in-flight turn in the terminal window/tab title
1912                // (OSC 2). No-op when `tui` is absent (unit tests).
1913                if let Some(tui) = &self.tui {
1914                    tui.set_title("rpi — working");
1915                }
1916                self.status_container.clear();
1917                self.loader.start();
1918                self.status_container.add_child(self.loader.clone());
1919            }
1920            RunStatus::Aborting => {
1921                self.footer.set_status("Aborting…");
1922            }
1923            RunStatus::Idle => {
1924                self.footer.set_status("");
1925                if let Some(tui) = &self.tui {
1926                    tui.set_title("rpi");
1927                }
1928                self.loader.stop();
1929                self.status_container.clear();
1930            }
1931        }
1932    }
1933
1934    /// Whether a selector overlay is currently open (routes keys to it first).
1935    fn selector_open(&self) -> bool {
1936        self.active_selector.lock().unwrap().is_some()
1937    }
1938
1939    /// Record a freshly created tool component as the "most recent" so Ctrl+T
1940    /// can toggle its expansion. Idempotent overwrites — only the latest lives.
1941    fn remember_tool(&self, comp: Arc<ToolExecutionComponent>) {
1942        *self.last_tool_comp.lock().unwrap() = Some(comp);
1943    }
1944
1945    /// Toggle `expanded` on the most recent tool component (Ctrl+T). Returns
1946    /// `true` if a component was toggled. Limitation: the key loop tracks no
1947    /// per-line focus, so this always targets the *last* tool shown — not the
1948    /// one under the cursor. Documented in the plan; a focused expansion would
1949    /// need mouse/line hit-testing which is out of scope this pass.
1950    fn toggle_expand_last_tool(&self) -> bool {
1951        if let Some(comp) = self.last_tool_comp.lock().unwrap().as_ref() {
1952            let cur = comp.is_expanded();
1953            comp.set_expanded(!cur);
1954            true
1955        } else {
1956            false
1957        }
1958    }
1959
1960    /// The model id currently tracked as active (footer + Ctrl+M anchor).
1961    fn current_model_id(&self) -> String {
1962        self.current_model_id.lock().unwrap().clone()
1963    }
1964
1965    /// Update the tracked model id + footer label after a switch (live or
1966    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
1967    fn set_current_model(&self, model: &rpi_ai::Model) {
1968        *self.current_model_id.lock().unwrap() = model.id.clone();
1969        self.footer.set_model(&short_model_name(&model.id));
1970    }
1971
1972    /// B5e: read a clone of the current assistant-markdown transformer (if any).
1973    /// New assistant components call this at construction so they render with
1974    /// whatever plugin `register_markdown_transformer` handlers are live.
1975    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
1976        self.markdown_transformer.lock().unwrap().clone()
1977    }
1978
1979    /// B5e: swap the live transformer. Used at startup (install the first
1980    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
1981    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
1982    /// transform should take effect on the VISIBLE streaming message too, so
1983    /// this re-installs on the in-flight `current_assistant` component — its
1984    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
1985    /// `None` clears the transform (identity), e.g. a reload that unregisters
1986    /// every markdown transformer.
1987    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
1988        *self.markdown_transformer.lock().unwrap() = transformer.clone();
1989        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
1990            comp.set_markdown_transformer(transformer);
1991        }
1992    }
1993}
1994
1995// ===========================================================================
1996// interactive_tui — the entry point
1997// ===========================================================================
1998
1999/// TUI-based interactive mode.
2000///
2001/// `event_rx` carries the live `AgentEvent` stream (installed by
2002/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
2003/// fn), it falls back to a blocking, await-final-text path.
2004///
2005/// `model_catalog` is the read-only catalog the `/model` selector displays.
2006///
2007/// This implementation mirrors the TypeScript `InteractiveMode` class:
2008/// build the layout root once, drain `AgentEvent`s into UI mutations that
2009/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
2010/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
2011/// autocomplete are layered on via the editor-container swap pattern.
2012pub async fn interactive_tui(
2013    harness: &AgentHarness,
2014    event_rx: Option<broadcast::Receiver<AgentEvent>>,
2015    args: &Args,
2016    model_catalog: Vec<rpi_ai::Model>,
2017    initial: Option<String>,
2018    extra_messages: &[String],
2019    theme: Option<&str>,
2020    reload_context: &crate::session::ReloadContext,
2021) -> i32 {
2022    let lane: Arc<dyn AgentLane> = harness.lane("main");
2023
2024    // Resolve the active model once, up front. The full id feeds the TuiState
2025    // tracking field + the selectors/key loop (which run on a blocking thread
2026    // and can't await `lane.get_model()`); the short name feeds the footer.
2027    let lane_model_id = lane
2028        .get_model()
2029        .await
2030        .map(|m| m.id)
2031        .unwrap_or_default();
2032    let model_name = short_model_name(&lane_model_id);
2033
2034    // The cwd for @file autocomplete + session discovery.
2035    let cwd = std::env::current_dir()
2036        .map(|p| p.to_path_buf())
2037        .unwrap_or_else(|_| std::path::PathBuf::from("."));
2038
2039    // Channel between the key/callback threads and the main async loop.
2040    let (tx, rx) = channel::<TuiMessage>();
2041
2042    // ---- TUI + containers ----
2043    let terminal = Box::new(ProcessTerminal::new());
2044    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
2045
2046    let chat_container = Arc::new(Container::new());
2047    add_welcome_message(&chat_container);
2048
2049    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
2050    // banner + the earendil announcement once, then write the sentinel. The TS
2051    // original is a multi-step dialog (theme picker + analytics opt-in); this
2052    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
2053    // analytics deferred — no telemetry wiring). See `extras.rs`.
2054    crate::extras::maybe_first_time_setup(&chat_container);
2055
2056    // A --continue/--resume/--session launch opens on an existing JSONL
2057    // session — render its prior user/assistant transcript so the user sees
2058    // where they left off (tool executions are skipped: their live display
2059    // belongs to the current run, and replaying old results would be noise).
2060    let initial_transformer = build_markdown_transformer(
2061        reload_context.extension_session.lock().unwrap().snapshot_arc(),
2062    );
2063    render_session_history(&harness, &chat_container, initial_transformer.clone()).await;
2064
2065    // `document_container` wraps the welcome header + chat so the scrollview
2066    // follows the whole transcript (mirrors TS `documentContainer`).
2067    let document_container = Arc::new(Container::new());
2068    document_container.add_child(chat_container.clone());
2069
2070    let scroll_view = Arc::new(ScrollView::new(
2071        document_container.clone(),
2072        ScrollViewOptions {
2073            follow: FollowMode::End,
2074            primary: true,
2075            ..Default::default()
2076        },
2077    ));
2078
2079    // ---- Editor ----
2080    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
2081    // editor renders full-width `─` top/bottom borders with padding-only lines
2082    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
2083    let editor = Arc::new(Editor::new(
2084        EditorOptions {
2085            padding_x: 1,
2086            ..Default::default()
2087        },
2088        EditorStyle::default(),
2089        Arc::new(rpi_tui::Keybindings::new()),
2090    ));
2091
2092    // ---- Footer + status ----
2093    let footer = Arc::new(FooterComponent::new());
2094    footer.set_model(&model_name);
2095    footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Abort/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help");
2096
2097    let status_container = Arc::new(Container::new());
2098    let loader = Arc::new(Loader::with_text("Working…"));
2099
2100    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
2101    // Prompt templates discovered at session build (Part A2) are surfaced as
2102    // `/`-prefixed entries alongside the built-in slash commands: typing
2103    // `/<name>` in the editor expands the template (mirrors pi
2104    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
2105    // the template's frontmatter description (or a fallback) so the autocomplete
2106    // popover shows what each template does.
2107    //
2108    // We snapshot the full resources once (skills + prompt-templates): the
2109    // autocomplete builder consumes the templates, and the `/context` command
2110    // (fired from the blocking submit handler, which can't `.await`) reads the
2111    // snapshot to render the discovered-resources panel without touching the
2112    // harness async accessor.
2113    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
2114    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
2115        .prompt_templates
2116        .clone()
2117        .unwrap_or_default()
2118        .iter()
2119        .map(|t| SlashCommandEntry {
2120            name: format!("/{}", t.name),
2121            description: t
2122                .description
2123                .clone()
2124                .unwrap_or_else(|| "Expand prompt template".to_string()),
2125        })
2126        .collect();
2127    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> = Arc::new(resources_snapshot);
2128    // Build the built-in command registry once — the single source of truth for
2129    // both dispatch and the built-in autocomplete entries. The discovered
2130    // prompt-template commands are merged into the autocomplete list separately
2131    // (they dispatch via template expansion, not the registry); built-ins come
2132    // first so they win on a fuzzy tie.
2133    let registry = Arc::new(build_builtin_registry());
2134    let mut all_slash_commands = registry.visible_entries();
2135    all_slash_commands.extend(template_slash_commands);
2136    let autocomplete = AutocompleteManager::new();
2137    {
2138        let mut combined = CombinedAutocompleteProvider::new();
2139        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2140            all_slash_commands,
2141        )));
2142        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(cwd.clone())));
2143        autocomplete.set_provider(Arc::new(combined));
2144    }
2145    let autocomplete_container = Arc::new(Container::new());
2146
2147    let state = Arc::new(TuiState {
2148        current_assistant: std::sync::Mutex::new(None),
2149        tool_components: std::sync::Mutex::new(HashMap::new()),
2150        bash_components: std::sync::Mutex::new(HashMap::new()),
2151        last_tool_comp: std::sync::Mutex::new(None),
2152        status: std::sync::Mutex::new(RunStatus::Idle),
2153        footer: footer.clone(),
2154        status_container: status_container.clone(),
2155        chat_container: chat_container.clone(),
2156        loader: loader.clone(),
2157        last_assistant_text: std::sync::Mutex::new(String::new()),
2158        active_selector: std::sync::Mutex::new(None),
2159        autocomplete,
2160        autocomplete_container: autocomplete_container.clone(),
2161        theme_manager: Arc::new(ThemeManager::new()),
2162        tui: Some(tui.clone()),
2163        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
2164        show_images: std::sync::Mutex::new(true),
2165        history: std::sync::Mutex::new(Vec::new()),
2166        history_index: std::sync::Mutex::new(-1),
2167        history_draft: std::sync::Mutex::new(None),
2168        last_input_tokens: std::sync::Mutex::new(0),
2169        scoped_edit: std::sync::Mutex::new(None),
2170        markdown_transformer: std::sync::Mutex::new(initial_transformer),
2171    });
2172
2173    // Apply the saved theme from `~/.rpi/agent/settings.json` (best-effort).
2174    // The host passes `theme` in; when it matches a known preset it is applied
2175    // immediately so launch opens in the user's chosen theme (matching pi
2176    // reading `Settings.theme` at startup). Unknown values are ignored.
2177    if let Some(theme_name) = theme {
2178        let preset = match theme_name {
2179            "light" => Some(ThemePreset::Light),
2180            "monochrome" => Some(ThemePreset::Monochrome),
2181            "dark" => Some(ThemePreset::Dark),
2182            _ => None,
2183        };
2184        if let Some(preset) = preset {
2185            state.theme_manager.apply_preset(preset);
2186        }
2187    }
2188
2189    // Capture the model catalog + cwd for the selector builders + the key loop
2190    // (the callbacks fire on blocking threads and need owned data).
2191    let model_catalog_arc = Arc::new(model_catalog.clone());
2192    let lane_model_id = lane
2193        .get_model()
2194        .await
2195        .map(|m| m.id)
2196        .unwrap_or_default();
2197
2198    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
2199    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
2200    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
2201    //
2202    // The scrollview gets `basis(0)` so the constrained stack allocator starts
2203    // it at zero height and grows it to fill the space the dock does not need
2204    // — this keeps the dock (editor borders + footer) pinned to the bottom and
2205    // never shrinks it below the editor's 3 rows (top border + content + bottom
2206    // border). The editor_container is `shrink(0).min_size(3)` so a tall
2207    // transcript can never clip the bordered editor below its minimum.
2208    let editor_container = Arc::new(Container::new());
2209    editor_container.add_child(editor.clone());
2210
2211    let dock = Arc::new(VStack::from_children(vec![
2212        StackChild::Entry(StackEntry::new(status_container.clone())),
2213        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
2214        StackChild::Entry(
2215            StackEntry::new(editor_container.clone())
2216                .shrink(0)
2217                .min_size(3),
2218        ),
2219        StackChild::Entry(StackEntry::new(footer.clone())),
2220    ]));
2221
2222    let root = VStack::from_children(vec![
2223        StackChild::Entry(
2224            StackEntry::new(scroll_view.clone())
2225                .basis(0)
2226                .grow(1)
2227                .shrink(1)
2228                .min_size(1),
2229        ),
2230        StackChild::Entry(StackEntry::new(dock).shrink(1)),
2231    ]);
2232
2233    tui.set_layout_root(Some(Arc::new(root)));
2234    tui.set_focus(Some(editor.clone()));
2235    editor.set_focused(true);
2236
2237    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
2238    //
2239    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
2240    // the old version made individually) + the registry, then routes `/`-text
2241    // through `dispatch_slash` and sends plain text directly. Each command's
2242    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
2243    // chat mutation) — the handler itself stays a thin router.
2244    //
2245    // One `CommandContext` is built and cloned for both the submit handler and
2246    // the key loop (Ctrl+L routes `/model` through the same registry); all
2247    // fields are `Arc`/cheap, so the clones are free.
2248    let ctx = CommandContext {
2249        chat: chat_container.clone(),
2250        tui: tui.clone(),
2251        tx: tx.clone(),
2252        state: state.clone(),
2253        editor: editor.clone(),
2254        editor_container: editor_container.clone(),
2255        lane: lane.clone(),
2256        model_catalog: model_catalog_arc.clone(),
2257        lane_model_id: lane_model_id.clone(),
2258        cwd: cwd.clone(),
2259        resources: resources_arc.clone(),
2260        reload_context: Arc::new(reload_context.clone()),
2261    };
2262    let ctx_for_cb = ctx.clone();
2263    let registry_for_cb = registry.clone();
2264    editor.on_submit(Arc::new(move |text: &str| {
2265        let text = text.trim();
2266        if text.is_empty() {
2267            return;
2268        }
2269
2270        if text.starts_with('/') {
2271            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
2272            return;
2273        }
2274
2275        add_user_message(&ctx_for_cb.chat, text);
2276        ctx_for_cb.tui.request_render(false);
2277        // Remember the message for ↑ recall (slash commands are not part of
2278        // the replayable message history).
2279        push_history(&ctx_for_cb.state, text);
2280        let _ = ctx_for_cb.tx.send(TuiMessage::UserInput(text.to_string()));
2281    }));
2282
2283    tui.start_readerless();
2284
2285    // ---- Streaming drain task ----
2286    let drain_handle = if let Some(rx) = event_rx {
2287        let tui_drain = tui.clone();
2288        let state_drain = state.clone();
2289        let chat_drain = chat_container.clone();
2290        Some(tokio::spawn(async move {
2291            drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
2292        }))
2293    } else {
2294        None
2295    };
2296
2297    // ---- B5d: plugin→TUI reload bridge ----
2298    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
2299    // (its cdylib would be unmapped while the call frame is still on the stack).
2300    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
2301    // (an `UnboundedSender<()>`); this task drains those signals and forwards
2302    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
2303    // `reload_extension_resources` routine asynchronously. The mailbox is the
2304    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
2305    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
2306    let (reload_sig_tx, mut reload_sig_rx) =
2307        tokio::sync::mpsc::unbounded_channel::<()>();
2308    reload_context.mailbox.install(reload_sig_tx);
2309    let reload_tx = tx.clone();
2310    let reload_bridge_handle = tokio::spawn(async move {
2311        while reload_sig_rx.recv().await.is_some() {
2312            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
2313                break; // main loop gone — stop forwarding
2314            }
2315        }
2316    });
2317
2318    // ---- Render-tick task (advances the loader spinner while Working) ----
2319    //
2320    // The `Loader` only advances its frame on render; without a periodic
2321    // `request_render` the spinner visibly freezes between events.
2322    let tui_tick = tui.clone();
2323    let state_tick = state.clone();
2324    let tick_handle = tokio::spawn(async move {
2325        let mut interval = tokio::time::interval(std::time::Duration::from_millis(120));
2326        interval.tick().await; // discard immediate
2327        loop {
2328            interval.tick().await;
2329            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
2330            if working {
2331                tui_tick.request_render(false);
2332            }
2333        }
2334    });
2335
2336    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
2337    let running = Arc::new(std::sync::Mutex::new(true));
2338    let running_key = running.clone();
2339    let tx_for_key = tx.clone();
2340    let tui_for_key = tui.clone();
2341    let editor_for_key = editor.clone();
2342    let editor_container_for_key = editor_container.clone();
2343    let scroll_for_key = scroll_view.clone();
2344    let lane_for_key = lane.clone();
2345    let state_for_key = state.clone();
2346    // Ctrl+L routes through the same registry as `/model` (one path, not two),
2347    // so the key loop needs the same `CommandContext` + registry the submit
2348    // handler uses. All fields are `Arc`/cheap, so this clone is free.
2349    let ctx_for_key = ctx.clone();
2350    let registry_for_key = registry.clone();
2351
2352    tokio::task::spawn_blocking(move || {
2353        loop {
2354            if !*running_key.lock().unwrap() {
2355                break;
2356            }
2357            let Ok(ev) = crossterm::event::read() else {
2358                continue;
2359            };
2360            // `Event::Resize` is delivered as its own event (not a Key). With
2361            // `start_readerless` there is no competing terminal-reader thread to
2362            // handle it, so refresh the cached terminal size here and force a
2363            // full redraw so the constrained layout re-fits the new dimensions.
2364            if let Event::Resize(_cols, _rows) = ev {
2365                tui_for_key.refresh_size();
2366                continue;
2367            }
2368            let Event::Key(key) = ev else { continue; };
2369            // Drop release/repeat events — on Windows a single keystroke
2370            // yields both a Press and a Release; without this filter every
2371            // char is inserted twice. (Mirrors the TS `isKeyRelease` guard;
2372            // the editor never sets `wants_key_release`.) On terminals that
2373            // only emit Press this is a no-op.
2374            if key.kind != KeyEventKind::Press {
2375                continue;
2376            }
2377
2378            // 0. Ctrl+C is ALWAYS the escape hatch — even with a selector
2379            //    open (a stuck run or a mis-open selector must never trap the
2380            //    user): abort an active run, else exit. Checked before the
2381            //    selector routing below.
2382            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
2383                let status = *state_for_key.status.lock().unwrap();
2384                if status == RunStatus::Working {
2385                    state_for_key.set_status(RunStatus::Aborting);
2386                    let lane = lane_for_key.clone();
2387                    tokio::spawn(async move {
2388                        let _ = lane.abort().await;
2389                    });
2390                } else {
2391                    let _ = tx_for_key.send(TuiMessage::Exit);
2392                }
2393                continue;
2394            }
2395
2396            // 1. A selector overlay is open → route to it first. Only Esc
2397            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
2398            //    escape to the selector; on done/cancel the selector callbacks
2399            //    restore the editor and clear `active_selector`.
2400            if state_for_key.selector_open() {
2401                // Esc always cancels the selector (even with modifiers off).
2402                // Route through `SelectList::handle_key(Esc)` so the list's
2403                // `on_cancel` fires (the `/scoped-models` toggle selector saves
2404                // its edits there) — the old shortcut called `close_selector`
2405                // directly and skipped the callback.
2406                if key.code == KeyCode::Esc {
2407                    let (selector, _kind) = state_for_key
2408                        .active_selector
2409                        .lock()
2410                        .unwrap()
2411                        .clone()
2412                        .expect("selector_open guaranteed Some");
2413                    selector.handle_key(key);
2414                    continue;
2415                }
2416                let (selector, _kind) = state_for_key
2417                    .active_selector
2418                    .lock()
2419                    .unwrap()
2420                    .clone()
2421                    .expect("selector_open guaranteed Some");
2422                selector.handle_key(key);
2423                tui_for_key.request_render(false);
2424                continue;
2425            }
2426
2427            // 2a. Ctrl+D (EOF): exit. Mirrors pi binding Ctrl+D to quit — and
2428            //     when a run is active, abort it first (same as Ctrl+C) so the
2429            //     key is never a no-op while a stuck command is running.
2430            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('d') {
2431                let status = *state_for_key.status.lock().unwrap();
2432                if status == RunStatus::Working {
2433                    state_for_key.set_status(RunStatus::Aborting);
2434                    let lane = lane_for_key.clone();
2435                    tokio::spawn(async move {
2436                        let _ = lane.abort().await;
2437                    });
2438                } else {
2439                    let _ = tx_for_key.send(TuiMessage::Exit);
2440                }
2441                continue;
2442            }
2443
2444            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
2445            //     selector is open Esc already cancelled it above; when idle,
2446            //     Esc falls through to the editor (no-op-ish). Only fire while
2447            //     Working so an idle Esc doesn't abort a non-existent run.
2448            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
2449                let status = *state_for_key.status.lock().unwrap();
2450                if status == RunStatus::Working {
2451                    state_for_key.set_status(RunStatus::Aborting);
2452                    let lane = lane_for_key.clone();
2453                    tokio::spawn(async move {
2454                        let _ = lane.abort().await;
2455                    });
2456                    continue;
2457                }
2458            }
2459
2460            // 2c. Ctrl+T: toggle expansion on the most recent tool component.
2461            //     The key loop tracks no per-line focus, so this is an "expand
2462            //     last tool" affordance rather than a cursor-targeted toggle
2463            //     (documented limitation; see `toggle_expand_last_tool`).
2464            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('t') {
2465                state_for_key.toggle_expand_last_tool();
2466                tui_for_key.request_render(false);
2467                continue;
2468            }
2469
2470            // 2d. Ctrl+M: cycle to the next model in the catalog after the one
2471            //     currently tracked in `current_model_id`, apply it live via
2472            //     `lane.set_model` (takes effect on the next user message — the
2473            //     in-flight run's config is already snapshotted), and update the
2474            //     footer. `set_model` is async so it runs on a spawned task.
2475            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('m') {
2476                let current = state_for_key.current_model_id();
2477                // Cycle within the `/scoped-models` set (settings.json) when
2478                // configured; otherwise the full catalog.
2479                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
2480                if let Some(next) = cycle_next_model(&scope, &current) {
2481                    state_for_key.set_current_model(&next);
2482                    let lane = lane_for_key.clone();
2483                    tokio::spawn(async move {
2484                        let _ = lane.set_model(next).await;
2485                    });
2486                    tui_for_key.request_render(false);
2487                }
2488                continue;
2489            }
2490
2491            // 3. Ctrl+L: open the model selector. Routed through the `/model`
2492            //    command so the hotkey and the slash command share one path
2493            //    (TS binds Ctrl+L to model-select).
2494            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
2495                if let Some(cmd) = registry_for_key.find("/model") {
2496                    cmd.execute(&ctx_for_key, "");
2497                }
2498                continue;
2499            }
2500
2501            // 4. Tab: accept the top autocomplete suggestion (if any).
2502            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
2503                if accept_top_suggestion(&state_for_key, &editor_for_key) {
2504                    tui_for_key.request_render(false);
2505                }
2506                continue;
2507            }
2508
2509            // 5. Global transcript scroll: PageUp/PageDown move the scrollview.
2510            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
2511                scroll_for_key.scroll_by(-10);
2512                tui_for_key.request_render(false);
2513                continue;
2514            }
2515            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
2516                scroll_for_key.scroll_by(10);
2517                tui_for_key.request_render(false);
2518                continue;
2519            }
2520
2521            // 5b. ↑/↓ browse submitted-message history when the caret sits at
2522            //     the start/end of the editor (mirrors TS
2523            //     `tui.editor.historyPrevious/Next`, which only intercept at
2524            //     the first/last visual line); anywhere else they fall through
2525            //     to the editor for multi-line cursor movement.
2526            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
2527                let (row, col) = editor_for_key.cursor_position();
2528                if row == 0 && col == 0 {
2529                    navigate_history(&state_for_key, &editor_for_key, -1);
2530                    tui_for_key.request_render(false);
2531                    continue;
2532                }
2533            }
2534            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
2535                let text = editor_for_key.get_text();
2536                let (row, col) = editor_for_key.cursor_position();
2537                let last_row = text.lines().count().saturating_sub(1);
2538                let last_len = text.lines().last().map(str::len).unwrap_or(0);
2539                if row == last_row && col >= last_len {
2540                    navigate_history(&state_for_key, &editor_for_key, 1);
2541                    tui_for_key.request_render(false);
2542                    continue;
2543                }
2544            }
2545
2546            // 6. Otherwise forward to the editor + refresh autocomplete.
2547            editor_for_key.handle_key(key);
2548            refresh_autocomplete(&state_for_key, &editor_for_key);
2549            tui_for_key.request_render(false);
2550        }
2551    });
2552
2553    // ---- Initial prompts (run before reading from the channel) ----
2554    let mut prompts: Vec<String> = Vec::new();
2555    if let Some(init) = initial {
2556        prompts.push(init);
2557    }
2558    for m in extra_messages {
2559        prompts.push(m.clone());
2560    }
2561    for prompt in prompts {
2562        if !*running.lock().unwrap() {
2563            break;
2564        }
2565        add_user_message(&chat_container, &prompt);
2566        tui.request_render(false);
2567        run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
2568    }
2569
2570    // ---- Main loop: process submitted input + lifecycle messages ----
2571    loop {
2572        if !*running.lock().unwrap() {
2573            break;
2574        }
2575        match rx.try_recv() {
2576            Ok(TuiMessage::UserInput(prompt)) => {
2577                // Clear the editor so the next prompt starts fresh (the submit
2578                // handler runs on the blocking key thread and can't mutate the
2579                // editor state safely there; clearing here, on the async loop,
2580                // keeps it on one thread).
2581                editor.clear();
2582                run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
2583            }
2584            Ok(TuiMessage::ClearChat) => {
2585                chat_container.clear();
2586                add_welcome_message(&chat_container);
2587                tui.request_render(false);
2588            }
2589            Ok(TuiMessage::Compact) => {
2590                run_compact(&lane, &tui, &state).await;
2591            }
2592            Ok(TuiMessage::Copy) => {
2593                copy_last_assistant(&state, &chat_container);
2594                tui.request_render(false);
2595            }
2596            Ok(TuiMessage::Exit) => {
2597                *running.lock().unwrap() = false;
2598                break;
2599            }
2600            Ok(TuiMessage::SwitchSession(id)) => {
2601                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
2602                tui.request_render(false);
2603            }
2604            Ok(TuiMessage::ImportSession(path)) => {
2605                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
2606                tui.request_render(false);
2607            }
2608            Ok(TuiMessage::ShareSession) => {
2609                share_session(&harness, &chat_container).await;
2610                tui.request_render(false);
2611            }
2612            Ok(TuiMessage::SetSessionName(name)) => {
2613                let outcome = harness.session().set_name(Some(&name)).await;
2614                match outcome {
2615                    Ok(_) => add_note_message(
2616                        &chat_container,
2617                        &format!("Session renamed to \"{name}\"."),
2618                    ),
2619                    Err(e) => add_error_message(
2620                        &chat_container,
2621                        &format!("Could not rename session: {e}"),
2622                    ),
2623                }
2624                tui.request_render(false);
2625            }
2626            Ok(TuiMessage::ExportSession) => {
2627                export_session(&harness, &chat_container).await;
2628                tui.request_render(false);
2629            }
2630            Ok(TuiMessage::ForkSession) => {
2631                fork_session(&harness, &cwd, &chat_container, &state).await;
2632                tui.request_render(false);
2633            }
2634            Ok(TuiMessage::ReloadExtensions) => {
2635                // B5d: drive the shared reload routine on the async runtime,
2636                // then surface the outcome. `reload_context` was passed into
2637                // `interactive_tui` and is the same `Arc<ReloadContext>` the
2638                // `ReloadCommand` + the plugin mailbox both route through —
2639                // clone the `Arc` out so the borrow of `harness` (the main
2640                // loop's `&AgentHarness`) lives across the await.
2641                let reload_ctx = ctx.reload_context.clone();
2642                add_note_message(&chat_container, "Reloading extensions + resources…");
2643                tui.request_render(false);
2644                let outcome =
2645                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
2646                // B5e: the reload swapped a fresh `ExtensionSession` into the
2647                // context's cell. Rebuild the markdown transformer from that
2648                // fresh snapshot and install it on the in-flight streaming
2649                // component (so a reloaded plugin's transformer takes effect on
2650                // the visible message immediately) + future components (they
2651                // read `state.markdown_transformer()` at construction). The old
2652                // closure no-ops once its snapshot's `active` flag flips false
2653                // (reload already did that before the swap).
2654                let fresh_transformer = build_markdown_transformer(
2655                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
2656                );
2657                state.set_markdown_transformer_with_reinstall(fresh_transformer);
2658                if outcome.had_warnings {
2659                    add_error_message(
2660                        &chat_container,
2661                        &format!("{} (with warnings — see stderr for details).", outcome.summary),
2662                    );
2663                } else {
2664                    add_note_message(&chat_container, &outcome.summary);
2665                }
2666                tui.request_render(false);
2667            }
2668            Err(std::sync::mpsc::TryRecvError::Empty) => {
2669                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2670            }
2671            Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
2672        }
2673    }
2674
2675    // ---- Shutdown ----
2676    tick_handle.abort();
2677    if let Some(handle) = drain_handle {
2678        handle.abort();
2679    }
2680    // Drop the reload bridge: clearing the mailbox closes the signal channel,
2681    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
2682    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
2683    reload_context.mailbox.clear();
2684    reload_bridge_handle.abort();
2685    tui.stop(Default::default());
2686    println!("\nGoodbye!");
2687    let _ = args;
2688
2689    0
2690}
2691
2692// ===========================================================================
2693// Run a single prompt (streaming or blocking)
2694// ===========================================================================
2695
2696/// Drive a single prompt through the lane. When `streaming` is true, the
2697/// `AgentEvent` drain task renders the response live and this function only
2698/// awaits completion (to surface hard errors). When false (no `event_rx`),
2699/// it falls back to the blocking await-final-text path.
2700async fn run_prompt_streaming(
2701    lane: &Arc<dyn AgentLane>,
2702    prompt: &str,
2703    tui: &Arc<TuiAltScreen>,
2704    state: &Arc<TuiState>,
2705    streaming: bool,
2706) {
2707    // Ensure the run starts in a clean streaming state.
2708    state.set_status(RunStatus::Working);
2709    tui.request_render(false);
2710
2711    let outcome = lane.prompt_text(prompt, Vec::new()).await;
2712
2713    // The drain task finalized the assistant message via MessageEnd/AgentEnd,
2714    // but guard against runs that ended without a terminal event (e.g. a hard
2715    // provider rejection before any streaming) by clearing streaming state.
2716    {
2717        let mut cur = state.current_assistant.lock().unwrap();
2718        if let Some(comp) = cur.take() {
2719            comp.set_streaming(false);
2720        }
2721    }
2722
2723    state.set_status(RunStatus::Idle);
2724
2725    match outcome {
2726        Ok(result) => match &result.outcome {
2727            HarnessRunOutcome::Failed { error, final_message, .. } => {
2728                // Only add an error line if the stream did NOT already render
2729                // an assistant message for it (drain task leaves
2730                // current_assistant Some only on an abrupt end).
2731                let already_rendered = final_message.is_some();
2732                if !already_rendered {
2733                    let msg = final_message
2734                        .as_ref()
2735                        .and_then(|m| m.error_message.clone())
2736                        .unwrap_or_else(|| format!("{error:?}"));
2737                    add_error_message(&state.chat_container, &msg);
2738                }
2739            }
2740            HarnessRunOutcome::Suspended { .. } => {
2741                add_error_message(
2742                    &state.chat_container,
2743                    "Run suspended (deferred) — resume is not supported in v1.",
2744                );
2745            }
2746            HarnessRunOutcome::Aborted { final_message, .. } => {
2747                // Aborted runs render their own partial/final message via the
2748                // stream; only add a note on the blocking fallback path.
2749                if !streaming {
2750                    add_error_message(&state.chat_container, "Request aborted.");
2751                    let _ = final_message; // (rendered by the stream in streaming mode)
2752                }
2753            }
2754            HarnessRunOutcome::Completed { final_message, .. } => {
2755                if !streaming {
2756                    let text = assistant_text(final_message);
2757                    if !text.is_empty() {
2758                        add_assistant_message_blocking(
2759                            &state.chat_container,
2760                            &text,
2761                            state.markdown_transformer(),
2762                        );
2763                        *state.last_assistant_text.lock().unwrap() = text;
2764                    }
2765                }
2766            }
2767        },
2768        Err(e) => {
2769            add_error_message(&state.chat_container, &e.to_string());
2770        }
2771    }
2772
2773    tui.request_render(false);
2774}
2775
2776/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
2777/// Reports the outcome as a transcript note; v1's compaction summarizes the
2778/// session in place, so no streaming display is wired (compaction emits no
2779/// `AgentEvent`s — only the harness bus `RunEnd`).
2780async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
2781    state.set_status(RunStatus::Working);
2782    tui.request_render(false);
2783    match lane.compact(None).await {
2784        Ok(_) => {
2785            add_note_message(&state.chat_container, "Conversation compacted.");
2786        }
2787        Err(e) => {
2788            add_error_message(
2789                &state.chat_container,
2790                &format!("Compact failed: {e}"),
2791            );
2792        }
2793    }
2794    state.set_status(RunStatus::Idle);
2795    tui.request_render(false);
2796}
2797
2798/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
2799/// when no clipboard is available (or the `clipboard` feature is off), prints a
2800/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
2801fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
2802    let text = state.last_assistant_text.lock().unwrap().clone();
2803    if text.is_empty() {
2804        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
2805        return;
2806    }
2807    if copy_to_clipboard(&text) {
2808        add_note_message(chat, "Copied last reply to the clipboard.");
2809    } else {
2810        // Clipboard unavailable — print the text to the transcript so the user
2811        // can select/copy it manually (degrades gracefully in headless envs).
2812        let preview: String = text.chars().take(200).collect();
2813        add_note_message(
2814            chat,
2815            &format!("Clipboard unavailable. Last reply: {preview}{}", if text.chars().count() > 200 { "…" } else { "" }),
2816        );
2817    }
2818}
2819
2820/// Best-effort clipboard write. Enabled only with the `clipboard` feature
2821/// (`arboard`); otherwise returns `false` so the caller degrades to a hint.
2822#[cfg(feature = "clipboard")]
2823fn copy_to_clipboard(text: &str) -> bool {
2824    match arboard::Clipboard::new() {
2825        Ok(mut cb) => cb.set_text(text).is_ok(),
2826        Err(_) => false,
2827    }
2828}
2829
2830#[cfg(not(feature = "clipboard"))]
2831fn copy_to_clipboard(_text: &str) -> bool {
2832    false
2833}
2834
2835/// Blocking fallback (no `event_rx`): render the final assistant text as a
2836/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
2837/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
2838/// the identity path. The blocking path only fires when `event_rx` is absent,
2839/// so it shares the same transformer the streaming path installs on its
2840/// components.
2841fn add_assistant_message_blocking(
2842    container: &Arc<Container>,
2843    text: &str,
2844    transformer: Option<MarkdownTransformer>,
2845) {
2846    if text.is_empty() {
2847        return;
2848    }
2849    let msg = Arc::new(AssistantMessageComponent::new(AssistantMessageOptions::default()));
2850    if let Some(t) = &transformer {
2851        msg.set_markdown_transformer(Some(t.clone()));
2852    }
2853    msg.update_text(text);
2854    container.add_child(msg);
2855    container.add_child(Arc::new(Spacer::new(1)));
2856}
2857
2858// ===========================================================================
2859// AgentEvent drain task — the streaming core
2860// ===========================================================================
2861
2862/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
2863/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
2864/// lifetime of the TUI.
2865async fn drain_agent_events(
2866    mut rx: broadcast::Receiver<AgentEvent>,
2867    tui: Arc<TuiAltScreen>,
2868    state: Arc<TuiState>,
2869    chat: Arc<Container>,
2870) {
2871    loop {
2872        match rx.recv().await {
2873            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
2874            Err(broadcast::error::RecvError::Lagged(_)) => {
2875                // We dropped some intermediate deltas; the next MessageUpdate/
2876                // MessageEnd carries a full partial snapshot so the UI re-syncs.
2877                continue;
2878            }
2879            Err(broadcast::error::RecvError::Closed) => break,
2880        }
2881    }
2882}
2883
2884/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
2885/// (`interactive-mode.ts:3068-3396`).
2886async fn handle_agent_event(
2887    event: AgentEvent,
2888    tui: &Arc<TuiAltScreen>,
2889    state: &Arc<TuiState>,
2890    chat: &Arc<Container>,
2891) {
2892    match event {
2893        AgentEvent::AgentStart => {
2894            state.set_status(RunStatus::Working);
2895            tui.request_render(false);
2896        }
2897
2898        AgentEvent::AgentEnd { .. } => {
2899            // Finalize any still-streaming assistant message.
2900            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
2901                comp.set_streaming(false);
2902            }
2903            state.set_status(RunStatus::Idle);
2904            tui.request_render(false);
2905        }
2906
2907        AgentEvent::TurnStart => {
2908            // A new turn: reset the streaming-assistant guard so the next
2909            // MessageStart creates a fresh component.
2910            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
2911                comp.set_streaming(false);
2912            }
2913        }
2914
2915        AgentEvent::TurnEnd { message, tool_results } => {
2916            // Finalize the assistant message for this turn.
2917            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
2918                if let AgentMessage::Assistant(a) = &message {
2919                    comp.update_blocks(&assistant_blocks(a));
2920                }
2921                comp.set_streaming(false);
2922            }
2923            // Any tool results whose components were never ended by a
2924            // ToolExecutionEnd get a static rendering here (best-effort). The
2925            // normal path removes the component via ToolExecutionEnd; this is
2926            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
2927            let tools = state.tool_components.lock().unwrap();
2928            for tr in &tool_results {
2929                if tools.contains_key(&tr.tool_call_id) {
2930                    // Will be removed below via ToolExecutionEnd in the normal
2931                    // path; leave as-is if still present.
2932                    let _ = tr;
2933                }
2934            }
2935            drop(tools);
2936            tui.request_render(false);
2937        }
2938
2939        AgentEvent::MessageStart { message } => match message {
2940            AgentMessage::Assistant(a) => {
2941                let comp = Arc::new(AssistantMessageComponent::new(
2942                    AssistantMessageOptions::default(),
2943                ));
2944                // B5e: install the live markdown transformer so the plugin's
2945                // `register_markdown_transformer` handlers apply from the very
2946                // first streamed delta. `set_streaming` before the transform
2947                // install is fine (transform fires on `update_blocks`, below).
2948                if let Some(t) = state.markdown_transformer() {
2949                    comp.set_markdown_transformer(Some(t));
2950                }
2951                comp.set_streaming(true);
2952                // Render text AND thinking blocks in order (the old path fed
2953                // only the concatenated text, so thinking blocks never showed).
2954                comp.update_blocks(&assistant_blocks(&a));
2955                chat.add_child(comp.clone());
2956                chat.add_child(Arc::new(Spacer::new(0)));
2957                *state.current_assistant.lock().unwrap() = Some(comp);
2958                tui.request_render(false);
2959            }
2960            // User / ToolResult / Custom starts are echoed at submit time or
2961            // via the tool-execution components; ignore here to avoid dupes.
2962            _ => {}
2963        },
2964
2965        AgentEvent::MessageUpdate { message, assistant_message_event } => {
2966            if let AgentMessage::Assistant(a) = &message {
2967                let text = assistant_text(a);
2968                // Scan content for finalized tool calls → proactively create
2969                // tool components (TS shows the tool as soon as the assistant
2970                // emits the ToolCall; ToolExecutionStart coalesces if it
2971                // already exists).
2972                for c in &a.content {
2973                    if let Content::ToolCall(tc) = c {
2974                        let mut tools = state.tool_components.lock().unwrap();
2975                        if !tools.contains_key(&tc.id) {
2976                            let comp = Arc::new(ToolExecutionComponent::new(
2977                                &tc.name,
2978                                &tc.arguments.to_string(),
2979                            ));
2980                            comp.set_running();
2981                            chat.add_child(comp.clone());
2982                            tools.insert(tc.id.clone(), comp);
2983                        }
2984                    }
2985                }
2986                let _ = assistant_message_event; // snapshot already applied via `a`
2987                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
2988                    // Stream the full block list (text + thinking) each update
2989                    // so thinking blocks render live as they arrive.
2990                    comp.update_blocks(&assistant_blocks(a));
2991                }
2992                *state.last_assistant_text.lock().unwrap() = text;
2993                tui.request_render(false);
2994            }
2995        }
2996
2997        AgentEvent::MessageEnd { message } => {
2998            if let AgentMessage::Assistant(a) = &message {
2999                let text = assistant_text(a);
3000                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
3001                    comp.update_blocks(&assistant_blocks(a));
3002                    comp.set_streaming(false);
3003                }
3004                // Cache the finalized text for `/copy`.
3005                if !text.is_empty() {
3006                    *state.last_assistant_text.lock().unwrap() = text;
3007                }
3008                // Cache-miss notice (simplified `maybeShowCacheMissNotice`):
3009                // the previous turn's input established a cacheable prefix; a
3010                // large input this turn that read nothing from cache means the
3011                // prefix was re-billed. No cost display — v1 has no per-run
3012                // cost tracking here.
3013                let usage = &a.usage;
3014                let prev_input = *state.last_input_tokens.lock().unwrap();
3015                if prev_input > 0
3016                    && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
3017                    && usage.cache_read == 0
3018                {
3019                    add_note_message(
3020                        &state.chat_container,
3021                        &format!(
3022                            "Cache miss: {} tokens re-billed",
3023                            format_tokens(usage.input)
3024                        ),
3025                    );
3026                }
3027                *state.last_input_tokens.lock().unwrap() = usage.input;
3028            }
3029            tui.request_render(false);
3030        }
3031
3032        AgentEvent::ToolExecutionStart { tool_call_id, tool_name, args } => {
3033            if tool_name == "bash" {
3034                // Bash streams into a dedicated BashExecutionComponent (command
3035                // header + live preview + exit/truncation status) rather than a
3036                // generic ToolExecutionComponent. The command comes from the
3037                // `command` field of the bash tool args.
3038                let command = args
3039                    .get("command")
3040                    .and_then(|v| v.as_str())
3041                    .unwrap_or("")
3042                    .to_string();
3043                let comp = Arc::new(BashExecutionComponent::new(command));
3044                chat.add_child(comp.clone());
3045                state
3046                    .bash_components
3047                    .lock()
3048                    .unwrap()
3049                    .insert(tool_call_id.clone(), comp);
3050            } else {
3051                let comp = {
3052                    let mut tools = state.tool_components.lock().unwrap();
3053                    if let Some(existing) = tools.get(&tool_call_id) {
3054                        existing.set_args(&args.to_string());
3055                        existing.clone()
3056                    } else {
3057                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
3058                        comp.set_running();
3059                        chat.add_child(comp.clone());
3060                        tools.insert(tool_call_id.clone(), comp.clone());
3061                        comp
3062                    }
3063                };
3064                state.remember_tool(comp);
3065            }
3066            tui.request_render(false);
3067        }
3068
3069        AgentEvent::ToolExecutionUpdate { tool_call_id, tool_name, partial_result, .. } => {
3070            if tool_name == "bash" {
3071                // Append the streamed chunk to the bash component's preview.
3072                let chunk = summarize_tool_result(&partial_result);
3073                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
3074                    bash.append_output(&chunk);
3075                } else {
3076                    // No component yet — create a running bash one so the
3077                    // partial shows (command unknown at Update time; leave blank).
3078                    let comp = Arc::new(BashExecutionComponent::new(""));
3079                    comp.append_output(&chunk);
3080                    chat.add_child(comp.clone());
3081                    state
3082                        .bash_components
3083                        .lock()
3084                        .unwrap()
3085                        .insert(tool_call_id.clone(), comp);
3086                }
3087            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
3088                let summary = summarize_tool_result(&partial_result);
3089                comp.set_result(&summary, false);
3090                apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
3091                state.remember_tool(comp.clone());
3092            } else {
3093                // No component yet — create a running one so the partial shows.
3094                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
3095                comp.set_running();
3096                comp.set_result(&summarize_tool_result(&partial_result), false);
3097                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
3098                chat.add_child(comp.clone());
3099                state
3100                    .tool_components
3101                    .lock()
3102                    .unwrap()
3103                    .insert(tool_call_id.clone(), comp.clone());
3104                state.remember_tool(comp);
3105            }
3106            tui.request_render(false);
3107        }
3108
3109        AgentEvent::ToolExecutionEnd { tool_call_id, tool_name, result, is_error } => {
3110            if tool_name == "bash" {
3111                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
3112                if let Some(bash) = bash {
3113                    finalize_bash(&bash, &result, is_error);
3114                } else {
3115                    // Bash ended without a Start/Update — render a finalized
3116                    // component directly from the result text.
3117                    let command = result
3118                        .details
3119                        .get("command")
3120                        .and_then(|v| v.as_str())
3121                        .unwrap_or("")
3122                        .to_string();
3123                    let comp = Arc::new(BashExecutionComponent::new(command));
3124                    comp.append_output(&summarize_tool_result(&result));
3125                    finalize_bash(&comp, &result, is_error);
3126                    chat.add_child(comp);
3127                }
3128            } else {
3129                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
3130                if let Some(comp) = comp {
3131                    comp.set_result(&summarize_tool_result(&result), is_error);
3132                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
3133                } else {
3134                    // Tool ended without a Start/Update (e.g. a very fast tool):
3135                    // render a finalized component directly.
3136                    let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
3137                    comp.set_result(&summarize_tool_result(&result), is_error);
3138                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
3139                    chat.add_child(comp.clone());
3140                    state.remember_tool(comp);
3141                }
3142            }
3143            tui.request_render(false);
3144        }
3145    }
3146}
3147
3148/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
3149/// tool result and mark the component complete. Mirrors the TS bash finalize
3150/// path; only the fields `BashExecutionComponent` needs are read.
3151fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &rpi_agent::AgentToolResult, is_error: bool) {
3152    // The exit code isn't in details directly (TS carries it elsewhere); use
3153    // `is_error` as the error signal and 0/1 as a best-effort exit code.
3154    let exit_code = if is_error { Some(1) } else { Some(0) };
3155    let truncated = result
3156        .details
3157        .get("truncation")
3158        .and_then(|t| t.get("truncated"))
3159        .and_then(|v| v.as_bool())
3160        .unwrap_or(false);
3161    let full_output_path = result
3162        .details
3163        .get("full_output_path")
3164        .and_then(|v| v.as_str())
3165        .map(|s| s.to_string());
3166    let truncation = BashTruncation {
3167        truncated,
3168        full_output_path,
3169    };
3170    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
3171    comp.set_complete(exit_code, cancelled, truncation);
3172}
3173
3174/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
3175/// display-diff string, render it with colors and attach to the component so
3176/// the changes show in the transcript. `write` has no diff (details: Null) and
3177/// stays a plain summary.
3178fn apply_edit_diff(
3179    comp: &Arc<ToolExecutionComponent>,
3180    tool_name: &str,
3181    details: &serde_json::Value,
3182    tui: &Arc<TuiAltScreen>,
3183) {
3184    if tool_name != "edit" {
3185        return;
3186    }
3187    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
3188        return;
3189    };
3190    if diff_text.is_empty() {
3191        return;
3192    }
3193    let width = tui.width();
3194    let lines = render_diff(diff_text, width);
3195    comp.set_diff(lines);
3196}
3197
3198/// Render an `AgentToolResult` as a single-line summary for the
3199/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
3200fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
3201    use rpi_agent::TextContentOrImage;
3202    let mut parts: Vec<String> = Vec::new();
3203    for c in &result.content {
3204        if let TextContentOrImage::Text(t) = c {
3205            parts.push(t.text.clone());
3206        }
3207    }
3208    let joined = parts.join("\n");
3209    // Keep the tool line compact: collapse to a single line, trim length.
3210    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
3211    if one_line.chars().count() > 200 {
3212        let truncated: String = one_line.chars().take(200).collect();
3213        format!("{truncated}…")
3214    } else {
3215        one_line
3216    }
3217}
3218
3219// ===========================================================================
3220// Selectors — editor-container swap (TS showSelector pattern)
3221// ===========================================================================
3222
3223/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
3224/// hiding the editor while the selector is open. Records the selector in
3225/// `state.active_selector` so the key loop routes to it.
3226fn open_selector(
3227    state: &Arc<TuiState>,
3228    editor_container: &Arc<Container>,
3229    editor: &Arc<Editor>,
3230    tui: &Arc<TuiAltScreen>,
3231    list: Arc<SelectList>,
3232    kind: SelectorKind,
3233) {
3234    // Unfocus the editor so its cursor marker doesn't render behind the list.
3235    editor.set_focused(false);
3236    // Swap: clear the container and add just the list.
3237    editor_container.clear();
3238    editor_container.add_child(list.clone());
3239    *state.active_selector.lock().unwrap() = Some((list, kind));
3240    tui.request_render(false);
3241}
3242
3243/// Restore the editor into the `editor_container` and clear the active
3244/// selector. Called by selector `on_cancel` and the Esc handler.
3245fn close_selector(state: &Arc<TuiState>, editor_container: &Arc<Container>, editor: &Arc<Editor>, tui: &Arc<TuiAltScreen>) {
3246    editor_container.clear();
3247    editor_container.add_child(editor.clone());
3248    editor.set_focused(true);
3249    *state.active_selector.lock().unwrap() = None;
3250    tui.request_render(false);
3251}
3252
3253/// Build + open the `/model` selector. Items are the resolved catalog (display
3254/// label = model name; description = id), with the current model marked.
3255/// Selecting applies the model **live** via `lane.set_model` (takes effect on
3256/// the next user message — the in-flight run's config is already snapshotted),
3257/// updates the footer, and notes the next-prompt effect.
3258fn open_model_selector(
3259    state: &Arc<TuiState>,
3260    editor_container: &Arc<Container>,
3261    editor: &Arc<Editor>,
3262    tui: &Arc<TuiAltScreen>,
3263    catalog: &[rpi_ai::Model],
3264    lane: &Arc<dyn AgentLane>,
3265    lane_model_id: &str,
3266    chat: &Arc<Container>,
3267) {
3268    let mut items: Vec<SelectItem> = Vec::new();
3269    for m in catalog {
3270        let label = if m.name.is_empty() { short_model_name(&m.id) } else { m.name.clone() };
3271        let marker = if m.id.eq_ignore_ascii_case(lane_model_id) { " (current)" } else { "" };
3272        items.push(
3273            SelectItem::new(&m.id, &label)
3274                .with_description(&format!("{id}{marker}", id = m.id)),
3275        );
3276    }
3277    if items.is_empty() {
3278        add_note_message(
3279            chat,
3280            "No models in the catalog. Use --model at startup to select one.",
3281        );
3282        tui.request_render(false);
3283        return;
3284    }
3285    let list = Arc::new(SelectList::new(items, 10));
3286
3287    // Capture the catalog + lane so the on_select closure can resolve the
3288    // chosen Model and apply it. `on_select` fires on the blocking key thread,
3289    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
3290    let catalog_arc = catalog.to_vec();
3291    let state_sel = state.clone();
3292    let ec_sel = editor_container.clone();
3293    let editor_sel = editor.clone();
3294    let tui_sel = tui.clone();
3295    let chat_sel = chat.clone();
3296    let lane_sel = lane.clone();
3297    list.on_select(Arc::new(move |item| {
3298        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3299            add_note_message(&chat_sel, &format!("Model {} not found in catalog.", item.label));
3300            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3301            return;
3302        };
3303        state_sel.set_current_model(&model);
3304        let lane = lane_sel.clone();
3305        tokio::spawn(async move {
3306            let _ = lane.set_model(model).await;
3307        });
3308        add_note_message(
3309            &chat_sel,
3310            &format!(
3311                "Model set to {} — applies to the next message.",
3312                short_model_name(&item.value)
3313            ),
3314        );
3315        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3316    }));
3317    let state_cancel = state.clone();
3318    let ec_cancel = editor_container.clone();
3319    let editor_cancel = editor.clone();
3320    let tui_cancel = tui.clone();
3321    list.on_cancel(Arc::new(move || {
3322        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3323    }));
3324
3325    open_selector(state, editor_container, editor, tui, list, SelectorKind::Model);
3326}
3327
3328/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
3329/// Returns `None` only when the catalog is empty or the current id isn't
3330/// found (in which case the first entry is returned — a no-op if it IS the
3331/// current). Used by the Ctrl+M model-cycle hotkey.
3332fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
3333    if catalog.is_empty() {
3334        return None;
3335    }
3336    let idx = catalog
3337        .iter()
3338        .position(|m| m.id.eq_ignore_ascii_case(current_id));
3339    match idx {
3340        Some(i) => {
3341            let next = (i + 1) % catalog.len();
3342            Some(catalog[next].clone())
3343        }
3344        None => Some(catalog[0].clone()),
3345    }
3346}
3347
3348/// Build + open the `/session` selector. Lists JSONL session files under the
3349/// default session dir (`<cwd>/.pi/sessions`). Selecting reports "restore not
3350/// implemented in v1" (existing constraint) but shows the list for
3351/// discoverability.
3352fn open_session_selector(
3353    state: &Arc<TuiState>,
3354    editor_container: &Arc<Container>,
3355    editor: &Arc<Editor>,
3356    tui: &Arc<TuiAltScreen>,
3357    cwd: &std::path::Path,
3358    tx: &mpsc::Sender<TuiMessage>,
3359) {
3360    let dir = crate::session::default_session_dir(cwd);
3361    let mut items: Vec<SelectItem> = Vec::new();
3362    if let Ok(entries) = std::fs::read_dir(&dir) {
3363        for entry in entries.flatten() {
3364            let path = entry.path();
3365            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
3366                continue;
3367            }
3368            let stem = path
3369                .file_stem()
3370                .and_then(|s| s.to_str())
3371                .unwrap_or("(unnamed)")
3372                .to_string();
3373            let display = path
3374                .file_name()
3375                .and_then(|s| s.to_str())
3376                .unwrap_or(&stem)
3377                .to_string();
3378            items.push(SelectItem::new(&stem, &display));
3379        }
3380    }
3381    if items.is_empty() {
3382        add_note_message(
3383            &state.chat_container,
3384            "No saved sessions found. Sessions are created automatically in interactive mode.",
3385        );
3386        tui.request_render(false);
3387        return;
3388    }
3389    let list = Arc::new(SelectList::new(items, 10));
3390
3391    let state_sel = state.clone();
3392    let ec_sel = editor_container.clone();
3393    let editor_sel = editor.clone();
3394    let tui_sel = tui.clone();
3395    let tx_sel = tx.clone();
3396    list.on_select(Arc::new(move |item| {
3397        // Close the selector first, then ask the async loop to hot-switch:
3398        // opening the session file + swapping the harness backing is async
3399        // (repo list/open) and must not run on the blocking key thread.
3400        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3401        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
3402    }));
3403    let state_cancel = state.clone();
3404    let ec_cancel = editor_container.clone();
3405    let editor_cancel = editor.clone();
3406    let tui_cancel = tui.clone();
3407    list.on_cancel(Arc::new(move || {
3408        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3409    }));
3410
3411    open_selector(state, editor_container, editor, tui, list, SelectorKind::Session);
3412}
3413
3414/// Build + open the `/theme` selector. Presets [dark, light, monochrome];
3415/// selecting applies it live via the owned `ThemeManager` + re-renders.
3416fn open_theme_selector(
3417    state: &Arc<TuiState>,
3418    editor_container: &Arc<Container>,
3419    editor: &Arc<Editor>,
3420    tui: &Arc<TuiAltScreen>,
3421) {
3422    let items = vec![
3423        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3424        SelectItem::new("light", "Light").with_description("Light background"),
3425        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3426    ];
3427    let list = Arc::new(SelectList::new(items, 10));
3428
3429    let state_sel = state.clone();
3430    let ec_sel = editor_container.clone();
3431    let editor_sel = editor.clone();
3432    let tui_sel = tui.clone();
3433    let chat_sel = state.chat_container.clone();
3434    list.on_select(Arc::new(move |item| {
3435        let preset = match item.value.as_str() {
3436            "light" => ThemePreset::Light,
3437            "monochrome" => ThemePreset::Monochrome,
3438            _ => ThemePreset::Dark,
3439        };
3440        state_sel.theme_manager.apply_preset(preset);
3441        // A quick accent note so the user sees the change registered even if
3442        // the terminal's own colors mask the preset difference.
3443        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3444        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3445        tui_sel.render_now(true);
3446    }));
3447    let state_cancel = state.clone();
3448    let ec_cancel = editor_container.clone();
3449    let editor_cancel = editor.clone();
3450    let tui_cancel = tui.clone();
3451    list.on_cancel(Arc::new(move || {
3452        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3453    }));
3454
3455    open_selector(state, editor_container, editor, tui, list, SelectorKind::Theme);
3456}
3457
3458// ===========================================================================
3459// Feasible selectors — /thinking, /tools, /images
3460// ===========================================================================
3461
3462/// One-line descriptions for each thinking level, ported from
3463/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
3464fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
3465    use rpi_ai::types::ThinkingLevel::*;
3466    match level {
3467        Off => "Off — No reasoning",
3468        Minimal => "Minimal — Brief reasoning (~1k tokens)",
3469        Low => "Low — Light reasoning (~1k tokens)",
3470        Medium => "Medium — Moderate reasoning (~80% of max)",
3471        High => "High — Extensive reasoning (~95% of max)",
3472        Xhigh => "Xhigh — Near-maximal reasoning",
3473        Max => "Max — Maximum reasoning",
3474    }
3475}
3476
3477/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
3478/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
3479fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
3480    use rpi_ai::types::ThinkingLevel::*;
3481    match level {
3482        Off => "off",
3483        Minimal => "minimal",
3484        Low => "low",
3485        Medium => "medium",
3486        High => "high",
3487        Xhigh => "xhigh",
3488        Max => "max",
3489    }
3490}
3491
3492/// Parse a thinking-level name back to the enum (case-insensitive). Returns
3493/// `None` for an unknown name; used by the `/thinking` selector callback.
3494fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
3495    use rpi_ai::types::ThinkingLevel::*;
3496    match name.to_ascii_lowercase().as_str() {
3497        "off" => Some(Off),
3498        "minimal" => Some(Minimal),
3499        "low" => Some(Low),
3500        "medium" => Some(Medium),
3501        "high" => Some(High),
3502        "xhigh" => Some(Xhigh),
3503        "max" => Some(Max),
3504        _ => None,
3505    }
3506}
3507
3508/// Build + open the `/thinking` selector. Items are the levels the current
3509/// model supports (`Model::supported_thinking_levels`), each with a
3510/// description; the current level (read beforehand via `lane.get_thinking_level`)
3511/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
3512///
3513/// `on_select` fires on the blocking key thread, so it can't await
3514/// `lane.get_thinking_level()` to know the current level — the opener resolves
3515/// it first (best-effort) and preselects; the toggle on_select just applies
3516/// whatever was picked.
3517fn open_thinking_selector(
3518    state: &Arc<TuiState>,
3519    editor_container: &Arc<Container>,
3520    editor: &Arc<Editor>,
3521    tui: &Arc<TuiAltScreen>,
3522    lane: &Arc<dyn AgentLane>,
3523    catalog: &[rpi_ai::Model],
3524    lane_model_id: &str,
3525    chat: &Arc<Container>,
3526) {
3527    // Find the current model in the catalog to read its supported levels. If
3528    // absent, fall back to all levels so the selector still opens.
3529    let model = catalog
3530        .iter()
3531        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3532    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3533        .map(|m| m.supported_thinking_levels())
3534        .unwrap_or_else(|| {
3535            use rpi_ai::types::ThinkingLevel::*;
3536            vec![Off, Minimal, Low, Medium, High]
3537        });
3538    let mut items: Vec<SelectItem> = Vec::new();
3539    for lvl in &levels {
3540        let name = thinking_level_name(*lvl);
3541        items.push(
3542            SelectItem::new(name, name)
3543                .with_description(thinking_level_description(*lvl)),
3544        );
3545    }
3546    if items.is_empty() {
3547        add_note_message(chat, "This model has no supported thinking levels.");
3548        tui.request_render(false);
3549        return;
3550    }
3551    let list = Arc::new(SelectList::new(items, 10));
3552
3553    let state_sel = state.clone();
3554    let ec_sel = editor_container.clone();
3555    let editor_sel = editor.clone();
3556    let tui_sel = tui.clone();
3557    let chat_sel = chat.clone();
3558    let lane_sel = lane.clone();
3559    list.on_select(Arc::new(move |item| {
3560        let Some(level) = thinking_level_from_name(&item.value) else {
3561            add_note_message(&chat_sel, &format!("Unknown thinking level: {}.", item.label));
3562            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3563            return;
3564        };
3565        let lane = lane_sel.clone();
3566        let footer_sel = state_sel.footer.clone();
3567        tokio::spawn(async move {
3568            let _ = lane.set_thinking_level(level).await;
3569        });
3570        // Reflect the chosen level in the footer's model suffix (pi parity:
3571        // `model • thinking off` / `model • medium`). The shown text for the
3572        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
3573        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3574        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
3575        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3576    }));
3577    let state_cancel = state.clone();
3578    let ec_cancel = editor_container.clone();
3579    let editor_cancel = editor.clone();
3580    let tui_cancel = tui.clone();
3581    list.on_cancel(Arc::new(move || {
3582        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3583    }));
3584
3585    open_selector(state, editor_container, editor, tui, list, SelectorKind::Thinking);
3586}
3587
3588/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
3589/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
3590/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
3591/// — the blocking key thread can't await) and selecting a tool **toggles** it
3592/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
3593fn open_tools_selector(
3594    state: &Arc<TuiState>,
3595    editor_container: &Arc<Container>,
3596    editor: &Arc<Editor>,
3597    tui: &Arc<TuiAltScreen>,
3598    lane: &Arc<dyn AgentLane>,
3599    chat: &Arc<Container>,
3600) {
3601    // Best-effort read of the current active set. The opener runs on the async
3602    // runtime (it's called from the main loop's channel dispatch or the submit
3603    // closure that lives on the blocking thread — but `handle.block_on` is safe
3604    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
3605    let active = match tokio::runtime::Handle::try_current() {
3606        Ok(h) => h.block_on(async { lane.get_active_tools().await }).unwrap_or_default(),
3607        Err(_) => Vec::new(),
3608    };
3609    let mut items: Vec<SelectItem> = Vec::new();
3610    for name in crate::session::BUILTIN_TOOL_NAMES {
3611        let on = active.iter().any(|a| a == name);
3612        let label = if on { format!("{name} (on)") } else { (*name).to_string() };
3613        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
3614    }
3615    let list = Arc::new(SelectList::new(items, 10));
3616
3617    // Capture the active set so on_select can toggle without re-reading.
3618    let active_captured = active.clone();
3619    let state_sel = state.clone();
3620    let ec_sel = editor_container.clone();
3621    let editor_sel = editor.clone();
3622    let tui_sel = tui.clone();
3623    let chat_sel = chat.clone();
3624    let lane_sel = lane.clone();
3625    list.on_select(Arc::new(move |item| {
3626        let mut next = active_captured.clone();
3627        if let Some(pos) = next.iter().position(|a| a == &item.value) {
3628            next.remove(pos);
3629        } else {
3630            next.push(item.value.clone());
3631        }
3632        let on = next.iter().any(|a| a == &item.value);
3633        let lane = lane_sel.clone();
3634        let next_clone = next.clone();
3635        tokio::spawn(async move {
3636            let _ = lane.set_active_tools(next_clone).await;
3637        });
3638        let list_str = if next.is_empty() {
3639            "(none)".to_string()
3640        } else {
3641            next.join(", ")
3642        };
3643        add_note_message(
3644            &chat_sel,
3645            &format!(
3646                "{} {} — active tools: {}",
3647                item.value,
3648                if on { "enabled" } else { "disabled" },
3649                list_str
3650            ),
3651        );
3652        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3653    }));
3654    let state_cancel = state.clone();
3655    let ec_cancel = editor_container.clone();
3656    let editor_cancel = editor.clone();
3657    let tui_cancel = tui.clone();
3658    list.on_cancel(Arc::new(move || {
3659        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3660    }));
3661
3662    open_selector(state, editor_container, editor, tui, list, SelectorKind::Tools);
3663}
3664
3665/// Build + open the `/images` selector (Yes/No). Stores the choice in
3666/// `state.show_images` and notes it. Image wiring is minimal this pass — the
3667/// flag is consulted where images would be shown and echoed back here.
3668fn open_images_selector(
3669    state: &Arc<TuiState>,
3670    editor_container: &Arc<Container>,
3671    editor: &Arc<Editor>,
3672    tui: &Arc<TuiAltScreen>,
3673    chat: &Arc<Container>,
3674) {
3675    let current = *state.show_images.lock().unwrap();
3676    let items = vec![
3677        SelectItem::new("yes", "Yes")
3678            .with_description(if current { "Inline images (current)" } else { "Inline images" }),
3679        SelectItem::new("no", "No")
3680            .with_description(if current { "Placeholder only" } else { "Placeholder only (current)" }),
3681    ];
3682    let list = Arc::new(SelectList::new(items, 5));
3683
3684    let state_sel = state.clone();
3685    let ec_sel = editor_container.clone();
3686    let editor_sel = editor.clone();
3687    let tui_sel = tui.clone();
3688    let chat_sel = chat.clone();
3689    list.on_select(Arc::new(move |item| {
3690        let on = item.value == "yes";
3691        *state_sel.show_images.lock().unwrap() = on;
3692        add_note_message(
3693            &chat_sel,
3694            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
3695        );
3696        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3697    }));
3698    let state_cancel = state.clone();
3699    let ec_cancel = editor_container.clone();
3700    let editor_cancel = editor.clone();
3701    let tui_cancel = tui.clone();
3702    list.on_cancel(Arc::new(move || {
3703        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3704    }));
3705
3706    open_selector(state, editor_container, editor, tui, list, SelectorKind::Images);
3707}
3708
3709// ===========================================================================
3710// Autocomplete
3711// ===========================================================================
3712
3713/// Refresh the autocomplete suggestion list from the current editor text +
3714/// cursor. Renders the suggestions into `autocomplete_container` (above the
3715/// editor) or clears it when there are none.
3716fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
3717    let text = editor.get_text();
3718    let (_row, col) = editor.cursor_position();
3719    // The editor's `cursor_col` is a byte offset into the current line; for
3720    // single-line input (the common case) that equals the byte offset into
3721    // `get_text()`, which is exactly what the autocomplete providers expect to
3722    // slice on. Clamp to the text length so a stale/multi-line col can't
3723    // overshoot. Providers snap to a char boundary internally as a safety net
3724    // (`autocomplete::snap_cursor`), so a byte col landing mid-character never
3725    // panics.
3726    let cursor = col.min(text.len());
3727    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
3728    render_autocomplete(state, suggestions);
3729}
3730
3731/// Render (or clear) the autocomplete suggestion list into the container.
3732fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
3733    state.autocomplete_container.clear();
3734    let Some(sugg) = suggestions else {
3735        return;
3736    };
3737    if sugg.items.is_empty() {
3738        return;
3739    }
3740    // Build a compact list: top item marked with `→`, rest with `  `.
3741    // Cap at 5 lines so the dock doesn't swallow the transcript.
3742    let accent = state.theme_manager.get().colors.accent;
3743    let muted = state.theme_manager.get().colors.muted;
3744    for (i, item) in sugg.items.iter().take(5).enumerate() {
3745        let prefix = if i == 0 { "→ " } else { "  " };
3746        let label = item.display_text();
3747        let line = if i == 0 {
3748            format!("{prefix}{} {}", accent.fg(label), muted.fg(item.description.as_deref().unwrap_or("")))
3749        } else {
3750            format!("{prefix}{} {}", muted.fg(label), muted.fg(item.description.as_deref().unwrap_or("")))
3751        };
3752        state
3753            .autocomplete_container
3754            .add_child(Arc::new(Text::new(line, 1, 0)));
3755    }
3756}
3757
3758/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
3759/// suggestion text, reposition the caret, and clear the suggestion list.
3760/// Returns `true` if a suggestion was accepted.
3761fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
3762    let text = editor.get_text();
3763    let (_row, col) = editor.cursor_position();
3764    let cursor = col.min(text.len());
3765    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
3766        return false;
3767    };
3768    let Some(top) = sugg.items.first() else {
3769        return false;
3770    };
3771    // Replace the [start, end) span with the suggestion text. `start`/`end`
3772    // are byte offsets emitted by the providers on char boundaries, so the
3773    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
3774    let start = sugg.start.min(text.len());
3775    let end = sugg.end.min(text.len());
3776    let mut replaced = String::with_capacity(text.len() + top.text.len());
3777    replaced.push_str(&text[..start]);
3778    replaced.push_str(&top.text);
3779    // Keep the text AFTER the replaced span (mid-line completion: replacing
3780    // `[start, end)` must not drop the rest of the line).
3781    replaced.push_str(&text[end..]);
3782    if top.insert_space && !replaced.ends_with('/') {
3783        replaced.push(' ');
3784    }
3785    // New caret position: after the inserted text (byte offset; the editor
3786    // snaps `set_cursor` to a char boundary as a safety net).
3787    let new_cursor = replaced.len().min(
3788        start + top.text.len()
3789            + if top.insert_space && !top.text.ends_with('/') {
3790                1
3791            } else {
3792                0
3793            },
3794    );
3795    editor.set_text(&replaced);
3796    editor.set_cursor(0, new_cursor);
3797    state.autocomplete_container.clear();
3798    true
3799}
3800
3801// ===========================================================================
3802// Transcript message helpers
3803// ===========================================================================
3804
3805/// Add the welcome header to the chat container.
3806fn add_welcome_message(container: &Arc<Container>) {
3807    container.add_child(Arc::new(Text::new("rpi interactive TUI", 1, 0)));
3808    container.add_child(Arc::new(Spacer::new(1)));
3809    container.add_child(Arc::new(Text::new(
3810        "Type your message and press Enter to send.",
3811        1, 0,
3812    )));
3813    container.add_child(Arc::new(Text::new(
3814        "Ctrl+C: Abort/Exit | Esc: Abort | Enter: Send | Shift+Enter: New line | Tab: Complete | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help",
3815        1, 0,
3816    )));
3817    container.add_child(Arc::new(Spacer::new(1)));
3818}
3819
3820/// Add the `/help` command listing to the chat container.
3821fn add_help_message(container: &Arc<Container>) {
3822    container.add_child(Arc::new(Text::new("📚 Available Commands:", 1, 0)));
3823    container.add_child(Arc::new(Spacer::new(1)));
3824    container.add_child(Arc::new(Text::new("  /help, /?       — Show this help message", 1, 0)));
3825    container.add_child(Arc::new(Text::new("  /clear, /new    — Clear the conversation", 1, 0)));
3826    container.add_child(Arc::new(Text::new("  /exit, /quit, /q — Exit the application", 1, 0)));
3827    container.add_child(Arc::new(Text::new("  /version, /v    — Show version information", 1, 0)));
3828    container.add_child(Arc::new(Text::new("  /model, /m      — Choose a model (live switch)", 1, 0)));
3829    container.add_child(Arc::new(Text::new("  /thinking, /think — Set reasoning depth (selector)", 1, 0)));
3830    container.add_child(Arc::new(Text::new("  /tools          — Toggle built-in tools on/off", 1, 0)));
3831    container.add_child(Arc::new(Text::new("  /images         — Toggle inline image rendering", 1, 0)));
3832    container.add_child(Arc::new(Text::new("  /session        — List saved sessions", 1, 0)));
3833    container.add_child(Arc::new(Text::new("  /theme          — Choose a theme (selector)", 1, 0)));
3834    container.add_child(Arc::new(Text::new("  /compact        — Compact the conversation", 1, 0)));
3835    container.add_child(Arc::new(Text::new("  /copy           — Copy last reply to clipboard", 1, 0)));
3836    container.add_child(Arc::new(Text::new("  /hotkeys        — Show keyboard shortcuts", 1, 0)));
3837    container.add_child(Arc::new(Text::new("  /armin          — 🐾 Easter egg", 1, 0)));
3838    container.add_child(Arc::new(Text::new("  /earendil       — Earendil announcement", 1, 0)));
3839    container.add_child(Arc::new(Spacer::new(1)));
3840}
3841
3842/// Add the `/version` block to the chat container.
3843fn add_version_message(container: &Arc<Container>) {
3844    container.add_child(Arc::new(Text::new("📦 Version Information:", 1, 0)));
3845    container.add_child(Arc::new(Spacer::new(1)));
3846    container.add_child(Arc::new(Text::new("  rpi-cli v0.1.2", 1, 0)));
3847    container.add_child(Arc::new(Text::new(
3848        "  Rust implementation of pi coding agent TUI",
3849        1, 0,
3850    )));
3851    container.add_child(Arc::new(Spacer::new(1)));
3852}
3853
3854/// Add the `/hotkeys` block to the chat container.
3855fn add_hotkeys_message(container: &Arc<Container>) {
3856    container.add_child(Arc::new(Text::new("⌨️  Keyboard Shortcuts:", 1, 0)));
3857    container.add_child(Arc::new(Spacer::new(1)));
3858    container.add_child(Arc::new(Text::new("  Enter         — Send message", 1, 0)));
3859    container.add_child(Arc::new(Text::new("  Shift+Enter   — New line", 1, 0)));
3860    container.add_child(Arc::new(Text::new("  Tab           — Accept autocomplete suggestion", 1, 0)));
3861    container.add_child(Arc::new(Text::new("  Ctrl+A / Ctrl+E — Line start / end", 1, 0)));
3862    container.add_child(Arc::new(Text::new("  Ctrl+K / Ctrl+U — Kill to end / start of line (Ctrl+Y yanks)", 1, 0)));
3863    container.add_child(Arc::new(Text::new("  Ctrl+- / Ctrl+R — Undo / redo", 1, 0)));
3864    container.add_child(Arc::new(Text::new("  Ctrl+Y / Alt+Y — Yank / yank-pop", 1, 0)));
3865    container.add_child(Arc::new(Text::new("  Alt+Backspace — Kill previous word", 1, 0)));
3866    container.add_child(Arc::new(Text::new("  Ctrl+C        — Abort a run, or exit when idle", 1, 0)));
3867    container.add_child(Arc::new(Text::new("  Esc           — Abort a running prompt", 1, 0)));
3868    container.add_child(Arc::new(Text::new("  Ctrl+L        — Open model selector", 1, 0)));
3869    container.add_child(Arc::new(Text::new("  Ctrl+M        — Cycle to the next model (live)", 1, 0)));
3870    container.add_child(Arc::new(Text::new("  Ctrl+T        — Expand/collapse last tool result", 1, 0)));
3871    container.add_child(Arc::new(Text::new("  PageUp/Down   — Scroll transcript", 1, 0)));
3872    container.add_child(Arc::new(Spacer::new(1)));
3873}
3874
3875/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
3876/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
3877/// plain `> text` echo.
3878fn add_user_message(container: &Arc<Container>, text: &str) {
3879    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
3880    container.add_child(Arc::new(Spacer::new(0)));
3881}
3882
3883/// Add an error message to the chat container.
3884fn add_error_message(container: &Arc<Container>, text: &str) {
3885    container.add_child(Arc::new(Text::new(format!("❌ {text}"), 1, 0)));
3886    container.add_child(Arc::new(Spacer::new(1)));
3887}
3888
3889/// Add a neutral note (e.g. unsupported-command message) to the chat container.
3890fn add_note_message(container: &Arc<Container>, text: &str) {
3891    container.add_child(Arc::new(Text::new(format!("ℹ️  {text}"), 1, 0)));
3892    container.add_child(Arc::new(Spacer::new(1)));
3893}
3894
3895/// Render the `/context` panel: a transcript message listing the discovered
3896/// context files, skills, and prompt templates loaded for this session
3897/// (Part A resource discovery). Reads the harness resources snapshot captured
3898/// at TUI startup (the blocking submit handler can't `await get_resources()`.
3899///
3900/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
3901/// via `/reload`); here it's a transcript note rather than an overlay since the
3902/// resource set is session-static between `/reload`s (deferred).
3903fn show_context_panel(
3904    chat: &Arc<Container>,
3905    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
3906) {
3907    let skills = resources.skills.as_deref().unwrap_or(&[]);
3908    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
3909    let mut lines: Vec<String> = Vec::new();
3910    lines.push("📂 Discovered resources for this session:".into());
3911
3912    if skills.is_empty() {
3913        lines.push("  Skills: (none discovered — create .pi/skills/ or ~/.rpi/agent/skills/)".into());
3914    } else {
3915        lines.push(format!("  Skills ({}):", skills.len()));
3916        for s in skills {
3917            let marker = if s.disable_model_invocation == Some(true) {
3918                " [hidden]"
3919            } else {
3920                ""
3921            };
3922            let desc: String = s.description.chars().take(72).collect();
3923            lines.push(format!("    • {}{marker} — {desc}", s.name));
3924        }
3925    }
3926
3927    if templates.is_empty() {
3928        lines.push("  Prompt templates: (none — create .pi/prompts/ or ~/.rpi/agent/prompts/)".into());
3929    } else {
3930        lines.push(format!("  Prompt templates ({}):", templates.len()));
3931        for t in templates {
3932            let desc = t
3933                .description
3934                .as_deref()
3935                .unwrap_or("(no description)")
3936                .chars()
3937                .take(72)
3938                .collect::<String>();
3939            lines.push(format!("    • /{} — {desc}", t.name));
3940        }
3941    }
3942    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
3943    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
3944    lines.push("  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress.".into());
3945    let body = lines.join("\n");
3946    container_note_block(chat, &body);
3947}
3948
3949/// Append a multi-line neutral note (header line + body) to the chat container.
3950fn container_note_block(container: &Arc<Container>, body: &str) {
3951    for line in body.lines() {
3952        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
3953    }
3954    container.add_child(Arc::new(Spacer::new(1)));
3955}
3956
3957// ===========================================================================
3958// TUI support + entry detection
3959// ===========================================================================
3960
3961/// Check if the terminal supports TUI mode.
3962pub fn is_tui_supported() -> bool {
3963    std::io::stdout().is_terminal()
3964}
3965
3966// Keep the `Color` import used (theme accent rendering in autocomplete).
3967#[allow(unused_imports)]
3968use rpi_tui::Color as _Color;
3969
3970#[cfg(test)]
3971mod tests {
3972    use super::*;
3973    use rpi_tui::Component;
3974
3975    #[test]
3976    fn test_layout_renders_welcome_message() {
3977        let chat = Arc::new(Container::new());
3978        add_welcome_message(&chat);
3979
3980        let scroll = Arc::new(ScrollView::new(
3981            chat.clone(),
3982            ScrollViewOptions {
3983                follow: FollowMode::End,
3984                primary: true,
3985                ..Default::default()
3986            },
3987        ));
3988
3989        let editor = Arc::new(Editor::new(
3990            EditorOptions {
3991                padding_x: 1,
3992                ..Default::default()
3993            },
3994            EditorStyle::default(),
3995            Arc::new(rpi_tui::Keybindings::new()),
3996        ));
3997        let dock = Arc::new(Container::new());
3998        dock.add_child(editor);
3999
4000        let footer = Arc::new(FooterComponent::new());
4001
4002        let root = VStack::from_children(vec![
4003            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
4004            StackChild::Entry(StackEntry::new(dock)),
4005            StackChild::Entry(StackEntry::new(footer)),
4006        ]);
4007
4008        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
4009
4010        let all: String = frame.lines.join("\n");
4011        assert!(all.contains("rpi interactive"), "Welcome message not found. Rendered: {}", all);
4012        assert!(all.contains("Type your message"), "Help text not found. Rendered: {}", all);
4013    }
4014
4015    #[test]
4016    fn test_chat_container_has_welcome_content() {
4017        let chat = Arc::new(Container::new());
4018        add_welcome_message(&chat);
4019
4020        let lines = chat.render(80);
4021        let all: String = lines.join("\n");
4022        assert!(all.contains("rpi interactive"), "Welcome message not in chat container: {:?}", lines);
4023    }
4024
4025    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
4026    /// replaces the editor text, the NEXT rendered frame must show the
4027    /// completed text (" /model " with the caret after it), not the old
4028    /// prefix. Mirrors the real dock layout (autocomplete_container above the
4029    /// bordered editor) and drives the same accept path the Tab handler uses.
4030    #[test]
4031    fn tab_accept_suggestion_reflects_in_next_render() {
4032        use rpi_tui::render_layout_frame;
4033
4034        let editor = Arc::new(Editor::new(
4035            EditorOptions { padding_x: 1, ..Default::default() },
4036            EditorStyle::default(),
4037            Arc::new(rpi_tui::Keybindings::new()),
4038        ));
4039        editor.set_focused(true);
4040        let editor_container = Arc::new(Container::new());
4041        editor_container.add_child(editor.clone());
4042        let autocomplete_container = Arc::new(Container::new());
4043        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
4044        let dock = Arc::new(VStack::from_children(vec![
4045            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
4046            StackChild::Entry(StackEntry::new(editor_container.clone()).shrink(0).min_size(3)),
4047            StackChild::Entry(StackEntry::new(footer)),
4048        ]));
4049
4050        // Simulate the user typing "/mo" (the popup shows suggestions).
4051        let mut manager = AutocompleteManager::new();
4052        let mut combined = CombinedAutocompleteProvider::new();
4053        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::with_default_commands()));
4054        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
4055        manager.set_provider(Arc::new(combined));
4056        // Simulate typing "/mo" via the real insert path (advances the caret
4057        // by char length, like `handle_key` does).
4058        editor.insert("/mo");
4059        assert_eq!(editor.cursor_position(), (0, 3));
4060
4061        let frame_before = render_layout_frame(dock.clone(), 80, 10);
4062        assert!(
4063            frame_before.lines.iter().any(|l| l.contains("/mo")),
4064            "precondition: editor shows the typed prefix. Frame rows:\n{}",
4065            frame_before.lines.iter().map(|l| format!("  [{l}]")).collect::<Vec<_>>().join("\n")
4066        );
4067
4068        // Tab: accept the top suggestion (the same code path as the key loop).
4069        let text = editor.get_text();
4070        let (_row, col) = editor.cursor_position();
4071        let cursor = col.min(text.len());
4072        let sugg = manager
4073            .get_suggestions(&text, cursor)
4074            .expect("slash suggestions for /mo");
4075        let top = sugg.items.first().expect("at least one suggestion");
4076        let start = sugg.start.min(text.len());
4077        let end = sugg.end.min(text.len());
4078        let mut replaced = String::new();
4079        replaced.push_str(&text[..start]);
4080        replaced.push_str(&top.text);
4081        replaced.push_str(&text[end..]);
4082        if top.insert_space && !replaced.ends_with('/') {
4083            replaced.push(' ');
4084        }
4085        editor.set_text(&replaced);
4086        editor.set_cursor(0, replaced.len().min(start + top.text.len()));
4087        autocomplete_container.clear();
4088        assert_eq!(editor.get_text(), "/model");
4089
4090        // The next render MUST display the completed text.
4091        let frame_after = render_layout_frame(dock, 80, 10);
4092        let all: String = frame_after.lines.join("\n");
4093        assert!(
4094            all.contains("/model"),
4095            "completed text missing from next render. Got:\n{all}"
4096        );
4097        // The caret must sit AFTER the completed command (the snap_boundary
4098        // regression put it one char early: "/mode|l" with the final char
4099        // dangling past the caret).
4100        let editor_line = frame_after
4101            .lines
4102            .iter()
4103            .find(|l| l.contains("/model"))
4104            .expect("editor row with completed text");
4105        assert!(
4106            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
4107            "caret must follow the full completed text. Got: {editor_line:?}"
4108        );
4109    }
4110
4111    #[test]
4112    fn test_slash_command_dispatch() {
4113        // The registry is the single source of truth for dispatch: `find(token)`
4114        // returns the command (by name or alias) whose `name()` is the canonical
4115        // form, or `None` for an unknown token. This replaces the old enum-based
4116        // `handle_slash_command` assertions with equivalent registry lookups.
4117        let registry = build_builtin_registry();
4118
4119        // Helper: a token resolves to the command with this canonical name.
4120        let resolves_to = |token: &str, canonical: &str| {
4121            let found = registry.find(token).expect("{token} should resolve");
4122            assert_eq!(
4123                found.name(),
4124                canonical,
4125                "{token} resolved to {} (expected {canonical})",
4126                found.name()
4127            );
4128        };
4129
4130        resolves_to("/help", "/help");
4131        resolves_to("/?", "/help"); // alias → canonical
4132        resolves_to("/clear", "/clear");
4133        resolves_to("/new", "/clear"); // alias
4134        resolves_to("/q", "/exit"); // alias
4135        resolves_to("/quit", "/exit"); // alias
4136        resolves_to("/version", "/version");
4137        resolves_to("/v", "/version"); // alias
4138        resolves_to("/hotkeys", "/hotkeys");
4139        resolves_to("/model", "/model");
4140        resolves_to("/m", "/model"); // alias
4141        resolves_to("/theme", "/theme");
4142        resolves_to("/session", "/session");
4143        resolves_to("/resume", "/session"); // alias
4144        resolves_to("/compact", "/compact");
4145        resolves_to("/copy", "/copy");
4146        resolves_to("/thinking", "/thinking");
4147        resolves_to("/think", "/thinking"); // alias
4148        resolves_to("/tools", "/tools");
4149        resolves_to("/images", "/images");
4150        resolves_to("/armin", "/armin");
4151        resolves_to("/earendil", "/earendil");
4152        resolves_to("/context", "/context");
4153        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
4154        resolves_to("/settings", "/settings");
4155        resolves_to("/name", "/name");
4156        resolves_to("/export", "/export");
4157
4158        // Unknown token → not found.
4159        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
4160    }
4161
4162    #[test]
4163
4164    fn test_registry_visible_entries_cover_dispatch() {
4165        // The autocomplete list is derived from the registry, so every visible
4166        // command the dispatcher recognizes must appear in it — by construction,
4167        // but this guards against a future command being registered with
4168        // `visible()` / a non-empty description that the builder drops.
4169        let registry = build_builtin_registry();
4170        let names: Vec<String> = registry
4171            .visible_entries()
4172            .iter()
4173            .map(|c| c.name.clone())
4174            .collect();
4175        for recognized in [
4176            "/help", "/clear", "/new", "/exit", "/quit", "/version", "/model", "/session", "/theme",
4177            "/compact", "/copy", "/hotkeys", "/tools", "/images", "/thinking", "/armin",
4178            "/earendil",
4179        ] {
4180            assert!(
4181                names.contains(&recognized.to_string()),
4182                "{recognized} missing from autocomplete list"
4183            );
4184        }
4185        // Hidden commands stay off the list.
4186        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
4187            assert!(
4188                !names.contains(&hidden.to_string()),
4189                "{hidden} should be hidden from autocomplete"
4190            );
4191        }
4192    }
4193
4194    #[test]
4195    fn test_agent_event_mapping_creates_assistant_and_tool() {
4196        // Synthetic AgentEvent sequence → UI mutations, exercised against the
4197        // real drain handler with a no-op TUI stand-in.
4198        use rpi_ai::types::{StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType, ToolCall, ToolCallType, Usage};
4199
4200        let state = Arc::new(TuiState {
4201            current_assistant: std::sync::Mutex::new(None),
4202            tool_components: std::sync::Mutex::new(HashMap::new()),
4203            bash_components: std::sync::Mutex::new(HashMap::new()),
4204            last_tool_comp: std::sync::Mutex::new(None),
4205            status: std::sync::Mutex::new(RunStatus::Idle),
4206            footer: Arc::new(FooterComponent::new()),
4207            status_container: Arc::new(Container::new()),
4208            chat_container: Arc::new(Container::new()),
4209            loader: Arc::new(Loader::new()),
4210            last_assistant_text: std::sync::Mutex::new(String::new()),
4211            active_selector: std::sync::Mutex::new(None),
4212            autocomplete: AutocompleteManager::new(),
4213            autocomplete_container: Arc::new(Container::new()),
4214            theme_manager: Arc::new(ThemeManager::new()),
4215            tui: None,
4216            current_model_id: std::sync::Mutex::new(String::new()),
4217            show_images: std::sync::Mutex::new(true),
4218            history: std::sync::Mutex::new(Vec::new()),
4219            history_index: std::sync::Mutex::new(-1),
4220            history_draft: std::sync::Mutex::new(None),
4221        last_input_tokens: std::sync::Mutex::new(0),
4222        scoped_edit: std::sync::Mutex::new(None),
4223        markdown_transformer: std::sync::Mutex::new(None),
4224        });
4225
4226        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
4227        // terminal; instead, exercise the *mutation* half directly against a
4228        // captured chat container via a synthetic message-start event's data.
4229        let assistant = AssistantMessage {
4230            role: rpi_ai::types::AssistantRole,
4231            content: vec![
4232                Content::Thinking(ThinkingContent {
4233                    kind: ThinkingContentType,
4234                    thinking: "Reasoning about the reply.".into(),
4235                    thinking_signature: None,
4236                    redacted: false,
4237                }),
4238                Content::Text(TextContent {
4239                    kind: TextContentType,
4240                    text: "Hello.".into(),
4241                    text_signature: None,
4242                }),
4243                Content::ToolCall(ToolCall {
4244                    kind: ToolCallType,
4245                    id: "tc1".into(),
4246                    name: "bash".into(),
4247                    arguments: serde_json::json!({"command": "echo hi"}),
4248                    thought_signature: None,
4249                    namespace: None,
4250                }),
4251            ],
4252            api: rpi_ai::Api::AnthropicMessages,
4253            provider: "anthropic".into(),
4254            model: "claude-sonnet-5".into(),
4255            response_model: None,
4256            response_id: None,
4257            usage: Usage::zero(),
4258            stop_reason: StopReason::Stop,
4259            deferred: None,
4260            error_message: None,
4261            raw_stop_reason: None,
4262            end_turn: None,
4263            timestamp: 0,
4264        };
4265
4266        // Manually apply the MessageStart assistant branch logic (mirrors the
4267        // drain handler, without needing a TuiAltScreen).
4268        let comp = Arc::new(AssistantMessageComponent::new(AssistantMessageOptions::default()));
4269        comp.set_streaming(true);
4270        comp.update_blocks(&assistant_blocks(&assistant));
4271        let chat = Arc::new(Container::new());
4272        chat.add_child(comp.clone());
4273        *state.current_assistant.lock().unwrap() = Some(comp);
4274
4275        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
4276        for c in &assistant.content {
4277            if let Content::ToolCall(tc) = c {
4278                let mut tools = state.tool_components.lock().unwrap();
4279                if !tools.contains_key(&tc.id) {
4280                    let tc_comp = Arc::new(ToolExecutionComponent::new(
4281                        &tc.name,
4282                        &tc.arguments.to_string(),
4283                    ));
4284                    tc_comp.set_running();
4285                    chat.add_child(tc_comp.clone());
4286                    tools.insert(tc.id.clone(), tc_comp);
4287                }
4288            }
4289        }
4290
4291        // Assert: the assistant component rendered the text + the thinking
4292        // block (the update_blocks path keeps thinking visible), and a tool
4293        // component was registered.
4294        let rendered = chat.render(80);
4295        let joined: String = rendered.join("\n");
4296        assert!(joined.contains("Hello."), "assistant text not rendered: {joined}");
4297        assert!(
4298            joined.contains("Reasoning about the reply."),
4299            "thinking block not rendered: {joined}"
4300        );
4301        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
4302        assert!(state.current_assistant.lock().unwrap().is_some());
4303
4304        // Manually apply ToolExecutionEnd (mirrors drain).
4305        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
4306        ended.set_result("hi", false);
4307        assert!(state.tool_components.lock().unwrap().is_empty());
4308    }
4309
4310    #[test]
4311    fn test_short_model_name() {
4312        assert_eq!(short_model_name("anthropic:claude-sonnet-5"), "claude-sonnet-5");
4313        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
4314    }
4315
4316    #[test]
4317    fn test_cycle_next_model_wraps_around() {
4318        use rpi_ai::{Api, Model};
4319        let mk = |id: &str| {
4320            Model::new(id, id, Api::AnthropicMessages, "anthropic", "https://api.anthropic.com")
4321        };
4322        let catalog = [mk("a"), mk("b"), mk("c")];
4323        // Next after "a" is "b"; after "c" wraps to "a".
4324        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
4325        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
4326        // An unknown current id falls back to the first model.
4327        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
4328        // Empty catalog yields None.
4329        let empty: Vec<Model> = vec![];
4330        assert!(cycle_next_model(&empty, "a").is_none());
4331    }
4332
4333    #[test]
4334    fn test_autocomplete_slash_suggestions_render() {
4335        // The autocomplete container should render at least one suggestion
4336        // line when the editor holds a `/` prefix, and clear when it doesn't.
4337        let state = Arc::new(TuiState {
4338            current_assistant: std::sync::Mutex::new(None),
4339            tool_components: std::sync::Mutex::new(HashMap::new()),
4340            bash_components: std::sync::Mutex::new(HashMap::new()),
4341            last_tool_comp: std::sync::Mutex::new(None),
4342            status: std::sync::Mutex::new(RunStatus::Idle),
4343            footer: Arc::new(FooterComponent::new()),
4344            status_container: Arc::new(Container::new()),
4345            chat_container: Arc::new(Container::new()),
4346            loader: Arc::new(Loader::new()),
4347            last_assistant_text: std::sync::Mutex::new(String::new()),
4348            active_selector: std::sync::Mutex::new(None),
4349            autocomplete: AutocompleteManager::new(),
4350            autocomplete_container: Arc::new(Container::new()),
4351            theme_manager: Arc::new(ThemeManager::new()),
4352            tui: None,
4353            current_model_id: std::sync::Mutex::new(String::new()),
4354            show_images: std::sync::Mutex::new(true),
4355            history: std::sync::Mutex::new(Vec::new()),
4356            history_index: std::sync::Mutex::new(-1),
4357            history_draft: std::sync::Mutex::new(None),
4358        last_input_tokens: std::sync::Mutex::new(0),
4359        scoped_edit: std::sync::Mutex::new(None),
4360        markdown_transformer: std::sync::Mutex::new(None),
4361        });
4362        {
4363            let mut combined = CombinedAutocompleteProvider::new();
4364            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
4365                build_builtin_registry().visible_entries(),
4366            )));
4367            state.autocomplete.set_provider(Arc::new(combined));
4368        }
4369
4370        let editor = Arc::new(Editor::simple());
4371        editor.set_text("/he");
4372        editor.set_cursor(0, 3);
4373        refresh_autocomplete(&state, &editor);
4374        let lines = state.autocomplete_container.render(80);
4375        let joined: String = lines.join("\n");
4376        assert!(joined.contains("/help"), "slash suggestions not rendered: {joined}");
4377
4378        // Clear: no suggestions for plain text.
4379        editor.set_text("hello");
4380        editor.set_cursor(0, 5);
4381        refresh_autocomplete(&state, &editor);
4382        assert!(state.autocomplete_container.render(80).is_empty());
4383    }
4384
4385    #[test]
4386    fn test_select_list_swap_restores_editor() {
4387        // The editor-container swap: opening a selector replaces the editor
4388        // child; closing restores it. Verify the container child count + the
4389        // active_selector flag round-trip.
4390        let state = Arc::new(TuiState {
4391            current_assistant: std::sync::Mutex::new(None),
4392            tool_components: std::sync::Mutex::new(HashMap::new()),
4393            bash_components: std::sync::Mutex::new(HashMap::new()),
4394            last_tool_comp: std::sync::Mutex::new(None),
4395            status: std::sync::Mutex::new(RunStatus::Idle),
4396            footer: Arc::new(FooterComponent::new()),
4397            status_container: Arc::new(Container::new()),
4398            chat_container: Arc::new(Container::new()),
4399            loader: Arc::new(Loader::new()),
4400            last_assistant_text: std::sync::Mutex::new(String::new()),
4401            active_selector: std::sync::Mutex::new(None),
4402            autocomplete: AutocompleteManager::new(),
4403            autocomplete_container: Arc::new(Container::new()),
4404            theme_manager: Arc::new(ThemeManager::new()),
4405            tui: None,
4406            current_model_id: std::sync::Mutex::new(String::new()),
4407            show_images: std::sync::Mutex::new(true),
4408            history: std::sync::Mutex::new(Vec::new()),
4409            history_index: std::sync::Mutex::new(-1),
4410            history_draft: std::sync::Mutex::new(None),
4411        last_input_tokens: std::sync::Mutex::new(0),
4412        scoped_edit: std::sync::Mutex::new(None),
4413        markdown_transformer: std::sync::Mutex::new(None),
4414        });
4415        let editor_container = Arc::new(Container::new());
4416        let editor = Arc::new(Editor::simple());
4417        editor_container.add_child(editor.clone());
4418        assert!(!state.selector_open());
4419
4420        let tui_terminal = Box::new(ProcessTerminal::new());
4421        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
4422        let list = Arc::new(SelectList::new(
4423            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
4424            5,
4425        ));
4426        open_selector(&state, &editor_container, &editor, &tui, list, SelectorKind::Theme);
4427        assert!(state.selector_open());
4428        // list only (editor swapped out).
4429        assert_eq!(editor_container.child_count(), 1);
4430
4431        close_selector(&state, &editor_container, &editor, &tui);
4432        assert!(!state.selector_open());
4433        // editor restored.
4434        assert_eq!(editor_container.child_count(), 1);
4435    }
4436
4437    #[test]
4438    fn test_message_history_browse_restores_draft() {
4439        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
4440        // messages, browse older → newer → back past the newest restores the
4441        // draft the user was typing.
4442        let state = Arc::new(TuiState {
4443            current_assistant: std::sync::Mutex::new(None),
4444            tool_components: std::sync::Mutex::new(HashMap::new()),
4445            bash_components: std::sync::Mutex::new(HashMap::new()),
4446            last_tool_comp: std::sync::Mutex::new(None),
4447            status: std::sync::Mutex::new(RunStatus::Idle),
4448            footer: Arc::new(FooterComponent::new()),
4449            status_container: Arc::new(Container::new()),
4450            chat_container: Arc::new(Container::new()),
4451            loader: Arc::new(Loader::new()),
4452            last_assistant_text: std::sync::Mutex::new(String::new()),
4453            active_selector: std::sync::Mutex::new(None),
4454            autocomplete: AutocompleteManager::new(),
4455            autocomplete_container: Arc::new(Container::new()),
4456            theme_manager: Arc::new(ThemeManager::new()),
4457            tui: None,
4458            current_model_id: std::sync::Mutex::new(String::new()),
4459            show_images: std::sync::Mutex::new(true),
4460            history: std::sync::Mutex::new(Vec::new()),
4461            history_index: std::sync::Mutex::new(-1),
4462            history_draft: std::sync::Mutex::new(None),
4463        last_input_tokens: std::sync::Mutex::new(0),
4464        scoped_edit: std::sync::Mutex::new(None),
4465        markdown_transformer: std::sync::Mutex::new(None),
4466        });
4467        let editor = Arc::new(Editor::simple());
4468
4469        push_history(&state, "first message");
4470        push_history(&state, "second message");
4471        // Consecutive duplicate is skipped.
4472        push_history(&state, "second message");
4473        push_history(&state, "   "); // empty → skipped
4474        assert_eq!(state.history.lock().unwrap().len(), 2);
4475        assert_eq!(state.history.lock().unwrap()[0], "second message");
4476
4477        // User starts typing a fresh prompt.
4478        editor.set_text("half-typed");
4479        editor.set_cursor(0, 11);
4480
4481        // ↑ → most recent.
4482        navigate_history(&state, &editor, -1);
4483        assert_eq!(editor.get_text(), "second message");
4484        assert_eq!(*state.history_index.lock().unwrap(), 0);
4485        // ↑ → older.
4486        navigate_history(&state, &editor, -1);
4487        assert_eq!(editor.get_text(), "first message");
4488        assert_eq!(*state.history_index.lock().unwrap(), 1);
4489        // ↑ past the oldest → stays (no wrap).
4490        navigate_history(&state, &editor, -1);
4491        assert_eq!(editor.get_text(), "first message");
4492        // ↓ → newer.
4493        navigate_history(&state, &editor, 1);
4494        assert_eq!(editor.get_text(), "second message");
4495        // ↓ past the newest → restores the draft.
4496        navigate_history(&state, &editor, 1);
4497        assert_eq!(editor.get_text(), "half-typed");
4498        assert_eq!(*state.history_index.lock().unwrap(), -1);
4499    }
4500
4501    #[test]
4502    fn test_accept_top_suggestion_replaces_prefix() {
4503        // `/he` + Tab → `/help ` (slash command provider inserts a space).
4504        let state = Arc::new(TuiState {
4505            current_assistant: std::sync::Mutex::new(None),
4506            tool_components: std::sync::Mutex::new(HashMap::new()),
4507            bash_components: std::sync::Mutex::new(HashMap::new()),
4508            last_tool_comp: std::sync::Mutex::new(None),
4509            status: std::sync::Mutex::new(RunStatus::Idle),
4510            footer: Arc::new(FooterComponent::new()),
4511            status_container: Arc::new(Container::new()),
4512            chat_container: Arc::new(Container::new()),
4513            loader: Arc::new(Loader::new()),
4514            last_assistant_text: std::sync::Mutex::new(String::new()),
4515            active_selector: std::sync::Mutex::new(None),
4516            autocomplete: AutocompleteManager::new(),
4517            autocomplete_container: Arc::new(Container::new()),
4518            theme_manager: Arc::new(ThemeManager::new()),
4519            tui: None,
4520            current_model_id: std::sync::Mutex::new(String::new()),
4521            show_images: std::sync::Mutex::new(true),
4522            history: std::sync::Mutex::new(Vec::new()),
4523            history_index: std::sync::Mutex::new(-1),
4524            history_draft: std::sync::Mutex::new(None),
4525        last_input_tokens: std::sync::Mutex::new(0),
4526        scoped_edit: std::sync::Mutex::new(None),
4527        markdown_transformer: std::sync::Mutex::new(None),
4528        });
4529        {
4530            let mut combined = CombinedAutocompleteProvider::new();
4531            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
4532                build_builtin_registry().visible_entries(),
4533            )));
4534            state.autocomplete.set_provider(Arc::new(combined));
4535        }
4536        let editor = Arc::new(Editor::simple());
4537        editor.set_text("/he");
4538        editor.set_cursor(0, 3);
4539        refresh_autocomplete(&state, &editor);
4540        let accepted = accept_top_suggestion(&state, &editor);
4541        assert!(accepted, "should accept the top suggestion");
4542        let text = editor.get_text();
4543        assert!(
4544            text.starts_with("/help"),
4545            "editor text should start with /help, got {text}"
4546        );
4547    }
4548}