Skip to main content

pi/modes/interactive/
runtime.rs

1//! Live interactive runtime: owns the [`Tui`] writer, the [`TerminalInput`]
2//! reader, a [`SessionHost`], and the [`ViewState`] projection.
3//!
4//! This module is the **only** stdout owner for the interactive mode and the
5//! only place that translates [`ViewAction`]s into session calls. Everything
6//! outside this file is pure (stateless compose, pure input mapping, view
7//! data). The runtime:
8//!
9//! 1. Spawns one event-pump task that converts the [`SessionHost`]'s callback
10//!    subscription into a bounded [`mpsc`] of [`AgentSessionEvent`]s.
11//! 2. Runs the main `tokio::select!` loop over: UI events (keys / paste /
12//!    resize / focus), session events, partial-message watch ticks, the
13//!    background coalescer deadline, and shutdown signals.
14//! 3. Routes every UI event first to the live [`Editor`] component, then to
15//!    [`InputMapper`] for app-level dispatch, then forwards the resulting
16//!    [`ViewAction`] queue to `dispatch_action`.
17//! 4. Projects each [`AgentSessionEvent`] into [`ViewState`] mutations and
18//!    schedules a coalesced background paint (≤ 16 ms window). Input-driven
19//!    paints bypass the coalescer and commit on the same loop turn.
20//! 5. On `Resize`: coalesces to one [`Txn::Reanchor`] without clearing.
21//!    On `settle`: emits [`Txn::Settle`] containing the scrollback block and
22//!    the inline redraw in one stage-3 write.
23//! 6. On `Suspend` / `Exit` / fatal I/O failure: restores terminal modes via
24//!    the [`TerminalGuard`] (owned by the caller) and returns.
25//!
26//! The runtime is generic over the writer `W` (so tests can inject a
27//! [`std::io::Cursor`]`<`[`Vec`]`<u8>>` or
28//! [`TransactionRecorder`](pi_tui::terminal::TransactionRecorder)) and the
29//! session host `S` (so tests inject a [`FakeSessionHost`]). Production wires
30//! `W = io::Stdout` and `S = AgentSessionHost` (a future thin wrapper around
31//! `Arc<AgentSession>`).
32//!
33//! # No stdout clone, no second stdin owner, no clears
34//!
35//! The runtime owns exactly one [`Tui<W>`], which owns the sole stdout handle.
36//! [`TerminalInput`] owns the sole [`crossterm::event::EventStream`]. All
37//! terminal mutations go through [`Tui::commit`], whose stage-3 audit rejects
38//! any banned clear sequence (`CSI 2J` / `CSI 3J`).
39
40use crossterm::event::{KeyCode, KeyModifiers};
41use std::collections::VecDeque;
42use std::fmt::Debug;
43use std::io::{self, Write};
44use std::sync::Arc;
45use std::task::Poll;
46use std::time::{Duration, Instant};
47
48use futures::future::{BoxFuture, poll_fn};
49use pi_ai::{AssistantMessage, ImageContent};
50use pi_tui::component::{Component, EventResult, UiEvent};
51use pi_tui::components::editor::{Editor, EditorOptions};
52use pi_tui::keys::{ParsedKeyId, encode_key_event, key_matches_parsed, parse_key_id};
53use pi_tui::terminal::caps::TerminalCapabilities;
54use pi_tui::terminal::input::TerminalInput;
55use pi_tui::terminal::writer::{ReanchorCause, SettledBlock, Tui, Txn};
56use ratatui::buffer::Buffer;
57use ratatui::layout::Rect;
58use ratatui::text::Line;
59use tokio::sync::{Notify, mpsc, oneshot, watch};
60use tokio::task::{JoinError, JoinSet};
61use tokio_util::sync::CancellationToken;
62
63use crate::core::agent_session::events::AgentSessionEvent;
64use crate::core::agent_session::extension_runner::ExtensionRunner;
65use crate::core::agent_session::prompt::{PromptOptions, StreamingBehavior};
66use crate::core::extension_host::{ExtensionUiEvent, HostExtensionRunner};
67use crate::core::migrations::MigrationResult;
68use crate::core::platform::external_editor::{EditOutcome, edit_text_in_external_editor};
69use pi_ext::client::{HostUiRequest, HostUiResponse};
70use pi_ext::protocol::{
71    KeyEventKindWire, KeyModifiersWire, NotifyLevel, SlotPlacement, UiEventRequest, UiEventWire,
72};
73use pi_ext::sanitize::SanitizedSlot;
74
75use super::input::{DoubleEscapeAction, InputMapper, InputState};
76use super::messages::{AssistantMessageView, MessageView};
77#[cfg(test)]
78use super::state;
79use super::state::{
80    BillingMode, DiagnosticSeverity, EditorBorder, FocusArea, Overlay, OverlayKind, PendingKind,
81    PendingMessage, SessionStatus, StartupDiagnostic, StatusKind, ViewAction, ViewState,
82    WidgetSlot,
83};
84use super::theme::ResolvedTheme;
85use super::view::{ComposedSection, compose};
86
87/// Maximum time the runtime will wait for one [`Tui::commit`] before declaring
88/// a draw deadlock (cursor-query trap, runaway probe, etc.).
89///
90/// Mirrors the 5 s hard per-draw timeout of master-plan check 6. The check
91/// itself is enforced by the PTY test harness (the synchronous `Tui::commit`
92/// cannot be interrupted mid-call), but the runtime surfaces the constant so
93/// callers can wire their own alarm.
94pub const DRAW_TIMEOUT: Duration = Duration::from_secs(5);
95
96/// Background coalescing window for streaming / tool / plugin updates.
97pub const BACKGROUND_COALESCE_WINDOW: Duration = Duration::from_millis(16);
98
99/// Bound on the runtime's incoming event channel. Matches the agent crate's
100/// extension-queue capacity so a lagging consumer surfaces backpressure early.
101pub const EVENT_CHANNEL_CAPACITY: usize = 256;
102
103// ---------------------------------------------------------------------------
104// SessionHost trait
105// ---------------------------------------------------------------------------
106
107/// Mutually exclusive foreground activity reported by a session snapshot.
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub enum SessionActivity {
110    /// No foreground session activity.
111    #[default]
112    Idle,
113    /// The agent is currently streaming a response.
114    Streaming,
115    /// Context compaction is running.
116    Compacting,
117    /// A retry backoff is in progress.
118    Retrying,
119    /// Branch summarization is in progress.
120    Summarizing,
121}
122
123/// Snapshot of session state used to project [`ViewState`].
124///
125/// Production builds a real snapshot from `AgentSession` accessors; tests
126/// return whatever they like.
127#[derive(Clone, Debug, Default)]
128pub struct SessionSnapshot {
129    /// Current mutually exclusive foreground activity.
130    pub activity: SessionActivity,
131    /// Whether a session run is admitted, including non-streaming lifecycle phases.
132    pub admission_active: bool,
133    /// Whether bash execution is running.
134    pub bash_running: bool,
135    /// Active thinking level label (for footer + editor border).
136    pub thinking_level_label: String,
137    /// Active model id (footer).
138    pub model_id: String,
139    /// Whether the active model supports reasoning.
140    pub reasoning: bool,
141    /// Pending steering messages (mirror).
142    pub steering: Vec<String>,
143    /// Pending follow-up messages (mirror).
144    pub follow_up: Vec<String>,
145    /// Queue delivery mode for follow-up messages.
146    pub follow_up_mode: super::state::QueueMode,
147}
148
149/// Session-derived footer values that require async access to persisted history.
150#[derive(Clone, Debug, PartialEq)]
151pub struct SessionFooterSnapshot {
152    /// Cumulative input tokens across the persisted session.
153    pub total_input: u64,
154    /// Cumulative output tokens across the persisted session.
155    pub total_output: u64,
156    /// Cumulative cache-read tokens.
157    pub total_cache_read: u64,
158    /// Cumulative cache-write tokens.
159    pub total_cache_write: u64,
160    /// Cumulative cost in USD.
161    pub total_cost: f64,
162    /// Context-window size in tokens.
163    pub context_window: u64,
164    /// Context usage percent when known.
165    pub context_percent: Option<f64>,
166    /// Active model provider.
167    pub provider: Option<String>,
168    /// Number of providers in the active model catalog.
169    pub provider_count: usize,
170    /// Active thinking level.
171    pub thinking_level: pi_ai::ModelThinkingLevel,
172    /// Whether bash execution is running.
173    pub bash_running: bool,
174    /// Whether billing is covered by an OAuth subscription.
175    pub subscription: bool,
176    /// Whether automatic compaction is enabled.
177    pub auto_compact: bool,
178}
179
180impl Default for SessionFooterSnapshot {
181    fn default() -> Self {
182        Self {
183            total_input: 0,
184            total_output: 0,
185            total_cache_read: 0,
186            total_cache_write: 0,
187            total_cost: 0.0,
188            context_window: 0,
189            context_percent: None,
190            provider: None,
191            provider_count: 0,
192            thinking_level: pi_ai::ModelThinkingLevel::Off,
193            bash_running: false,
194            subscription: false,
195            auto_compact: true,
196        }
197    }
198}
199
200impl SessionSnapshot {
201    /// Whether product input should be routed into the admitted run.
202    #[must_use]
203    pub fn is_admission_active(&self) -> bool {
204        self.admission_active
205    }
206}
207
208/// Scoped-model selector entries and their enabled-state map.
209pub type ScopedModelEntries = (
210    Vec<super::state::ModelSelectorEntry>,
211    std::collections::BTreeMap<String, bool>,
212);
213
214/// Asynchronous session surface consumed by the runtime.
215///
216/// All async methods return `BoxFuture` so the trait stays object-safe; the
217/// runtime is generic over `S: SessionHost` so production wires a thin
218/// `AgentSessionHost` wrapper and tests wire [`FakeSessionHost`]. Methods that
219/// can fail return `Result<_, String>`; the runtime records the error onto
220/// the status indicator (never panics, never aborts the loop).
221///
222/// # Implementation invariants
223///
224/// - `subscribe` MUST invoke its callback for every public session event,
225///   including ones emitted during async actions performed by this trait.
226/// - `partial_rx` MAY return a receiver that never fires (no streaming); the
227///   runtime treats `None` updates as no-ops.
228/// - The runtime NEVER holds the host across `.await` points that touch the
229///   same host mutably; each action is dispatched on a fresh `&self` borrow.
230pub trait SessionHost: Send + Sync + 'static {
231    /// Snapshot of synchronous state for view projection.
232    fn snapshot(&self) -> SessionSnapshot;
233
234    /// Snapshot persisted token/cost/context state for the footer.
235    fn footer_snapshot(&self) -> BoxFuture<'_, SessionFooterSnapshot> {
236        Box::pin(std::future::ready(SessionFooterSnapshot::default()))
237    }
238
239    /// Subscribe to public session events. The returned [`EventSubscription`]
240    /// owns an mpsc receiver plus the unsubscribe token.
241    fn subscribe(&self) -> EventSubscription;
242
243    /// Receiver for the latest partial assistant message (`None` when idle).
244    fn partial_rx(&self) -> watch::Receiver<Option<Arc<AssistantMessage>>>;
245
246    // ----- Async actions (object-safe via BoxFuture) -----
247
248    /// Submit a prompt.
249    fn prompt(&self, text: &str, opts: PromptOptions) -> BoxFuture<'_, Result<(), String>>;
250
251    /// Steer the in-flight stream (mid-turn injection).
252    fn steer(&self, text: &str) -> BoxFuture<'_, Result<(), String>>;
253
254    /// Queue a follow-up message for the next turn.
255    fn follow_up(&self, text: &str) -> BoxFuture<'_, Result<(), String>>;
256
257    /// Abort the active run, retry, compaction, bash, or branch summary.
258    ///
259    /// The returned future owns the concrete session selected at method-call
260    /// time. Interactive prompt operations retain it so a later session
261    /// replacement cannot redirect cleanup to the replacement session.
262    fn abort(&self) -> BoxFuture<'static, Result<(), String>>;
263
264    /// Manually compact the context with optional custom instructions.
265    fn compact(&self, instructions: Option<&str>) -> BoxFuture<'_, Result<(), String>>;
266
267    /// Cycle the thinking level forward.
268    fn cycle_thinking_level(&self) -> BoxFuture<'_, Result<(), String>>;
269
270    /// Cycle the active model in the given direction.
271    fn cycle_model(&self, forward: bool) -> BoxFuture<'_, Result<(), String>>;
272
273    /// Reload extensions / resources / keybindings.
274    fn reload(&self) -> BoxFuture<'_, Result<(), String>>;
275
276    /// Returns the full transcript for the current session (used on rebind).
277    fn messages(&self) -> Vec<pi_agent::AgentMessage>;
278
279    /// Concrete extension host for interactive UI bridging, when enabled.
280    fn host_extension_runner(&self) -> Option<Arc<HostExtensionRunner>> {
281        None
282    }
283
284    /// Initial persisted thinking-block visibility.
285    fn hide_thinking_block(&self) -> bool {
286        false
287    }
288
289    /// Persist thinking-block visibility.
290    ///
291    /// # Errors
292    ///
293    /// Returns a human-readable message when persisting the preference fails.
294    fn set_hide_thinking_block(&self, _hide: bool) -> Result<(), String> {
295        Ok(())
296    }
297
298    /// Configured external editor command.
299    fn external_editor_command(&self) -> String {
300        if cfg!(windows) {
301            "notepad".to_owned()
302        } else {
303            "nano".to_owned()
304        }
305    }
306
307    /// Fetch the model list for the model selector.
308    fn get_model_entries(
309        &self,
310    ) -> BoxFuture<'_, Result<Vec<super::state::ModelSelectorEntry>, String>>;
311
312    /// Fetch the recent sessions for the session picker.
313    fn get_session_entries(
314        &self,
315    ) -> BoxFuture<'_, Result<Vec<super::state::SessionPickerEntry>, String>>;
316
317    /// Fetch the session tree (entries with depth) for the tree selector.
318    fn get_tree_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>>;
319
320    /// Fetch the user-message fork list (tree entries, only user messages).
321    fn get_fork_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>>;
322
323    /// Fetch the trust-state settings rows.
324    fn get_trust_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>>;
325
326    /// Fetch the auth selector entries (provider list).
327    fn get_auth_entries(
328        &self,
329    ) -> BoxFuture<'_, Result<Vec<super::state::AuthSelectorEntry>, String>>;
330
331    /// Fetch the scoped-models selector entries with current enabled map.
332    fn get_scoped_models_entries(&self) -> BoxFuture<'_, Result<ScopedModelEntries, String>>;
333
334    /// Fetch the settings selector rows.
335    fn get_settings_entries(&self)
336    -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>>;
337
338    /// Fetch the config selector rows.
339    fn get_config_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>>;
340
341    /// Execute a bash command (the runtime passes the typed command minus the
342    /// `!` / `!!` prefix).
343    fn execute_bash(
344        &self,
345        command: &str,
346        exclude_from_context: bool,
347    ) -> BoxFuture<'_, Result<(), String>>;
348
349    /// Start a new session (replacement pipeline).
350    fn new_session(&self) -> BoxFuture<'_, Result<(), String>>;
351
352    /// Open the fork selector's confirmation; runtime supplies the entry id.
353    fn fork(&self, entry_id: &str) -> BoxFuture<'_, Result<(), String>>;
354
355    /// Clone the session at the current leaf.
356    fn clone(&self) -> BoxFuture<'_, Result<(), String>>;
357
358    /// Switch to a different session file (resume).
359    fn switch_session(&self, path: &str) -> BoxFuture<'_, Result<(), String>>;
360
361    /// Export the current session to HTML; runtime passes an optional path.
362    fn export_html(&self, path: Option<&str>) -> BoxFuture<'_, Result<String, String>>;
363
364    /// Set the session display name.
365    fn set_session_name(&self, name: &str) -> BoxFuture<'_, Result<(), String>>;
366
367    /// Log out of the active auth / open the login selector.
368    fn logout(&self) -> BoxFuture<'_, Result<(), String>>;
369
370    /// Copy the last assistant text (returns the text so the runtime can
371    /// resolve the platform clipboard).
372    fn last_assistant_text(&self) -> BoxFuture<'_, Result<Option<String>, String>>;
373}
374
375/// Subscription returned by [`SessionHost::subscribe`].
376///
377/// Owns the receiver side of the event channel plus the unsubscribe token.
378/// Dropping this drops both — listeners are cleaned up automatically.
379pub struct EventSubscription {
380    /// Receiver for events pumped from the host.
381    pub rx: mpsc::UnboundedReceiver<AgentSessionEvent>,
382    /// Unsubscribe handle; fires on drop.
383    pub unsubscribe: Option<Box<dyn FnOnce() + Send + Sync>>,
384}
385
386impl Debug for EventSubscription {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        f.debug_struct("EventSubscription").finish_non_exhaustive()
389    }
390}
391
392impl Drop for EventSubscription {
393    fn drop(&mut self) {
394        if let Some(unsub) = self.unsubscribe.take() {
395            unsub();
396        }
397    }
398}
399
400// ---------------------------------------------------------------------------
401// Runtime options / exit / outcome
402// ---------------------------------------------------------------------------
403/// Why the runtime exited.
404#[derive(Clone, Copy, Debug, PartialEq, Eq)]
405pub enum InteractiveExit {
406    /// User exited cleanly (Ctrl+D / double Ctrl+C / `/quit`).
407    Clean,
408    /// Terminal I/O failed; the process should exit nonzero.
409    IoFailure,
410    /// A draw timed out (cursor-query deadlock guard).
411    DrawDeadlock,
412    /// The session ended (host signaled shutdown).
413    SessionEnded,
414    /// Process suspension requested (`Ctrl+Z`). The caller (`run_interactive_mode`)
415    /// drives the actual SIGTSTP via the [`pi_tui::terminal::guard::TerminalGuard`]
416    /// then loops `run()` to resume.
417    Suspend,
418    /// Temporarily restore the terminal and run the configured external editor.
419    ExternalEditor,
420}
421
422/// Outcome of dispatching one [`ViewAction`].
423pub struct InteractiveRuntimeOptions {
424    /// Initial resolved theme (dark / light).
425    pub theme: Arc<ResolvedTheme>,
426    /// Terminal capabilities (sync output, image protocol, hyperlinks, …).
427    pub caps: TerminalCapabilities,
428    /// Initial terminal size.
429    pub size: (u16, u16),
430    /// Initial inline viewport height.
431    pub viewport_height: u16,
432    /// Quiet mode suppresses the logo header.
433    pub quiet: bool,
434    /// Double-Esc action ("none" / "tree" / "fork").
435    pub double_escape: DoubleEscapeAction,
436    /// Show hardware cursor (debug / accessibility).
437    pub hardware_cursor: bool,
438    /// Initial CLI prompt assembled during bootstrap.
439    pub initial_message: Option<String>,
440    /// Images attached to the initial CLI prompt.
441    pub initial_images: Vec<ImageContent>,
442    /// Additional CLI prompts dispatched after the initial prompt settles.
443    pub remaining_messages: Vec<String>,
444    /// Startup migration results surfaced by the interactive UI.
445    pub migrations: MigrationResult,
446}
447
448/// Outcome of dispatching one [`ViewAction`].
449#[derive(Clone, Copy, Debug, PartialEq, Eq)]
450pub(crate) enum ActionOutcome {
451    /// No observable effect.
452    None,
453    /// The view changed and a repaint is needed.
454    Repaint,
455    /// The runtime should exit cleanly.
456    Exit,
457    /// The process should suspend after restoring terminal state.
458    Suspend,
459    /// Pause the runtime while the outer terminal owner runs an editor child.
460    ExternalEditor,
461}
462
463#[derive(Clone, Copy, Debug, PartialEq, Eq)]
464enum TypedBuiltin<'a> {
465    Compact(Option<&'a str>),
466    Fork,
467    Resume,
468    Reload,
469}
470
471#[derive(Clone, Copy, Debug, PartialEq, Eq)]
472enum SessionReplacement {
473    New,
474    Fork,
475    Clone,
476}
477
478fn parse_typed_builtin(text: &str) -> Option<TypedBuiltin<'_>> {
479    match text {
480        "/compact" => Some(TypedBuiltin::Compact(None)),
481        "/fork" => Some(TypedBuiltin::Fork),
482        "/resume" => Some(TypedBuiltin::Resume),
483        "/reload" => Some(TypedBuiltin::Reload),
484        _ => text
485            .strip_prefix("/compact ")
486            .map(str::trim)
487            .map(Some)
488            .map(TypedBuiltin::Compact),
489    }
490}
491
492impl Default for InteractiveRuntimeOptions {
493    fn default() -> Self {
494        Self {
495            theme: super::theme::dark(),
496            caps: TerminalCapabilities::default(),
497            size: (80, 24),
498            viewport_height: 24,
499            quiet: false,
500            double_escape: DoubleEscapeAction::None,
501            hardware_cursor: false,
502            initial_message: None,
503            initial_images: Vec::new(),
504            remaining_messages: Vec::new(),
505            migrations: MigrationResult::default(),
506        }
507    }
508}
509
510/// Component wrapper that splices the live editor into the composed view.
511struct InteractiveRoot {
512    pre_editor: Vec<ComposedSection>,
513    editor: Editor,
514    post_editor: Vec<ComposedSection>,
515    overlay: Option<Box<dyn Component>>,
516    overlay_spec: Option<pi_tui::layout::OverlaySpec>,
517    selector: Option<Box<dyn Component>>,
518    dialog_title: Option<Box<dyn Component>>,
519    focus: FocusArea,
520}
521
522impl InteractiveRoot {
523    #[cfg(test)]
524    fn build(view: &ViewState, editor: Editor, selector: Option<Box<dyn Component>>) -> Self {
525        let composed = compose(view);
526        let mut sections = composed.sections;
527        let editor_idx = sections
528            .iter()
529            .position(|section| section.label == "editor")
530            .unwrap_or(sections.len().saturating_sub(1));
531        let pre_editor: Vec<_> = sections.drain(0..editor_idx).collect();
532        if !sections.is_empty() {
533            sections.remove(0);
534        }
535        Self {
536            pre_editor,
537            editor,
538            post_editor: sections,
539            overlay: composed.overlay,
540            overlay_spec: composed.overlay_spec,
541            selector,
542            dialog_title: None,
543            focus: view.focus,
544        }
545    }
546
547    fn build_with_chat(
548        view: &mut ViewState,
549        editor: Editor,
550        selector: Option<Box<dyn Component>>,
551        dialog_title: Option<Box<dyn Component>>,
552        prefix: Box<dyn Component>,
553        tail: Box<dyn Component>,
554    ) -> Self {
555        let messages = std::mem::take(&mut view.messages);
556        let mut composed = compose(view);
557        view.messages = messages;
558        if let Some(index) = composed
559            .sections
560            .iter()
561            .position(|section| section.label == "chat")
562        {
563            composed.sections[index] = ComposedSection {
564                label: "chat-prefix",
565                component: prefix,
566            };
567            composed.sections.insert(
568                index + 1,
569                ComposedSection {
570                    label: "chat-tail",
571                    component: tail,
572                },
573            );
574        }
575        let mut sections = composed.sections;
576        let editor_idx = sections
577            .iter()
578            .position(|section| section.label == "editor")
579            .unwrap_or(sections.len().saturating_sub(1));
580        let pre_editor: Vec<_> = sections.drain(..editor_idx).collect();
581        let overlay = composed.overlay;
582        let overlay_spec = composed.overlay_spec;
583        if !sections.is_empty() {
584            sections.remove(0);
585        }
586        Self {
587            pre_editor,
588            editor,
589            post_editor: sections,
590            overlay,
591            overlay_spec,
592            selector,
593            dialog_title,
594            focus: view.focus,
595        }
596    }
597
598    fn take_section(&mut self, label: &'static str) -> Option<Box<dyn Component>> {
599        let section = self
600            .pre_editor
601            .iter_mut()
602            .find(|section| section.label == label)?;
603        Some(std::mem::replace(
604            &mut section.component,
605            Box::new(pi_tui::components::Text::new(String::new())),
606        ))
607    }
608
609    fn editor_mut(&mut self) -> &mut Editor {
610        &mut self.editor
611    }
612
613    fn render_middle(
614        &mut self,
615        area: Rect,
616        y: u16,
617        height: u16,
618        title_height: u16,
619        buf: &mut Buffer,
620    ) {
621        if height == 0 {
622            return;
623        }
624        let rendered_title_height = title_height.min(height);
625        if rendered_title_height > 0
626            && let Some(title) = self.dialog_title.as_mut()
627        {
628            title.render(Rect::new(area.x, y, area.width, rendered_title_height), buf);
629        }
630        let body_height = height.saturating_sub(rendered_title_height);
631        if body_height == 0 {
632            return;
633        }
634        let body_area = Rect::new(
635            area.x,
636            y.saturating_add(rendered_title_height),
637            area.width,
638            body_height,
639        );
640        if self.focus == FocusArea::Selector {
641            if let Some(selector) = self.selector.as_mut() {
642                selector.render(body_area, buf);
643            }
644        } else {
645            self.editor.render(body_area, buf);
646        }
647    }
648
649    fn render_overlay(&mut self, area: Rect, buf: &mut Buffer) {
650        let Some(overlay) = self.overlay.as_mut() else {
651            return;
652        };
653        let measured = overlay.measure(area.width).min(area.height);
654        let overlay_area = self.overlay_spec.as_ref().map_or_else(
655            || Rect::new(area.x, area.y, area.width, measured),
656            |spec| {
657                let layout =
658                    pi_tui::layout::resolve_overlay_layout(spec, measured, area.width, area.height);
659                let overlay_height = layout
660                    .max_height
661                    .map_or(measured, |max_height| measured.min(max_height))
662                    .min(area.height.saturating_sub(layout.row));
663                Rect::new(
664                    area.x.saturating_add(layout.col),
665                    area.y.saturating_add(layout.row),
666                    layout.width,
667                    overlay_height,
668                )
669            },
670        );
671        if overlay_area.height > 0 {
672            overlay.render(overlay_area, buf);
673        }
674    }
675}
676
677fn visible_suffix(heights: &[u16], available: u16) -> (usize, u16) {
678    let mut used = 0_u16;
679    let mut start = heights.len();
680    let mut skipped_rows = 0_u16;
681    for (index, &height) in heights.iter().enumerate().rev() {
682        if used == available {
683            break;
684        }
685        start = index;
686        let remaining = available - used;
687        if height > remaining {
688            skipped_rows = height - remaining;
689            break;
690        }
691        used += height;
692    }
693    (start, skipped_rows)
694}
695
696fn render_bottom_clipped(
697    component: &mut dyn Component,
698    area: Rect,
699    measured_height: u16,
700    skipped_rows: u16,
701    buf: &mut Buffer,
702) {
703    if area.is_empty() {
704        return;
705    }
706    if skipped_rows == 0 {
707        component.render(area, buf);
708        return;
709    }
710
711    let source_area = Rect::new(0, 0, area.width, measured_height);
712    let mut source = Buffer::empty(source_area);
713    component.render(source_area, &mut source);
714    for row in 0..area.height {
715        for column in 0..area.width {
716            let source_position = (column, skipped_rows + row);
717            let target_position = (area.x + column, area.y + row);
718            if let (Some(source_cell), Some(target_cell)) =
719                (source.cell(source_position), buf.cell_mut(target_position))
720            {
721                *target_cell = source_cell.clone();
722            }
723        }
724    }
725}
726
727impl Component for InteractiveRoot {
728    fn measure(&mut self, width: u16) -> u16 {
729        let pre_height = self.pre_editor.iter_mut().fold(0_u16, |height, section| {
730            height.saturating_add(section.component.measure(width))
731        });
732        let title_height = self
733            .dialog_title
734            .as_mut()
735            .map_or(0, |title| title.measure(width));
736        let body_height = if self.focus == FocusArea::Selector {
737            self.selector
738                .as_mut()
739                .map_or(0, |selector| selector.measure(width))
740        } else {
741            self.editor.measure(width)
742        };
743        let middle_height = title_height.saturating_add(body_height);
744        self.post_editor.iter_mut().fold(
745            pre_height.saturating_add(middle_height),
746            |height, section| height.saturating_add(section.component.measure(width)),
747        )
748    }
749
750    fn render(&mut self, area: Rect, buf: &mut Buffer) {
751        self.editor.focused = self.focus == FocusArea::Editor;
752        let pre_heights = self
753            .pre_editor
754            .iter_mut()
755            .map(|section| section.component.measure(area.width))
756            .collect::<Vec<_>>();
757        let title_height = self
758            .dialog_title
759            .as_mut()
760            .map_or(0, |title| title.measure(area.width));
761        let body_height = if self.focus == FocusArea::Selector {
762            self.selector
763                .as_mut()
764                .map_or(0, |selector| selector.measure(area.width))
765        } else {
766            self.editor.measure(area.width)
767        };
768        let middle_height = title_height.saturating_add(body_height);
769        let post_heights = self
770            .post_editor
771            .iter_mut()
772            .map(|section| section.component.measure(area.width))
773            .collect::<Vec<_>>();
774        let middle_height = middle_height.min(area.height);
775        let post_height = post_heights
776            .iter()
777            .copied()
778            .fold(0_u16, u16::saturating_add)
779            .min(area.height - middle_height);
780        let pre_height = area.height - middle_height - post_height;
781        let (pre_start, skipped_rows) = visible_suffix(&pre_heights, pre_height);
782        let bottom = area.bottom();
783        let mut y = area.y;
784
785        for (offset, section) in self.pre_editor[pre_start..].iter_mut().enumerate() {
786            let measured_height = pre_heights[pre_start + offset];
787            let skipped_rows = if offset == 0 { skipped_rows } else { 0 };
788            let height = measured_height
789                .saturating_sub(skipped_rows)
790                .min(bottom.saturating_sub(y));
791            render_bottom_clipped(
792                section.component.as_mut(),
793                Rect::new(area.x, y, area.width, height),
794                measured_height,
795                skipped_rows,
796                buf,
797            );
798            y = y.saturating_add(height);
799        }
800
801        let height = middle_height.min(bottom.saturating_sub(y));
802        self.render_middle(area, y, height, title_height, buf);
803        y = y.saturating_add(height);
804
805        for (section, measured_height) in self.post_editor.iter_mut().zip(post_heights) {
806            if y == bottom {
807                break;
808            }
809            let height = measured_height.min(bottom - y);
810            if height == 0 {
811                continue;
812            }
813            section
814                .component
815                .render(Rect::new(area.x, y, area.width, height), buf);
816            y = y.saturating_add(height);
817        }
818        self.render_overlay(area, buf);
819    }
820
821    fn handle_event(&mut self, event: &UiEvent) -> EventResult {
822        match self.focus {
823            FocusArea::Editor => self.editor.handle_event(event),
824            FocusArea::Selector => self
825                .selector
826                .as_mut()
827                .map_or(EventResult::Ignored, |selector| {
828                    selector.handle_event(event)
829                }),
830            FocusArea::Overlay => self
831                .overlay
832                .as_mut()
833                .map_or(EventResult::Ignored, |overlay| overlay.handle_event(event)),
834            FocusArea::Widget => EventResult::Ignored,
835        }
836    }
837
838    fn invalidate(&mut self) {
839        for section in &mut self.pre_editor {
840            section.component.invalidate();
841        }
842        self.editor.invalidate();
843        for section in &mut self.post_editor {
844            section.component.invalidate();
845        }
846        if let Some(selector) = self.selector.as_mut() {
847            selector.invalidate();
848        }
849        if let Some(title) = self.dialog_title.as_mut() {
850            title.invalidate();
851        }
852        if let Some(overlay) = self.overlay.as_mut() {
853            overlay.invalidate();
854        }
855    }
856}
857
858// ---------------------------------------------------------------------------
859// InteractiveRuntime
860// ---------------------------------------------------------------------------
861
862/// Persistent transcript display preferences applied to every projection.
863#[derive(Clone, Copy, Default)]
864struct DisplayPreferences {
865    /// Whether tool blocks render expanded.
866    tools_expanded: bool,
867    /// Whether thinking blocks are hidden behind a static label.
868    hide_thinking: bool,
869}
870
871/// Live interactive runtime.
872///
873/// Owns:
874/// - `tui` — the sole stdout owner.
875/// - `input` — the sole stdin owner (`crossterm::EventStream`).
876/// - `editor` — the live, stateful editor (preserved across frames).
877/// - `view` — the [`ViewState`] snapshot mutated by events and actions.
878/// - `mapper` / `input_state` — pure input dispatch state.
879/// - `focus` — single-focus manager (used by selectors and overlays).
880/// - `events` — the bridged session-event channel.
881/// - `partial` — the partial-assistant watch receiver.
882/// - `shutdown` — notify for graceful exit.
883///
884/// The caller owns the [`pi_tui::terminal::guard::TerminalGuard`] so it can
885/// outlive the runtime and write restore bytes on process exit even if the
886/// runtime itself panics.
887pub struct InteractiveRuntime<W: Write, S: SessionHost> {
888    tui: Tui<W>,
889    input: TerminalInput,
890    session: Arc<S>,
891    editor: Editor,
892    view: ViewState,
893    mapper: InputMapper,
894    input_state: InputState,
895    events: EventSubscription,
896    partial: watch::Receiver<Option<Arc<AssistantMessage>>>,
897    prompt_operations: PromptOperations,
898    startup_prompts: VecDeque<(String, Vec<ImageContent>)>,
899    coalesce_deadline: Option<Instant>,
900    pending_settle: Option<Vec<SettledBlock>>,
901    shutdown: Arc<Notify>,
902    exited: bool,
903    exit_kind: InteractiveExit,
904    last_error: Option<String>,
905    shutdown_flag: Arc<std::sync::atomic::AtomicBool>,
906    pending_ui_reinject: Vec<UiEvent>,
907    extension_runner: Option<Arc<HostExtensionRunner>>,
908    extension_events: Option<tokio::sync::broadcast::Receiver<ExtensionUiEvent>>,
909    extension_requests: Option<mpsc::Receiver<HostUiRequest>>,
910    extension_slots: std::collections::HashMap<String, ProjectedExtensionSlot>,
911    focused_extension_slot: Option<String>,
912    effective_extension_shortcuts: Vec<EffectiveExtensionShortcut>,
913    extension_action_rx: mpsc::UnboundedReceiver<Result<(), String>>,
914    extension_action_tx: mpsc::UnboundedSender<Result<(), String>>,
915    pending_extension_dialog: Option<PendingExtensionDialog>,
916    extension_select_rx: mpsc::UnboundedReceiver<String>,
917    extension_select_tx: mpsc::UnboundedSender<String>,
918    display: DisplayPreferences,
919    chat_prefix_cache: Option<Box<dyn Component>>,
920    chat_prefix_len: usize,
921    chat_tail_cache: Option<Box<dyn Component>>,
922    chat_dirty: bool,
923    /// Live selector component (replaces the editor while focused).
924    active_selector: Option<Box<dyn Component>>,
925    /// Kind of the active selector for confirm/cancel routing.
926    active_selector_kind: Option<super::state::SelectorKind>,
927    /// Pending editor submits emitted via `Editor::on_submit`.
928    submit_rx: mpsc::UnboundedReceiver<String>,
929    /// Sender retained so the editor callback stays valid across rebuilds.
930    submit_tx: mpsc::UnboundedSender<String>,
931    /// Pending selector confirm values.
932    select_rx: mpsc::UnboundedReceiver<(super::state::SelectorKind, String)>,
933    select_tx: mpsc::UnboundedSender<(super::state::SelectorKind, String)>,
934    /// Pending selector cancels.
935    cancel_rx: mpsc::UnboundedReceiver<()>,
936    cancel_tx: mpsc::UnboundedSender<()>,
937}
938
939#[derive(Clone, Copy, Debug, Eq, PartialEq)]
940enum SessionOperationKind {
941    Prompt,
942    Bash,
943}
944
945/// Completion of one session operation owned by the interactive runtime.
946struct PromptCompletion {
947    id: u64,
948    epoch: u64,
949    kind: SessionOperationKind,
950    result: Result<(), String>,
951}
952
953/// Runtime-owned session tasks plus their per-session abort signals.
954///
955/// `epoch` advances before session replacement or runtime exit. Results from an
956/// older epoch are drained but never projected onto the replacement session.
957struct PromptOperations {
958    epoch: u64,
959    next_id: u64,
960    tasks: JoinSet<PromptCompletion>,
961    aborts: std::collections::BTreeMap<u64, oneshot::Sender<()>>,
962    bash_operation: Option<u64>,
963}
964
965#[derive(Debug)]
966struct PendingExtensionDialog {
967    request: HostUiRequest,
968    saved_editor_text: Option<String>,
969    saved_editor_placeholder: String,
970    deadline: Option<Instant>,
971}
972
973#[derive(Clone, Debug)]
974struct EffectiveExtensionShortcut {
975    key: String,
976    dispatch_key: String,
977    parsed: ParsedKeyId,
978    description: Option<String>,
979    source: Option<String>,
980}
981
982#[derive(Clone, Debug)]
983struct ProjectedExtensionSlot {
984    placement: SlotPlacement,
985    generation: u64,
986    focusable: bool,
987}
988
989impl PromptOperations {
990    fn new() -> Self {
991        Self {
992            epoch: 0,
993            next_id: 0,
994            tasks: JoinSet::new(),
995            aborts: std::collections::BTreeMap::new(),
996            bash_operation: None,
997        }
998    }
999}
1000
1001fn initial_view(options: &InteractiveRuntimeOptions) -> ViewState {
1002    let mut view = ViewState::empty();
1003    view.theme = options.theme.clone();
1004    view.width = options.size.0;
1005    view.height = options.size.1;
1006    view.quiet = options.quiet;
1007    view.resize(options.size.0, options.size.1);
1008    if !options.migrations.migrated_auth_providers.is_empty() {
1009        view.diagnostics.entries.push(StartupDiagnostic {
1010            severity: DiagnosticSeverity::Warning,
1011            source: "migration".to_owned(),
1012            message: format!(
1013                "Migrated credentials to auth.json: {}",
1014                options.migrations.migrated_auth_providers.join(", ")
1015            ),
1016        });
1017    }
1018    view.diagnostics
1019        .entries
1020        .extend(
1021            options
1022                .migrations
1023                .deprecation_warnings
1024                .iter()
1025                .cloned()
1026                .map(|message| StartupDiagnostic {
1027                    severity: DiagnosticSeverity::Warning,
1028                    source: "migration".to_owned(),
1029                    message,
1030                }),
1031        );
1032    view
1033}
1034
1035fn initial_prompts(options: &InteractiveRuntimeOptions) -> VecDeque<(String, Vec<ImageContent>)> {
1036    let mut prompts = VecDeque::new();
1037    if let Some(message) = options.initial_message.clone() {
1038        prompts.push_back((message, options.initial_images.clone()));
1039    }
1040    prompts.extend(
1041        options
1042            .remaining_messages
1043            .iter()
1044            .cloned()
1045            .map(|message| (message, Vec::new())),
1046    );
1047    prompts
1048}
1049
1050impl<W: Write, S: SessionHost> InteractiveRuntime<W, S> {
1051    /// Construct the runtime around an already-active [`Tui`] and
1052    /// [`TerminalInput`].
1053    ///
1054    /// The caller is responsible for activating the
1055    /// [`pi_tui::terminal::guard::TerminalGuard`] before this call and dropping
1056    /// it after the runtime exits.
1057    ///
1058    /// # Panics
1059    ///
1060    /// Never. Construction is infallible.
1061    #[must_use]
1062    #[allow(clippy::too_many_arguments)]
1063    pub fn new(
1064        mut tui: Tui<W>,
1065        input: TerminalInput,
1066        session: Arc<S>,
1067        options: &InteractiveRuntimeOptions,
1068    ) -> Self {
1069        tui.set_hardware_cursor(options.hardware_cursor);
1070        let mut view = initial_view(options);
1071        let startup_prompts = initial_prompts(options);
1072
1073        let events = session.subscribe();
1074        let partial = session.partial_rx();
1075        let snapshot = session.snapshot();
1076        project_snapshot(&mut view, &snapshot, None);
1077        view.messages = project_messages(&session.messages());
1078        let hide_thinking = session.hide_thinking_block();
1079        apply_display_preferences(&mut view.messages, false, hide_thinking);
1080        let extension_runner = session.host_extension_runner();
1081        let extension_events = extension_runner
1082            .as_ref()
1083            .map(|runner| runner.subscribe_ui());
1084        let extension_requests = extension_runner
1085            .as_ref()
1086            .and_then(|runner| runner.take_ui_requests());
1087        let initial_extension_slots = extension_runner
1088            .as_ref()
1089            .map_or_else(Vec::new, |runner| runner.current_slots());
1090        let effective_extension_shortcuts =
1091            extension_runner.as_ref().map_or_else(Vec::new, |runner| {
1092                build_effective_extension_shortcuts(&runner.raw_shortcuts())
1093            });
1094        view.extension_shortcuts = shortcut_hints(&effective_extension_shortcuts);
1095
1096        let (submit_tx, submit_rx) = mpsc::unbounded_channel::<String>();
1097        let (select_tx, select_rx) =
1098            mpsc::unbounded_channel::<(super::state::SelectorKind, String)>();
1099        let (cancel_tx, cancel_rx) = mpsc::unbounded_channel::<()>();
1100        let (extension_select_tx, extension_select_rx) = mpsc::unbounded_channel::<String>();
1101        let (extension_action_tx, extension_action_rx) = mpsc::unbounded_channel();
1102
1103        let mut editor = Editor::new(
1104            &pi_tui::components::editor::EditorTheme::default(),
1105            &EditorOptions {
1106                padding_x: 1,
1107                autocomplete_max_visible: 5,
1108                terminal_rows: options.size.1,
1109            },
1110        );
1111        let submit_tx_cb = submit_tx.clone();
1112        editor.on_submit = Some(Box::new(move |text: String| {
1113            let _ = submit_tx_cb.send(text);
1114        }));
1115
1116        let mut runtime = Self {
1117            tui,
1118            input,
1119            session,
1120            editor,
1121            view,
1122            mapper: InputMapper::new(),
1123            input_state: InputState::new(options.double_escape),
1124            events,
1125            partial,
1126            prompt_operations: PromptOperations::new(),
1127            startup_prompts,
1128            coalesce_deadline: None,
1129            pending_settle: None,
1130            shutdown: Arc::new(Notify::new()),
1131            exited: false,
1132            exit_kind: InteractiveExit::Clean,
1133            last_error: None,
1134            shutdown_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1135            pending_ui_reinject: Vec::new(),
1136            extension_runner,
1137            extension_events,
1138            extension_requests,
1139            extension_slots: std::collections::HashMap::new(),
1140            focused_extension_slot: None,
1141            effective_extension_shortcuts,
1142            extension_action_rx,
1143            extension_action_tx,
1144            pending_extension_dialog: None,
1145            extension_select_rx,
1146            extension_select_tx,
1147            display: DisplayPreferences {
1148                tools_expanded: false,
1149                hide_thinking,
1150            },
1151            chat_prefix_cache: None,
1152            chat_prefix_len: usize::MAX,
1153            chat_tail_cache: None,
1154            chat_dirty: true,
1155            active_selector: None,
1156            active_selector_kind: None,
1157            submit_rx,
1158            submit_tx,
1159            select_rx,
1160            select_tx,
1161            cancel_rx,
1162            cancel_tx,
1163        };
1164        for slot in initial_extension_slots {
1165            runtime.project_extension_slot(slot);
1166        }
1167        runtime
1168    }
1169
1170    // ----- Public accessors (driver seam) -----
1171
1172    /// Borrow the view state (tests / driver seam).
1173    pub fn view(&self) -> &ViewState {
1174        &self.view
1175    }
1176
1177    /// Last row occupied by the current terminal viewport.
1178    #[must_use]
1179    pub fn viewport_bottom_row(&self) -> u16 {
1180        self.view.height.saturating_sub(1)
1181    }
1182
1183    /// Mutably borrow the view state (tests / driver seam).
1184    pub fn view_mut(&mut self) -> &mut ViewState {
1185        &mut self.view
1186    }
1187
1188    /// Borrow the live editor (tests / driver seam).
1189    pub fn editor(&self) -> &Editor {
1190        &self.editor
1191    }
1192
1193    /// Mutably borrow the live editor (tests / driver seam).
1194    pub fn editor_mut(&mut self) -> &mut Editor {
1195        &mut self.editor
1196    }
1197
1198    /// Borrow the input mapper state (tests).
1199    pub fn input_state(&self) -> &InputState {
1200        &self.input_state
1201    }
1202
1203    /// Last recorded session error message, if any.
1204    pub fn last_error(&self) -> Option<&str> {
1205        self.last_error.as_deref()
1206    }
1207
1208    /// Signal the runtime to exit at the next loop turn (signal handler hook).
1209    pub fn request_shutdown(&self) {
1210        self.shutdown_flag
1211            .store(true, std::sync::atomic::Ordering::SeqCst);
1212        self.shutdown.notify_one();
1213    }
1214
1215    /// Shared shutdown notify (for registering multiple signal sources).
1216    pub fn shutdown_notify(&self) -> Arc<Notify> {
1217        Arc::clone(&self.shutdown)
1218    }
1219
1220    /// Borrow the underlying [`Tui`] (for suspend / resume / reprobe).
1221    pub fn tui(&self) -> &Tui<W> {
1222        &self.tui
1223    }
1224
1225    /// Mutably borrow the underlying [`Tui`].
1226    pub fn tui_mut(&mut self) -> &mut Tui<W> {
1227        &mut self.tui
1228    }
1229
1230    /// Borrow the input handle (driver seam).
1231    pub fn input(&self) -> &TerminalInput {
1232        &self.input
1233    }
1234
1235    /// Mutably borrow the input handle (driver seam).
1236    pub fn input_mut(&mut self) -> &mut TerminalInput {
1237        &mut self.input
1238    }
1239
1240    // ----- Main loop -----
1241
1242    async fn initialize_run(&mut self) -> bool {
1243        self.refresh_footer().await;
1244        if let Err(error) = self.paint_frame() {
1245            self.exit_kind = InteractiveExit::IoFailure;
1246            self.last_error = Some(error.to_string());
1247            return false;
1248        }
1249        self.enqueue_next_startup_prompt().await;
1250        true
1251    }
1252
1253    /// Latched shutdown check catches notifications fired before the select
1254    /// arm was awaiting (Ctrl+Z, signal handler, etc.). Only forces Clean when
1255    /// no more-specific exit was already set (Suspend sets `exit_kind` + exited
1256    /// without using this flag). Returns true when the loop must stop.
1257    fn take_latched_shutdown(&mut self) -> bool {
1258        if !self
1259            .shutdown_flag
1260            .swap(false, std::sync::atomic::Ordering::SeqCst)
1261        {
1262            return false;
1263        }
1264        if !self.exited {
1265            self.exit_kind = InteractiveExit::Clean;
1266            self.exited = true;
1267        }
1268        true
1269    }
1270
1271    async fn reinject_pending_ui_event(&mut self) -> Option<bool> {
1272        let event = self.pending_ui_reinject.pop()?;
1273        if let Err(err) = self.handle_ui_event(event).await {
1274            self.fail_io(&err);
1275            return Some(false);
1276        }
1277        Some(self.settle_pending())
1278    }
1279
1280    /// Run the main event loop until shutdown is requested or stdin closes.
1281    ///
1282    /// Returns the exit reason; the caller drops the runtime and the
1283    /// [`pi_tui::terminal::guard::TerminalGuard`] in that order.
1284    ///
1285    /// # Errors
1286    ///
1287    /// Returns [`io::Error`] only when a terminal write fails irrecoverably.
1288    pub async fn run(&mut self) -> io::Result<InteractiveExit> {
1289        if !self.initialize_run().await {
1290            return Ok(self.exit_kind);
1291        }
1292
1293        while !self.exited {
1294            if self.take_latched_shutdown() {
1295                break;
1296            }
1297
1298            // Re-inject events preserved by resize coalescing before pulling
1299            // new ones, so ordering across the storm is preserved.
1300            if let Some(keep_running) = self.reinject_pending_ui_event().await {
1301                if !keep_running {
1302                    break;
1303                }
1304                continue;
1305            }
1306
1307            let now = Instant::now();
1308            let coalesce_wait = self
1309                .coalesce_deadline
1310                .map_or(Duration::from_hours(1), |deadline| {
1311                    deadline.saturating_duration_since(now)
1312                });
1313
1314            tokio::select! {
1315                biased;
1316
1317                () = self.shutdown.notified() => {
1318                    self.exit_kind = InteractiveExit::Clean;
1319                    self.exited = true;
1320                }
1321                ui = self.input.recv() => {
1322                    if let Some(event) = ui {
1323                        if let Err(err) = self.handle_ui_event(event).await {
1324                            self.fail_io(&err);
1325                        }
1326                    } else {
1327                        // stdin EOF: clean exit.
1328                        self.exit_kind = InteractiveExit::Clean;
1329                        self.exited = true;
1330                    }
1331                }
1332                ev = self.events.rx.recv() => {
1333                    if let Some(event) = ev {
1334                        self.handle_session_event(&event);
1335                        if event_refreshes_footer(&event) {
1336                            self.refresh_footer().await;
1337                        }
1338                    } else {
1339                        self.exit_kind = InteractiveExit::SessionEnded;
1340                        self.exited = true;
1341                    }
1342                }
1343                extension_event = recv_extension_event(&mut self.extension_events) => {
1344                    if let Some(extension_event) = extension_event {
1345                        self.handle_extension_event(extension_event);
1346                    } else {
1347                        self.extension_events = None;
1348                    }
1349                }
1350                extension_request = recv_extension_request(&mut self.extension_requests) => {
1351                    if let Some(extension_request) = extension_request {
1352                        self.begin_extension_dialog(extension_request).await;
1353                    } else {
1354                        self.extension_requests = None;
1355                    }
1356                }
1357                () = wait_extension_deadline(
1358                    self.pending_extension_dialog.as_ref().and_then(|dialog| dialog.deadline),
1359                ), if self.pending_extension_dialog.as_ref().and_then(|dialog| dialog.deadline).is_some() => {
1360                    self.cancel_extension_dialog().await;
1361                }
1362                changed = self.partial.changed() => {
1363                    if changed.is_ok() {
1364                        self.handle_partial_update();
1365                    }
1366                }
1367                completion = self.prompt_operations.tasks.join_next(), if !self.prompt_operations.tasks.is_empty() => {
1368                    if let Some(completion) = completion {
1369                        if self.handle_prompt_completion(completion) {
1370                            self.refresh_footer().await;
1371                        }
1372                        if self.prompt_operations.tasks.is_empty() {
1373                            self.enqueue_next_startup_prompt().await;
1374                        }
1375                    }
1376                }
1377                () = tokio::time::sleep(coalesce_wait) => {
1378                    if self.coalesce_deadline.is_some() {
1379                        self.coalesce_deadline = None;
1380                        if let Err(err) = self.paint_frame() {
1381                            self.fail_io(&err);
1382                        }
1383                    }
1384                }
1385                extension_result = self.extension_action_rx.recv() => {
1386                    if let Some(Err(error)) = extension_result {
1387                        self.record_error(error);
1388                    }
1389                }
1390            }
1391
1392            // Run any pending settle as its own transaction.
1393            self.settle_pending();
1394        }
1395
1396        Ok(self.finish_run().await)
1397    }
1398
1399    /// Record an unrecoverable terminal I/O failure and request exit.
1400    fn fail_io(&mut self, err: &io::Error) {
1401        self.exit_kind = InteractiveExit::IoFailure;
1402        self.last_error = Some(err.to_string());
1403        self.exited = true;
1404    }
1405
1406    /// Commit any pending settle transaction; returns `false` on I/O failure.
1407    fn settle_pending(&mut self) -> bool {
1408        if let Some(blocks) = self.pending_settle.take()
1409            && let Err(err) = self.commit_settle(blocks)
1410        {
1411            self.fail_io(&err);
1412            return false;
1413        }
1414        true
1415    }
1416
1417    async fn finish_run(&mut self) -> InteractiveExit {
1418        // A prompt owns AgentSession turn cleanup until it settles. Abort and
1419        // drain before returning so dropping the runtime cannot detach a turn.
1420        self.quiesce_prompt_operations().await;
1421
1422        // Final paint so the last view-state mutation is visible.
1423        if matches!(
1424            self.exit_kind,
1425            InteractiveExit::Clean | InteractiveExit::SessionEnded
1426        ) {
1427            let _ = self.paint_frame();
1428        }
1429        self.exit_kind
1430    }
1431
1432    // -----------------------------------------------------------------------
1433    // Event handlers
1434    // -----------------------------------------------------------------------
1435
1436    async fn handle_ui_event(&mut self, event: UiEvent) -> io::Result<()> {
1437        let Some(event) = self.intercept_terminal_input(event).await else {
1438            return Ok(());
1439        };
1440        if self.route_extension_input(&event) {
1441            return Ok(());
1442        }
1443        let released_extension_focus = matches!(
1444            &event,
1445            UiEvent::Key(key)
1446                if key.code == KeyCode::Esc
1447                    && key.kind != crossterm::event::KeyEventKind::Release
1448        ) && self.release_extension_focus();
1449        // Esc that only unfocused an extension widget/overlay is fully
1450        // consumed: repaint and stop. A later Esc still maps to the normal
1451        // interrupt / clear-editor path once focus is back on the editor.
1452        if released_extension_focus {
1453            self.paint_frame()?;
1454            return Ok(());
1455        }
1456        // Swap the editor (and active selector) into a throwaway-built
1457        // InteractiveRoot so we can route the event, then recover both.
1458        let app_preempts_focus = is_global_app_binding(&event);
1459        let saved_editor = std::mem::replace(&mut self.editor, Editor::with_defaults());
1460        let saved_selector = self.active_selector.take();
1461        let mut root = self.build_root(saved_editor, saved_selector);
1462        let editor_result = if app_preempts_focus {
1463            EventResult::Ignored
1464        } else {
1465            root.handle_event(&event)
1466        };
1467        self.recover_root(root);
1468        // Re-attach on_submit after the swap (Editor does not preserve it
1469        // through with_defaults temporary).
1470        self.ensure_editor_on_submit();
1471
1472        // Refresh view.editor.text from the live buffer so the mapper sees
1473        // the freshest value.
1474        let live_text = self.editor.get_text();
1475        self.view.editor.text.clone_from(&live_text);
1476        let (_line, col) = self.editor.get_cursor();
1477        self.view.editor.cursor = col;
1478
1479        // Drain editor on_submit notifications first (plain Enter).
1480        let mut actions: Vec<ViewAction> = Vec::new();
1481        while let Ok(text) = self.submit_rx.try_recv() {
1482            actions.push(ViewAction::Submit { text });
1483            if self.pending_extension_dialog.is_none() {
1484                actions.push(ViewAction::ClearEditor);
1485            }
1486        }
1487        while let Ok((selector, value)) = self.select_rx.try_recv() {
1488            actions.push(ViewAction::SelectConfirmed { selector, value });
1489        }
1490        while let Ok(value) = self.extension_select_rx.try_recv() {
1491            self.finish_extension_selection(value).await;
1492        }
1493        while self.cancel_rx.try_recv().is_ok() {
1494            if self.pending_extension_dialog.is_some() {
1495                self.cancel_extension_dialog().await;
1496            } else {
1497                actions.push(ViewAction::SelectCancelled);
1498            }
1499        }
1500
1501        // Map app-level keys (skipped when the focused component already
1502        // handled the event — including selector confirm/cancel).
1503        actions.extend(self.mapper.map(
1504            &event,
1505            &self.view,
1506            &live_text,
1507            &mut self.input_state,
1508            editor_result.is_handled(),
1509        ));
1510
1511        let mut needs_immediate_repaint = editor_result.needs_render();
1512        for action in actions {
1513            let outcome = self.dispatch_action(action).await;
1514            if matches!(outcome, ActionOutcome::Repaint) {
1515                needs_immediate_repaint = true;
1516            }
1517            if matches!(outcome, ActionOutcome::Exit) {
1518                self.exited = true;
1519                self.exit_kind = InteractiveExit::Clean;
1520            }
1521            if matches!(outcome, ActionOutcome::Suspend) {
1522                self.exited = true;
1523                self.exit_kind = InteractiveExit::Suspend;
1524            }
1525            if matches!(outcome, ActionOutcome::ExternalEditor) {
1526                self.exited = true;
1527                self.exit_kind = InteractiveExit::ExternalEditor;
1528            }
1529        }
1530
1531        if needs_immediate_repaint {
1532            // Input-driven paints BYPASS the coalescer (per master plan D9).
1533            self.paint_frame()?;
1534        }
1535        Ok(())
1536    }
1537
1538    fn handle_session_event(&mut self, event: &AgentSessionEvent) {
1539        project_event(&mut self.view, event);
1540        apply_display_preferences(
1541            &mut self.view.messages,
1542            self.display.tools_expanded,
1543            self.display.hide_thinking,
1544        );
1545        if matches!(event, AgentSessionEvent::MessageUpdate { .. }) {
1546            self.chat_dirty = true;
1547        } else {
1548            self.chat_prefix_cache = None;
1549            self.chat_prefix_len = usize::MAX;
1550            self.chat_dirty = true;
1551        }
1552        self.arm_coalescer();
1553    }
1554
1555    fn handle_partial_update(&mut self) {
1556        let partial = self.partial.borrow_and_update().clone();
1557        if let Some(message) = partial {
1558            // Replace the streaming assistant tail (or push if none yet).
1559            let mut found = false;
1560            for item in &mut self.view.messages {
1561                if let MessageView::Assistant(view) = item
1562                    && view.streaming
1563                {
1564                    view.message = (*message).clone();
1565                    found = true;
1566                    break;
1567                }
1568            }
1569            if !found {
1570                self.view
1571                    .messages
1572                    .push(MessageView::streaming_assistant((*message).clone()));
1573            }
1574            self.view.streaming = true;
1575            apply_display_preferences(
1576                &mut self.view.messages,
1577                self.display.tools_expanded,
1578                self.display.hide_thinking,
1579            );
1580            self.chat_dirty = true;
1581            self.arm_coalescer();
1582        } else {
1583            // Stream ended; the next MessageEnd event will finalize the tail.
1584            self.arm_coalescer();
1585        }
1586    }
1587
1588    // -----------------------------------------------------------------------
1589    // Action dispatch
1590    // -----------------------------------------------------------------------
1591
1592    /// Flip thinking-block visibility, persist it, and reproject messages.
1593    fn toggle_thinking(&mut self) -> ActionOutcome {
1594        self.display.hide_thinking = !self.display.hide_thinking;
1595        if let Err(error) = self
1596            .session
1597            .set_hide_thinking_block(self.display.hide_thinking)
1598        {
1599            self.last_error = Some(error);
1600        }
1601        self.reapply_display_preferences()
1602    }
1603
1604    /// Flip tool/bash expansion and reproject messages.
1605    fn toggle_tool_expand(&mut self) -> ActionOutcome {
1606        self.display.tools_expanded = !self.display.tools_expanded;
1607        self.reapply_display_preferences()
1608    }
1609
1610    fn reapply_display_preferences(&mut self) -> ActionOutcome {
1611        apply_display_preferences(
1612            &mut self.view.messages,
1613            self.display.tools_expanded,
1614            self.display.hide_thinking,
1615        );
1616        self.chat_dirty = true;
1617        ActionOutcome::Repaint
1618    }
1619
1620    async fn dispatch_action(&mut self, action: ViewAction) -> ActionOutcome {
1621        match action {
1622            ViewAction::None | ViewAction::Consumed => ActionOutcome::None,
1623            ViewAction::ExternalEditor => ActionOutcome::ExternalEditor,
1624            ViewAction::Render | ViewAction::OpenSettingsSubmenu { .. } => ActionOutcome::Repaint,
1625            ViewAction::ToggleThinking => self.toggle_thinking(),
1626            ViewAction::ToggleToolExpand => self.toggle_tool_expand(),
1627            ViewAction::Submit { text } => self.submit_text(text, false).await,
1628            ViewAction::SubmitBash {
1629                command,
1630                exclude_from_context,
1631            } => self.dispatch_bash(&command, exclude_from_context).await,
1632            ViewAction::Interrupt => self.dispatch_interrupt().await,
1633            ViewAction::ClearEditor => self.clear_editor(),
1634            ViewAction::Exit => ActionOutcome::Exit,
1635            ViewAction::Suspend => ActionOutcome::Suspend,
1636            ViewAction::CycleThinking { .. } => {
1637                self.record_err(self.session.cycle_thinking_level().await);
1638                self.refresh_footer().await;
1639                ActionOutcome::Repaint
1640            }
1641            ViewAction::CycleModel { forward } => {
1642                self.record_err(self.session.cycle_model(forward).await);
1643                self.refresh_footer().await;
1644                ActionOutcome::Repaint
1645            }
1646            ViewAction::OpenModelSelector => {
1647                self.open_selector(super::state::SelectorKind::Model).await
1648            }
1649            ViewAction::OpenSettings => {
1650                self.open_selector(super::state::SelectorKind::Settings)
1651                    .await
1652            }
1653            ViewAction::OpenSessionPicker => {
1654                self.open_selector(super::state::SelectorKind::Session)
1655                    .await
1656            }
1657            ViewAction::OpenTreeSelector => {
1658                self.open_selector(super::state::SelectorKind::Tree).await
1659            }
1660            ViewAction::OpenForkSelector => {
1661                self.open_selector(super::state::SelectorKind::Fork).await
1662            }
1663            ViewAction::OpenTrustSelector => {
1664                self.open_selector(super::state::SelectorKind::Trust).await
1665            }
1666            ViewAction::OpenLogin { .. } => self.open_overlay(OverlayKind::Login),
1667            ViewAction::Logout => {
1668                self.record_err(self.session.logout().await);
1669                ActionOutcome::None
1670            }
1671            ViewAction::OpenScopedModels => {
1672                self.open_selector(super::state::SelectorKind::ScopedModels)
1673                    .await
1674            }
1675            ViewAction::OpenConfigSelector => {
1676                self.open_selector(super::state::SelectorKind::Config).await
1677            }
1678            ViewAction::ToggleShortcutHelp => self.toggle_shortcut_help(),
1679            ViewAction::ShowChangelog => self.open_overlay(OverlayKind::Changelog),
1680            ViewAction::Paste { text } => self.paste_text(&text),
1681            ViewAction::QueueFollowUp { text } => self.queue_follow_up(text).await,
1682            ViewAction::DequeueFollowUp => self.dequeue_follow_up(),
1683            ViewAction::CopyLastAssistant => self.copy_last_assistant().await,
1684            ViewAction::Reload => {
1685                if self.pending_extension_dialog.is_some() {
1686                    self.cancel_extension_dialog().await;
1687                }
1688                self.record_err(self.session.reload().await);
1689                self.rebind_extension_channels().await;
1690                ActionOutcome::Repaint
1691            }
1692            ViewAction::SlashCommand { name, args } => self.submit_slash_command(name, args).await,
1693            ViewAction::SelectConfirmed { selector, value } => {
1694                self.handle_select_confirmed(selector, value).await
1695            }
1696            ViewAction::SelectCancelled => {
1697                self.close_selector();
1698                ActionOutcome::Repaint
1699            }
1700            ViewAction::FocusChanged { area } => {
1701                self.view.focus = area;
1702                ActionOutcome::Repaint
1703            }
1704            ViewAction::ShowOverlay { kind } => self.open_overlay(kind),
1705            ViewAction::DismissOverlay => self.dismiss_overlay(),
1706            ViewAction::NewSession => self.replace_session(SessionReplacement::New).await,
1707            ViewAction::Fork => self.replace_session(SessionReplacement::Fork).await,
1708            ViewAction::Clone => self.replace_session(SessionReplacement::Clone).await,
1709            ViewAction::Compact { instructions } => {
1710                self.record_err(self.session.compact(instructions.as_deref()).await);
1711                ActionOutcome::None
1712            }
1713            ViewAction::Resize { width, height } => self.handle_resize(width, height),
1714        }
1715    }
1716
1717    async fn submit_slash_command(&mut self, name: String, args: String) -> ActionOutcome {
1718        if let Some(runner) = self.extension_runner.as_ref()
1719            && runner
1720                .registry()
1721                .commands()
1722                .iter()
1723                .any(|command| command.name == name)
1724        {
1725            let runner = Arc::clone(runner);
1726            let result_tx = self.extension_action_tx.clone();
1727            tokio::spawn(async move {
1728                let result = runner
1729                    .execute_command(&name, &args)
1730                    .await
1731                    .map(|_| ())
1732                    .map_err(|error| error.to_string());
1733                let _ = result_tx.send(result);
1734            });
1735            return ActionOutcome::Repaint;
1736        }
1737
1738        let command = if args.is_empty() {
1739            format!("/{name}")
1740        } else {
1741            format!("/{name} {args}")
1742        };
1743        self.submit_text(command, false).await
1744    }
1745
1746    async fn dispatch_bash(&mut self, command: &str, exclude_from_context: bool) -> ActionOutcome {
1747        if !self
1748            .enqueue_bash(command.to_owned(), exclude_from_context)
1749            .await
1750        {
1751            self.last_error = Some("a bash command is already running".to_owned());
1752            return ActionOutcome::Repaint;
1753        }
1754        self.view.editor.border = EditorBorder::Bash;
1755        ActionOutcome::Repaint
1756    }
1757
1758    async fn dispatch_interrupt(&mut self) -> ActionOutcome {
1759        self.set_status(SessionStatus {
1760            kind: StatusKind::Working,
1761            frame: 0,
1762            message: "Aborting…".to_owned(),
1763        });
1764        self.record_err(self.session.abort().await);
1765        self.refresh_footer().await;
1766        ActionOutcome::Repaint
1767    }
1768
1769    fn clear_editor(&mut self) -> ActionOutcome {
1770        self.editor.set_text("");
1771        self.view.editor.text.clear();
1772        self.view.editor.cursor = 0;
1773        ActionOutcome::Repaint
1774    }
1775
1776    fn toggle_shortcut_help(&mut self) -> ActionOutcome {
1777        if self.view.overlay.is_some() {
1778            self.view.overlay = None;
1779            self.view.focus = FocusArea::Editor;
1780        } else {
1781            self.view.overlay = Some(Overlay {
1782                kind: OverlayKind::ShortcutHelp,
1783                lines: Vec::new(),
1784                height: 1,
1785            });
1786            self.view.extension_overlay_slot = None;
1787            self.view.focus = FocusArea::Overlay;
1788        }
1789        self.view.extension_overlay_slot = None;
1790        self.input_state.reset_taps();
1791        ActionOutcome::Repaint
1792    }
1793
1794    fn paste_text(&mut self, text: &str) -> ActionOutcome {
1795        if text.is_empty() {
1796            return ActionOutcome::None;
1797        }
1798        self.editor.insert_text_at_cursor(text);
1799        self.view.editor.text = self.editor.get_text();
1800        ActionOutcome::Repaint
1801    }
1802
1803    async fn queue_follow_up(&mut self, text: String) -> ActionOutcome {
1804        self.record_err(self.session.follow_up(&text).await);
1805        self.view.pending.follow_up.push(PendingMessage {
1806            kind: PendingKind::FollowUp,
1807            text,
1808        });
1809        ActionOutcome::Repaint
1810    }
1811
1812    fn dequeue_follow_up(&mut self) -> ActionOutcome {
1813        let Some(message) = self.view.pending.follow_up.pop() else {
1814            return ActionOutcome::None;
1815        };
1816        self.editor.set_text(&message.text);
1817        self.view.editor.text = self.editor.get_text();
1818        ActionOutcome::Repaint
1819    }
1820
1821    async fn copy_last_assistant(&mut self) -> ActionOutcome {
1822        match self.session.last_assistant_text().await {
1823            Ok(Some(text)) if !text.is_empty() => {
1824                if crate::core::platform::clipboard::copy_to_clipboard(&text).is_ok() {
1825                    self.set_status(SessionStatus {
1826                        kind: StatusKind::Working,
1827                        frame: 0,
1828                        message: "Copied last assistant message".to_owned(),
1829                    });
1830                } else {
1831                    self.last_error = Some("Failed to copy to clipboard".to_owned());
1832                }
1833            }
1834            Ok(_) => self.set_status(SessionStatus {
1835                kind: StatusKind::Working,
1836                frame: 0,
1837                message: "No assistant text to copy".to_owned(),
1838            }),
1839            Err(error) => self.last_error = Some(error),
1840        }
1841        ActionOutcome::Repaint
1842    }
1843
1844    fn release_extension_focus(&mut self) -> bool {
1845        let Some(key) = self.focused_extension_slot.take() else {
1846            return false;
1847        };
1848        for widget in self
1849            .view
1850            .widgets_above
1851            .iter_mut()
1852            .chain(self.view.widgets_below.iter_mut())
1853        {
1854            widget.focused = false;
1855        }
1856        if self
1857            .extension_slots
1858            .get(&key)
1859            .is_some_and(|slot| slot.placement == SlotPlacement::Overlay)
1860        {
1861            self.view.overlay = None;
1862            self.view.extension_overlay_slot = None;
1863        }
1864        self.view.focus = FocusArea::Editor;
1865        self.input_state.reset_taps();
1866        true
1867    }
1868
1869    fn dismiss_overlay(&mut self) -> ActionOutcome {
1870        self.release_extension_focus();
1871        self.view.overlay = None;
1872        self.view.extension_overlay_slot = None;
1873        self.view.focus = FocusArea::Editor;
1874        self.input_state.reset_taps();
1875        ActionOutcome::Repaint
1876    }
1877
1878    async fn replace_session(&mut self, replacement: SessionReplacement) -> ActionOutcome {
1879        self.quiesce_prompt_operations().await;
1880        if self.pending_extension_dialog.is_some() {
1881            self.cancel_extension_dialog().await;
1882        }
1883        let result = match replacement {
1884            SessionReplacement::New => self.session.new_session().await,
1885            SessionReplacement::Fork => self.session.fork("").await,
1886            SessionReplacement::Clone => <S as SessionHost>::clone(&self.session).await,
1887        };
1888        self.record_err(result);
1889        self.rebind_session_channels().await;
1890        self.refresh_footer().await;
1891        ActionOutcome::Repaint
1892    }
1893
1894    async fn submit_text(&mut self, text: String, force_follow_up: bool) -> ActionOutcome {
1895        if let Some(dialog) = self.pending_extension_dialog.as_ref() {
1896            match &dialog.request {
1897                HostUiRequest::Input { id, .. } => {
1898                    let response = HostUiResponse::Input {
1899                        id: *id,
1900                        value: Some(text),
1901                    };
1902                    self.finish_extension_dialog(response).await;
1903                    return ActionOutcome::Repaint;
1904                }
1905                HostUiRequest::Editor { id, .. } => {
1906                    let response = HostUiResponse::Editor {
1907                        id: *id,
1908                        value: Some(text),
1909                    };
1910                    self.finish_extension_dialog(response).await;
1911                    return ActionOutcome::Repaint;
1912                }
1913                HostUiRequest::Select { .. } | HostUiRequest::Confirm { .. } => {}
1914            }
1915        }
1916        let trimmed = text.trim().to_owned();
1917        if trimmed.is_empty() {
1918            return ActionOutcome::None;
1919        }
1920        if trimmed == "/quit" {
1921            return ActionOutcome::Exit;
1922        }
1923        if let Some(command) = parse_typed_builtin(&trimmed) {
1924            return match command {
1925                TypedBuiltin::Compact(instructions) => {
1926                    self.record_err(self.session.compact(instructions).await);
1927                    ActionOutcome::None
1928                }
1929                TypedBuiltin::Fork => self.open_selector(super::state::SelectorKind::Fork).await,
1930                TypedBuiltin::Resume => {
1931                    self.open_selector(super::state::SelectorKind::Session)
1932                        .await
1933                }
1934                TypedBuiltin::Reload => {
1935                    self.record_err(self.session.reload().await);
1936                    ActionOutcome::Repaint
1937                }
1938            };
1939        }
1940
1941        // `!`/`!!` bash prefix routes directly to execute_bash.
1942        if let Some(stripped) = trimmed.strip_prefix("!!") {
1943            let cmd = stripped.trim().to_owned();
1944            if !cmd.is_empty() {
1945                return self.dispatch_bash(&cmd, true).await;
1946            }
1947        } else if let Some(stripped) = trimmed.strip_prefix('!') {
1948            let cmd = stripped.trim().to_owned();
1949            if !cmd.is_empty() {
1950                return self.dispatch_bash(&cmd, false).await;
1951            }
1952        }
1953
1954        let is_slash = trimmed.starts_with('/');
1955        let snapshot = self.session.snapshot();
1956        // Always go through prompt so extension-command dispatch and input
1957        // transforms run before any steering / follow-up queueing.
1958        let opts = if snapshot.is_admission_active() && !is_slash {
1959            PromptOptions {
1960                streaming_behavior: Some(if force_follow_up {
1961                    StreamingBehavior::FollowUp
1962                } else {
1963                    StreamingBehavior::Steer
1964                }),
1965                ..PromptOptions::default()
1966            }
1967        } else if force_follow_up {
1968            PromptOptions {
1969                streaming_behavior: Some(StreamingBehavior::FollowUp),
1970                ..PromptOptions::default()
1971            }
1972        } else {
1973            PromptOptions::default()
1974        };
1975        self.enqueue_prompt(trimmed, opts).await;
1976        ActionOutcome::None
1977    }
1978
1979    /// Enqueue a prompt without holding the UI loop for the full agent turn.
1980    ///
1981    /// Admission polls the prompt exactly once before returning. This preserves
1982    /// submit order and lets a rapid second submit observe the first prompt's
1983    /// streaming/preflight state, while all later polling belongs to the task.
1984    async fn enqueue_prompt(&mut self, text: String, opts: PromptOptions) {
1985        let id = self.prompt_operations.next_id;
1986        self.prompt_operations.next_id = id.wrapping_add(1);
1987        let epoch = self.prompt_operations.epoch;
1988        let session = Arc::clone(&self.session);
1989        let abort = self.session.abort();
1990        let (abort_tx, mut abort_rx) = oneshot::channel();
1991        let (admitted_tx, admitted_rx) = oneshot::channel();
1992
1993        self.prompt_operations.tasks.spawn(async move {
1994            let mut prompt = session.prompt(&text, opts);
1995            let first_poll = poll_fn(|cx| {
1996                Poll::Ready(match prompt.as_mut().poll(cx) {
1997                    Poll::Ready(result) => Some(result),
1998                    Poll::Pending => None,
1999                })
2000            })
2001            .await;
2002            let _ = admitted_tx.send(());
2003
2004            let result = if let Some(result) = first_poll {
2005                result
2006            } else {
2007                tokio::select! {
2008                    result = &mut prompt => result,
2009                    _ = &mut abort_rx => {
2010                        let abort_result = abort.await;
2011                        let prompt_result = prompt.await;
2012                        prompt_result.and(abort_result)
2013                    }
2014                }
2015            };
2016            PromptCompletion {
2017                id,
2018                epoch,
2019                kind: SessionOperationKind::Prompt,
2020                result,
2021            }
2022        });
2023        self.prompt_operations.aborts.insert(id, abort_tx);
2024
2025        // This waits only for one poll (preflight admission), never for the
2026        // provider stream or AgentSettled cleanup.
2027        let _ = admitted_rx.await;
2028    }
2029
2030    async fn enqueue_next_startup_prompt(&mut self) {
2031        let Some((text, images)) = self.startup_prompts.pop_front() else {
2032            return;
2033        };
2034        self.enqueue_prompt(
2035            text,
2036            PromptOptions {
2037                images,
2038                source: Some("cli".to_owned()),
2039                ..PromptOptions::default()
2040            },
2041        )
2042        .await;
2043    }
2044
2045    async fn enqueue_bash(&mut self, command: String, exclude_from_context: bool) -> bool {
2046        if self.prompt_operations.bash_operation.is_some() {
2047            return false;
2048        }
2049        let id = self.prompt_operations.next_id;
2050        self.prompt_operations.next_id = id.wrapping_add(1);
2051        let epoch = self.prompt_operations.epoch;
2052        let session = Arc::clone(&self.session);
2053        let abort = self.session.abort();
2054        let (abort_tx, mut abort_rx) = oneshot::channel();
2055        let (admitted_tx, admitted_rx) = oneshot::channel();
2056
2057        self.prompt_operations.tasks.spawn(async move {
2058            let mut execution = session.execute_bash(&command, exclude_from_context);
2059            let first_poll = poll_fn(|cx| {
2060                Poll::Ready(match execution.as_mut().poll(cx) {
2061                    Poll::Ready(result) => Some(result),
2062                    Poll::Pending => None,
2063                })
2064            })
2065            .await;
2066            let _ = admitted_tx.send(());
2067            let result = if let Some(result) = first_poll {
2068                result
2069            } else {
2070                tokio::select! {
2071                    result = &mut execution => result,
2072                    _ = &mut abort_rx => {
2073                        let abort_result = abort.await;
2074                        let execution_result = execution.await;
2075                        execution_result.and(abort_result)
2076                    }
2077                }
2078            };
2079            PromptCompletion {
2080                id,
2081                epoch,
2082                kind: SessionOperationKind::Bash,
2083                result,
2084            }
2085        });
2086        self.prompt_operations.aborts.insert(id, abort_tx);
2087        self.prompt_operations.bash_operation = Some(id);
2088        let _ = admitted_rx.await;
2089        true
2090    }
2091
2092    fn handle_prompt_completion(
2093        &mut self,
2094        completion: Result<PromptCompletion, JoinError>,
2095    ) -> bool {
2096        match completion {
2097            Ok(completion) => {
2098                self.prompt_operations.aborts.remove(&completion.id);
2099                if completion.kind == SessionOperationKind::Bash {
2100                    self.prompt_operations.bash_operation = None;
2101                }
2102                if completion.epoch != self.prompt_operations.epoch {
2103                    return false;
2104                }
2105                let refresh_footer = completion.kind == SessionOperationKind::Bash;
2106                self.record_err(completion.result);
2107                refresh_footer
2108            }
2109            Err(error) => {
2110                self.prompt_operations
2111                    .aborts
2112                    .retain(|_, abort| !abort.is_closed());
2113                if self
2114                    .prompt_operations
2115                    .bash_operation
2116                    .is_some_and(|id| !self.prompt_operations.aborts.contains_key(&id))
2117                {
2118                    self.prompt_operations.bash_operation = None;
2119                }
2120                if !error.is_cancelled() {
2121                    self.record_err(Err(format!("session operation failed: {error}")));
2122                }
2123                false
2124            }
2125        }
2126    }
2127
2128    /// Abort every session operation against the session it captured, then
2129    /// await its cleanup before session replacement or runtime exit.
2130    async fn quiesce_prompt_operations(&mut self) {
2131        self.prompt_operations.epoch = self.prompt_operations.epoch.wrapping_add(1);
2132        for (_, abort) in std::mem::take(&mut self.prompt_operations.aborts) {
2133            let _ = abort.send(());
2134        }
2135        self.prompt_operations.bash_operation = None;
2136        while self.prompt_operations.tasks.join_next().await.is_some() {}
2137    }
2138
2139    async fn handle_select_confirmed(
2140        &mut self,
2141        selector: super::state::SelectorKind,
2142        value: String,
2143    ) -> ActionOutcome {
2144        match selector {
2145            super::state::SelectorKind::Model
2146            | super::state::SelectorKind::Tree
2147            | super::state::SelectorKind::Trust
2148            | super::state::SelectorKind::Settings
2149            | super::state::SelectorKind::Config
2150            | super::state::SelectorKind::ScopedModels
2151            | super::state::SelectorKind::Auth => {
2152                self.close_selector();
2153                ActionOutcome::Repaint
2154            }
2155            super::state::SelectorKind::Session => {
2156                self.quiesce_prompt_operations().await;
2157                if self.pending_extension_dialog.is_some() {
2158                    self.cancel_extension_dialog().await;
2159                }
2160                self.record_err(self.session.switch_session(&value).await);
2161                self.rebind_session_channels().await;
2162                self.refresh_footer().await;
2163                self.close_selector();
2164                ActionOutcome::Repaint
2165            }
2166            super::state::SelectorKind::Fork => {
2167                self.quiesce_prompt_operations().await;
2168                if self.pending_extension_dialog.is_some() {
2169                    self.cancel_extension_dialog().await;
2170                }
2171                self.record_err(self.session.fork(&value).await);
2172                self.rebind_session_channels().await;
2173                self.refresh_footer().await;
2174                self.close_selector();
2175                ActionOutcome::Repaint
2176            }
2177        }
2178    }
2179
2180    fn close_selector(&mut self) {
2181        self.view.overlay = None;
2182        self.view.extension_overlay_slot = None;
2183        self.active_selector = None;
2184        self.active_selector_kind = None;
2185        self.view.focus = FocusArea::Editor;
2186        self.input_state.reset_taps();
2187    }
2188
2189    /// Coalesce consecutive resize events into a single [`Txn::Reanchor`].
2190    /// Non-resize events queued during the storm are pushed back onto the
2191    /// channel so they redeliver on the next loop turn.
2192    fn handle_resize(&mut self, width: u16, height: u16) -> ActionOutcome {
2193        self.tui.note_resize(width, height);
2194        self.view.resize(width, height);
2195
2196        // Drain queued events. Only Resize events coalesce; everything else
2197        // is preserved in `pending_ui_reinject` for the next loop iteration
2198        // (in arrival order — the loop pops from the back, so we push in
2199        // reverse).
2200        let mut preserved: Vec<UiEvent> = Vec::new();
2201        while let Ok(next) = self.input.receiver_mut().try_recv() {
2202            match next {
2203                UiEvent::Resize { width, height } => {
2204                    self.tui.note_resize(width, height);
2205                    self.view.resize(width, height);
2206                }
2207                other => preserved.push(other),
2208            }
2209        }
2210        for event in preserved.into_iter().rev() {
2211            self.pending_ui_reinject.push(event);
2212        }
2213
2214        let result = self.commit_reanchor();
2215        if result.is_err() {
2216            self.exited = true;
2217            self.exit_kind = InteractiveExit::IoFailure;
2218        }
2219        ActionOutcome::Repaint
2220    }
2221
2222    fn open_overlay(&mut self, kind: OverlayKind) -> ActionOutcome {
2223        self.view.overlay = Some(Overlay {
2224            kind,
2225            lines: Vec::new(),
2226            height: 1,
2227        });
2228        self.view.extension_overlay_slot = None;
2229        self.view.focus = FocusArea::Overlay;
2230        self.input_state.reset_taps();
2231        ActionOutcome::Repaint
2232    }
2233
2234    async fn open_selector(&mut self, kind: super::state::SelectorKind) -> ActionOutcome {
2235        match self.load_selector_component(kind).await {
2236            Ok(component) => {
2237                self.active_selector = Some(component);
2238                self.active_selector_kind = Some(kind);
2239                self.view.focus = FocusArea::Selector;
2240                self.view.overlay = None;
2241                self.view.extension_overlay_slot = None;
2242                self.input_state.reset_taps();
2243            }
2244            Err(error) => self.last_error = Some(error),
2245        }
2246        ActionOutcome::Repaint
2247    }
2248
2249    async fn load_selector_component(
2250        &mut self,
2251        kind: super::state::SelectorKind,
2252    ) -> Result<Box<dyn Component>, String> {
2253        use pi_tui::components::SelectItem;
2254
2255        match kind {
2256            super::state::SelectorKind::Model => {
2257                let entries = self.session.get_model_entries().await?;
2258                let items = entries
2259                    .into_iter()
2260                    .map(|entry| {
2261                        SelectItem::new(entry.value, entry.label)
2262                            .with_description(entry.description.unwrap_or_default())
2263                    })
2264                    .collect();
2265                Ok(self.build_select_list(kind, items))
2266            }
2267            super::state::SelectorKind::Session => {
2268                let entries = self.session.get_session_entries().await?;
2269                let items = entries
2270                    .into_iter()
2271                    .map(|entry| {
2272                        SelectItem::new(entry.value, entry.label)
2273                            .with_description(entry.description.unwrap_or_default())
2274                    })
2275                    .collect();
2276                Ok(self.build_select_list(kind, items))
2277            }
2278            super::state::SelectorKind::Tree => {
2279                let entries = self.session.get_tree_entries().await?;
2280                Ok(self.build_tree_select_list(kind, entries))
2281            }
2282            super::state::SelectorKind::Fork => {
2283                let entries = self.session.get_fork_entries().await?;
2284                Ok(self.build_tree_select_list(kind, entries))
2285            }
2286            super::state::SelectorKind::Auth => {
2287                let entries = self.session.get_auth_entries().await?;
2288                let items = entries
2289                    .into_iter()
2290                    .map(|entry| {
2291                        SelectItem::new(entry.value, entry.label)
2292                            .with_description(entry.description.unwrap_or_default())
2293                    })
2294                    .collect();
2295                Ok(self.build_select_list(kind, items))
2296            }
2297            super::state::SelectorKind::ScopedModels => {
2298                let (entries, enabled) = self.session.get_scoped_models_entries().await?;
2299                let items = entries
2300                    .into_iter()
2301                    .map(|entry| {
2302                        let mark = if enabled.get(&entry.value).copied().unwrap_or(false) {
2303                            "[x]"
2304                        } else {
2305                            "[ ]"
2306                        };
2307                        SelectItem::new(entry.value, format!("{mark} {}", entry.label))
2308                            .with_description(entry.description.unwrap_or_default())
2309                    })
2310                    .collect();
2311                Ok(self.build_select_list(kind, items))
2312            }
2313            super::state::SelectorKind::Trust => {
2314                let rows = self.session.get_trust_entries().await?;
2315                Ok(self.build_settings_list(kind, rows))
2316            }
2317            super::state::SelectorKind::Settings => {
2318                let rows = self.session.get_settings_entries().await?;
2319                Ok(self.build_settings_list(kind, rows))
2320            }
2321            super::state::SelectorKind::Config => {
2322                let rows = self.session.get_config_entries().await?;
2323                Ok(self.build_settings_list(kind, rows))
2324            }
2325        }
2326    }
2327
2328    fn build_select_list(
2329        &self,
2330        kind: super::state::SelectorKind,
2331        items: Vec<pi_tui::components::SelectItem>,
2332    ) -> Box<dyn Component> {
2333        let mut list = pi_tui::components::SelectList::new(
2334            items,
2335            super::selectors::SELECTOR_MAX_VISIBLE,
2336            super::theme::select_list_theme(),
2337        );
2338        list.set_selected_index(0);
2339        let select_tx = self.select_tx.clone();
2340        list.on_select = Some(Box::new(move |item| {
2341            let _ = select_tx.send((kind, item.value.clone()));
2342        }));
2343        let cancel_tx = self.cancel_tx.clone();
2344        list.on_cancel = Some(Box::new(move || {
2345            let _ = cancel_tx.send(());
2346        }));
2347        Box::new(list)
2348    }
2349
2350    fn build_tree_select_list(
2351        &self,
2352        kind: super::state::SelectorKind,
2353        entries: Vec<super::state::TreeEntry>,
2354    ) -> Box<dyn Component> {
2355        let items = entries
2356            .into_iter()
2357            .map(|entry| {
2358                let label = format!("{}{}", "  ".repeat(entry.depth), entry.label);
2359                pi_tui::components::SelectItem::new(entry.value, label)
2360            })
2361            .collect();
2362        self.build_select_list(kind, items)
2363    }
2364
2365    fn build_settings_list(
2366        &self,
2367        kind: super::state::SelectorKind,
2368        rows: Vec<super::state::SettingsRow>,
2369    ) -> Box<dyn Component> {
2370        let items = rows
2371            .into_iter()
2372            .map(|row| {
2373                pi_tui::components::SelectItem::new(
2374                    row.id,
2375                    format!("{}  {}", row.label, row.current_value),
2376                )
2377                .with_description(row.description.unwrap_or_default())
2378            })
2379            .collect();
2380        self.build_select_list(kind, items)
2381    }
2382
2383    fn build_extension_select_list(
2384        &mut self,
2385        title: &str,
2386        items: Vec<pi_tui::components::SelectItem>,
2387    ) -> Box<dyn Component> {
2388        let mut list = pi_tui::components::SelectList::new(
2389            items,
2390            super::selectors::SELECTOR_MAX_VISIBLE,
2391            super::theme::select_list_theme(),
2392        );
2393        list.set_selected_index(0);
2394        let select_tx = self.extension_select_tx.clone();
2395        list.on_select = Some(Box::new(move |item| {
2396            let _ = select_tx.send(item.value.clone());
2397        }));
2398        let cancel_tx = self.cancel_tx.clone();
2399        list.on_cancel = Some(Box::new(move || {
2400            let _ = cancel_tx.send(());
2401        }));
2402        title.clone_into(&mut self.view.editor.placeholder);
2403        Box::new(list)
2404    }
2405
2406    async fn begin_extension_dialog(&mut self, request: HostUiRequest) {
2407        if self.pending_extension_dialog.is_some() {
2408            self.cancel_extension_dialog().await;
2409        }
2410        let deadline = dialog_timeout(&request).map(|timeout| Instant::now() + timeout);
2411        let saved_editor_placeholder = self.view.editor.placeholder.clone();
2412        let mut saved_editor_text = None;
2413        match &request {
2414            HostUiRequest::Select { request, .. } => {
2415                let items = request
2416                    .options
2417                    .iter()
2418                    .map(|option| {
2419                        pi_tui::components::SelectItem::new(option.clone(), option.clone())
2420                    })
2421                    .collect();
2422                self.active_selector =
2423                    Some(self.build_extension_select_list(&request.title, items));
2424                self.active_selector_kind = None;
2425                self.view.focus = FocusArea::Selector;
2426            }
2427            HostUiRequest::Confirm { request, .. } => {
2428                let items = vec![
2429                    pi_tui::components::SelectItem::new("true", "Yes")
2430                        .with_description(request.message.clone()),
2431                    pi_tui::components::SelectItem::new("false", "No"),
2432                ];
2433                self.active_selector =
2434                    Some(self.build_extension_select_list(&request.title, items));
2435                self.active_selector_kind = None;
2436                self.view.focus = FocusArea::Selector;
2437            }
2438            HostUiRequest::Input { request, .. } => {
2439                saved_editor_text = Some(self.editor.get_text());
2440                self.editor.set_text("");
2441                self.view.editor.text.clear();
2442                self.view.editor.placeholder = request
2443                    .placeholder
2444                    .clone()
2445                    .unwrap_or_else(|| request.title.clone());
2446                self.view.focus = FocusArea::Editor;
2447            }
2448            HostUiRequest::Editor { request, .. } => {
2449                saved_editor_text = Some(self.editor.get_text());
2450                let prefill = request.prefill.clone().unwrap_or_default();
2451                self.editor.set_text(&prefill);
2452                self.view.editor.text = prefill;
2453                self.view.editor.placeholder.clone_from(&request.title);
2454                self.view.focus = FocusArea::Editor;
2455            }
2456        }
2457        self.pending_extension_dialog = Some(PendingExtensionDialog {
2458            request,
2459            saved_editor_text,
2460            saved_editor_placeholder,
2461            deadline,
2462        });
2463        self.input_state.reset_taps();
2464        self.arm_coalescer();
2465    }
2466
2467    async fn finish_extension_selection(&mut self, value: String) {
2468        let Some(dialog) = self.pending_extension_dialog.as_ref() else {
2469            return;
2470        };
2471        let response = match &dialog.request {
2472            HostUiRequest::Select { id, .. } => HostUiResponse::Select {
2473                id: *id,
2474                value: Some(value),
2475            },
2476            HostUiRequest::Confirm { id, .. } => HostUiResponse::Confirm {
2477                id: *id,
2478                confirmed: value == "true",
2479            },
2480            HostUiRequest::Input { .. } | HostUiRequest::Editor { .. } => return,
2481        };
2482        self.finish_extension_dialog(response).await;
2483    }
2484
2485    async fn cancel_extension_dialog(&mut self) {
2486        let Some(dialog) = self.pending_extension_dialog.as_ref() else {
2487            return;
2488        };
2489        let response = default_extension_dialog_response(&dialog.request);
2490        self.finish_extension_dialog(response).await;
2491    }
2492
2493    async fn finish_extension_dialog(&mut self, response: HostUiResponse) {
2494        let dialog = self.pending_extension_dialog.take();
2495        if let Some(runner) = &self.extension_runner
2496            && let Err(error) = runner.respond_ui(response).await
2497        {
2498            self.last_error = Some(error.to_string());
2499        }
2500        if let Some(dialog) = dialog {
2501            if let Some(saved) = dialog.saved_editor_text {
2502                self.editor.set_text(&saved);
2503                self.view.editor.text = saved;
2504            }
2505            self.view.editor.placeholder = dialog.saved_editor_placeholder;
2506        }
2507        self.close_selector();
2508        self.arm_coalescer();
2509    }
2510
2511    fn handle_extension_event(&mut self, event: ExtensionUiEvent) {
2512        match event {
2513            ExtensionUiEvent::Notify(notification) => {
2514                let severity = match notification.level {
2515                    NotifyLevel::Info | NotifyLevel::Warning => DiagnosticSeverity::Warning,
2516                    NotifyLevel::Error => DiagnosticSeverity::Error,
2517                };
2518                self.view.diagnostics.entries.push(StartupDiagnostic {
2519                    severity,
2520                    source: "extension".to_owned(),
2521                    message: notification.message,
2522                });
2523            }
2524            ExtensionUiEvent::Slot(slot) => self.project_extension_slot(slot),
2525            ExtensionUiEvent::Dispose { key } => self.dispose_extension_slot(&key),
2526        }
2527        self.arm_coalescer();
2528    }
2529
2530    fn project_extension_slot(&mut self, slot: SanitizedSlot) {
2531        self.dispose_extension_slot(&slot.key);
2532        let non_capturing = slot
2533            .overlay_options
2534            .as_ref()
2535            .is_some_and(|options| options.non_capturing);
2536        let captures_focus = slot.focusable && !non_capturing;
2537        if captures_focus {
2538            for widget in self
2539                .view
2540                .widgets_above
2541                .iter_mut()
2542                .chain(self.view.widgets_below.iter_mut())
2543            {
2544                widget.focused = false;
2545            }
2546        }
2547        let widget = WidgetSlot {
2548            slot: slot.clone(),
2549            focused: captures_focus,
2550        };
2551        match slot.placement {
2552            SlotPlacement::Footer | SlotPlacement::BelowEditor => {
2553                self.view.widgets_below.push(widget);
2554            }
2555            SlotPlacement::Overlay => {
2556                self.view.overlay = Some(Overlay {
2557                    kind: OverlayKind::Extension,
2558                    height: slot.height,
2559                    lines: Vec::new(),
2560                });
2561                self.view.extension_overlay_slot = Some(slot.clone());
2562            }
2563            SlotPlacement::Header
2564            | SlotPlacement::AboveEditor
2565            | SlotPlacement::Editor
2566            | SlotPlacement::MessageRenderer => self.view.widgets_above.push(widget),
2567        }
2568        if captures_focus {
2569            self.focused_extension_slot = Some(slot.key.clone());
2570            self.view.focus = if slot.placement == SlotPlacement::Overlay {
2571                FocusArea::Overlay
2572            } else {
2573                FocusArea::Widget
2574            };
2575        }
2576        self.extension_slots.insert(
2577            slot.key,
2578            ProjectedExtensionSlot {
2579                placement: slot.placement,
2580                generation: slot.generation,
2581                focusable: captures_focus,
2582            },
2583        );
2584    }
2585
2586    fn dispose_extension_slot(&mut self, key: &str) {
2587        self.view.widgets_above.retain(|slot| slot.slot.key != key);
2588        self.view.widgets_below.retain(|slot| slot.slot.key != key);
2589        if matches!(
2590            self.extension_slots.remove(key).map(|slot| slot.placement),
2591            Some(SlotPlacement::Overlay)
2592        ) && self
2593            .view
2594            .overlay
2595            .as_ref()
2596            .is_some_and(|overlay| overlay.kind == OverlayKind::Extension)
2597        {
2598            self.view.overlay = None;
2599            self.view.extension_overlay_slot = None;
2600            self.view.focus = FocusArea::Editor;
2601        }
2602        if self.focused_extension_slot.as_deref() == Some(key) {
2603            self.focused_extension_slot = None;
2604            self.view.focus = FocusArea::Editor;
2605        }
2606    }
2607
2608    async fn rebind_extension_channels(&mut self) {
2609        if self.pending_extension_dialog.is_some() {
2610            self.cancel_extension_dialog().await;
2611        }
2612        self.extension_runner = self.session.host_extension_runner();
2613        let current_slots = self
2614            .extension_runner
2615            .as_ref()
2616            .map_or_else(Vec::new, |runner| runner.current_slots());
2617        self.extension_events = self
2618            .extension_runner
2619            .as_ref()
2620            .map(|runner| runner.subscribe_ui());
2621        self.extension_requests = self
2622            .extension_runner
2623            .as_ref()
2624            .and_then(|runner| runner.take_ui_requests());
2625        self.pending_extension_dialog = None;
2626        self.extension_slots.clear();
2627        self.focused_extension_slot = None;
2628        self.view.extension_overlay_slot = None;
2629        self.effective_extension_shortcuts = self
2630            .extension_runner
2631            .as_ref()
2632            .map_or_else(Vec::new, |runner| {
2633                build_effective_extension_shortcuts(&runner.raw_shortcuts())
2634            });
2635        self.view.extension_shortcuts = shortcut_hints(&self.effective_extension_shortcuts);
2636        self.view.widgets_above.clear();
2637        self.view.widgets_below.clear();
2638        if self
2639            .view
2640            .overlay
2641            .as_ref()
2642            .is_some_and(|overlay| overlay.kind == OverlayKind::Extension)
2643        {
2644            self.view.overlay = None;
2645        }
2646        for slot in current_slots {
2647            self.project_extension_slot(slot);
2648        }
2649    }
2650
2651    fn route_extension_input(&mut self, event: &UiEvent) -> bool {
2652        if !matches!(event, UiEvent::Key(_) | UiEvent::Paste(_)) || is_global_app_binding(event) {
2653            return false;
2654        }
2655        if let Some(key) = self.focused_extension_slot.clone()
2656            && let Some(slot) = self.extension_slots.get(&key)
2657            && slot.focusable
2658            && let Some(runner) = self.extension_runner.as_ref()
2659        {
2660            let request = UiEventRequest {
2661                key,
2662                generation: slot.generation,
2663                event: ui_event_wire(event),
2664                data: encode_terminal_input(event),
2665            };
2666            let runner = Arc::clone(runner);
2667            let result_tx = self.extension_action_tx.clone();
2668            tokio::spawn(async move {
2669                let result = runner
2670                    .send_ui_event(request)
2671                    .await
2672                    .map(|_| ())
2673                    .map_err(|error| error.to_string());
2674                let _ = result_tx.send(result);
2675            });
2676            return true;
2677        }
2678
2679        let UiEvent::Key(key_event) = event else {
2680            return false;
2681        };
2682        if key_event.kind == crossterm::event::KeyEventKind::Release {
2683            return false;
2684        }
2685        let Some(shortcut) = self
2686            .effective_extension_shortcuts
2687            .iter()
2688            .find(|shortcut| key_matches_parsed(key_event, &shortcut.parsed))
2689        else {
2690            return false;
2691        };
2692        let Some(runner) = self.extension_runner.as_ref() else {
2693            return false;
2694        };
2695        let runner = Arc::clone(runner);
2696        let key = shortcut.dispatch_key.clone();
2697        let result_tx = self.extension_action_tx.clone();
2698        tokio::spawn(async move {
2699            let result = runner
2700                .execute_shortcut(key)
2701                .await
2702                .map(|_| ())
2703                .map_err(|error| error.to_string());
2704            let _ = result_tx.send(result);
2705        });
2706        true
2707    }
2708
2709    async fn intercept_terminal_input(&mut self, event: UiEvent) -> Option<UiEvent> {
2710        let Some(runner) = &self.extension_runner else {
2711            return Some(event);
2712        };
2713        if !runner.has_terminal_input_handlers() {
2714            return Some(event);
2715        }
2716        let Some(data) = encode_terminal_input(&event) else {
2717            return Some(event);
2718        };
2719        match runner.terminal_input(&data).await {
2720            Ok(result) if result.consume => None,
2721            Ok(result) => result
2722                .data
2723                .filter(|rewritten| rewritten != &data)
2724                .map_or(Some(event), |rewritten| {
2725                    Some(decode_terminal_input(rewritten))
2726                }),
2727            Err(_) => Some(event),
2728        }
2729    }
2730
2731    fn ensure_editor_on_submit(&mut self) {
2732        if self.editor.on_submit.is_none() {
2733            let submit_tx = self.submit_tx.clone();
2734            self.editor.on_submit = Some(Box::new(move |text: String| {
2735                let _ = submit_tx.send(text);
2736            }));
2737        }
2738    }
2739
2740    /// Rebind event/partial subscriptions and reload the transcript after a
2741    /// session replacement. Used by production rebind callback and tests.
2742    pub async fn rebind_session_channels(&mut self) {
2743        self.events = self.session.subscribe();
2744        self.partial = self.session.partial_rx();
2745        let snapshot = self.session.snapshot();
2746        project_snapshot(&mut self.view, &snapshot, None);
2747        self.view.messages = project_messages(&self.session.messages());
2748        apply_display_preferences(
2749            &mut self.view.messages,
2750            self.display.tools_expanded,
2751            self.display.hide_thinking,
2752        );
2753        self.chat_prefix_cache = None;
2754        self.chat_prefix_len = usize::MAX;
2755        self.chat_tail_cache = None;
2756        self.chat_dirty = true;
2757        self.rebind_extension_channels().await;
2758    }
2759
2760    async fn refresh_footer(&mut self) {
2761        let snapshot = self.session.footer_snapshot().await;
2762        project_footer(&mut self.view, &snapshot);
2763    }
2764
2765    /// Clear selector focus before process suspension.
2766    pub fn close_selector_for_suspend(&mut self) {
2767        self.close_selector();
2768        self.exited = false;
2769    }
2770
2771    fn set_status(&mut self, status: SessionStatus) {
2772        self.view.status = Some(status);
2773    }
2774
2775    /// Record an async session-action error into `last_error` so the UI can
2776    /// surface it on the next paint. Never panics.
2777    fn record_err(&mut self, result: Result<(), String>) {
2778        if let Err(error) = result {
2779            self.record_error(error);
2780        }
2781    }
2782
2783    fn record_error(&mut self, error: String) {
2784        self.last_error = Some(error);
2785        self.arm_coalescer();
2786    }
2787
2788    fn surface_last_error(&mut self) {
2789        let Some(error) = self.last_error.as_ref() else {
2790            return;
2791        };
2792        if let Some(entry) = self
2793            .view
2794            .diagnostics
2795            .entries
2796            .iter_mut()
2797            .find(|entry| entry.source == "runtime")
2798        {
2799            entry.severity = DiagnosticSeverity::Error;
2800            entry.message.clone_from(error);
2801        } else {
2802            self.view.diagnostics.entries.push(StartupDiagnostic {
2803                severity: DiagnosticSeverity::Error,
2804                source: "runtime".to_owned(),
2805                message: error.clone(),
2806            });
2807        }
2808    }
2809
2810    // -----------------------------------------------------------------------
2811    // Painting
2812    // -----------------------------------------------------------------------
2813
2814    fn refresh_chat_caches(&mut self) {
2815        let prefix_len = self.view.messages.len().saturating_sub(1);
2816        if self.chat_prefix_cache.is_none() || self.chat_prefix_len != prefix_len {
2817            let mut messages = std::mem::take(&mut self.view.messages);
2818            let tail = messages.split_off(prefix_len);
2819            self.view.messages = messages;
2820            self.chat_prefix_cache = Some(extract_chat_component(&self.view));
2821            let mut messages = std::mem::take(&mut self.view.messages);
2822            messages.extend(tail);
2823            self.view.messages = messages;
2824            self.chat_prefix_len = prefix_len;
2825            self.chat_dirty = true;
2826        }
2827
2828        if self.chat_tail_cache.is_none() || self.chat_dirty {
2829            let mut messages = std::mem::take(&mut self.view.messages);
2830            let tail = messages.split_off(prefix_len);
2831            let prefix = messages;
2832            self.view.messages = tail;
2833            self.chat_tail_cache = Some(extract_chat_component(&self.view));
2834            let mut tail = std::mem::take(&mut self.view.messages);
2835            let mut all = prefix;
2836            all.append(&mut tail);
2837            self.view.messages = all;
2838            self.chat_dirty = false;
2839        }
2840    }
2841
2842    fn build_root(
2843        &mut self,
2844        editor: Editor,
2845        selector: Option<Box<dyn Component>>,
2846    ) -> InteractiveRoot {
2847        self.surface_last_error();
2848        self.refresh_chat_caches();
2849        let prefix = self
2850            .chat_prefix_cache
2851            .take()
2852            .unwrap_or_else(empty_chat_component);
2853        let tail = self
2854            .chat_tail_cache
2855            .take()
2856            .unwrap_or_else(empty_chat_component);
2857        let dialog_title = self.pending_extension_dialog.as_ref().map(|dialog| {
2858            Box::new(pi_tui::components::Text::with_padding(
2859                super::theme::bold(&self.view.theme.fg(
2860                    super::theme::ThemeColor::Accent,
2861                    &extension_dialog_title(&dialog.request),
2862                )),
2863                1,
2864                0,
2865            )) as Box<dyn Component>
2866        });
2867        InteractiveRoot::build_with_chat(
2868            &mut self.view,
2869            editor,
2870            selector,
2871            dialog_title,
2872            prefix,
2873            tail,
2874        )
2875    }
2876
2877    fn recover_root(&mut self, mut root: InteractiveRoot) {
2878        self.chat_prefix_cache = root.take_section("chat-prefix");
2879        self.chat_tail_cache = root.take_section("chat-tail");
2880        self.editor = std::mem::replace(root.editor_mut(), Editor::with_defaults());
2881        self.active_selector = root.selector.take();
2882    }
2883
2884    fn arm_coalescer(&mut self) {
2885        if self.coalesce_deadline.is_none() {
2886            self.coalesce_deadline = Some(Instant::now() + BACKGROUND_COALESCE_WINDOW);
2887        }
2888    }
2889
2890    fn paint_frame(&mut self) -> io::Result<()> {
2891        let saved_editor = std::mem::replace(&mut self.editor, Editor::with_defaults());
2892        let saved_selector = self.active_selector.take();
2893        let mut root = self.build_root(saved_editor, saved_selector);
2894        let result = self.tui.commit(Txn::Frame, &mut root);
2895        self.recover_root(root);
2896        self.ensure_editor_on_submit();
2897        result
2898    }
2899
2900    fn commit_settle(&mut self, blocks: Vec<SettledBlock>) -> io::Result<()> {
2901        let saved_editor = std::mem::replace(&mut self.editor, Editor::with_defaults());
2902        let saved_selector = self.active_selector.take();
2903        let mut root = self.build_root(saved_editor, saved_selector);
2904        let result = self.tui.commit(Txn::Settle(blocks), &mut root);
2905        self.recover_root(root);
2906        self.ensure_editor_on_submit();
2907        result
2908    }
2909
2910    fn commit_reanchor(&mut self) -> io::Result<()> {
2911        let saved_editor = std::mem::replace(&mut self.editor, Editor::with_defaults());
2912        let saved_selector = self.active_selector.take();
2913        let mut root = self.build_root(saved_editor, saved_selector);
2914        let result = self
2915            .tui
2916            .commit(Txn::Reanchor(ReanchorCause::Resize), &mut root);
2917        self.recover_root(root);
2918        self.ensure_editor_on_submit();
2919        result
2920    }
2921
2922    // -----------------------------------------------------------------------
2923    // Test driver seam
2924    // -----------------------------------------------------------------------
2925
2926    /// Advance one UI event without running the full event loop. Returns the
2927    /// list of dispatch outcomes; the caller may then assert on view state.
2928    ///
2929    /// This is the test driver seam: a fake [`TerminalInput`] can be injected
2930    /// via [`InteractiveRuntime::new`], and tests call `step_ui` to feed
2931    /// scripted key sequences while observing the view and session host.
2932    ///
2933    /// # Errors
2934    ///
2935    /// Propagates I/O failures from the underlying [`Tui::commit`].
2936    pub async fn step_ui(&mut self, event: UiEvent) -> io::Result<()> {
2937        self.handle_ui_event(event).await
2938    }
2939
2940    /// Advance one session event without running the full event loop.
2941    ///
2942    /// # Errors
2943    ///
2944    /// Propagates I/O failures from the underlying [`Tui::commit`].
2945    pub fn step_session_event(
2946        &mut self,
2947        event: impl std::borrow::Borrow<AgentSessionEvent>,
2948    ) -> std::future::Ready<io::Result<()>> {
2949        self.handle_session_event(event.borrow());
2950        std::future::ready(Ok(()))
2951    }
2952
2953    /// Force a single paint (tests / driver seam).
2954    ///
2955    /// # Errors
2956    ///
2957    /// Propagates I/O failures from the underlying [`Tui::commit`].
2958    pub fn paint_now(&mut self) -> io::Result<()> {
2959        self.paint_frame()
2960    }
2961
2962    /// Force a coalesced paint tick (tests). Clears the deadline and commits.
2963    ///
2964    /// # Errors
2965    ///
2966    /// Propagates I/O failures from the underlying [`Tui::commit`].
2967    pub fn flush_coalescer(&mut self) -> io::Result<()> {
2968        self.coalesce_deadline = None;
2969        self.paint_frame()
2970    }
2971
2972    /// Enqueue a settle transaction for the next loop turn (tests / driver).
2973    pub fn enqueue_settle(&mut self, blocks: Vec<SettledBlock>) {
2974        self.pending_settle = Some(blocks);
2975    }
2976}
2977
2978// ---------------------------------------------------------------------------
2979// Pure projection helpers
2980// ---------------------------------------------------------------------------
2981
2982/// Apply a [`SessionSnapshot`] to [`ViewState`]. `partial` may overwrite the
2983/// streaming tail when present.
2984fn project_snapshot(
2985    view: &mut ViewState,
2986    snapshot: &SessionSnapshot,
2987    partial: Option<&Arc<AssistantMessage>>,
2988) {
2989    view.streaming = snapshot.is_admission_active();
2990    view.status = match snapshot.activity {
2991        SessionActivity::Streaming => Some(SessionStatus {
2992            kind: StatusKind::Working,
2993            frame: 0,
2994            message: "Working…".to_owned(),
2995        }),
2996        SessionActivity::Compacting => Some(SessionStatus {
2997            kind: StatusKind::Compaction,
2998            frame: 0,
2999            message: "Compacting…".to_owned(),
3000        }),
3001        SessionActivity::Retrying => Some(SessionStatus {
3002            kind: StatusKind::Retry,
3003            frame: 0,
3004            message: "Retrying…".to_owned(),
3005        }),
3006        SessionActivity::Summarizing => Some(SessionStatus {
3007            kind: StatusKind::BranchSummary,
3008            frame: 0,
3009            message: "Summarizing…".to_owned(),
3010        }),
3011        SessionActivity::Idle if snapshot.is_admission_active() => Some(SessionStatus {
3012            kind: StatusKind::Working,
3013            frame: 0,
3014            message: "Working…".to_owned(),
3015        }),
3016        SessionActivity::Idle => None,
3017    };
3018
3019    view.pending.steering = snapshot
3020        .steering
3021        .iter()
3022        .map(|t| PendingMessage {
3023            kind: PendingKind::Steering,
3024            text: t.clone(),
3025        })
3026        .collect();
3027    view.pending.follow_up = snapshot
3028        .follow_up
3029        .iter()
3030        .map(|t| PendingMessage {
3031            kind: PendingKind::FollowUp,
3032            text: t.clone(),
3033        })
3034        .collect();
3035    view.pending.follow_up_mode = snapshot.follow_up_mode;
3036
3037    view.footer.model_id.clone_from(&snapshot.model_id);
3038    view.footer.flags.reasoning = snapshot.reasoning;
3039
3040    if let Some(message) = partial {
3041        let has_streaming = view
3042            .messages
3043            .iter_mut()
3044            .any(|m| matches!(m, MessageView::Assistant(v) if v.streaming));
3045        if !has_streaming {
3046            view.messages
3047                .push(MessageView::streaming_assistant((**message).clone()));
3048        }
3049    }
3050}
3051
3052fn project_footer(view: &mut ViewState, snapshot: &SessionFooterSnapshot) {
3053    let footer = &mut view.footer;
3054    footer.total_input = snapshot.total_input;
3055    footer.total_output = snapshot.total_output;
3056    footer.total_cache_read = snapshot.total_cache_read;
3057    footer.total_cache_write = snapshot.total_cache_write;
3058    footer.total_cost = snapshot.total_cost;
3059    footer.context_window = snapshot.context_window;
3060    footer.context_percent = snapshot.context_percent;
3061    footer.provider.clone_from(&snapshot.provider);
3062    footer.provider_count = snapshot.provider_count;
3063    footer.thinking_level = snapshot.thinking_level;
3064    footer.flags.billing = if snapshot.subscription {
3065        BillingMode::Subscription
3066    } else {
3067        BillingMode::Metered
3068    };
3069    footer.flags.auto_compact = snapshot.auto_compact;
3070    view.editor.border = if snapshot.bash_running {
3071        EditorBorder::Bash
3072    } else if snapshot.thinking_level == pi_ai::ModelThinkingLevel::Off {
3073        EditorBorder::Muted
3074    } else {
3075        EditorBorder::Thinking(snapshot.thinking_level)
3076    };
3077}
3078
3079const fn event_refreshes_footer(event: &AgentSessionEvent) -> bool {
3080    matches!(
3081        event,
3082        AgentSessionEvent::AgentSettled
3083            | AgentSessionEvent::CompactionEnd { .. }
3084            | AgentSessionEvent::ThinkingLevelChanged { .. }
3085    )
3086}
3087
3088/// Project a single [`AgentSessionEvent`] into [`ViewState`] mutations.
3089fn project_event(view: &mut ViewState, event: &AgentSessionEvent) {
3090    use crate::core::agent_session::events::AgentSessionEvent as Event;
3091
3092    match event {
3093        Event::AgentStart => {
3094            view.streaming = true;
3095            view.status = Some(SessionStatus {
3096                kind: StatusKind::Working,
3097                frame: 0,
3098                message: "Working…".to_owned(),
3099            });
3100        }
3101        Event::AgentEnd { will_retry, .. } => {
3102            if !will_retry {
3103                view.streaming = false;
3104                view.status = None;
3105            }
3106        }
3107        Event::AgentSettled => {
3108            view.streaming = false;
3109            view.status = None;
3110        }
3111        Event::TurnStart
3112        | Event::TurnEnd { .. }
3113        | Event::SessionBeforeSwitch { .. }
3114        | Event::SessionBeforeFork { .. }
3115        | Event::SessionStart { .. }
3116        | Event::SessionShutdown { .. }
3117        | Event::ModelSelect { .. } => {}
3118        Event::MessageStart { message } => project_message_start(view, message),
3119        Event::MessageUpdate { message, .. } => {
3120            project_assistant_message(view, message, false);
3121        }
3122        Event::MessageEnd { message } => project_assistant_message(view, message, true),
3123        Event::ToolExecutionStart {
3124            tool_call_id,
3125            tool_name,
3126            args,
3127        } => project_tool_start(view, tool_call_id, tool_name, args),
3128        Event::ToolExecutionUpdate {
3129            tool_call_id,
3130            partial_result,
3131            ..
3132        } => update_tool_message(
3133            view,
3134            tool_call_id,
3135            Some(partial_result),
3136            false,
3137            super::tool_renderer::ToolPhase::Pending,
3138        ),
3139        Event::ToolExecutionEnd {
3140            tool_call_id,
3141            result,
3142            is_error,
3143            ..
3144        } => project_tool_end(view, tool_call_id, result, *is_error),
3145        Event::QueueUpdate {
3146            steering,
3147            follow_up,
3148        } => project_queue(view, steering, follow_up),
3149        Event::CompactionStart { reason } => project_compaction_start(view, *reason),
3150        Event::CompactionEnd { .. } | Event::AutoRetryEnd { .. } => view.status = None,
3151        Event::EntryAppended { entry } => project_entry(view, entry),
3152        Event::SessionInfoChanged { name } => view.footer.session_name.clone_from(name),
3153        Event::ThinkingLevelChanged { level } => {
3154            view.footer.thinking_level = *level;
3155        }
3156        Event::AutoRetryStart {
3157            attempt,
3158            max_attempts,
3159            delay_ms,
3160            ..
3161        } => {
3162            view.status = Some(SessionStatus {
3163                kind: StatusKind::Retry,
3164                frame: 0,
3165                message: format!(
3166                    "Retrying ({}/{}) in {}s",
3167                    attempt,
3168                    max_attempts,
3169                    delay_ms / 1000
3170                ),
3171            });
3172        }
3173    }
3174}
3175
3176fn project_message_start(view: &mut ViewState, message: &pi_agent::AgentMessage) {
3177    let Some(view_message) = message_view_from_agent(message) else {
3178        return;
3179    };
3180    if matches!(view_message, MessageView::Assistant(_)) {
3181        let has_streaming = view
3182            .messages
3183            .iter()
3184            .any(|message| matches!(message, MessageView::Assistant(item) if item.streaming));
3185        if !has_streaming {
3186            view.messages.push(view_message);
3187        }
3188    } else {
3189        view.messages.push(view_message);
3190    }
3191}
3192
3193fn project_assistant_message(
3194    view: &mut ViewState,
3195    message: &pi_agent::AgentMessage,
3196    finished: bool,
3197) {
3198    let pi_agent::AgentMessage::Llm(boxed) = message else {
3199        return;
3200    };
3201    let pi_ai::Message::Assistant(assistant_message) = boxed.as_ref() else {
3202        return;
3203    };
3204
3205    for message in &mut view.messages {
3206        if let MessageView::Assistant(assistant) = message
3207            && assistant.streaming
3208        {
3209            assistant.streaming = !finished;
3210            assistant.message.clone_from(assistant_message);
3211            return;
3212        }
3213    }
3214
3215    if finished {
3216        view.messages
3217            .push(MessageView::Assistant(AssistantMessageView {
3218                message: assistant_message.clone(),
3219                hide_thinking: false,
3220                hidden_thinking_label: String::new(),
3221                streaming: false,
3222            }));
3223    } else {
3224        view.messages
3225            .push(MessageView::streaming_assistant(assistant_message.clone()));
3226    }
3227}
3228
3229fn project_tool_start(
3230    view: &mut ViewState,
3231    tool_call_id: &str,
3232    tool_name: &str,
3233    args: &serde_json::Map<String, serde_json::Value>,
3234) {
3235    let args_value = serde_json::Value::Object(args.clone());
3236    let args_summary = summarize_tool_args(&args_value);
3237    view.messages
3238        .push(MessageView::Tool(super::messages::ToolMessageView {
3239            renderer: tool_name.to_owned(),
3240            state: super::tool_renderer::ToolState {
3241                call: super::tool_renderer::ToolCallView {
3242                    name: tool_name.to_owned(),
3243                    id: tool_call_id.to_owned(),
3244                    args_summary,
3245                    raw_args: args_value,
3246                },
3247                result: None,
3248                expanded: false,
3249                phase: super::tool_renderer::ToolPhase::Pending,
3250            },
3251        }));
3252}
3253
3254fn project_tool_end(
3255    view: &mut ViewState,
3256    tool_call_id: &str,
3257    result: &pi_agent::AgentToolResult,
3258    is_error: bool,
3259) {
3260    let phase = if is_error {
3261        super::tool_renderer::ToolPhase::Error
3262    } else {
3263        super::tool_renderer::ToolPhase::Success
3264    };
3265    update_tool_message(view, tool_call_id, Some(result), is_error, phase);
3266}
3267
3268fn project_queue(view: &mut ViewState, steering: &[String], follow_up: &[String]) {
3269    view.pending.steering = steering
3270        .iter()
3271        .map(|text| PendingMessage {
3272            kind: PendingKind::Steering,
3273            text: text.clone(),
3274        })
3275        .collect();
3276    view.pending.follow_up = follow_up
3277        .iter()
3278        .map(|text| PendingMessage {
3279            kind: PendingKind::FollowUp,
3280            text: text.clone(),
3281        })
3282        .collect();
3283}
3284
3285fn project_compaction_start(
3286    view: &mut ViewState,
3287    reason: crate::core::agent_session::events::CompactionReason,
3288) {
3289    let message = match reason {
3290        crate::core::agent_session::events::CompactionReason::Manual => "Compacting…",
3291        crate::core::agent_session::events::CompactionReason::Threshold => "Auto-compacting…",
3292        crate::core::agent_session::events::CompactionReason::Overflow => "Overflow auto-compact…",
3293    };
3294    view.status = Some(SessionStatus {
3295        kind: StatusKind::Compaction,
3296        frame: 0,
3297        message: message.to_owned(),
3298    });
3299}
3300
3301fn project_entry(view: &mut ViewState, entry: &crate::core::sessions::SessionEntry) {
3302    let Some(view_message) = message_view_from_entry(entry) else {
3303        return;
3304    };
3305    match &view_message {
3306        MessageView::User(user) => {
3307            let already_present = view.messages.iter().rev().any(
3308                |message| matches!(message, MessageView::User(item) if item.text == user.text),
3309            );
3310            if !already_present {
3311                view.messages.push(view_message);
3312            }
3313        }
3314        MessageView::Assistant(_) => {
3315            // Assistants stream via MessageUpdate/partial.
3316        }
3317        MessageView::Tool(_)
3318        | MessageView::Bash(_)
3319        | MessageView::Custom(_)
3320        | MessageView::Compaction(_)
3321        | MessageView::Branch(_)
3322        | MessageView::Skill(_) => view.messages.push(view_message),
3323    }
3324}
3325
3326// ---------------------------------------------------------------------------
3327// Message projection helpers
3328// ---------------------------------------------------------------------------
3329
3330fn project_messages(messages: &[pi_agent::AgentMessage]) -> Vec<MessageView> {
3331    messages
3332        .iter()
3333        .filter_map(message_view_from_agent)
3334        .collect()
3335}
3336
3337fn extract_chat_component(view: &ViewState) -> Box<dyn Component> {
3338    compose(view)
3339        .sections
3340        .into_iter()
3341        .find(|section| section.label == "chat")
3342        .map_or_else(empty_chat_component, |section| section.component)
3343}
3344
3345fn empty_chat_component() -> Box<dyn Component> {
3346    Box::new(pi_tui::components::Text::new(String::new()))
3347}
3348
3349fn apply_display_preferences(
3350    messages: &mut [MessageView],
3351    tools_expanded: bool,
3352    hide_thinking: bool,
3353) {
3354    for message in messages {
3355        match message {
3356            MessageView::Assistant(view) => view.hide_thinking = hide_thinking,
3357            MessageView::Tool(view) => view.state.expanded = tools_expanded,
3358            MessageView::Bash(view) => view.expanded = tools_expanded,
3359            MessageView::User(_)
3360            | MessageView::Custom(_)
3361            | MessageView::Compaction(_)
3362            | MessageView::Branch(_)
3363            | MessageView::Skill(_) => {}
3364        }
3365    }
3366}
3367
3368fn message_view_from_agent(message: &pi_agent::AgentMessage) -> Option<MessageView> {
3369    match message {
3370        pi_agent::AgentMessage::Llm(boxed) => match boxed.as_ref() {
3371            pi_ai::Message::User(user) => {
3372                Some(MessageView::User(super::messages::UserMessageView {
3373                    text: user_message_text(user),
3374                }))
3375            }
3376            pi_ai::Message::Assistant(am) => Some(MessageView::Assistant(
3377                super::messages::AssistantMessageView {
3378                    message: am.clone(),
3379                    hide_thinking: false,
3380                    hidden_thinking_label: String::new(),
3381                    streaming: false,
3382                },
3383            )),
3384            pi_ai::Message::ToolResult(_) => None,
3385        },
3386        pi_agent::AgentMessage::Custom(custom) => Some(message_view_from_custom(custom)),
3387    }
3388}
3389
3390fn message_view_from_custom(custom: &pi_agent::CustomAgentMessage) -> MessageView {
3391    let text = custom
3392        .payload
3393        .get("text")
3394        .and_then(serde_json::Value::as_str)
3395        .or_else(|| {
3396            custom
3397                .payload
3398                .get("content")
3399                .and_then(serde_json::Value::as_str)
3400        })
3401        .unwrap_or("")
3402        .to_owned();
3403    match custom.role.as_str() {
3404        "bashExecution" => bash_message_view(custom, &text),
3405        "compactionSummary" => MessageView::Compaction(super::messages::CompactionSummaryView {
3406            summary: custom
3407                .payload
3408                .get("summary")
3409                .and_then(serde_json::Value::as_str)
3410                .unwrap_or(&text)
3411                .to_owned(),
3412            tokens_before: custom
3413                .payload
3414                .get("tokensBefore")
3415                .and_then(serde_json::Value::as_i64)
3416                .unwrap_or(0),
3417        }),
3418        "branchSummary" => MessageView::Branch(super::messages::BranchSummaryView {
3419            summary: custom
3420                .payload
3421                .get("summary")
3422                .and_then(serde_json::Value::as_str)
3423                .unwrap_or(&text)
3424                .to_owned(),
3425            from_id: custom
3426                .payload
3427                .get("fromId")
3428                .and_then(serde_json::Value::as_str)
3429                .unwrap_or("root")
3430                .to_owned(),
3431        }),
3432        "skillInvocation" => MessageView::Skill(super::messages::SkillInvocationView {
3433            name: custom
3434                .payload
3435                .get("name")
3436                .and_then(serde_json::Value::as_str)
3437                .unwrap_or("skill")
3438                .to_owned(),
3439            text,
3440        }),
3441        other => MessageView::Custom(super::messages::CustomMessageView {
3442            custom_type: other.to_owned(),
3443            text,
3444        }),
3445    }
3446}
3447
3448fn bash_message_view(custom: &pi_agent::CustomAgentMessage, text: &str) -> MessageView {
3449    let command = custom
3450        .payload
3451        .get("command")
3452        .and_then(serde_json::Value::as_str)
3453        .unwrap_or("")
3454        .to_owned();
3455    let output = custom
3456        .payload
3457        .get("output")
3458        .and_then(serde_json::Value::as_str)
3459        .unwrap_or(text)
3460        .to_owned();
3461    MessageView::Bash(super::messages::BashMessageView {
3462        command,
3463        output,
3464        expanded: false,
3465        exit_code: custom
3466            .payload
3467            .get("exitCode")
3468            .and_then(serde_json::Value::as_i64)
3469            .map(clamp_i64_to_i32),
3470        cancelled: custom
3471            .payload
3472            .get("cancelled")
3473            .and_then(serde_json::Value::as_bool)
3474            .unwrap_or(false),
3475        truncated: custom
3476            .payload
3477            .get("truncated")
3478            .and_then(serde_json::Value::as_bool)
3479            .unwrap_or(false),
3480        full_output_path: custom
3481            .payload
3482            .get("fullOutputPath")
3483            .and_then(serde_json::Value::as_str)
3484            .map(str::to_owned),
3485    })
3486}
3487
3488fn message_view_from_entry(entry: &crate::core::sessions::SessionEntry) -> Option<MessageView> {
3489    use crate::core::sessions::SessionEntry;
3490    match entry {
3491        SessionEntry::Message(m) => message_view_from_agent(&m.message),
3492        SessionEntry::Compaction(c) => Some(MessageView::Compaction(
3493            super::messages::CompactionSummaryView {
3494                summary: c.summary.clone(),
3495                tokens_before: c.tokens_before,
3496            },
3497        )),
3498        SessionEntry::BranchSummary(b) => {
3499            Some(MessageView::Branch(super::messages::BranchSummaryView {
3500                summary: b.summary.clone(),
3501                from_id: b.from_id.clone(),
3502            }))
3503        }
3504        SessionEntry::CustomMessage(m) => {
3505            let text = match &m.content {
3506                crate::core::messages::CustomMessageContent::Text(s) => s.clone(),
3507                crate::core::messages::CustomMessageContent::Blocks(blocks) => blocks
3508                    .iter()
3509                    .filter_map(|b| match b {
3510                        pi_ai::UserContent::Text(t) => Some(t.text.to_string()),
3511                        pi_ai::UserContent::Image(_) => None,
3512                    })
3513                    .collect::<String>(),
3514            };
3515            Some(MessageView::Custom(super::messages::CustomMessageView {
3516                custom_type: m.custom_type.clone(),
3517                text,
3518            }))
3519        }
3520        SessionEntry::Custom(c) => Some(MessageView::Custom(super::messages::CustomMessageView {
3521            custom_type: c.custom_type.clone(),
3522            text: c
3523                .data
3524                .as_ref()
3525                .and_then(|v| v.as_str())
3526                .unwrap_or("")
3527                .to_owned(),
3528        })),
3529        _ => None,
3530    }
3531}
3532
3533fn user_message_text(user: &pi_ai::UserMessage) -> String {
3534    match &user.content {
3535        pi_ai::UserMessageContent::Text(s) => s.clone(),
3536        pi_ai::UserMessageContent::Blocks(blocks) => blocks
3537            .iter()
3538            .filter_map(|b| match b {
3539                pi_ai::UserContent::Text(t) => Some(t.text.to_string()),
3540                pi_ai::UserContent::Image(_) => None,
3541            })
3542            .collect::<String>(),
3543    }
3544}
3545
3546fn clamp_i64_to_i32(value: i64) -> i32 {
3547    match i32::try_from(value) {
3548        Ok(value) => value,
3549        Err(_) if value.is_negative() => i32::MIN,
3550        Err(_) => i32::MAX,
3551    }
3552}
3553
3554fn summarize_tool_args(args: &serde_json::Value) -> String {
3555    match args {
3556        serde_json::Value::Object(map) if map.len() == 1 => map.iter().next().map_or_else(
3557            || args.to_string(),
3558            |(key, value)| match value {
3559                serde_json::Value::String(text) => format!("{key}={text}"),
3560                other => format!("{key}={other}"),
3561            },
3562        ),
3563        other => other.to_string(),
3564    }
3565}
3566
3567fn tool_result_view(
3568    result: &pi_agent::AgentToolResult,
3569    is_error: bool,
3570) -> super::tool_renderer::ToolResultView {
3571    let mut text = String::new();
3572    for content in &result.content {
3573        if let pi_ai::ToolResultContent::Text(t) = content {
3574            if !text.is_empty() {
3575                text.push('\n');
3576            }
3577            text.push_str(&t.text.read());
3578        }
3579    }
3580    super::tool_renderer::ToolResultView {
3581        text,
3582        truncated: false,
3583        full_output_path: None,
3584        images: Vec::new(),
3585        error: if is_error {
3586            Some(
3587                result
3588                    .details
3589                    .get("error")
3590                    .and_then(|v| v.as_str())
3591                    .unwrap_or("tool error")
3592                    .to_owned(),
3593            )
3594        } else {
3595            None
3596        },
3597    }
3598}
3599
3600fn update_tool_message(
3601    view: &mut ViewState,
3602    tool_call_id: &str,
3603    result: Option<&pi_agent::AgentToolResult>,
3604    is_error: bool,
3605    phase: super::tool_renderer::ToolPhase,
3606) {
3607    for message in view.messages.iter_mut().rev() {
3608        if let MessageView::Tool(tool) = message
3609            && tool.state.call.id == tool_call_id
3610        {
3611            if let Some(result) = result {
3612                tool.state.result = Some(tool_result_view(result, is_error));
3613            }
3614            tool.state.phase = phase;
3615            return;
3616        }
3617    }
3618}
3619
3620// ---------------------------------------------------------------------------
3621// Settle policy helpers
3622// ---------------------------------------------------------------------------
3623
3624/// Build a [`SettledBlock::Lines`] from a slice of styled lines.
3625#[cfg(test)]
3626fn settled_lines(lines: Vec<Line<'static>>) -> SettledBlock {
3627    SettledBlock::Lines(lines)
3628}
3629
3630#[allow(dead_code)]
3631fn settled_raw(rows: u16, bytes: Vec<u8>, fallback: Vec<Line<'static>>) -> SettledBlock {
3632    SettledBlock::Raw {
3633        rows,
3634        bytes,
3635        kitty_id: None,
3636        fallback,
3637    }
3638}
3639
3640// ---------------------------------------------------------------------------
3641// Test driver seam: SharedWriter + helpers
3642// ---------------------------------------------------------------------------
3643
3644/// Shared-buffer writer for tests so the [`pi_tui::terminal::guard::TerminalGuard`]
3645/// and [`Tui`] can write to the same in-memory sink without owning the same
3646/// `Vec`.
3647#[derive(Clone, Default)]
3648pub struct SharedWriter {
3649    inner: Arc<std::sync::Mutex<Vec<u8>>>,
3650}
3651
3652impl SharedWriter {
3653    /// Construct a fresh shared writer.
3654    #[must_use]
3655    pub fn new() -> Self {
3656        Self::default()
3657    }
3658
3659    /// Snapshot the bytes written so far.
3660    #[must_use]
3661    pub fn snapshot(&self) -> Vec<u8> {
3662        self.inner.lock().map(|g| g.clone()).unwrap_or_default()
3663    }
3664}
3665
3666impl Write for SharedWriter {
3667    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
3668        let mut guard = self
3669            .inner
3670            .lock()
3671            .map_err(|_| io::Error::other("shared writer poisoned"))?;
3672        guard.extend_from_slice(buf);
3673        Ok(buf.len())
3674    }
3675
3676    fn flush(&mut self) -> io::Result<()> {
3677        Ok(())
3678    }
3679}
3680
3681impl Debug for SharedWriter {
3682    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3683        f.debug_struct("SharedWriter").finish_non_exhaustive()
3684    }
3685}
3686
3687/// Build a [`TerminalInput`] backed by an in-memory channel for tests.
3688#[must_use]
3689pub fn mock_input(rx: mpsc::UnboundedReceiver<UiEvent>) -> TerminalInput {
3690    TerminalInput::mock(rx)
3691}
3692
3693// ---------------------------------------------------------------------------
3694// Production adapter: AgentSessionHost + run_interactive_mode
3695// ---------------------------------------------------------------------------
3696
3697use std::io::IsTerminal;
3698
3699use crate::core::agent_session::bash::ExecuteBashOptions;
3700use crate::core::agent_session::{AgentSession, AgentSessionEventListener};
3701use crate::core::agent_session_runtime::{
3702    AgentSessionRuntime, AgentSessionRuntimeError, ForkPosition, NewSessionOptions,
3703    SwitchSessionOptions,
3704};
3705use pi_tui::terminal::{
3706    TerminalGuard, install_panic_emergency_hook, write_emergency_restore_bytes,
3707};
3708
3709/// Production [`SessionHost`] over a live `Arc<AgentSession>` and the
3710/// owning `Arc<AgentSessionRuntime>`.
3711///
3712/// All async session methods route to the real `AgentSession`. New / fork /
3713/// switch / clone go through `AgentSessionRuntime` so the replacement
3714/// pipeline runs (teardown → apply → rebind). The host clones the `Arc`s so
3715/// it is `'static` and cheap to share with the runtime.
3716#[derive(Clone)]
3717pub struct AgentSessionHost {
3718    session: Arc<std::sync::RwLock<Arc<AgentSession>>>,
3719    runtime: Arc<AgentSessionRuntime>,
3720}
3721
3722impl AgentSessionHost {
3723    /// Construct a new host around the live runtime + its current session.
3724    #[must_use]
3725    pub fn new(runtime: Arc<AgentSessionRuntime>) -> Self {
3726        let session = runtime.session();
3727        Self {
3728            session: Arc::new(std::sync::RwLock::new(session)),
3729            runtime,
3730        }
3731    }
3732
3733    /// Snapshot the underlying session Arc (for rebind wiring).
3734    #[must_use]
3735    pub fn session(&self) -> Arc<AgentSession> {
3736        self.read_session()
3737    }
3738
3739    /// Refresh the cached session Arc from the runtime (after a replacement).
3740    pub fn refresh(&self) {
3741        let next = self.runtime.session();
3742        if let Ok(mut guard) = self.session.write() {
3743            guard.clone_from(&next);
3744        }
3745    }
3746
3747    fn read_session(&self) -> Arc<AgentSession> {
3748        self.session.read().map_or_else(
3749            |poisoned| Arc::clone(&*poisoned.into_inner()),
3750            |guard| Arc::clone(&*guard),
3751        )
3752    }
3753}
3754
3755impl SessionHost for AgentSessionHost {
3756    fn snapshot(&self) -> SessionSnapshot {
3757        let session = self.read_session();
3758        let model = session.model();
3759        let thinking = session.thinking_level();
3760        let admission_active = session.is_admission_active();
3761        let activity = if session.is_compacting() {
3762            SessionActivity::Compacting
3763        } else if session.is_retrying() {
3764            SessionActivity::Retrying
3765        } else if session.is_summarizing() {
3766            SessionActivity::Summarizing
3767        } else if admission_active {
3768            SessionActivity::Streaming
3769        } else {
3770            SessionActivity::Idle
3771        };
3772        let (steering, follow_up) = session.pending_messages();
3773        SessionSnapshot {
3774            activity,
3775            admission_active,
3776            bash_running: session.is_bash_running(),
3777            thinking_level_label: format!("{thinking:?}").to_lowercase(),
3778            model_id: model.id.clone(),
3779            reasoning: model.reasoning,
3780            steering,
3781            follow_up,
3782            follow_up_mode: match session.follow_up_mode() {
3783                pi_agent::QueueMode::All => super::state::QueueMode::All,
3784                pi_agent::QueueMode::OneAtATime => super::state::QueueMode::OneAtATime,
3785            },
3786        }
3787    }
3788
3789    fn footer_snapshot(&self) -> BoxFuture<'_, SessionFooterSnapshot> {
3790        let session = self.read_session();
3791        Box::pin(async move {
3792            let model = session.model();
3793            let stats = session.get_session_stats().await;
3794            let context = stats.context_usage;
3795            let runtime = session.model_runtime_handle();
3796            let subscription = runtime
3797                .as_ref()
3798                .is_some_and(|runtime| runtime.is_using_oauth(&model.provider));
3799            let provider_count = runtime.as_ref().map_or(1, |runtime| {
3800                runtime
3801                    .get_models(None)
3802                    .into_iter()
3803                    .map(|model| model.provider)
3804                    .collect::<std::collections::BTreeSet<_>>()
3805                    .len()
3806                    .max(1)
3807            });
3808            SessionFooterSnapshot {
3809                total_input: stats.tokens.input,
3810                total_output: stats.tokens.output,
3811                total_cache_read: stats.tokens.cache_read,
3812                total_cache_write: stats.tokens.cache_write,
3813                total_cost: stats.cost,
3814                context_window: context.map_or(model.context_window, |usage| usage.context_window),
3815                context_percent: context.and_then(|usage| usage.percent),
3816                provider: Some(model.provider),
3817                provider_count,
3818                thinking_level: session.thinking_level(),
3819                bash_running: session.is_bash_running(),
3820                subscription,
3821                auto_compact: session.auto_compaction_enabled(),
3822            }
3823        })
3824    }
3825
3826    fn subscribe(&self) -> EventSubscription {
3827        let (tx, rx) = mpsc::unbounded_channel::<AgentSessionEvent>();
3828        let session = self.read_session();
3829        let listener: AgentSessionEventListener = Arc::new(move |event: &AgentSessionEvent| {
3830            let _ = tx.send(event.clone());
3831        });
3832        let unsubscribe = session.subscribe_arc_listener(listener);
3833        EventSubscription {
3834            rx,
3835            unsubscribe: Some(Box::new(unsubscribe)),
3836        }
3837    }
3838
3839    fn partial_rx(&self) -> watch::Receiver<Option<Arc<AssistantMessage>>> {
3840        self.read_session().agent().partial()
3841    }
3842
3843    fn prompt(&self, text: &str, opts: PromptOptions) -> BoxFuture<'_, Result<(), String>> {
3844        let session = self.read_session();
3845        let text = text.to_owned();
3846        Box::pin(async move { session.prompt(&text, opts).await.map_err(|e| e.to_string()) })
3847    }
3848
3849    fn steer(&self, text: &str) -> BoxFuture<'_, Result<(), String>> {
3850        let session = self.read_session();
3851        let text = text.to_owned();
3852        Box::pin(async move { session.steer(&text, Vec::new()).map_err(|e| e.to_string()) })
3853    }
3854
3855    fn follow_up(&self, text: &str) -> BoxFuture<'_, Result<(), String>> {
3856        let session = self.read_session();
3857        let text = text.to_owned();
3858        Box::pin(async move {
3859            session
3860                .follow_up(&text, Vec::new())
3861                .map_err(|e| e.to_string())
3862        })
3863    }
3864
3865    fn abort(&self) -> BoxFuture<'static, Result<(), String>> {
3866        let session = self.read_session();
3867        Box::pin(async move {
3868            session.abort().await;
3869            Ok(())
3870        })
3871    }
3872
3873    fn compact(&self, instructions: Option<&str>) -> BoxFuture<'_, Result<(), String>> {
3874        let session = self.read_session();
3875        let instructions = instructions.map(str::to_owned);
3876        Box::pin(async move {
3877            session
3878                .compact(instructions.as_deref())
3879                .await
3880                .map(|_| ())
3881                .map_err(|e| e.to_string())
3882        })
3883    }
3884
3885    fn cycle_thinking_level(&self) -> BoxFuture<'_, Result<(), String>> {
3886        let session = self.read_session();
3887        Box::pin(async move {
3888            session
3889                .cycle_thinking_level()
3890                .await
3891                .ok_or_else(|| "model does not support thinking".to_owned())
3892                .map(|_| ())
3893        })
3894    }
3895
3896    fn cycle_model(&self, forward: bool) -> BoxFuture<'_, Result<(), String>> {
3897        let session = self.read_session();
3898        Box::pin(async move {
3899            let direction = if forward {
3900                crate::core::agent_session::model::CycleDirection::Forward
3901            } else {
3902                crate::core::agent_session::model::CycleDirection::Backward
3903            };
3904            session
3905                .cycle_model(direction)
3906                .await
3907                .ok_or_else(|| "only one model available".to_owned())
3908                .map(|_| ())
3909        })
3910    }
3911
3912    fn reload(&self) -> BoxFuture<'_, Result<(), String>> {
3913        let session = self.read_session();
3914        Box::pin(async move { session.reload().await.map_err(|e| e.to_string()) })
3915    }
3916
3917    fn messages(&self) -> Vec<pi_agent::AgentMessage> {
3918        self.read_session().messages()
3919    }
3920
3921    fn host_extension_runner(&self) -> Option<Arc<HostExtensionRunner>> {
3922        self.read_session().host_extension_runner()
3923    }
3924
3925    fn hide_thinking_block(&self) -> bool {
3926        self.read_session()
3927            .lock_settings()
3928            .get_hide_thinking_block()
3929    }
3930
3931    fn set_hide_thinking_block(&self, hide: bool) -> Result<(), String> {
3932        self.read_session()
3933            .lock_settings()
3934            .set_hide_thinking_block(hide);
3935        Ok(())
3936    }
3937
3938    fn external_editor_command(&self) -> String {
3939        self.read_session()
3940            .lock_settings()
3941            .get_external_editor_command()
3942    }
3943
3944    fn get_model_entries(
3945        &self,
3946    ) -> BoxFuture<'_, Result<Vec<super::state::ModelSelectorEntry>, String>> {
3947        let session = self.read_session();
3948        Box::pin(async move {
3949            let models = session
3950                .model_runtime_handle()
3951                .map_or_else(|| vec![session.model()], |runtime| runtime.get_models(None));
3952            Ok(models
3953                .into_iter()
3954                .map(|m| super::state::ModelSelectorEntry {
3955                    value: format!("{}/{}", m.provider, m.id),
3956                    label: if m.name.is_empty() {
3957                        m.id.clone()
3958                    } else {
3959                        m.name.clone()
3960                    },
3961                    description: Some(m.provider.clone()),
3962                })
3963                .collect())
3964        })
3965    }
3966
3967    fn get_session_entries(
3968        &self,
3969    ) -> BoxFuture<'_, Result<Vec<super::state::SessionPickerEntry>, String>> {
3970        let session = self.read_session();
3971        Box::pin(async move {
3972            let cwd = session.cwd.clone();
3973            let session_dir = {
3974                let manager = session.session_manager();
3975                let sm = manager.lock().await;
3976                sm.get_session_dir().to_owned()
3977            };
3978            let dir = if session_dir.is_empty() {
3979                crate::core::config::get_sessions_dir()
3980            } else {
3981                std::path::PathBuf::from(session_dir)
3982            };
3983            let infos = crate::core::sessions::list_sessions_for_cwd(&cwd, &dir, true, None).await;
3984            Ok(infos
3985                .into_iter()
3986                .map(|info| {
3987                    let label = info
3988                        .name
3989                        .clone()
3990                        .filter(|n| !n.is_empty())
3991                        .unwrap_or_else(|| {
3992                            if info.first_message.is_empty() {
3993                                info.path.clone()
3994                            } else {
3995                                info.first_message.chars().take(80).collect()
3996                            }
3997                        });
3998                    super::state::SessionPickerEntry {
3999                        value: info.path,
4000                        label,
4001                        description: Some(format!("{} msgs", info.message_count)),
4002                    }
4003                })
4004                .collect())
4005        })
4006    }
4007
4008    fn get_tree_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>> {
4009        let session = self.read_session();
4010        Box::pin(async move {
4011            let manager = session.session_manager();
4012            let sm = manager.lock().await;
4013            let tree = sm.get_tree();
4014            let mut out = Vec::new();
4015            flatten_tree_nodes(&tree, 0, &mut out);
4016            Ok(out)
4017        })
4018    }
4019
4020    fn get_fork_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>> {
4021        let session = self.read_session();
4022        Box::pin(async move {
4023            let users = session.get_user_messages_for_forking().await;
4024            Ok(users
4025                .into_iter()
4026                .map(|u| super::state::TreeEntry {
4027                    value: u.entry_id,
4028                    label: u.text.chars().take(80).collect(),
4029                    depth: 0,
4030                })
4031                .collect())
4032        })
4033    }
4034
4035    fn get_trust_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
4036        let session = self.read_session();
4037        Box::pin(async move {
4038            let settings = session.lock_settings();
4039            let trust = settings.get_default_project_trust();
4040            Ok(vec![super::state::SettingsRow {
4041                id: "defaultProjectTrust".to_owned(),
4042                label: "Default project trust".to_owned(),
4043                description: Some("Trust policy for newly discovered project dirs".to_owned()),
4044                current_value: format!("{trust:?}").to_lowercase(),
4045                values: Some(vec![
4046                    "ask".to_owned(),
4047                    "always".to_owned(),
4048                    "never".to_owned(),
4049                ]),
4050            }])
4051        })
4052    }
4053
4054    fn get_auth_entries(
4055        &self,
4056    ) -> BoxFuture<'_, Result<Vec<super::state::AuthSelectorEntry>, String>> {
4057        let session = self.read_session();
4058        Box::pin(async move {
4059            let mut out = Vec::new();
4060            if let Some(runtime) = session.model_runtime_handle() {
4061                for provider in runtime.get_registered_provider_ids() {
4062                    let configured = runtime.has_configured_auth(&provider);
4063                    out.push(super::state::AuthSelectorEntry {
4064                        value: provider.clone(),
4065                        label: provider.clone(),
4066                        description: Some(if configured {
4067                            "configured".to_owned()
4068                        } else {
4069                            "not configured".to_owned()
4070                        }),
4071                    });
4072                }
4073            }
4074            if out.is_empty() {
4075                let model = session.model();
4076                out.push(super::state::AuthSelectorEntry {
4077                    value: model.provider.clone(),
4078                    label: model.provider.clone(),
4079                    description: Some("active provider".to_owned()),
4080                });
4081            }
4082            Ok(out)
4083        })
4084    }
4085
4086    fn get_scoped_models_entries(&self) -> BoxFuture<'_, Result<ScopedModelEntries, String>> {
4087        let session = self.read_session();
4088        Box::pin(async move {
4089            let scoped = session.scoped_models();
4090            let mut enabled = std::collections::BTreeMap::new();
4091            let entries = scoped
4092                .into_iter()
4093                .map(|sm| {
4094                    let value = format!("{}/{}", sm.model.provider, sm.model.id);
4095                    enabled.insert(value.clone(), true);
4096                    super::state::ModelSelectorEntry {
4097                        value,
4098                        label: if sm.model.name.is_empty() {
4099                            sm.model.id.clone()
4100                        } else {
4101                            sm.model.name.clone()
4102                        },
4103                        description: Some(sm.model.provider.clone()),
4104                    }
4105                })
4106                .collect();
4107            Ok((entries, enabled))
4108        })
4109    }
4110
4111    fn get_settings_entries(
4112        &self,
4113    ) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
4114        let session = self.read_session();
4115        Box::pin(async move {
4116            let settings = session.lock_settings();
4117            Ok(vec![
4118                super::state::SettingsRow {
4119                    id: "theme".to_owned(),
4120                    label: "Theme".to_owned(),
4121                    description: Some("Color scheme".to_owned()),
4122                    current_value: settings.get_theme().unwrap_or_else(|| "default".to_owned()),
4123                    values: Some(vec!["dark".to_owned(), "light".to_owned()]),
4124                },
4125                super::state::SettingsRow {
4126                    id: "compaction.enabled".to_owned(),
4127                    label: "Auto-compact".to_owned(),
4128                    description: Some("Automatically compact long contexts".to_owned()),
4129                    current_value: if settings.get_compaction_enabled() {
4130                        "on".to_owned()
4131                    } else {
4132                        "off".to_owned()
4133                    },
4134                    values: Some(vec!["on".to_owned(), "off".to_owned()]),
4135                },
4136                super::state::SettingsRow {
4137                    id: "retry.enabled".to_owned(),
4138                    label: "Auto-retry".to_owned(),
4139                    description: Some("Retry transient provider errors".to_owned()),
4140                    current_value: if settings.get_retry_enabled() {
4141                        "on".to_owned()
4142                    } else {
4143                        "off".to_owned()
4144                    },
4145                    values: Some(vec!["on".to_owned(), "off".to_owned()]),
4146                },
4147                super::state::SettingsRow {
4148                    id: "doubleEscapeAction".to_owned(),
4149                    label: "Double-Esc action".to_owned(),
4150                    description: Some("tree / fork / none".to_owned()),
4151                    current_value: format!("{:?}", settings.get_double_escape_action())
4152                        .to_lowercase(),
4153                    values: Some(vec![
4154                        "tree".to_owned(),
4155                        "fork".to_owned(),
4156                        "none".to_owned(),
4157                    ]),
4158                },
4159            ])
4160        })
4161    }
4162
4163    fn get_config_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
4164        let session = self.read_session();
4165        Box::pin(async move {
4166            let settings = session.lock_settings();
4167            Ok(vec![
4168                super::state::SettingsRow {
4169                    id: "quietStartup".to_owned(),
4170                    label: "Quiet startup".to_owned(),
4171                    description: Some("Suppress logo/header on launch".to_owned()),
4172                    current_value: if settings.get_quiet_startup() {
4173                        "on".to_owned()
4174                    } else {
4175                        "off".to_owned()
4176                    },
4177                    values: Some(vec!["on".to_owned(), "off".to_owned()]),
4178                },
4179                super::state::SettingsRow {
4180                    id: "showImages".to_owned(),
4181                    label: "Show images".to_owned(),
4182                    description: Some("Render inline images in the transcript".to_owned()),
4183                    current_value: if settings.get_show_images() {
4184                        "on".to_owned()
4185                    } else {
4186                        "off".to_owned()
4187                    },
4188                    values: Some(vec!["on".to_owned(), "off".to_owned()]),
4189                },
4190            ])
4191        })
4192    }
4193
4194    fn execute_bash(
4195        &self,
4196        command: &str,
4197        exclude_from_context: bool,
4198    ) -> BoxFuture<'_, Result<(), String>> {
4199        let session = self.read_session();
4200        let command = command.to_owned();
4201        Box::pin(async move {
4202            let opts = ExecuteBashOptions {
4203                exclude_from_context,
4204                ..ExecuteBashOptions::default()
4205            };
4206            session
4207                .execute_bash(command.as_str(), None::<fn(&str)>, opts)
4208                .await
4209                .map(|_| ())
4210                .map_err(|e| e.to_string())
4211        })
4212    }
4213
4214    fn new_session(&self) -> BoxFuture<'_, Result<(), String>> {
4215        let runtime = Arc::clone(&self.runtime);
4216        let host_session = Arc::clone(&self.session);
4217        Box::pin(async move {
4218            runtime
4219                .new_session(NewSessionOptions::default())
4220                .await
4221                .map(|_| ())
4222                .map_err(|err| runtime_err_to_string(&err))?;
4223            if let Ok(mut guard) = host_session.write() {
4224                *guard = runtime.session();
4225            }
4226            Ok(())
4227        })
4228    }
4229
4230    fn fork(&self, entry_id: &str) -> BoxFuture<'_, Result<(), String>> {
4231        let runtime = Arc::clone(&self.runtime);
4232        let host_session = Arc::clone(&self.session);
4233        let entry_id = entry_id.to_owned();
4234        Box::pin(async move {
4235            runtime
4236                .fork(&entry_id, ForkPosition::Before)
4237                .await
4238                .map(|_| ())
4239                .map_err(|err| runtime_err_to_string(&err))?;
4240            if let Ok(mut guard) = host_session.write() {
4241                *guard = runtime.session();
4242            }
4243            Ok(())
4244        })
4245    }
4246
4247    fn clone(&self) -> BoxFuture<'_, Result<(), String>> {
4248        let runtime = Arc::clone(&self.runtime);
4249        let host_session = Arc::clone(&self.session);
4250        Box::pin(async move {
4251            let leaf = {
4252                let session = runtime.session();
4253                let manager = session.session_manager();
4254                let sm = manager.lock().await;
4255                sm.get_leaf_id().map(str::to_owned)
4256            };
4257            let leaf =
4258                leaf.ok_or_else(|| "Cannot clone session: no current entry selected".to_owned())?;
4259            runtime
4260                .fork(&leaf, ForkPosition::At)
4261                .await
4262                .map(|_| ())
4263                .map_err(|err| runtime_err_to_string(&err))?;
4264            if let Ok(mut guard) = host_session.write() {
4265                *guard = runtime.session();
4266            }
4267            Ok(())
4268        })
4269    }
4270
4271    fn switch_session(&self, path: &str) -> BoxFuture<'_, Result<(), String>> {
4272        let runtime = Arc::clone(&self.runtime);
4273        let host_session = Arc::clone(&self.session);
4274        let path = path.to_owned();
4275        Box::pin(async move {
4276            runtime
4277                .switch_session(&path, SwitchSessionOptions::default())
4278                .await
4279                .map(|_| ())
4280                .map_err(|err| runtime_err_to_string(&err))?;
4281            if let Ok(mut guard) = host_session.write() {
4282                *guard = runtime.session();
4283            }
4284            Ok(())
4285        })
4286    }
4287
4288    fn export_html(&self, path: Option<&str>) -> BoxFuture<'_, Result<String, String>> {
4289        let session = self.read_session();
4290        let path = path.map(str::to_owned);
4291        Box::pin(async move {
4292            session
4293                .export_to_html(path.as_deref(), None)
4294                .await
4295                .map_err(|e| e.to_string())
4296        })
4297    }
4298
4299    fn set_session_name(&self, name: &str) -> BoxFuture<'_, Result<(), String>> {
4300        let session = self.read_session();
4301        let name = name.to_owned();
4302        Box::pin(async move {
4303            session
4304                .set_session_name(&name)
4305                .await
4306                .map_err(|e| e.to_string())
4307        })
4308    }
4309
4310    fn logout(&self) -> BoxFuture<'_, Result<(), String>> {
4311        Box::pin(async { Ok(()) })
4312    }
4313
4314    fn last_assistant_text(&self) -> BoxFuture<'_, Result<Option<String>, String>> {
4315        let session = self.read_session();
4316        Box::pin(async move { Ok(session.get_last_assistant_text()) })
4317    }
4318}
4319
4320/// Map a runtime error into a `String` for [`SessionHost`] consumers.
4321fn runtime_err_to_string(err: &AgentSessionRuntimeError) -> String {
4322    err.to_string()
4323}
4324
4325fn flatten_tree_nodes(
4326    nodes: &[crate::core::sessions::SessionTreeNode],
4327    depth: usize,
4328    out: &mut Vec<super::state::TreeEntry>,
4329) {
4330    for node in nodes {
4331        let id = node.entry.id().unwrap_or("").to_owned();
4332        let label = node
4333            .label
4334            .clone()
4335            .unwrap_or_else(|| tree_entry_label(&node.entry));
4336        if !id.is_empty() {
4337            out.push(super::state::TreeEntry {
4338                value: id,
4339                label,
4340                depth,
4341            });
4342        }
4343        flatten_tree_nodes(&node.children, depth.saturating_add(1), out);
4344    }
4345}
4346
4347fn tree_entry_label(entry: &crate::core::sessions::SessionEntry) -> String {
4348    use crate::core::sessions::SessionEntry;
4349    match entry {
4350        SessionEntry::Message(message) => {
4351            let text =
4352                crate::core::agent_session::tree::extract_user_message_text_pub(&message.message);
4353            if text.is_empty() {
4354                message.message.role().to_owned()
4355            } else {
4356                text.chars().take(80).collect()
4357            }
4358        }
4359        SessionEntry::Compaction(compaction) => format!(
4360            "compaction: {}",
4361            compaction.summary.chars().take(40).collect::<String>()
4362        ),
4363        SessionEntry::BranchSummary(branch) => format!(
4364            "branch: {}",
4365            branch.summary.chars().take(40).collect::<String>()
4366        ),
4367        SessionEntry::Custom(custom) => format!("custom:{}", custom.custom_type),
4368        SessionEntry::CustomMessage(custom) => {
4369            format!("custom_message:{}", custom.custom_type)
4370        }
4371        SessionEntry::Label(label) => format!("label:{}", label.id),
4372        SessionEntry::SessionInfo(info) => format!("session_info:{}", info.id),
4373        SessionEntry::ThinkingLevelChange(change) => {
4374            format!("thinking:{}", change.thinking_level)
4375        }
4376        SessionEntry::ModelChange(change) => {
4377            format!("model:{}/{}", change.provider, change.model_id)
4378        }
4379        SessionEntry::Unknown(_) => "unknown".to_owned(),
4380    }
4381}
4382
4383/// Initial terminal size before raw mode (an ioctl, not an escape probe).
4384///
4385/// Returns `(80, 24)` when the size cannot be queried (non-tty stdout).
4386fn initial_terminal_size() -> (u16, u16) {
4387    match crossterm::terminal::size() {
4388        Ok((width, height)) => (width.clamp(20, 1024), height.clamp(1, 256)),
4389        Err(_) => (80, 24),
4390    }
4391}
4392
4393fn install_product_panic_emergency_hook<W>(
4394    emergency: Arc<std::sync::atomic::AtomicBool>,
4395    writer: W,
4396) -> Arc<dyn Fn() + Send + Sync>
4397where
4398    W: Write + Send + 'static,
4399{
4400    let writer = std::sync::Mutex::new(writer);
4401    let restore: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
4402        if let Ok(mut writer) = writer.lock() {
4403            let _ = write_emergency_restore_bytes(&mut *writer);
4404        }
4405    });
4406    install_panic_emergency_hook(emergency, Arc::clone(&restore));
4407    restore
4408}
4409
4410/// Run interactive mode end-to-end against a real [`AgentSessionRuntime`].
4411///
4412/// Wires (in order):
4413/// 1. `io::stdout()` handle + initial ioctl size.
4414/// 2. Panic emergency-restore hook and [`TerminalGuard`] viewport/activation.
4415/// 3. [`Tui<Stdout>`] construction with the cached capabilities + size.
4416/// 4. [`TerminalInput::spawn`] (sole `EventStream` owner).
4417/// 5. [`AgentSessionHost`] wrapping the runtime.
4418/// 6. [`InteractiveRuntime::run`] to completion.
4419///
4420/// On exit the runtime is dropped, then the guard (which writes the restore
4421/// bytes via its `Drop` impl). Returns the process exit code.
4422///
4423/// # Errors
4424///
4425/// Returns an error string when terminal initialization fails. The caller
4426/// should surface it on stderr and exit nonzero.
4427pub async fn run_interactive_mode(
4428    runtime: Arc<AgentSessionRuntime>,
4429    options: InteractiveRuntimeOptions,
4430) -> Result<u8, String> {
4431    use std::io::stdout;
4432    if !stdout().is_terminal() {
4433        return Err("interactive mode requires a tty".to_owned());
4434    }
4435
4436    // 1. Capture the real terminal size before enabling raw mode. The guard
4437    // parks the cursor below this viewport on every normal restore.
4438    let size = initial_terminal_size();
4439    let mut guard = TerminalGuard::new(stdout());
4440    guard.set_viewport_bottom_row(size.1.saturating_sub(1));
4441    let _panic_restore = install_product_panic_emergency_hook(guard.emergency_flag(), stdout());
4442    let enable_kitty = !cfg!(windows);
4443    guard
4444        .activate(enable_kitty)
4445        .map_err(|e| format!("terminal activation failed: {e}"))?;
4446
4447    // 2. Tui takes a separate stdout handle (Stdout is a cheap cloneable
4448    //    handle to the same underlying stream). No stdout clone of the
4449    //    process's stdout fd — both handles write to the OS stream, but Tui
4450    //    is the sole writer of paint bytes (guard only wrote mode setup).
4451    let stdout_writer = stdout();
4452    let viewport_height = options.viewport_height.max(1).min(size.1);
4453    let tui = Tui::new(
4454        stdout_writer,
4455        ratatui::layout::Size::new(size.0, size.1),
4456        ratatui::layout::Position::ORIGIN,
4457        viewport_height,
4458        options.caps.clone(),
4459    )
4460    .map_err(|e| format!("tui initialization failed: {e}"))?;
4461
4462    // 3. Spawn the sole TerminalInput task.
4463    let input = TerminalInput::spawn();
4464
4465    // 4. Wire the host and runtime. Session replacement rebinds the host's
4466    //    cached session Arc; InteractiveRuntime also rebinds events/partial
4467    //    via an interior rebind signal.
4468    let host = AgentSessionHost::new(Arc::clone(&runtime));
4469    let host_arc = Arc::new(host);
4470
4471    // Initial bind: emits the stored session_start{startup} to extensions
4472    // and runs bind-time resource discovery. Bind errors are non-fatal
4473    // extension errors (the session survives with base resources).
4474    let _ = host_arc
4475        .session()
4476        .bind_extensions(crate::core::agent_session::ExtensionBindings {
4477            mode: Some(crate::core::agent_session::ExtensionMode::Tui),
4478            ..Default::default()
4479        })
4480        .await;
4481
4482    // Rebind callback keeps AgentSessionHost's cached session Arc current and
4483    // binds the replacement session (emitting its stored
4484    // session_start{new|resume|fork}).
4485    {
4486        let host_for_rebind = Arc::clone(&host_arc);
4487        runtime.set_rebind_session(Some(Arc::new(move |_session| {
4488            let host_for_rebind = Arc::clone(&host_for_rebind);
4489            Box::pin(async move {
4490                host_for_rebind.refresh();
4491                let _ = host_for_rebind
4492                    .session()
4493                    .bind_extensions(crate::core::agent_session::ExtensionBindings {
4494                        mode: Some(crate::core::agent_session::ExtensionMode::Tui),
4495                        ..Default::default()
4496                    })
4497                    .await;
4498            })
4499        })));
4500    }
4501
4502    let mut rt = InteractiveRuntime::new(tui, input, host_arc, &options);
4503
4504    // 5. Drive the loop. Suspend restores the terminal, raises SIGTSTP on
4505    //    Unix, then resumes/resizes and re-enters run() without exiting.
4506    let exit = loop {
4507        let exit = rt.run().await;
4508        // Resize events update the runtime view while the guard remains owned
4509        // here. Synchronize before every path that can restore terminal modes.
4510        guard.set_viewport_bottom_row(rt.viewport_bottom_row());
4511        let exit = exit.map_err(|e| format!("runtime loop: {e}"))?;
4512        match exit {
4513            InteractiveExit::Suspend => {
4514                // Drop active selector focus so resume returns to the editor.
4515                rt.close_selector_for_suspend();
4516                // Restore modes, suspend the process, then re-activate using
4517                // the terminal dimensions observed after SIGCONT.
4518                guard
4519                    .suspend()
4520                    .map_err(|e| format!("terminal suspend failed: {e}"))?;
4521                let size = initial_terminal_size();
4522                guard.set_viewport_bottom_row(size.1.saturating_sub(1));
4523                guard
4524                    .resume(enable_kitty)
4525                    .map_err(|e| format!("terminal resume failed: {e}"))?;
4526                // Reanchor without a clear and retain the runtime's clamped
4527                // view row as the source for the next normal restore.
4528                let _ = rt
4529                    .step_ui(UiEvent::Resize {
4530                        width: size.0,
4531                        height: size.1,
4532                    })
4533                    .await;
4534                guard.set_viewport_bottom_row(rt.viewport_bottom_row());
4535                // Rebind channels in case a replacement happened while we
4536                // were suspended (defensive; replacement normally rebinds
4537                // via the host callback + next action).
4538                rt.rebind_session_channels().await;
4539            }
4540            InteractiveExit::ExternalEditor => {
4541                run_external_editor_handoff(&mut rt, &mut guard, enable_kitty).await?;
4542            }
4543            other => break other,
4544        }
4545    };
4546
4547    // 6. Drop runtime first so any final paint commits before guard restore.
4548    drop(rt);
4549    runtime.set_rebind_session(None);
4550
4551    // 7. Guard restores on Drop. Convert exit kind to a process exit code.
4552    let code = match exit {
4553        InteractiveExit::Clean
4554        | InteractiveExit::SessionEnded
4555        | InteractiveExit::Suspend
4556        | InteractiveExit::ExternalEditor => 0u8,
4557        InteractiveExit::IoFailure | InteractiveExit::DrawDeadlock => 1u8,
4558    };
4559
4560    guard.restore();
4561    Ok(code)
4562}
4563
4564/// Hand the terminal to the configured external editor, then restore the
4565/// interactive session and apply the edited prompt text.
4566async fn run_external_editor_handoff<W, G, S>(
4567    rt: &mut InteractiveRuntime<W, S>,
4568    guard: &mut TerminalGuard<G>,
4569    enable_kitty: bool,
4570) -> Result<(), String>
4571where
4572    W: Write,
4573    G: Write,
4574    S: SessionHost,
4575{
4576    let initial = rt.editor.get_text();
4577    let editor_command = rt.session.external_editor_command();
4578    rt.input
4579        .pause()
4580        .await
4581        .map_err(|e| format!("pause terminal input for editor: {e}"))?;
4582    guard.restore();
4583
4584    let cancel = CancellationToken::new();
4585    let cancel_on_shutdown = cancel.clone();
4586    let shutdown = Arc::clone(&rt.shutdown);
4587    let watcher = tokio::spawn(async move {
4588        shutdown.notified().await;
4589        cancel_on_shutdown.cancel();
4590    });
4591    let edited = edit_text_in_external_editor(&editor_command, &initial, &cancel)
4592        .await
4593        .map_err(|error| error.to_string());
4594    watcher.abort();
4595
4596    guard
4597        .resume(enable_kitty)
4598        .map_err(|e| format!("terminal resume after editor failed: {e}"))?;
4599    rt.input
4600        .resume(Vec::new())
4601        .await
4602        .map_err(|e| format!("resume terminal input after editor: {e}"))?;
4603    rt.exited = false;
4604    rt.exit_kind = InteractiveExit::Clean;
4605    match edited {
4606        Ok(EditOutcome::Changed(text)) => {
4607            rt.editor.set_text(&text);
4608            rt.view.editor.text = text;
4609        }
4610        Ok(EditOutcome::Unchanged | EditOutcome::Aborted) => {}
4611        Err(error) => rt.last_error = Some(error),
4612    }
4613    let size = initial_terminal_size();
4614    guard.set_viewport_bottom_row(size.1.saturating_sub(1));
4615    let _ = rt
4616        .step_ui(UiEvent::Resize {
4617            width: size.0,
4618            height: size.1,
4619        })
4620        .await;
4621    guard.set_viewport_bottom_row(rt.viewport_bottom_row());
4622    Ok(())
4623}
4624
4625/// Extension trait so the host can subscribe with an [`Arc<EventListener>`].
4626/// [`AgentSession::subscribe`] takes `Fn(&Event)` (not `Arc`) and returns an
4627/// unsubscribe closure. We adapt by cloning the Arc into a wrapped fn.
4628trait AgentSessionSubscribeExt {
4629    fn subscribe_arc_listener(
4630        &self,
4631        listener: AgentSessionEventListener,
4632    ) -> Box<dyn FnOnce() + Send + Sync>;
4633}
4634
4635impl AgentSessionSubscribeExt for AgentSession {
4636    fn subscribe_arc_listener(
4637        &self,
4638        listener: AgentSessionEventListener,
4639    ) -> Box<dyn FnOnce() + Send + Sync> {
4640        let unsubscribe = self.subscribe(move |event: &AgentSessionEvent| {
4641            listener(event);
4642        });
4643        Box::new(unsubscribe)
4644    }
4645}
4646
4647async fn recv_extension_event(
4648    receiver: &mut Option<tokio::sync::broadcast::Receiver<ExtensionUiEvent>>,
4649) -> Option<ExtensionUiEvent> {
4650    match receiver {
4651        Some(receiver) => loop {
4652            match receiver.recv().await {
4653                Ok(event) => return Some(event),
4654                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
4655                Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
4656            }
4657        },
4658        None => std::future::pending().await,
4659    }
4660}
4661
4662async fn recv_extension_request(
4663    receiver: &mut Option<mpsc::Receiver<HostUiRequest>>,
4664) -> Option<HostUiRequest> {
4665    match receiver {
4666        Some(receiver) => receiver.recv().await,
4667        None => std::future::pending().await,
4668    }
4669}
4670
4671async fn wait_extension_deadline(deadline: Option<Instant>) {
4672    match deadline {
4673        Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
4674        None => std::future::pending().await,
4675    }
4676}
4677
4678fn dialog_timeout(request: &HostUiRequest) -> Option<Duration> {
4679    let timeout_ms = match request {
4680        HostUiRequest::Select { request, .. } => request.options_meta.timeout_ms,
4681        HostUiRequest::Confirm { request, .. } => request.options_meta.timeout_ms,
4682        HostUiRequest::Input { request, .. } => request.options_meta.timeout_ms,
4683        HostUiRequest::Editor { .. } => None,
4684    }?;
4685    Some(Duration::from_millis(timeout_ms))
4686}
4687
4688fn extension_dialog_title(request: &HostUiRequest) -> String {
4689    match request {
4690        HostUiRequest::Select { request, .. } => request.title.clone(),
4691        HostUiRequest::Confirm { request, .. } => {
4692            format!("{}\n{}", request.title, request.message)
4693        }
4694        HostUiRequest::Input { request, .. } => request.title.clone(),
4695        HostUiRequest::Editor { request, .. } => request.title.clone(),
4696    }
4697}
4698
4699const RESERVED_EXTENSION_SHORTCUTS: &[&str] = &[
4700    "escape",
4701    "ctrl+c",
4702    "ctrl+d",
4703    "ctrl+z",
4704    "shift+tab",
4705    "ctrl+p",
4706    "shift+ctrl+p",
4707    "ctrl+l",
4708    "ctrl+o",
4709    "ctrl+t",
4710    "ctrl+g",
4711    "ctrl+x",
4712    "alt+enter",
4713    "enter",
4714    "ctrl+k",
4715];
4716
4717fn build_effective_extension_shortcuts(
4718    registrations: &[pi_ext::adapters::ShortcutRegistration],
4719) -> Vec<EffectiveExtensionShortcut> {
4720    let reserved = RESERVED_EXTENSION_SHORTCUTS
4721        .iter()
4722        .filter_map(|key| parse_key_id(key).ok())
4723        .map(|key| key.canonical_id())
4724        .collect::<Vec<_>>();
4725    let mut effective = Vec::<EffectiveExtensionShortcut>::new();
4726    for registration in registrations {
4727        let Ok(parsed) = parse_key_id(&registration.key) else {
4728            continue;
4729        };
4730        let key = parsed.canonical_id().as_str().to_owned();
4731        if reserved.iter().any(|reserved| reserved.as_str() == key) {
4732            continue;
4733        }
4734        effective.retain(|shortcut| shortcut.key != key);
4735        effective.push(EffectiveExtensionShortcut {
4736            key,
4737            dispatch_key: registration.key.clone(),
4738            parsed,
4739            description: registration.description.clone(),
4740            source: registration.extension_path.clone(),
4741        });
4742    }
4743    effective
4744}
4745
4746fn shortcut_hints(shortcuts: &[EffectiveExtensionShortcut]) -> Vec<super::state::ShortcutHint> {
4747    shortcuts
4748        .iter()
4749        .map(|shortcut| super::state::ShortcutHint {
4750            key: shortcut.key.clone(),
4751            action: shortcut
4752                .description
4753                .clone()
4754                .or_else(|| shortcut.source.clone())
4755                .unwrap_or_else(|| "Extension shortcut".to_owned()),
4756        })
4757        .collect()
4758}
4759
4760fn ui_event_wire(event: &UiEvent) -> UiEventWire {
4761    match event {
4762        UiEvent::Key(key) => {
4763            let (code, modifiers) = pi_tui::keys::normalize_event(key)
4764                .unwrap_or_else(|| (format!("{:?}", key.code), key.modifiers));
4765            UiEventWire::Key {
4766                code,
4767                modifiers: KeyModifiersWire {
4768                    shift: modifiers
4769                        .contains(crossterm::event::KeyModifiers::SHIFT)
4770                        .then_some(true),
4771                    alt: modifiers
4772                        .contains(crossterm::event::KeyModifiers::ALT)
4773                        .then_some(true),
4774                    ctrl: modifiers
4775                        .contains(crossterm::event::KeyModifiers::CONTROL)
4776                        .then_some(true),
4777                    super_key: modifiers
4778                        .contains(crossterm::event::KeyModifiers::SUPER)
4779                        .then_some(true),
4780                },
4781                kind: match key.kind {
4782                    crossterm::event::KeyEventKind::Press => KeyEventKindWire::Press,
4783                    crossterm::event::KeyEventKind::Repeat => KeyEventKindWire::Repeat,
4784                    crossterm::event::KeyEventKind::Release => KeyEventKindWire::Release,
4785                },
4786            }
4787        }
4788        UiEvent::Paste(text) => UiEventWire::Paste { text: text.clone() },
4789        UiEvent::FocusGained => UiEventWire::FocusGained,
4790        UiEvent::FocusLost => UiEventWire::FocusLost,
4791        UiEvent::Resize { width, height } => UiEventWire::Resize {
4792            width: *width,
4793            height: *height,
4794        },
4795    }
4796}
4797
4798fn default_extension_dialog_response(request: &HostUiRequest) -> HostUiResponse {
4799    match request {
4800        HostUiRequest::Select { id, .. } => HostUiResponse::Select {
4801            id: *id,
4802            value: None,
4803        },
4804        HostUiRequest::Confirm { id, .. } => HostUiResponse::Confirm {
4805            id: *id,
4806            confirmed: false,
4807        },
4808        HostUiRequest::Input { id, .. } => HostUiResponse::Input {
4809            id: *id,
4810            value: None,
4811        },
4812        HostUiRequest::Editor { id, .. } => HostUiResponse::Editor {
4813            id: *id,
4814            value: None,
4815        },
4816    }
4817}
4818
4819fn encode_terminal_input(event: &UiEvent) -> Option<String> {
4820    match event {
4821        UiEvent::Paste(text) => Some(text.clone()),
4822        UiEvent::Key(key) => encode_key_event(key),
4823        UiEvent::FocusGained | UiEvent::FocusLost | UiEvent::Resize { .. } => None,
4824    }
4825}
4826
4827fn decode_terminal_input(data: String) -> UiEvent {
4828    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4829    let key = match data.as_str() {
4830        "\r" | "\n" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
4831        "\t" => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
4832        "\u{7f}" | "\u{8}" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
4833        "\u{1b}" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
4834        "\u{1b}[A" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
4835        "\u{1b}[B" => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
4836        "\u{1b}[C" => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
4837        "\u{1b}[D" => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
4838        "\u{1b}[H" => Some(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
4839        "\u{1b}[F" => Some(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)),
4840        "\u{1b}[3~" => Some(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)),
4841        "\u{1b}[Z" => Some(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)),
4842        _ if data.starts_with('\u{1b}') && data.chars().count() == 2 => data
4843            .chars()
4844            .nth(1)
4845            .map(|character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::ALT)),
4846        _ => {
4847            let mut characters = data.chars();
4848            match (characters.next(), characters.next()) {
4849                (Some(character), None) if (character as u32) < 0x20 => {
4850                    let letter = char::from((character as u8) | 0x60);
4851                    Some(KeyEvent::new(KeyCode::Char(letter), KeyModifiers::CONTROL))
4852                }
4853                (Some(character), None) => {
4854                    Some(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE))
4855                }
4856                _ => None,
4857            }
4858        }
4859    };
4860    key.map_or(UiEvent::Paste(data), UiEvent::Key)
4861}
4862
4863fn is_global_app_binding(event: &UiEvent) -> bool {
4864    let UiEvent::Key(key) = event else {
4865        return false;
4866    };
4867    if key.kind == crossterm::event::KeyEventKind::Release {
4868        return false;
4869    }
4870    let modifiers = key.modifiers;
4871    match key.code {
4872        KeyCode::Esc => true,
4873        KeyCode::BackTab | KeyCode::Tab => modifiers == KeyModifiers::SHIFT,
4874        KeyCode::Enter | KeyCode::Up => modifiers == KeyModifiers::ALT,
4875        KeyCode::Char(character) => {
4876            let character = character.to_ascii_lowercase();
4877            (modifiers == KeyModifiers::CONTROL
4878                && matches!(
4879                    character,
4880                    'c' | 'd' | 'z' | 'p' | 'l' | 'o' | 't' | 'g' | 'x' | 'r' | 'b' | 'f' | 'n'
4881                ))
4882                || (character == 'p'
4883                    && modifiers.contains(KeyModifiers::CONTROL)
4884                    && modifiers.contains(KeyModifiers::SHIFT))
4885                || (character == 'v'
4886                    && matches!(modifiers, KeyModifiers::CONTROL | KeyModifiers::ALT))
4887        }
4888        _ => false,
4889    }
4890}
4891
4892#[cfg(test)]
4893mod tests {
4894    use std::path::PathBuf;
4895    use std::sync::Arc;
4896    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4897    use std::time::Duration;
4898
4899    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4900    use futures::future::BoxFuture;
4901    use futures::stream::{self, StreamExt};
4902    use pi_ai::{
4903        AssistantContent, AssistantMessage, AssistantMessageEvent, Context, DoneReason, Model,
4904        ModelCost, ModelInput, Provider, ProviderError, StopReason, StreamOptions, TextContent,
4905    };
4906    use pi_tui::component::UiEvent;
4907    use pi_tui::terminal::caps::TerminalCapabilities;
4908    use pi_tui::terminal::writer::Tui;
4909    use ratatui::layout::{Position, Size};
4910    use tokio::sync::{Mutex, mpsc, watch};
4911
4912    use super::*;
4913    use crate::core::agent_session::events::AgentSessionEvent;
4914    use crate::core::agent_session::{AgentSession, AgentSessionConfig};
4915    use crate::core::agent_session_runtime::{
4916        AgentSessionRuntimeServices, CreateAgentSessionRuntimeFactory,
4917        CreateAgentSessionRuntimeOptions, CreateAgentSessionRuntimeResult,
4918    };
4919    use crate::modes::interactive::state::SelectorKind;
4920
4921    /// Records every action dispatched to it; tests assert on the call log.
4922    #[derive(Default)]
4923    struct ActionLog {
4924        prompts: Mutex<Vec<String>>,
4925        prompt_images: Mutex<Vec<Vec<ImageContent>>>,
4926        bash_started: Notify,
4927        bash_release: Notify,
4928        prompt_behaviors: Mutex<Vec<Option<StreamingBehavior>>>,
4929        aborts: Mutex<u32>,
4930        compacts: Mutex<Vec<Option<String>>>,
4931        cycles: Mutex<u32>,
4932        reloads: Mutex<u32>,
4933        bashes: Mutex<Vec<(String, bool)>>,
4934        new_sessions: Mutex<u32>,
4935        forks: Mutex<Vec<String>>,
4936        clones: Mutex<u32>,
4937        switches: Mutex<Vec<String>>,
4938        logouts: Mutex<u32>,
4939        follows: Mutex<Vec<String>>,
4940        steers: Mutex<Vec<String>>,
4941        last_text: Mutex<Option<String>>,
4942    }
4943
4944    struct FakeHost {
4945        log: Arc<ActionLog>,
4946        partial_tx: watch::Sender<Option<Arc<AssistantMessage>>>,
4947        snapshot: Arc<std::sync::Mutex<SessionSnapshot>>,
4948        event_senders: Arc<std::sync::Mutex<Vec<mpsc::UnboundedSender<AgentSessionEvent>>>>,
4949        stream_chunks: Arc<AtomicUsize>,
4950    }
4951
4952    type GateEvents = Vec<Result<AssistantMessageEvent, ProviderError>>;
4953    type GateResponses = Arc<std::sync::Mutex<Vec<GateEvents>>>;
4954
4955    #[derive(Clone)]
4956    struct GateProvider {
4957        hold: Arc<AtomicBool>,
4958        started: Arc<AtomicBool>,
4959        responses: GateResponses,
4960    }
4961
4962    impl Provider for GateProvider {
4963        fn stream(
4964            &self,
4965            _model: &Model,
4966            _context: Context,
4967            options: StreamOptions,
4968        ) -> futures::stream::BoxStream<'static, Result<AssistantMessageEvent, ProviderError>>
4969        {
4970            let hold = Arc::clone(&self.hold);
4971            let started = Arc::clone(&self.started);
4972            let responses = Arc::clone(&self.responses);
4973            let signal = options.signal.clone();
4974            let events = responses
4975                .lock()
4976                .unwrap_or_else(std::sync::PoisonError::into_inner)
4977                .pop()
4978                .unwrap_or_default();
4979            stream::once(async move {
4980                started.store(true, Ordering::SeqCst);
4981                // Hold mid-turn so admission remains active, but honor
4982                // provider cancel so abort/settle cannot deadlock.
4983                loop {
4984                    if !hold.load(Ordering::SeqCst) {
4985                        break;
4986                    }
4987                    if signal
4988                        .as_ref()
4989                        .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
4990                    {
4991                        break;
4992                    }
4993                    tokio::task::yield_now().await;
4994                }
4995                stream::iter(events)
4996            })
4997            .flatten()
4998            .boxed()
4999        }
5000    }
5001
5002    struct UnusedReplacementFactory;
5003
5004    impl CreateAgentSessionRuntimeFactory for UnusedReplacementFactory {
5005        fn create(
5006            &self,
5007            _options: CreateAgentSessionRuntimeOptions,
5008        ) -> BoxFuture<'_, Result<CreateAgentSessionRuntimeResult, AgentSessionRuntimeError>>
5009        {
5010            Box::pin(async {
5011                Err(AgentSessionRuntimeError::Factory(
5012                    "replacement factory unused by admission projection test".to_owned(),
5013                ))
5014            })
5015        }
5016    }
5017
5018    struct AdmissionFixture {
5019        runtime: InteractiveRuntime<SharedWriter, AgentSessionHost>,
5020        session_runtime: Arc<AgentSessionRuntime>,
5021        hold: Arc<AtomicBool>,
5022        started: Arc<AtomicBool>,
5023    }
5024
5025    fn make_admission_fixture() -> Result<AdmissionFixture, String> {
5026        let model = Model {
5027            id: "m".to_owned(),
5028            name: "m".to_owned(),
5029            api: "test-api".to_owned(),
5030            provider: "test-provider".to_owned(),
5031            base_url: String::new(),
5032            reasoning: false,
5033            thinking_level_map: None,
5034            input: vec![ModelInput::Text],
5035            cost: ModelCost::default(),
5036            context_window: 8_192,
5037            max_tokens: 1_024,
5038            headers: None,
5039            compat: None,
5040            extra: std::collections::BTreeMap::new(),
5041        };
5042        let mut done =
5043            AssistantMessage::new("test-api", "test-provider", "m", pi_agent::now_millis());
5044        done.content
5045            .push(AssistantContent::Text(TextContent::new("ok")));
5046        done.stop_reason = StopReason::Stop;
5047        let hold = Arc::new(AtomicBool::new(true));
5048        let started = Arc::new(AtomicBool::new(false));
5049        // Responses are popped LIFO: second call (steered continuation) first.
5050        let provider = Arc::new(GateProvider {
5051            hold: Arc::clone(&hold),
5052            started: Arc::clone(&started),
5053            responses: Arc::new(std::sync::Mutex::new(vec![
5054                vec![
5055                    Ok(AssistantMessageEvent::Start {
5056                        partial: AssistantMessage::new(
5057                            "test-api",
5058                            "test-provider",
5059                            "m",
5060                            pi_agent::now_millis(),
5061                        ),
5062                    }),
5063                    Ok(AssistantMessageEvent::Done {
5064                        reason: DoneReason::Stop,
5065                        message: done.clone(),
5066                    }),
5067                ],
5068                vec![
5069                    Ok(AssistantMessageEvent::Start {
5070                        partial: AssistantMessage::new(
5071                            "test-api",
5072                            "test-provider",
5073                            "m",
5074                            pi_agent::now_millis(),
5075                        ),
5076                    }),
5077                    Ok(AssistantMessageEvent::Done {
5078                        reason: DoneReason::Stop,
5079                        message: done,
5080                    }),
5081                ],
5082            ])),
5083        });
5084        let session = AgentSession::new(
5085            AgentSessionConfig::test_config(provider, model)
5086                .map_err(|error| format!("session config: {error}"))?,
5087        )
5088        .map_err(|error| format!("session create: {error}"))?;
5089        let session_runtime = Arc::new(AgentSessionRuntime::new(
5090            session,
5091            AgentSessionRuntimeServices {
5092                cwd: PathBuf::from("."),
5093                agent_dir: PathBuf::from("."),
5094            },
5095            Arc::new(UnusedReplacementFactory),
5096            Vec::new(),
5097            None,
5098        ));
5099        let host = AgentSessionHost::new(Arc::clone(&session_runtime));
5100        let tui = Tui::new(
5101            SharedWriter::new(),
5102            Size::new(80, 24),
5103            Position::ORIGIN,
5104            8,
5105            TerminalCapabilities::default(),
5106        )
5107        .map_err(|error| format!("tui construction: {error}"))?;
5108        let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
5109        let input = TerminalInput::mock(rx);
5110        let options = InteractiveRuntimeOptions {
5111            size: (80, 24),
5112            ..InteractiveRuntimeOptions::default()
5113        };
5114        Ok(AdmissionFixture {
5115            runtime: InteractiveRuntime::new(tui, input, Arc::new(host), &options),
5116            session_runtime,
5117            hold,
5118            started,
5119        })
5120    }
5121
5122    impl FakeHost {
5123        fn new() -> (Self, Arc<ActionLog>) {
5124            let log = Arc::new(ActionLog::default());
5125            let (partial_tx, _partial_rx) = watch::channel(None);
5126            let host = Self {
5127                log: Arc::clone(&log),
5128                partial_tx,
5129                snapshot: Arc::new(std::sync::Mutex::new(SessionSnapshot::default())),
5130                event_senders: Arc::new(std::sync::Mutex::new(Vec::new())),
5131                stream_chunks: Arc::new(AtomicUsize::new(0)),
5132            };
5133            (host, log)
5134        }
5135
5136        fn set_stream_chunks(&self, chunks: usize) {
5137            self.stream_chunks.store(chunks, Ordering::SeqCst);
5138        }
5139    }
5140
5141    impl SessionHost for FakeHost {
5142        fn snapshot(&self) -> SessionSnapshot {
5143            self.snapshot
5144                .lock()
5145                .unwrap_or_else(std::sync::PoisonError::into_inner)
5146                .clone()
5147        }
5148
5149        fn subscribe(&self) -> EventSubscription {
5150            let (tx, rx) = mpsc::unbounded_channel();
5151            self.event_senders
5152                .lock()
5153                .unwrap_or_else(std::sync::PoisonError::into_inner)
5154                .push(tx);
5155            EventSubscription {
5156                rx,
5157                unsubscribe: None,
5158            }
5159        }
5160
5161        fn partial_rx(&self) -> watch::Receiver<Option<Arc<AssistantMessage>>> {
5162            self.partial_tx.subscribe()
5163        }
5164
5165        fn prompt(&self, text: &str, opts: PromptOptions) -> BoxFuture<'_, Result<(), String>> {
5166            let log = Arc::clone(&self.log);
5167            let owned = text.to_owned();
5168            let partial_tx = self.partial_tx.clone();
5169            let snapshot = Arc::clone(&self.snapshot);
5170            let stream_chunks = Arc::clone(&self.stream_chunks);
5171            Box::pin(async move {
5172                log.prompts.lock().await.push(owned);
5173                log.prompt_images.lock().await.push(opts.images.clone());
5174                log.prompt_behaviors
5175                    .lock()
5176                    .await
5177                    .push(opts.streaming_behavior);
5178                if opts.streaming_behavior.is_some() {
5179                    return Ok(());
5180                }
5181
5182                let chunks = stream_chunks.load(Ordering::SeqCst);
5183                if chunks == 0 {
5184                    return Ok(());
5185                }
5186                {
5187                    let mut state = snapshot
5188                        .lock()
5189                        .unwrap_or_else(std::sync::PoisonError::into_inner);
5190                    state.admission_active = true;
5191                    state.activity = SessionActivity::Streaming;
5192                }
5193                for index in 0..chunks {
5194                    let text = if index + 1 == chunks {
5195                        "<<Done>>".to_owned()
5196                    } else {
5197                        format!("stream-chunk-{index:02}")
5198                    };
5199                    let mut message = AssistantMessage::new("test", "test", "test", 0);
5200                    message
5201                        .content
5202                        .push(AssistantContent::Text(TextContent::new(text)));
5203                    partial_tx.send_replace(Some(Arc::new(message)));
5204                    tokio::time::sleep(Duration::from_millis(2)).await;
5205                }
5206                {
5207                    let mut state = snapshot
5208                        .lock()
5209                        .unwrap_or_else(std::sync::PoisonError::into_inner);
5210                    state.admission_active = false;
5211                    state.activity = SessionActivity::Idle;
5212                }
5213                Ok(())
5214            })
5215        }
5216
5217        fn steer(&self, text: &str) -> BoxFuture<'_, Result<(), String>> {
5218            let log = Arc::clone(&self.log);
5219            let owned = text.to_owned();
5220            Box::pin(async move {
5221                log.steers.lock().await.push(owned);
5222                Ok(())
5223            })
5224        }
5225
5226        fn follow_up(&self, text: &str) -> BoxFuture<'_, Result<(), String>> {
5227            let log = Arc::clone(&self.log);
5228            let owned = text.to_owned();
5229            Box::pin(async move {
5230                log.follows.lock().await.push(owned);
5231                Ok(())
5232            })
5233        }
5234
5235        fn abort(&self) -> BoxFuture<'static, Result<(), String>> {
5236            let log = Arc::clone(&self.log);
5237            Box::pin(async move {
5238                *log.aborts.lock().await += 1;
5239                log.bash_release.notify_one();
5240                Ok(())
5241            })
5242        }
5243
5244        fn compact(&self, instructions: Option<&str>) -> BoxFuture<'_, Result<(), String>> {
5245            let log = Arc::clone(&self.log);
5246            let instructions = instructions.map(str::to_owned);
5247            Box::pin(async move {
5248                log.compacts.lock().await.push(instructions);
5249                Ok(())
5250            })
5251        }
5252
5253        fn cycle_thinking_level(&self) -> BoxFuture<'_, Result<(), String>> {
5254            let log = Arc::clone(&self.log);
5255            Box::pin(async move {
5256                *log.cycles.lock().await += 1;
5257                Ok(())
5258            })
5259        }
5260
5261        fn cycle_model(&self, _forward: bool) -> BoxFuture<'_, Result<(), String>> {
5262            let log = Arc::clone(&self.log);
5263            Box::pin(async move {
5264                *log.cycles.lock().await += 1;
5265                Ok(())
5266            })
5267        }
5268
5269        fn reload(&self) -> BoxFuture<'_, Result<(), String>> {
5270            let log = Arc::clone(&self.log);
5271            Box::pin(async move {
5272                *log.reloads.lock().await += 1;
5273                Ok(())
5274            })
5275        }
5276
5277        fn execute_bash(&self, command: &str, exclude: bool) -> BoxFuture<'_, Result<(), String>> {
5278            let log = Arc::clone(&self.log);
5279            let owned = command.to_owned();
5280            Box::pin(async move {
5281                let should_wait = owned == "hang";
5282                log.bashes.lock().await.push((owned, exclude));
5283                if should_wait {
5284                    log.bash_started.notify_one();
5285                    log.bash_release.notified().await;
5286                }
5287                Ok(())
5288            })
5289        }
5290
5291        fn new_session(&self) -> BoxFuture<'_, Result<(), String>> {
5292            let log = Arc::clone(&self.log);
5293            Box::pin(async move {
5294                *log.new_sessions.lock().await += 1;
5295                Ok(())
5296            })
5297        }
5298
5299        fn fork(&self, entry_id: &str) -> BoxFuture<'_, Result<(), String>> {
5300            let log = Arc::clone(&self.log);
5301            let owned = entry_id.to_owned();
5302            Box::pin(async move {
5303                log.forks.lock().await.push(owned);
5304                Ok(())
5305            })
5306        }
5307
5308        fn clone(&self) -> BoxFuture<'_, Result<(), String>> {
5309            let log = Arc::clone(&self.log);
5310            Box::pin(async move {
5311                *log.clones.lock().await += 1;
5312                Ok(())
5313            })
5314        }
5315
5316        fn switch_session(&self, path: &str) -> BoxFuture<'_, Result<(), String>> {
5317            let log = Arc::clone(&self.log);
5318            let owned = path.to_owned();
5319            Box::pin(async move {
5320                log.switches.lock().await.push(owned);
5321                Ok(())
5322            })
5323        }
5324
5325        fn export_html(&self, _path: Option<&str>) -> BoxFuture<'_, Result<String, String>> {
5326            Box::pin(async { Ok("<html></html>".to_owned()) })
5327        }
5328
5329        fn set_session_name(&self, _name: &str) -> BoxFuture<'_, Result<(), String>> {
5330            Box::pin(async { Ok(()) })
5331        }
5332
5333        fn logout(&self) -> BoxFuture<'_, Result<(), String>> {
5334            let log = Arc::clone(&self.log);
5335            Box::pin(async move {
5336                *log.logouts.lock().await += 1;
5337                Ok(())
5338            })
5339        }
5340
5341        fn messages(&self) -> Vec<pi_agent::AgentMessage> {
5342            Vec::new()
5343        }
5344
5345        fn get_model_entries(
5346            &self,
5347        ) -> BoxFuture<'_, Result<Vec<super::state::ModelSelectorEntry>, String>> {
5348            Box::pin(async {
5349                Ok(vec![super::state::ModelSelectorEntry {
5350                    value: "test/model".to_owned(),
5351                    label: "Test Model".to_owned(),
5352                    description: None,
5353                }])
5354            })
5355        }
5356
5357        fn get_session_entries(
5358            &self,
5359        ) -> BoxFuture<'_, Result<Vec<super::state::SessionPickerEntry>, String>> {
5360            Box::pin(async {
5361                Ok(vec![super::state::SessionPickerEntry {
5362                    value: "/tmp/sess.jsonl".to_owned(),
5363                    label: "fixture session".to_owned(),
5364                    description: None,
5365                }])
5366            })
5367        }
5368
5369        fn get_tree_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>> {
5370            Box::pin(async {
5371                Ok(vec![super::state::TreeEntry {
5372                    value: "root".to_owned(),
5373                    label: "root".to_owned(),
5374                    depth: 0,
5375                }])
5376            })
5377        }
5378
5379        fn get_fork_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>> {
5380            Box::pin(async {
5381                Ok(vec![super::state::TreeEntry {
5382                    value: "user-1".to_owned(),
5383                    label: "hello".to_owned(),
5384                    depth: 0,
5385                }])
5386            })
5387        }
5388
5389        fn get_trust_entries(
5390            &self,
5391        ) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
5392            Box::pin(async {
5393                Ok(vec![super::state::SettingsRow {
5394                    id: "defaultProjectTrust".to_owned(),
5395                    label: "Default project trust".to_owned(),
5396                    description: None,
5397                    current_value: "ask".to_owned(),
5398                    values: Some(vec![
5399                        "ask".to_owned(),
5400                        "always".to_owned(),
5401                        "never".to_owned(),
5402                    ]),
5403                }])
5404            })
5405        }
5406
5407        fn get_auth_entries(
5408            &self,
5409        ) -> BoxFuture<'_, Result<Vec<super::state::AuthSelectorEntry>, String>> {
5410            Box::pin(async {
5411                Ok(vec![super::state::AuthSelectorEntry {
5412                    value: "anthropic".to_owned(),
5413                    label: "Anthropic".to_owned(),
5414                    description: Some("configured".to_owned()),
5415                }])
5416            })
5417        }
5418
5419        fn get_scoped_models_entries(
5420            &self,
5421        ) -> BoxFuture<
5422            '_,
5423            Result<
5424                (
5425                    Vec<super::state::ModelSelectorEntry>,
5426                    std::collections::BTreeMap<String, bool>,
5427                ),
5428                String,
5429            >,
5430        > {
5431            Box::pin(async {
5432                let mut enabled = std::collections::BTreeMap::new();
5433                enabled.insert("test/model".to_owned(), true);
5434                Ok((
5435                    vec![super::state::ModelSelectorEntry {
5436                        value: "test/model".to_owned(),
5437                        label: "Test Model".to_owned(),
5438                        description: None,
5439                    }],
5440                    enabled,
5441                ))
5442            })
5443        }
5444
5445        fn get_settings_entries(
5446            &self,
5447        ) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
5448            Box::pin(async {
5449                Ok(vec![super::state::SettingsRow {
5450                    id: "theme".to_owned(),
5451                    label: "Theme".to_owned(),
5452                    description: None,
5453                    current_value: "dark".to_owned(),
5454                    values: Some(vec!["dark".to_owned(), "light".to_owned()]),
5455                }])
5456            })
5457        }
5458
5459        fn get_config_entries(
5460            &self,
5461        ) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
5462            Box::pin(async {
5463                Ok(vec![super::state::SettingsRow {
5464                    id: "quietStartup".to_owned(),
5465                    label: "Quiet startup".to_owned(),
5466                    description: None,
5467                    current_value: "off".to_owned(),
5468                    values: Some(vec!["on".to_owned(), "off".to_owned()]),
5469                }])
5470            })
5471        }
5472
5473        fn last_assistant_text(&self) -> BoxFuture<'_, Result<Option<String>, String>> {
5474            let log = Arc::clone(&self.log);
5475            Box::pin(async move {
5476                let t = log.last_text.lock().await.clone();
5477                Ok(t)
5478            })
5479        }
5480    }
5481
5482    fn key(code: KeyCode, mods: KeyModifiers) -> UiEvent {
5483        UiEvent::Key(KeyEvent::new(code, mods))
5484    }
5485
5486    fn try_make_runtime()
5487    -> Result<(InteractiveRuntime<SharedWriter, FakeHost>, Arc<ActionLog>), String> {
5488        let writer = SharedWriter::new();
5489        let caps = TerminalCapabilities::default();
5490        let tui = Tui::new(writer, Size::new(80, 24), Position::ORIGIN, 8, caps)
5491            .map_err(|error| format!("tui construction: {error}"))?;
5492        let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
5493        let input = TerminalInput::mock(rx);
5494        let (host, log) = FakeHost::new();
5495        let options = InteractiveRuntimeOptions {
5496            size: (80, 24),
5497            ..InteractiveRuntimeOptions::default()
5498        };
5499        let mut rt = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
5500        let _ = rt.paint_now();
5501        Ok((rt, log))
5502    }
5503
5504    #[test]
5505    fn runtime_options_control_hardware_cursor_visibility() -> Result<(), String> {
5506        for (enabled, expected, unexpected) in [
5507            (true, b"\x1b[?25h".as_slice(), b"\x1b[?25l".as_slice()),
5508            (false, b"\x1b[?25l".as_slice(), b"\x1b[?25h".as_slice()),
5509        ] {
5510            let writer = SharedWriter::new();
5511            let captured = writer.clone();
5512            let tui = Tui::new(
5513                writer,
5514                Size::new(80, 24),
5515                Position::ORIGIN,
5516                8,
5517                TerminalCapabilities::default(),
5518            )
5519            .map_err(|error| format!("tui construction: {error}"))?;
5520            let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
5521            let input = TerminalInput::mock(rx);
5522            let (host, _log) = FakeHost::new();
5523            let options = InteractiveRuntimeOptions {
5524                hardware_cursor: enabled,
5525                size: (80, 24),
5526                ..InteractiveRuntimeOptions::default()
5527            };
5528            let mut runtime = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
5529
5530            runtime
5531                .paint_now()
5532                .map_err(|error| format!("initial paint: {error}"))?;
5533
5534            let bytes = captured.snapshot();
5535            let expected_position = bytes
5536                .windows(expected.len())
5537                .rposition(|window| window == expected)
5538                .ok_or("missing configured cursor visibility sequence")?;
5539            let unexpected_position = bytes
5540                .windows(unexpected.len())
5541                .rposition(|window| window == unexpected);
5542            if unexpected_position.is_some_and(|position| position > expected_position) {
5543                return Err("configured cursor visibility was not the final decision".to_owned());
5544            }
5545        }
5546        Ok(())
5547    }
5548
5549    #[tokio::test]
5550    async fn bash_stays_interruptible_and_rejects_overlap() -> Result<(), String> {
5551        let (mut rt, log) = try_make_runtime()?;
5552        let _ = rt.dispatch_bash("hang", false).await;
5553        let _ = rt.dispatch_bash("second", false).await;
5554        assert_eq!(
5555            rt.last_error.as_deref(),
5556            Some("a bash command is already running")
5557        );
5558        tokio::time::timeout(Duration::from_secs(1), log.bash_started.notified())
5559            .await
5560            .map_err(|_| "bash operation did not start".to_owned())?;
5561        assert_eq!(rt.view.editor.border, EditorBorder::Bash);
5562
5563        let _ = rt.dispatch_interrupt().await;
5564        assert_eq!(*log.aborts.lock().await, 1);
5565        assert_eq!(
5566            log.bashes.lock().await.as_slice(),
5567            &[("hang".to_owned(), false)]
5568        );
5569        let completion = tokio::time::timeout(
5570            Duration::from_secs(1),
5571            rt.prompt_operations.tasks.join_next(),
5572        )
5573        .await
5574        .map_err(|_| "bash operation did not finish after abort".to_owned())?
5575        .ok_or_else(|| "bash operation task was missing".to_owned())?;
5576        assert!(rt.handle_prompt_completion(completion));
5577        assert_eq!(rt.view.editor.border, EditorBorder::Muted);
5578        Ok(())
5579    }
5580
5581    fn make_runtime() -> (InteractiveRuntime<SharedWriter, FakeHost>, Arc<ActionLog>) {
5582        match try_make_runtime() {
5583            Ok(runtime) => runtime,
5584            Err(error) => std::panic::resume_unwind(Box::new(error)),
5585        }
5586    }
5587
5588    #[tokio::test]
5589    async fn dispatch_submit_calls_prompt_on_host() {
5590        let (mut rt, log) = make_runtime();
5591        let _ = rt
5592            .dispatch_action(ViewAction::Submit {
5593                text: "hello".to_owned(),
5594            })
5595            .await;
5596        let prompts = log.prompts.lock().await.clone();
5597        assert_eq!(prompts, vec!["hello".to_owned()]);
5598    }
5599
5600    #[tokio::test]
5601    async fn startup_options_dispatch_prompts_images_and_migrations_in_order() -> Result<(), String>
5602    {
5603        let (host, log) = FakeHost::new();
5604        let image = ImageContent::new("AA==", "image/png");
5605        let options = InteractiveRuntimeOptions {
5606            initial_message: Some("first".to_owned()),
5607            initial_images: vec![image.clone()],
5608            remaining_messages: vec!["second".to_owned(), "third".to_owned()],
5609            migrations: MigrationResult {
5610                migrated_auth_providers: vec!["openrouter".to_owned()],
5611                deprecation_warnings: vec!["legacy extension directory".to_owned()],
5612            },
5613            ..InteractiveRuntimeOptions::default()
5614        };
5615        let writer = SharedWriter::new();
5616        let tui = Tui::new(
5617            writer,
5618            Size::new(80, 24),
5619            Position::ORIGIN,
5620            8,
5621            TerminalCapabilities::default(),
5622        )
5623        .map_err(|error| format!("tui construction failed: {error}"))?;
5624        let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
5625        let input = TerminalInput::mock(rx);
5626        let mut rt = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
5627
5628        assert!(rt.initialize_run().await);
5629        while let Some(completion) = rt.prompt_operations.tasks.join_next().await {
5630            rt.handle_prompt_completion(completion);
5631            if rt.prompt_operations.tasks.is_empty() {
5632                rt.enqueue_next_startup_prompt().await;
5633            }
5634        }
5635
5636        assert_eq!(
5637            log.prompts.lock().await.as_slice(),
5638            ["first", "second", "third"]
5639        );
5640        assert_eq!(
5641            log.prompt_images.lock().await.as_slice(),
5642            [vec![image], Vec::new(), Vec::new()]
5643        );
5644        assert!(
5645            rt.view
5646                .diagnostics
5647                .entries
5648                .iter()
5649                .any(|entry| { entry.message == "Migrated credentials to auth.json: openrouter" })
5650        );
5651        assert!(
5652            rt.view
5653                .diagnostics
5654                .entries
5655                .iter()
5656                .any(|entry| { entry.message == "legacy extension directory" })
5657        );
5658        Ok(())
5659    }
5660
5661    #[tokio::test]
5662    async fn dispatch_quit_exits_without_prompting() {
5663        let (mut rt, log) = make_runtime();
5664        let outcome = rt
5665            .dispatch_action(ViewAction::Submit {
5666                text: "/quit".to_owned(),
5667            })
5668            .await;
5669        assert_eq!(outcome, ActionOutcome::Exit);
5670        assert!(log.prompts.lock().await.is_empty());
5671    }
5672
5673    #[tokio::test]
5674    async fn dispatch_interrupt_calls_abort() {
5675        let (mut rt, log) = make_runtime();
5676        let _ = rt.dispatch_action(ViewAction::Interrupt).await;
5677        assert_eq!(*log.aborts.lock().await, 1);
5678    }
5679
5680    #[tokio::test]
5681    async fn dispatch_compact_passes_through() {
5682        let (mut rt, log) = make_runtime();
5683        let _ = rt
5684            .dispatch_action(ViewAction::Compact {
5685                instructions: Some("focus on tools".to_owned()),
5686            })
5687            .await;
5688        assert_eq!(
5689            *log.compacts.lock().await,
5690            vec![Some("focus on tools".to_owned())]
5691        );
5692    }
5693
5694    #[tokio::test]
5695    async fn dispatch_bash_routes_to_execute_bash() {
5696        let (mut rt, log) = make_runtime();
5697        let _ = rt
5698            .dispatch_action(ViewAction::SubmitBash {
5699                command: "ls".to_owned(),
5700                exclude_from_context: true,
5701            })
5702            .await;
5703        let bashes = log.bashes.lock().await.clone();
5704        assert_eq!(bashes, vec![("ls".to_owned(), true)]);
5705    }
5706
5707    #[tokio::test]
5708    async fn dispatch_slash_command_with_args() {
5709        let (mut rt, log) = make_runtime();
5710        let _ = rt
5711            .dispatch_action(ViewAction::SlashCommand {
5712                name: "name".to_owned(),
5713                args: "my session".to_owned(),
5714            })
5715            .await;
5716        let prompts = log.prompts.lock().await.clone();
5717        assert_eq!(prompts, vec!["/name my session".to_owned()]);
5718    }
5719
5720    #[tokio::test]
5721    async fn dispatch_bang_prefix_routes_to_bash_not_prompt() {
5722        let (mut rt, log) = make_runtime();
5723        let _ = rt
5724            .dispatch_action(ViewAction::Submit {
5725                text: "!ls -la".to_owned(),
5726            })
5727            .await;
5728        let bashes = log.bashes.lock().await.clone();
5729        assert_eq!(bashes, vec![("ls -la".to_owned(), false)]);
5730        let prompts = log.prompts.lock().await.clone();
5731        assert!(prompts.is_empty());
5732    }
5733
5734    #[tokio::test]
5735    async fn dispatch_double_bang_routes_to_excluded_bash() {
5736        let (mut rt, log) = make_runtime();
5737        let _ = rt
5738            .dispatch_action(ViewAction::Submit {
5739                text: "!!rm -rf /tmp/x".to_owned(),
5740            })
5741            .await;
5742        let bashes = log.bashes.lock().await.clone();
5743        assert_eq!(bashes, vec![("rm -rf /tmp/x".to_owned(), true)]);
5744    }
5745
5746    #[tokio::test]
5747    async fn dispatch_clear_editor_empties_view() {
5748        let (mut rt, _log) = make_runtime();
5749        rt.view.editor.text = "draft".to_owned();
5750        rt.editor.set_text("draft");
5751        let _ = rt.dispatch_action(ViewAction::ClearEditor).await;
5752        assert!(rt.view.editor.text.is_empty());
5753        assert!(rt.editor.get_text().is_empty());
5754    }
5755
5756    #[tokio::test]
5757    async fn dispatch_open_overlay_sets_focus_to_overlay() {
5758        let (mut rt, _log) = make_runtime();
5759        let _ = rt
5760            .dispatch_action(ViewAction::ShowOverlay {
5761                kind: OverlayKind::ShortcutHelp,
5762            })
5763            .await;
5764        assert_eq!(rt.view.focus, FocusArea::Overlay);
5765        assert!(rt.view.overlay.is_some());
5766    }
5767
5768    #[tokio::test]
5769    async fn dispatch_dismiss_overlay_restores_editor_focus() {
5770        let (mut rt, _log) = make_runtime();
5771        let _ = rt
5772            .dispatch_action(ViewAction::ShowOverlay {
5773                kind: OverlayKind::Changelog,
5774            })
5775            .await;
5776        assert_eq!(rt.view.focus, FocusArea::Overlay);
5777        let _ = rt.dispatch_action(ViewAction::DismissOverlay).await;
5778        assert_eq!(rt.view.focus, FocusArea::Editor);
5779        assert!(rt.view.overlay.is_none());
5780    }
5781
5782    #[tokio::test]
5783    async fn project_event_agent_start_sets_streaming_status() {
5784        let mut view = ViewState::empty();
5785        project_event(&mut view, &AgentSessionEvent::AgentStart);
5786        assert!(view.streaming);
5787        assert!(view.status.is_some());
5788    }
5789
5790    #[tokio::test]
5791    async fn project_event_agent_end_clears_status() {
5792        let mut view = ViewState::empty();
5793        project_event(&mut view, &AgentSessionEvent::AgentStart);
5794        project_event(
5795            &mut view,
5796            &AgentSessionEvent::AgentEnd {
5797                messages: Vec::new(),
5798                will_retry: false,
5799            },
5800        );
5801        assert!(!view.streaming);
5802        assert!(view.status.is_none());
5803    }
5804
5805    #[test]
5806    fn project_snapshot_projects_summarizing_and_pending_queues() {
5807        let mut view = ViewState::empty();
5808        let snapshot = SessionSnapshot {
5809            activity: SessionActivity::Summarizing,
5810            steering: vec!["steer".to_owned()],
5811            follow_up: vec!["later".to_owned()],
5812            follow_up_mode: super::state::QueueMode::All,
5813            ..SessionSnapshot::default()
5814        };
5815        project_snapshot(&mut view, &snapshot, None);
5816        assert_eq!(
5817            view.status.as_ref().map(|status| status.kind),
5818            Some(StatusKind::BranchSummary)
5819        );
5820        assert_eq!(view.pending.steering[0].text, "steer");
5821        assert_eq!(view.pending.follow_up[0].text, "later");
5822        assert_eq!(view.pending.follow_up_mode, super::state::QueueMode::All);
5823    }
5824
5825    #[test]
5826    fn project_footer_sets_stats_billing_and_border_from_one_snapshot() {
5827        let mut view = ViewState::empty();
5828        project_footer(
5829            &mut view,
5830            &SessionFooterSnapshot {
5831                total_input: 10,
5832                total_output: 20,
5833                total_cache_read: 30,
5834                total_cache_write: 40,
5835                total_cost: 1.25,
5836                context_window: 200,
5837                context_percent: Some(50.0),
5838                provider: Some("provider".to_owned()),
5839                provider_count: 2,
5840                thinking_level: pi_ai::ModelThinkingLevel::High,
5841                subscription: true,
5842                auto_compact: false,
5843                ..SessionFooterSnapshot::default()
5844            },
5845        );
5846        assert_eq!(view.footer.total_input, 10);
5847        assert_eq!(view.footer.total_output, 20);
5848        assert_eq!(view.footer.total_cache_read, 30);
5849        assert_eq!(view.footer.total_cache_write, 40);
5850        assert!((view.footer.total_cost - 1.25).abs() <= f64::EPSILON);
5851        assert_eq!(view.footer.context_percent, Some(50.0));
5852        assert_eq!(view.footer.provider.as_deref(), Some("provider"));
5853        assert_eq!(view.footer.provider_count, 2);
5854        assert_eq!(view.footer.flags.billing, BillingMode::Subscription);
5855        assert!(!view.footer.flags.auto_compact);
5856        assert_eq!(
5857            view.editor.border,
5858            EditorBorder::Thinking(pi_ai::ModelThinkingLevel::High)
5859        );
5860    }
5861
5862    #[tokio::test]
5863    async fn project_event_queue_update_syncs_pending_lists() {
5864        let mut view = ViewState::empty();
5865        project_event(
5866            &mut view,
5867            &AgentSessionEvent::QueueUpdate {
5868                steering: vec!["s1".to_owned()],
5869                follow_up: vec!["f1".to_owned(), "f2".to_owned()],
5870            },
5871        );
5872        assert_eq!(view.pending.steering.len(), 1);
5873        assert_eq!(view.pending.follow_up.len(), 2);
5874    }
5875
5876    #[tokio::test]
5877    async fn project_event_compaction_start_sets_status() -> Result<(), String> {
5878        let mut view = ViewState::empty();
5879        project_event(
5880            &mut view,
5881            &AgentSessionEvent::CompactionStart {
5882                reason: crate::core::agent_session::events::CompactionReason::Manual,
5883            },
5884        );
5885        let status = view.status.as_ref().ok_or("compaction status not set")?;
5886        assert_eq!(status.kind, StatusKind::Compaction);
5887        Ok(())
5888    }
5889
5890    #[tokio::test]
5891    async fn project_event_auto_retry_start_sets_retry_status() -> Result<(), String> {
5892        let mut view = ViewState::empty();
5893        project_event(
5894            &mut view,
5895            &AgentSessionEvent::AutoRetryStart {
5896                attempt: 2,
5897                max_attempts: 5,
5898                delay_ms: 2000,
5899                error_message: "x".to_owned(),
5900            },
5901        );
5902        let status = view.status.as_ref().ok_or("retry status not set")?;
5903        assert_eq!(status.kind, StatusKind::Retry);
5904        Ok(())
5905    }
5906
5907    #[tokio::test]
5908    async fn project_snapshot_streaming_state_projects_to_view() -> Result<(), String> {
5909        let mut view = ViewState::empty();
5910        let snap = SessionSnapshot {
5911            admission_active: true,
5912            activity: SessionActivity::Streaming,
5913            ..SessionSnapshot::default()
5914        };
5915        project_snapshot(&mut view, &snap, None);
5916        assert!(view.streaming);
5917        let status = view.status.as_ref().ok_or("working status not set")?;
5918        assert_eq!(status.kind, StatusKind::Working);
5919        Ok(())
5920    }
5921
5922    #[tokio::test]
5923    async fn pre_stream_admission_projects_busy_and_routes_submit_as_steer() -> Result<(), String> {
5924        let (mut rt, log) = try_make_runtime()?;
5925        let admitted = SessionSnapshot {
5926            admission_active: true,
5927            activity: SessionActivity::Idle,
5928            ..SessionSnapshot::default()
5929        };
5930        *rt.session
5931            .snapshot
5932            .lock()
5933            .unwrap_or_else(std::sync::PoisonError::into_inner) = admitted.clone();
5934        project_snapshot(&mut rt.view, &admitted, None);
5935
5936        assert!(
5937            rt.view.streaming,
5938            "admitted pre-stream session projected idle"
5939        );
5940        let status = rt
5941            .view
5942            .status
5943            .as_ref()
5944            .ok_or("admitted pre-stream session has no working status")?;
5945        assert_eq!(status.kind, StatusKind::Working);
5946
5947        let _ = rt
5948            .dispatch_action(ViewAction::Submit {
5949                text: "second".to_owned(),
5950            })
5951            .await;
5952        assert_eq!(
5953            *log.prompt_behaviors.lock().await,
5954            vec![Some(StreamingBehavior::Steer)]
5955        );
5956        rt.quiesce_prompt_operations().await;
5957        Ok(())
5958    }
5959
5960    /// Real [`AgentSessionHost`] projection/admission regression.
5961    ///
5962    /// Proves the production host surfaces `admission_active` from a live
5963    /// admitted `AgentSession` (not a hand-built `FakeHost` snapshot), that
5964    /// projection marks the view busy, and that a second submit is routed as
5965    /// steering while the run remains admitted.
5966    #[tokio::test]
5967    async fn agent_session_host_admission_projects_busy_and_routes_steer() -> Result<(), String> {
5968        let AdmissionFixture {
5969            runtime: mut rt,
5970            session_runtime,
5971            hold,
5972            started,
5973        } = make_admission_fixture()?;
5974
5975        // Admit a live run through the real host/session path, then hold the
5976        // provider mid-turn so admission remains active for the second submit.
5977        let _ = rt
5978            .dispatch_action(ViewAction::Submit {
5979                text: "first".to_owned(),
5980            })
5981            .await;
5982        tokio::time::timeout(Duration::from_secs(2), async {
5983            while !started.load(Ordering::SeqCst) {
5984                tokio::task::yield_now().await;
5985            }
5986        })
5987        .await
5988        .map_err(|_| "provider did not start after first submit".to_owned())?;
5989
5990        let snap = rt.session.snapshot();
5991        assert!(
5992            snap.admission_active,
5993            "AgentSessionHost must report admission from the live session flag"
5994        );
5995        assert_eq!(snap.activity, SessionActivity::Streaming);
5996
5997        let mut view = ViewState::empty();
5998        project_snapshot(&mut view, &snap, None);
5999        assert!(view.streaming, "admitted host snapshot must project busy");
6000        let status = view
6001            .status
6002            .as_ref()
6003            .ok_or("admitted host snapshot missing working status")?;
6004        assert_eq!(status.kind, StatusKind::Working);
6005
6006        // Runtime submit path must observe the real host snapshot and steer.
6007        project_snapshot(&mut rt.view, &snap, None);
6008        let _ = rt
6009            .dispatch_action(ViewAction::Submit {
6010                text: "second".to_owned(),
6011            })
6012            .await;
6013        let (steering, follow_up) = session_runtime.session().pending_messages();
6014        assert!(
6015            steering.iter().any(|text| text == "second"),
6016            "expected second submit steered into pending queue, got steering={steering:?} follow_up={follow_up:?}"
6017        );
6018
6019        // Assertions already proved the host/projection contract. Cleanup must
6020        // not depend on session.abort()/wait_for_idle while the same prompt
6021        // task still holds admission; abort the runtime-owned tasks instead.
6022        hold.store(false, Ordering::SeqCst);
6023        session_runtime.session().agent().abort();
6024        rt.prompt_operations.tasks.abort_all();
6025        while rt.prompt_operations.tasks.join_next().await.is_some() {}
6026        Ok(())
6027    }
6028
6029    #[tokio::test]
6030    async fn step_ui_ctrl_l_opens_model_selector() -> Result<(), String> {
6031        let (mut rt, _log) = try_make_runtime()?;
6032        rt.step_ui(key(KeyCode::Char('l'), KeyModifiers::CONTROL))
6033            .await
6034            .map_err(|error| format!("model selector step failed: {error}"))?;
6035        assert_eq!(rt.view.focus, FocusArea::Selector);
6036        Ok(())
6037    }
6038
6039    #[tokio::test]
6040    async fn step_ui_ctrl_z_requests_suspend() -> Result<(), String> {
6041        let (mut rt, _log) = try_make_runtime()?;
6042        rt.step_ui(key(KeyCode::Char('z'), KeyModifiers::CONTROL))
6043            .await
6044            .map_err(|error| format!("suspend step failed: {error}"))?;
6045        assert!(rt.exited);
6046        assert_eq!(rt.exit_kind, InteractiveExit::Suspend);
6047        Ok(())
6048    }
6049
6050    #[tokio::test]
6051    async fn step_ui_resize_updates_tui_size_cache() -> Result<(), String> {
6052        let (mut rt, _log) = try_make_runtime()?;
6053        rt.step_ui(UiEvent::Resize {
6054            width: 100,
6055            height: 40,
6056        })
6057        .await
6058        .map_err(|error| format!("resize step failed: {error}"))?;
6059        assert_eq!(rt.tui.size(), Size::new(100, 40));
6060        assert_eq!(rt.view.width, 100);
6061        assert_eq!(rt.view.height, 40);
6062        Ok(())
6063    }
6064
6065    #[tokio::test]
6066    async fn step_ui_paste_inserts_into_editor() -> Result<(), String> {
6067        let (mut rt, _log) = try_make_runtime()?;
6068        rt.step_ui(UiEvent::Paste("hello paste".to_owned()))
6069            .await
6070            .map_err(|error| format!("paste step failed: {error}"))?;
6071        assert_eq!(rt.editor.get_text(), "hello paste");
6072        assert_eq!(rt.view.editor.text, "hello paste");
6073        Ok(())
6074    }
6075
6076    #[tokio::test]
6077    async fn step_session_event_agent_start_marks_streaming() -> Result<(), String> {
6078        let (mut rt, _log) = try_make_runtime()?;
6079        rt.step_session_event(AgentSessionEvent::AgentStart)
6080            .await
6081            .map_err(|error| format!("session event step failed: {error}"))?;
6082        assert!(rt.view.streaming);
6083        assert!(rt.view.status.is_some());
6084        Ok(())
6085    }
6086
6087    #[tokio::test]
6088    async fn flush_coalescer_clears_deadline_and_paints() -> Result<(), String> {
6089        let (mut rt, _log) = try_make_runtime()?;
6090        rt.arm_coalescer();
6091        assert!(rt.coalesce_deadline.is_some());
6092        rt.flush_coalescer()
6093            .map_err(|error| format!("coalescer flush failed: {error}"))?;
6094        assert!(rt.coalesce_deadline.is_none());
6095        Ok(())
6096    }
6097
6098    #[tokio::test]
6099    async fn prompt_stream_paints_an_intermediate_chunk_before_done() -> Result<(), String> {
6100        let writer = SharedWriter::new();
6101        let captured = writer.clone();
6102        let tui = Tui::new(
6103            writer,
6104            Size::new(80, 24),
6105            Position::ORIGIN,
6106            8,
6107            TerminalCapabilities::default(),
6108        )
6109        .map_err(|error| format!("tui construction failed: {error}"))?;
6110        let (_input_tx, input_rx) = mpsc::unbounded_channel::<UiEvent>();
6111        let input = TerminalInput::mock(input_rx);
6112        let (host, _log) = FakeHost::new();
6113        host.set_stream_chunks(16);
6114        let options = InteractiveRuntimeOptions {
6115            size: (80, 24),
6116            ..InteractiveRuntimeOptions::default()
6117        };
6118        let mut rt = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
6119
6120        let _ = rt
6121            .dispatch_action(ViewAction::Submit {
6122                text: "stream".to_owned(),
6123            })
6124            .await;
6125        let shutdown_flag = Arc::clone(&rt.shutdown_flag);
6126        let shutdown = Arc::clone(&rt.shutdown);
6127        tokio::spawn(async move {
6128            tokio::time::sleep(Duration::from_millis(75)).await;
6129            shutdown_flag.store(true, std::sync::atomic::Ordering::SeqCst);
6130            shutdown.notify_one();
6131        });
6132
6133        let exit = tokio::time::timeout(Duration::from_millis(500), rt.run())
6134            .await
6135            .map_err(|_| "runtime blocked on prompt".to_owned())?
6136            .map_err(|error| format!("runtime failed: {error}"))?;
6137        assert_eq!(exit, InteractiveExit::Clean);
6138
6139        let output = String::from_utf8_lossy(&captured.snapshot()).into_owned();
6140        let intermediate = output
6141            .find("stream-chunk-")
6142            .ok_or("no intermediate streaming frame")?;
6143        let done = output.rfind("Done").ok_or("no final Done frame")?;
6144        assert!(
6145            intermediate < done,
6146            "intermediate frame must be written before Done"
6147        );
6148        Ok(())
6149    }
6150
6151    #[tokio::test]
6152    async fn rapid_second_submit_reenters_prompt_with_streaming_behavior() -> Result<(), String> {
6153        let writer = SharedWriter::new();
6154        let tui = Tui::new(
6155            writer,
6156            Size::new(80, 24),
6157            Position::ORIGIN,
6158            8,
6159            TerminalCapabilities::default(),
6160        )
6161        .map_err(|error| format!("tui construction failed: {error}"))?;
6162        let (_input_tx, input_rx) = mpsc::unbounded_channel::<UiEvent>();
6163        let input = TerminalInput::mock(input_rx);
6164        let (host, log) = FakeHost::new();
6165        host.set_stream_chunks(16);
6166        let mut rt = InteractiveRuntime::new(
6167            tui,
6168            input,
6169            Arc::new(host),
6170            &InteractiveRuntimeOptions::default(),
6171        );
6172
6173        let _ = rt
6174            .dispatch_action(ViewAction::Submit {
6175                text: "first".to_owned(),
6176            })
6177            .await;
6178        let _ = rt
6179            .dispatch_action(ViewAction::Submit {
6180                text: "second".to_owned(),
6181            })
6182            .await;
6183
6184        assert_eq!(
6185            *log.prompt_behaviors.lock().await,
6186            vec![None, Some(StreamingBehavior::Steer)]
6187        );
6188        rt.quiesce_prompt_operations().await;
6189        Ok(())
6190    }
6191
6192    #[tokio::test]
6193    async fn session_replacement_aborts_and_drains_prompt_operations() -> Result<(), String> {
6194        let writer = SharedWriter::new();
6195        let tui = Tui::new(
6196            writer,
6197            Size::new(80, 24),
6198            Position::ORIGIN,
6199            8,
6200            TerminalCapabilities::default(),
6201        )
6202        .map_err(|error| format!("tui construction failed: {error}"))?;
6203        let (_input_tx, input_rx) = mpsc::unbounded_channel::<UiEvent>();
6204        let input = TerminalInput::mock(input_rx);
6205        let (host, log) = FakeHost::new();
6206        host.set_stream_chunks(8);
6207        let mut rt = InteractiveRuntime::new(
6208            tui,
6209            input,
6210            Arc::new(host),
6211            &InteractiveRuntimeOptions::default(),
6212        );
6213        let _ = rt
6214            .dispatch_action(ViewAction::Submit {
6215                text: "old session".to_owned(),
6216            })
6217            .await;
6218
6219        let _ = rt.dispatch_action(ViewAction::NewSession).await;
6220
6221        assert_eq!(*log.aborts.lock().await, 1);
6222        assert_eq!(*log.new_sessions.lock().await, 1);
6223        assert!(rt.prompt_operations.tasks.is_empty());
6224        assert!(rt.prompt_operations.aborts.is_empty());
6225        Ok(())
6226    }
6227
6228    #[tokio::test]
6229    async fn viewport_bottom_row_tracks_terminal_resize() -> Result<(), String> {
6230        let (mut rt, _log) = try_make_runtime()?;
6231        assert_eq!(rt.viewport_bottom_row(), 23);
6232
6233        rt.step_ui(UiEvent::Resize {
6234            width: 100,
6235            height: 41,
6236        })
6237        .await
6238        .map_err(|error| format!("resize step failed: {error}"))?;
6239
6240        assert_eq!(rt.viewport_bottom_row(), 40);
6241        Ok(())
6242    }
6243    #[test]
6244    fn editor_only_repaint_reuses_long_transcript_chat_components() -> io::Result<()> {
6245        let (mut rt, _log) = make_runtime();
6246        rt.view.messages = (0..1_000)
6247            .map(|index| {
6248                MessageView::User(crate::modes::interactive::messages::UserMessageView {
6249                    text: format!("message {index} with **markdown**"),
6250                })
6251            })
6252            .collect();
6253        rt.chat_prefix_cache = None;
6254        rt.chat_prefix_len = usize::MAX;
6255        rt.chat_tail_cache = None;
6256        rt.chat_dirty = true;
6257        rt.paint_frame()?;
6258
6259        let prefix_before = rt
6260            .chat_prefix_cache
6261            .as_deref()
6262            .map(|component| std::ptr::from_ref(component).cast::<()>())
6263            .ok_or_else(|| io::Error::other("missing prefix cache"))?;
6264        let tail_before = rt
6265            .chat_tail_cache
6266            .as_deref()
6267            .map(|component| std::ptr::from_ref(component).cast::<()>())
6268            .ok_or_else(|| io::Error::other("missing tail cache"))?;
6269
6270        rt.editor.set_text("editor-only change");
6271        rt.view.editor.text = "editor-only change".to_owned();
6272        rt.paint_frame()?;
6273
6274        assert_eq!(rt.chat_prefix_len, 999);
6275        assert_eq!(
6276            rt.chat_prefix_cache
6277                .as_deref()
6278                .map(|component| std::ptr::from_ref(component).cast::<()>()),
6279            Some(prefix_before)
6280        );
6281        assert_eq!(
6282            rt.chat_tail_cache
6283                .as_deref()
6284                .map(|component| std::ptr::from_ref(component).cast::<()>()),
6285            Some(tail_before)
6286        );
6287        Ok(())
6288    }
6289
6290    #[test]
6291    fn installed_product_panic_hook_emits_complete_restore_sequence() -> io::Result<()> {
6292        const CHILD_ENV: &str = "PI_TEST_PRODUCT_PANIC_HOOK_PATH";
6293        if let Some(path) = std::env::var_os(CHILD_ENV) {
6294            let writer = std::fs::OpenOptions::new()
6295                .create(true)
6296                .append(true)
6297                .open(path)?;
6298            let emergency = Arc::new(std::sync::atomic::AtomicBool::new(false));
6299            let _restore = install_product_panic_emergency_hook(emergency, writer);
6300            // The fixture MUST execute the installed panic hook;
6301            // `resume_unwind` deliberately bypasses hooks, so an explicit
6302            // panic is the only honest trigger. Test-only lint exception.
6303            #[allow(clippy::panic)]
6304            {
6305                panic!("intentional product panic-hook fixture");
6306            }
6307        }
6308
6309        let directory = tempfile::tempdir()?;
6310        let capture = directory.path().join("panic-restore.bin");
6311        let output = std::process::Command::new(std::env::current_exe()?)
6312            .args([
6313                "--exact",
6314                "modes::interactive::runtime::tests::installed_product_panic_hook_emits_complete_restore_sequence",
6315                "--nocapture",
6316            ])
6317            .env(CHILD_ENV, &capture)
6318            .output()?;
6319        assert!(
6320            !output.status.success(),
6321            "panic fixture unexpectedly succeeded"
6322        );
6323        assert_eq!(
6324            std::fs::read(capture)?,
6325            b"\x1b[?2026l\x1b[<u\x1b[?2004l\x1b[?1004l\x1b[?2031l\x1b[?25h\x1b[0m"
6326        );
6327        Ok(())
6328    }
6329
6330    #[tokio::test]
6331    async fn shared_writer_aggregates_writes_from_two_handles() -> Result<(), String> {
6332        let writer = SharedWriter::new();
6333        let mut a = writer.clone();
6334        let mut b = writer.clone();
6335        a.write_all(b"hello")
6336            .map_err(|error| format!("first write failed: {error}"))?;
6337        b.write_all(b" world")
6338            .map_err(|error| format!("second write failed: {error}"))?;
6339        assert_eq!(writer.snapshot(), b"hello world");
6340        Ok(())
6341    }
6342
6343    #[tokio::test]
6344    async fn open_overlay_then_dismiss_restores_focus_and_clears_state() {
6345        let (mut rt, _log) = make_runtime();
6346        rt.input_state
6347            .set_last_sigint_for_test(Some(std::time::Instant::now()));
6348        let _ = rt
6349            .dispatch_action(ViewAction::ShowOverlay {
6350                kind: OverlayKind::Login,
6351            })
6352            .await;
6353        let _ = rt.dispatch_action(ViewAction::DismissOverlay).await;
6354        assert!(rt.input_state.last_sigint().is_none());
6355        assert!(rt.input_state.last_escape().is_none());
6356        assert_eq!(rt.view.focus, FocusArea::Editor);
6357    }
6358
6359    #[tokio::test]
6360    async fn select_confirmed_session_invokes_switch_session() {
6361        let (mut rt, log) = make_runtime();
6362        let _ = rt
6363            .dispatch_action(ViewAction::SelectConfirmed {
6364                selector: SelectorKind::Session,
6365                value: "/tmp/sess.json".to_owned(),
6366            })
6367            .await;
6368        let switches = log.switches.lock().await.clone();
6369        assert_eq!(switches, vec!["/tmp/sess.json".to_owned()]);
6370        assert_eq!(rt.view.focus, FocusArea::Editor);
6371    }
6372
6373    #[tokio::test]
6374    async fn select_cancelled_restores_editor_focus() {
6375        let (mut rt, _log) = make_runtime();
6376        rt.view.focus = FocusArea::Selector;
6377        let _ = rt.dispatch_action(ViewAction::SelectCancelled).await;
6378        assert_eq!(rt.view.focus, FocusArea::Editor);
6379    }
6380
6381    #[tokio::test]
6382    async fn draw_timeout_constant_matches_master_plan() {
6383        assert_eq!(DRAW_TIMEOUT, Duration::from_secs(5));
6384    }
6385
6386    #[tokio::test]
6387    async fn coalesce_window_constant_matches_master_plan() {
6388        assert_eq!(BACKGROUND_COALESCE_WINDOW, Duration::from_millis(16));
6389    }
6390
6391    #[tokio::test]
6392    async fn enqueue_settle_runs_on_next_loop_turn() -> Result<(), String> {
6393        let (mut rt, _log) = try_make_runtime()?;
6394        rt.enqueue_settle(vec![settled_lines(vec![Line::raw("settled")])]);
6395        assert!(rt.pending_settle.is_some());
6396        // Simulate the loop post-turn processing.
6397        if let Some(blocks) = rt.pending_settle.take() {
6398            rt.commit_settle(blocks)
6399                .map_err(|error| format!("settle commit failed: {error}"))?;
6400        }
6401        assert!(rt.pending_settle.is_none());
6402        Ok(())
6403    }
6404
6405    #[tokio::test]
6406    async fn request_shutdown_exits_main_loop_cleanly() -> Result<(), String> {
6407        let (mut rt, _log) = try_make_runtime()?;
6408        rt.request_shutdown();
6409        let exit = tokio::time::timeout(Duration::from_millis(500), rt.run())
6410            .await
6411            .map_err(|_| "runtime did not return after shutdown".to_owned())?
6412            .map_err(|error| format!("runtime shutdown failed: {error}"))?;
6413        assert_eq!(exit, InteractiveExit::Clean);
6414        Ok(())
6415    }
6416
6417    #[tokio::test]
6418    async fn plain_enter_submits_via_on_submit_channel() -> Result<(), String> {
6419        let (mut rt, log) = try_make_runtime()?;
6420        rt.submit_tx
6421            .send("hello enter".to_owned())
6422            .map_err(|error| format!("submit channel closed: {error}"))?;
6423        rt.step_ui(key(KeyCode::F(24), KeyModifiers::NONE))
6424            .await
6425            .map_err(|error| format!("submit step failed: {error}"))?;
6426        let prompts = log.prompts.lock().await.clone();
6427        assert_eq!(prompts, vec!["hello enter".to_owned()]);
6428        Ok(())
6429    }
6430
6431    #[tokio::test]
6432    async fn rebind_session_channels_reloads_snapshot() {
6433        let (mut rt, _log) = make_runtime();
6434        rt.view.streaming = true;
6435        rt.rebind_session_channels().await;
6436        assert!(!rt.view.streaming);
6437    }
6438
6439    #[tokio::test]
6440    async fn open_model_selector_installs_component_and_focus() {
6441        let (mut rt, _log) = make_runtime();
6442        let outcome = rt.dispatch_action(ViewAction::OpenModelSelector).await;
6443        assert_eq!(outcome, ActionOutcome::Repaint);
6444        assert_eq!(rt.view.focus, FocusArea::Selector);
6445        assert!(rt.active_selector.is_some());
6446        assert_eq!(rt.active_selector_kind, Some(SelectorKind::Model));
6447    }
6448
6449    #[tokio::test]
6450    async fn selector_confirm_channel_routes_to_switch_session() -> Result<(), String> {
6451        let (mut rt, log) = try_make_runtime()?;
6452        let _ = rt.dispatch_action(ViewAction::OpenSessionPicker).await;
6453        rt.select_tx
6454            .send((SelectorKind::Session, "/tmp/from-select.jsonl".to_owned()))
6455            .map_err(|error| format!("selector channel closed: {error}"))?;
6456        rt.step_ui(key(KeyCode::F(24), KeyModifiers::NONE))
6457            .await
6458            .map_err(|error| format!("selector step failed: {error}"))?;
6459        let switches = log.switches.lock().await.clone();
6460        assert_eq!(switches, vec!["/tmp/from-select.jsonl".to_owned()]);
6461        assert_eq!(rt.view.focus, FocusArea::Editor);
6462        assert!(rt.active_selector.is_none());
6463        Ok(())
6464    }
6465
6466    #[tokio::test]
6467    async fn streaming_submit_uses_prompt_with_steer_behavior() -> Result<(), String> {
6468        let writer = SharedWriter::new();
6469        let caps = TerminalCapabilities::default();
6470        let tui = Tui::new(writer, Size::new(80, 24), Position::ORIGIN, 8, caps)
6471            .map_err(|error| format!("tui construction failed: {error}"))?;
6472        let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
6473        let input = TerminalInput::mock(rx);
6474        let (host, log) = FakeHost::new();
6475        *host
6476            .snapshot
6477            .lock()
6478            .unwrap_or_else(std::sync::PoisonError::into_inner) = SessionSnapshot {
6479            activity: SessionActivity::Streaming,
6480            admission_active: true,
6481            ..SessionSnapshot::default()
6482        };
6483        let options = InteractiveRuntimeOptions {
6484            size: (80, 24),
6485            ..InteractiveRuntimeOptions::default()
6486        };
6487        let mut rt = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
6488        let _ = rt
6489            .dispatch_action(ViewAction::Submit {
6490                text: "steer me".to_owned(),
6491            })
6492            .await;
6493        let prompts = log.prompts.lock().await.clone();
6494        assert_eq!(prompts, vec!["steer me".to_owned()]);
6495        assert!(log.steers.lock().await.is_empty());
6496        Ok(())
6497    }
6498
6499    #[tokio::test]
6500    async fn suspend_action_sets_suspend_exit_not_clean_shutdown() {
6501        let (mut rt, _log) = make_runtime();
6502        let outcome = rt.dispatch_action(ViewAction::Suspend).await;
6503        assert_eq!(outcome, ActionOutcome::Suspend);
6504        assert!(!rt.shutdown_flag.load(std::sync::atomic::Ordering::SeqCst));
6505    }
6506
6507    #[tokio::test]
6508    async fn external_editor_action_requests_outer_terminal_handoff() {
6509        let (mut rt, _log) = make_runtime();
6510        let outcome = rt.dispatch_action(ViewAction::ExternalEditor).await;
6511        assert_eq!(outcome, ActionOutcome::ExternalEditor);
6512    }
6513
6514    #[tokio::test]
6515    async fn display_toggles_update_existing_assistant_and_tool_messages() {
6516        let (mut rt, _log) = make_runtime();
6517        rt.view
6518            .messages
6519            .push(MessageView::Assistant(AssistantMessageView {
6520                message: AssistantMessage::new(
6521                    "test-api",
6522                    "test-provider",
6523                    "test-model",
6524                    pi_agent::now_millis(),
6525                ),
6526                hide_thinking: false,
6527                hidden_thinking_label: "Thinking hidden".to_owned(),
6528                streaming: false,
6529            }));
6530        project_event(
6531            &mut rt.view,
6532            &AgentSessionEvent::ToolExecutionStart {
6533                tool_call_id: "tool-1".to_owned(),
6534                tool_name: "read".to_owned(),
6535                args: serde_json::Map::new(),
6536            },
6537        );
6538
6539        assert_eq!(
6540            rt.dispatch_action(ViewAction::ToggleThinking).await,
6541            ActionOutcome::Repaint
6542        );
6543        assert_eq!(
6544            rt.dispatch_action(ViewAction::ToggleToolExpand).await,
6545            ActionOutcome::Repaint
6546        );
6547        assert!(
6548            rt.view.messages.iter().any(
6549                |message| matches!(message, MessageView::Assistant(view) if view.hide_thinking)
6550            )
6551        );
6552        assert!(
6553            rt.view
6554                .messages
6555                .iter()
6556                .any(|message| matches!(message, MessageView::Tool(view) if view.state.expanded))
6557        );
6558    }
6559
6560    #[test]
6561    fn effective_extension_shortcuts_reject_invalid_reserved_and_use_last_registration() {
6562        use pi_ext::adapters::ShortcutRegistration;
6563
6564        let shortcuts = build_effective_extension_shortcuts(&[
6565            ShortcutRegistration {
6566                key: "ctrl+not-a-key".to_owned(),
6567                description: Some("invalid".to_owned()),
6568                extension_path: Some("invalid.ts".to_owned()),
6569            },
6570            ShortcutRegistration {
6571                key: "ctrl+c".to_owned(),
6572                description: Some("reserved".to_owned()),
6573                extension_path: Some("reserved.ts".to_owned()),
6574            },
6575            ShortcutRegistration {
6576                key: "alt+ctrl+y".to_owned(),
6577                description: Some("first".to_owned()),
6578                extension_path: Some("first.ts".to_owned()),
6579            },
6580            ShortcutRegistration {
6581                key: "CTRL+ALT+Y".to_owned(),
6582                description: Some("last".to_owned()),
6583                extension_path: Some("last.ts".to_owned()),
6584            },
6585        ]);
6586
6587        assert_eq!(shortcuts.len(), 1);
6588        assert_eq!(shortcuts[0].key, "ctrl+alt+y");
6589        assert_eq!(shortcuts[0].dispatch_key, "CTRL+ALT+Y");
6590        assert_eq!(shortcuts[0].description.as_deref(), Some("last"));
6591        assert_eq!(shortcuts[0].source.as_deref(), Some("last.ts"));
6592        let non_reserved = KeyEvent::new(
6593            KeyCode::Char('y'),
6594            KeyModifiers::CONTROL | KeyModifiers::ALT,
6595        );
6596        assert!(key_matches_parsed(&non_reserved, &shortcuts[0].parsed));
6597        let hints = shortcut_hints(&shortcuts);
6598        assert_eq!(hints[0].action, "last");
6599    }
6600
6601    #[tokio::test]
6602    async fn reserved_extension_conflict_falls_through_to_native_binding() -> Result<(), String> {
6603        let (mut rt, _log) = try_make_runtime()?;
6604        rt.editor.set_text("draft");
6605        rt.view.editor.text = "draft".to_owned();
6606        rt.effective_extension_shortcuts =
6607            build_effective_extension_shortcuts(&[pi_ext::adapters::ShortcutRegistration {
6608                key: "ctrl+c".to_owned(),
6609                description: Some("must not run".to_owned()),
6610                extension_path: Some("extension.ts".to_owned()),
6611            }]);
6612        assert!(rt.effective_extension_shortcuts.is_empty());
6613
6614        rt.step_ui(key(KeyCode::Char('c'), KeyModifiers::CONTROL))
6615            .await
6616            .map_err(|error| format!("native fallthrough failed: {error}"))?;
6617        assert!(rt.editor.get_text().is_empty());
6618        Ok(())
6619    }
6620
6621    #[test]
6622    fn focused_slot_projection_retains_generation_and_typed_key_payload() {
6623        let (mut rt, _log) = make_runtime();
6624        let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6625            key: "editor.status".to_owned(),
6626            generation: 7,
6627            placement: SlotPlacement::AboveEditor,
6628            height: 1,
6629            runs: vec![vec![pi_ext::protocol::StyledRun {
6630                text: "focused".to_owned(),
6631                style: pi_ext::protocol::Style::default(),
6632            }]],
6633            focusable: true,
6634            cursor: None,
6635            overlay_options: None,
6636        });
6637        rt.project_extension_slot(slot);
6638        assert_eq!(rt.focused_extension_slot.as_deref(), Some("editor.status"));
6639        assert_eq!(rt.view.focus, FocusArea::Widget);
6640        assert_eq!(
6641            rt.extension_slots
6642                .get("editor.status")
6643                .map(|slot| slot.generation),
6644            Some(7)
6645        );
6646
6647        let event = UiEvent::Key(KeyEvent::new_with_kind(
6648            KeyCode::Enter,
6649            KeyModifiers::ALT,
6650            crossterm::event::KeyEventKind::Repeat,
6651        ));
6652        assert_eq!(
6653            ui_event_wire(&event),
6654            UiEventWire::Key {
6655                code: "enter".to_owned(),
6656                modifiers: KeyModifiersWire {
6657                    alt: Some(true),
6658                    ..KeyModifiersWire::default()
6659                },
6660                kind: KeyEventKindWire::Repeat,
6661            }
6662        );
6663        assert_eq!(
6664            encode_terminal_input(&event).as_deref(),
6665            Some("\u{1b}[13;3:2u")
6666        );
6667    }
6668
6669    #[tokio::test]
6670    async fn focused_extension_preserves_global_escape_and_interrupt_bindings() -> Result<(), String>
6671    {
6672        let (mut rt, _log) = try_make_runtime()?;
6673        let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6674            key: "editor.focused".to_owned(),
6675            generation: 1,
6676            placement: SlotPlacement::AboveEditor,
6677            height: 1,
6678            runs: vec![vec![pi_ext::protocol::StyledRun {
6679                text: "focused".to_owned(),
6680                style: pi_ext::protocol::Style::default(),
6681            }]],
6682            focusable: true,
6683            cursor: None,
6684            overlay_options: None,
6685        });
6686        rt.project_extension_slot(slot);
6687        rt.editor.set_text("draft");
6688        rt.view.editor.text = "draft".to_owned();
6689        let escape = key(KeyCode::Esc, KeyModifiers::NONE);
6690        let interrupt = key(KeyCode::Char('c'), KeyModifiers::CONTROL);
6691
6692        assert_eq!(rt.view.focus, FocusArea::Widget);
6693        assert!(is_global_app_binding(&escape));
6694        assert!(is_global_app_binding(&interrupt));
6695        assert!(!rt.route_extension_input(&escape));
6696        assert!(!rt.route_extension_input(&interrupt));
6697
6698        rt.handle_ui_event(interrupt)
6699            .await
6700            .map_err(|error| format!("global interrupt routing failed: {error}"))?;
6701        assert!(rt.editor.get_text().is_empty());
6702        Ok(())
6703    }
6704
6705    #[tokio::test]
6706    async fn escape_releases_focused_extension_widget_before_later_typing() -> Result<(), String> {
6707        let (mut rt, log) = try_make_runtime()?;
6708        // Admitted run + draft must survive the focus-release Esc.
6709        *rt.session
6710            .snapshot
6711            .lock()
6712            .unwrap_or_else(std::sync::PoisonError::into_inner) = SessionSnapshot {
6713            admission_active: true,
6714            activity: SessionActivity::Streaming,
6715            ..SessionSnapshot::default()
6716        };
6717        rt.view.streaming = true;
6718        rt.view.status = Some(SessionStatus {
6719            kind: StatusKind::Working,
6720            frame: 0,
6721            message: "Working…".to_owned(),
6722        });
6723        rt.editor.set_text("draft");
6724        rt.view.editor.text = "draft".to_owned();
6725
6726        let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6727            key: "editor.focused".to_owned(),
6728            generation: 1,
6729            placement: SlotPlacement::AboveEditor,
6730            height: 1,
6731            runs: vec![vec![pi_ext::protocol::StyledRun {
6732                text: "focused".to_owned(),
6733                style: pi_ext::protocol::Style::default(),
6734            }]],
6735            focusable: true,
6736            cursor: None,
6737            overlay_options: None,
6738        });
6739        rt.project_extension_slot(slot);
6740
6741        rt.handle_ui_event(key(KeyCode::Esc, KeyModifiers::NONE))
6742            .await
6743            .map_err(|error| format!("widget escape routing failed: {error}"))?;
6744        assert!(rt.focused_extension_slot.is_none());
6745        assert_eq!(rt.view.focus, FocusArea::Editor);
6746        assert!(rt.view.widgets_above.iter().all(|widget| !widget.focused));
6747        // Focus-release Esc is consumed: no interrupt/clear side effects.
6748        assert_eq!(*log.aborts.lock().await, 0);
6749        assert_eq!(rt.editor.get_text(), "draft");
6750        assert_eq!(rt.view.editor.text, "draft");
6751        assert!(rt.view.streaming);
6752
6753        // Second Esc follows the normal interrupt path.
6754        rt.handle_ui_event(key(KeyCode::Esc, KeyModifiers::NONE))
6755            .await
6756            .map_err(|error| format!("second escape interrupt failed: {error}"))?;
6757        assert_eq!(*log.aborts.lock().await, 1);
6758        assert_eq!(rt.editor.get_text(), "draft");
6759
6760        rt.handle_ui_event(key(KeyCode::Char('x'), KeyModifiers::NONE))
6761            .await
6762            .map_err(|error| format!("editor typing after widget escape failed: {error}"))?;
6763        assert_eq!(rt.editor.get_text(), "draftx");
6764        Ok(())
6765    }
6766
6767    #[tokio::test]
6768    async fn escape_dismisses_focused_extension_overlay_and_restores_editor() -> Result<(), String>
6769    {
6770        let (mut rt, log) = try_make_runtime()?;
6771        *rt.session
6772            .snapshot
6773            .lock()
6774            .unwrap_or_else(std::sync::PoisonError::into_inner) = SessionSnapshot {
6775            admission_active: true,
6776            activity: SessionActivity::Streaming,
6777            ..SessionSnapshot::default()
6778        };
6779        rt.view.streaming = true;
6780        rt.view.status = Some(SessionStatus {
6781            kind: StatusKind::Working,
6782            frame: 0,
6783            message: "Working…".to_owned(),
6784        });
6785        rt.editor.set_text("keep-me");
6786        rt.view.editor.text = "keep-me".to_owned();
6787
6788        let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6789            key: "overlay.focused".to_owned(),
6790            generation: 1,
6791            placement: SlotPlacement::Overlay,
6792            height: 1,
6793            runs: vec![vec![pi_ext::protocol::StyledRun {
6794                text: "focused overlay".to_owned(),
6795                style: pi_ext::protocol::Style::default(),
6796            }]],
6797            focusable: true,
6798            cursor: None,
6799            overlay_options: Some(pi_ext::protocol::OverlaySpec::default()),
6800        });
6801        rt.project_extension_slot(slot);
6802        assert_eq!(rt.view.focus, FocusArea::Overlay);
6803
6804        rt.handle_ui_event(key(KeyCode::Esc, KeyModifiers::NONE))
6805            .await
6806            .map_err(|error| format!("overlay escape routing failed: {error}"))?;
6807        assert!(rt.focused_extension_slot.is_none());
6808        assert!(rt.view.extension_overlay_slot.is_none());
6809        assert_eq!(rt.view.focus, FocusArea::Editor);
6810        assert_eq!(*log.aborts.lock().await, 0);
6811        assert_eq!(rt.editor.get_text(), "keep-me");
6812        assert!(rt.view.streaming);
6813
6814        rt.handle_ui_event(key(KeyCode::Char('x'), KeyModifiers::NONE))
6815            .await
6816            .map_err(|error| format!("editor typing after overlay escape failed: {error}"))?;
6817        assert_eq!(rt.editor.get_text(), "keep-mex");
6818        Ok(())
6819    }
6820
6821    #[test]
6822    fn non_capturing_overlay_preserves_editor_focus_and_structured_metadata() -> Result<(), String>
6823    {
6824        let (mut rt, _log) = make_runtime();
6825        let link = pi_ext::protocol::Hyperlink {
6826            id: Some("docs".to_owned()),
6827            uri: "https://example.com/docs".to_owned(),
6828        };
6829        let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6830            key: "overlay.help".to_owned(),
6831            generation: 3,
6832            placement: SlotPlacement::Overlay,
6833            height: 1,
6834            runs: vec![vec![pi_ext::protocol::StyledRun {
6835                text: "help".to_owned(),
6836                style: pi_ext::protocol::Style {
6837                    bold: Some(true),
6838                    link: Some(link.clone()),
6839                    ..pi_ext::protocol::Style::default()
6840                },
6841            }]],
6842            focusable: true,
6843            cursor: None,
6844            overlay_options: Some(pi_ext::protocol::OverlaySpec {
6845                non_capturing: true,
6846                ..pi_ext::protocol::OverlaySpec::default()
6847            }),
6848        });
6849
6850        rt.project_extension_slot(slot);
6851        assert_eq!(rt.view.focus, FocusArea::Editor);
6852        assert!(rt.focused_extension_slot.is_none());
6853        let projected = rt
6854            .view
6855            .extension_overlay_slot
6856            .as_ref()
6857            .ok_or_else(|| "structured overlay was not projected".to_owned())?;
6858        assert_eq!(projected.lines[0][0].style.link.as_ref(), Some(&link));
6859        Ok(())
6860    }
6861
6862    #[tokio::test]
6863    async fn extension_input_dialog_temporarily_owns_then_restores_editor() {
6864        let (mut rt, _log) = make_runtime();
6865        rt.editor.set_text("draft prompt");
6866        rt.view.editor.text = "draft prompt".to_owned();
6867        rt.view.editor.placeholder = "Type a message…".to_owned();
6868        rt.begin_extension_dialog(HostUiRequest::Input {
6869            id: 17,
6870            request: pi_ext::protocol::InputRequest {
6871                title: "Extension input".to_owned(),
6872                placeholder: Some("value".to_owned()),
6873                options_meta: pi_ext::protocol::DialogOptions::default(),
6874            },
6875        })
6876        .await;
6877        assert_eq!(rt.editor.get_text(), "");
6878        assert_eq!(rt.view.editor.placeholder, "value");
6879
6880        let outcome = rt.submit_text("answer".to_owned(), false).await;
6881        assert_eq!(outcome, ActionOutcome::Repaint);
6882        assert!(rt.pending_extension_dialog.is_none());
6883        assert_eq!(rt.editor.get_text(), "draft prompt");
6884        assert_eq!(rt.view.editor.placeholder, "Type a message…");
6885    }
6886
6887    #[tokio::test]
6888    async fn reload_cancels_pending_extension_dialog_and_restores_editor() {
6889        let (mut rt, _log) = make_runtime();
6890        rt.editor.set_text("draft prompt");
6891        rt.view.editor.text = "draft prompt".to_owned();
6892        rt.view.editor.placeholder = "Type a message…".to_owned();
6893        rt.begin_extension_dialog(HostUiRequest::Input {
6894            id: 18,
6895            request: pi_ext::protocol::InputRequest {
6896                title: "Extension input".to_owned(),
6897                placeholder: None,
6898                options_meta: pi_ext::protocol::DialogOptions::default(),
6899            },
6900        })
6901        .await;
6902
6903        let outcome = rt.dispatch_action(ViewAction::Reload).await;
6904        assert_eq!(outcome, ActionOutcome::Repaint);
6905        assert!(rt.pending_extension_dialog.is_none());
6906        assert_eq!(rt.editor.get_text(), "draft prompt");
6907        assert_eq!(rt.view.editor.placeholder, "Type a message…");
6908    }
6909
6910    #[tokio::test]
6911    async fn extension_confirmation_renders_title_and_message() {
6912        let (mut rt, _log) = make_runtime();
6913        rt.begin_extension_dialog(HostUiRequest::Confirm {
6914            id: 19,
6915            request: pi_ext::protocol::ConfirmRequest {
6916                title: "Verification confirm prompt".to_owned(),
6917                message: "Choose Yes".to_owned(),
6918                options_meta: pi_ext::protocol::DialogOptions::default(),
6919            },
6920        })
6921        .await;
6922        let editor = std::mem::replace(&mut rt.editor, Editor::with_defaults());
6923        let selector = rt.active_selector.take();
6924        let mut root = rt.build_root(editor, selector);
6925        let area = Rect::new(0, 0, 80, 24);
6926        let mut buffer = Buffer::empty(area);
6927
6928        root.render(area, &mut buffer);
6929
6930        let visible = buffer
6931            .content()
6932            .iter()
6933            .map(ratatui::buffer::Cell::symbol)
6934            .collect::<String>();
6935        assert!(visible.contains("Verification confirm prompt"));
6936        assert!(visible.contains("Choose Yes"));
6937    }
6938
6939    #[test]
6940    fn extension_slot_update_and_dispose_projects_live_widgets() {
6941        let (mut rt, _log) = make_runtime();
6942        let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6943            key: "status".to_owned(),
6944            generation: 1,
6945            placement: SlotPlacement::AboveEditor,
6946            height: 1,
6947            runs: vec![vec![pi_ext::protocol::StyledRun {
6948                text: "extension ready".to_owned(),
6949                style: pi_ext::protocol::Style::default(),
6950            }]],
6951            focusable: false,
6952            cursor: None,
6953            overlay_options: None,
6954        });
6955        rt.project_extension_slot(slot);
6956        assert_eq!(rt.view.widgets_above.len(), 1);
6957        assert_eq!(
6958            rt.view.widgets_above[0].slot.lines[0][0].text,
6959            "extension ready"
6960        );
6961
6962        rt.dispose_extension_slot("status");
6963        assert!(rt.view.widgets_above.is_empty());
6964    }
6965
6966    #[test]
6967    fn terminal_input_codec_covers_extension_rewrite_keyspace() -> Result<(), String> {
6968        let events = [
6969            UiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
6970            UiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)),
6971            UiEvent::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)),
6972            UiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
6973            UiEvent::Key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)),
6974            UiEvent::Key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)),
6975        ];
6976        for event in events {
6977            let encoded = encode_terminal_input(&event)
6978                .ok_or_else(|| format!("unsupported event: {event:?}"))?;
6979            assert_eq!(decode_terminal_input(encoded), event);
6980        }
6981        Ok(())
6982    }
6983
6984    #[tokio::test]
6985    async fn copy_last_assistant_produces_feedback() {
6986        let (mut rt, log) = make_runtime();
6987        *log.last_text.lock().await = Some("assistant says hi".to_owned());
6988        let _ = rt.dispatch_action(ViewAction::CopyLastAssistant).await;
6989        let had_status =
6990            rt.view.status.as_ref().is_some_and(|s| {
6991                s.message.contains("Copied") || s.message.contains("No assistant")
6992            });
6993        let had_error = rt
6994            .last_error
6995            .as_ref()
6996            .is_some_and(|e| e.contains("clipboard") || e.contains("Failed"));
6997        assert!(had_status || had_error);
6998    }
6999
7000    #[test]
7001    fn stored_runtime_error_is_painted_and_replaced() -> Result<(), String> {
7002        let (mut rt, _log) = try_make_runtime()?;
7003
7004        rt.record_err(Err("clipboard failed".to_owned()));
7005        assert!(rt.coalesce_deadline.is_some());
7006        rt.paint_frame()
7007            .map_err(|error| format!("paint failed: {error}"))?;
7008
7009        let runtime_errors = rt
7010            .view
7011            .diagnostics
7012            .entries
7013            .iter()
7014            .filter(|entry| entry.source == "runtime")
7015            .collect::<Vec<_>>();
7016        assert_eq!(runtime_errors.len(), 1);
7017        assert_eq!(runtime_errors[0].severity, DiagnosticSeverity::Error);
7018        assert_eq!(runtime_errors[0].message, "clipboard failed");
7019
7020        rt.record_error("extension failed".to_owned());
7021        rt.paint_frame()
7022            .map_err(|error| format!("second paint failed: {error}"))?;
7023        let runtime_errors = rt
7024            .view
7025            .diagnostics
7026            .entries
7027            .iter()
7028            .filter(|entry| entry.source == "runtime")
7029            .collect::<Vec<_>>();
7030        assert_eq!(runtime_errors.len(), 1);
7031        assert_eq!(runtime_errors[0].message, "extension failed");
7032        Ok(())
7033    }
7034
7035    #[tokio::test]
7036    async fn project_event_message_start_user_appears_in_chat() {
7037        let mut view = ViewState::empty();
7038        let user = pi_agent::user_text("hi from user", std::iter::empty());
7039        project_event(
7040            &mut view,
7041            &AgentSessionEvent::MessageStart { message: user },
7042        );
7043        assert!(
7044            view.messages
7045                .iter()
7046                .any(|m| matches!(m, MessageView::User(_)))
7047        );
7048    }
7049
7050    #[tokio::test]
7051    async fn project_event_tool_start_appears_in_chat() {
7052        let mut view = ViewState::empty();
7053        project_event(
7054            &mut view,
7055            &AgentSessionEvent::ToolExecutionStart {
7056                tool_call_id: "t1".to_owned(),
7057                tool_name: "read".to_owned(),
7058                args: serde_json::Map::from_iter([(
7059                    "path".to_owned(),
7060                    serde_json::Value::String("a.rs".to_owned()),
7061                )]),
7062            },
7063        );
7064        assert!(
7065            view.messages
7066                .iter()
7067                .any(|m| matches!(m, MessageView::Tool(_)))
7068        );
7069    }
7070
7071    #[test]
7072    fn root_render_clips_overflow_and_keeps_editor_visible() {
7073        let mut view = ViewState::empty();
7074        for index in 0..30 {
7075            let message = pi_agent::user_text(
7076                format!("message {index}: {}", "overflow ".repeat(20)),
7077                std::iter::empty(),
7078            );
7079            project_event(&mut view, &AgentSessionEvent::MessageStart { message });
7080        }
7081        let mut editor = Editor::with_defaults();
7082        editor.set_text("EDITOR_VISIBLE");
7083        let mut root = InteractiveRoot::build(&view, editor, None);
7084        let area = Rect::new(0, 0, 80, 24);
7085        let mut buffer = Buffer::empty(area);
7086
7087        root.render(area, &mut buffer);
7088
7089        let visible = buffer
7090            .content()
7091            .iter()
7092            .map(ratatui::buffer::Cell::symbol)
7093            .collect::<String>();
7094        assert!(visible.contains("EDITOR_VISIBLE"));
7095    }
7096
7097    #[test]
7098    fn live_root_render_applies_extension_overlay_layout() {
7099        let mut root = InteractiveRoot {
7100            pre_editor: Vec::new(),
7101            editor: Editor::with_defaults(),
7102            post_editor: Vec::new(),
7103            overlay: Some(Box::new(pi_tui::components::Text::with_padding(
7104                "OVERLAY".to_owned(),
7105                0,
7106                0,
7107            ))),
7108            overlay_spec: Some(pi_tui::layout::OverlaySpec {
7109                width: Some(pi_tui::layout::SizeValue::cells(12)),
7110                row: Some(pi_tui::layout::SizeValue::cells(5)),
7111                col: Some(pi_tui::layout::SizeValue::cells(9)),
7112                ..pi_tui::layout::OverlaySpec::default()
7113            }),
7114            selector: None,
7115            dialog_title: None,
7116            focus: FocusArea::Editor,
7117        };
7118        let area = Rect::new(0, 0, 40, 12);
7119        let mut buffer = Buffer::empty(area);
7120
7121        root.render(area, &mut buffer);
7122
7123        assert_eq!(
7124            buffer.cell((9, 5)).map(ratatui::buffer::Cell::symbol),
7125            Some("O")
7126        );
7127        let overlay_row = (9..21)
7128            .filter_map(|column| buffer.cell((column, 5)))
7129            .map(ratatui::buffer::Cell::symbol)
7130            .collect::<String>();
7131        assert!(overlay_row.contains("OVERLAY"));
7132        let origin_row = (0..9)
7133            .filter_map(|column| buffer.cell((column, 0)))
7134            .map(ratatui::buffer::Cell::symbol)
7135            .collect::<String>();
7136        assert!(!origin_row.contains("OVERLAY"));
7137    }
7138
7139    #[tokio::test]
7140    async fn dispatch_slash_compact_without_instructions_calls_compact() {
7141        let (mut rt, log) = make_runtime();
7142        let outcome = rt
7143            .dispatch_action(ViewAction::SlashCommand {
7144                name: "compact".to_owned(),
7145                args: String::new(),
7146            })
7147            .await;
7148        assert_eq!(outcome, ActionOutcome::None);
7149        assert_eq!(*log.compacts.lock().await, vec![None]);
7150        assert!(log.prompts.lock().await.is_empty());
7151    }
7152
7153    #[tokio::test]
7154    async fn dispatch_typed_compact_trims_custom_instructions() {
7155        let (mut rt, log) = make_runtime();
7156        let outcome = rt
7157            .dispatch_action(ViewAction::Submit {
7158                text: "  /compact   focus on tools   ".to_owned(),
7159            })
7160            .await;
7161        assert_eq!(outcome, ActionOutcome::None);
7162        assert_eq!(
7163            *log.compacts.lock().await,
7164            vec![Some("focus on tools".to_owned())]
7165        );
7166        assert!(log.prompts.lock().await.is_empty());
7167    }
7168
7169    #[tokio::test]
7170    async fn dispatch_typed_fork_opens_user_message_selector() {
7171        let (mut rt, log) = make_runtime();
7172        let outcome = rt
7173            .dispatch_action(ViewAction::Submit {
7174                text: "/fork".to_owned(),
7175            })
7176            .await;
7177        assert_eq!(outcome, ActionOutcome::Repaint);
7178        assert_eq!(rt.view.focus, FocusArea::Selector);
7179        assert!(rt.active_selector.is_some());
7180        assert_eq!(rt.active_selector_kind, Some(SelectorKind::Fork));
7181        assert!(log.prompts.lock().await.is_empty());
7182    }
7183
7184    #[tokio::test]
7185    async fn dispatch_typed_resume_opens_session_selector() {
7186        let (mut rt, log) = make_runtime();
7187        let outcome = rt
7188            .dispatch_action(ViewAction::Submit {
7189                text: "/resume".to_owned(),
7190            })
7191            .await;
7192        assert_eq!(outcome, ActionOutcome::Repaint);
7193        assert_eq!(rt.view.focus, FocusArea::Selector);
7194        assert!(rt.active_selector.is_some());
7195        assert_eq!(rt.active_selector_kind, Some(SelectorKind::Session));
7196        assert!(log.prompts.lock().await.is_empty());
7197    }
7198
7199    #[tokio::test]
7200    async fn dispatch_typed_reload_awaits_host_and_repaints() {
7201        let (mut rt, log) = make_runtime();
7202        let outcome = rt
7203            .dispatch_action(ViewAction::Submit {
7204                text: "/reload".to_owned(),
7205            })
7206            .await;
7207        assert_eq!(outcome, ActionOutcome::Repaint);
7208        assert_eq!(*log.reloads.lock().await, 1);
7209        assert!(log.prompts.lock().await.is_empty());
7210    }
7211
7212    #[tokio::test]
7213    async fn dispatch_unknown_slash_command_routes_through_prompt() {
7214        let (mut rt, log) = make_runtime();
7215        let outcome = rt
7216            .dispatch_action(ViewAction::SlashCommand {
7217                name: "foo".to_owned(),
7218                args: "custom args".to_owned(),
7219            })
7220            .await;
7221        assert_eq!(outcome, ActionOutcome::None);
7222        assert_eq!(
7223            *log.prompts.lock().await,
7224            vec!["/foo custom args".to_owned()]
7225        );
7226    }
7227}