//! Root view component. //! //! [`AppView`] owns all application state and provides the top-level //! `handle_input()` and `draw()` methods. The event loop calls these //! and knows nothing about input routing, overlays, or view internals. use super::ScreenMode; use crate::acp::model_state::ModelState; use crate::actions::{ActionId, ActionRegistry, When}; use crate::appearance::AppearanceConfig; use crate::input::KeyboardNormalizer; use crate::input::key::KeyShortcut; use crate::input::line_editor::{LineEditOutcome, LineEditor}; use crate::input::mouse::{MouseScrollState, ScrollConfig, ScrollDirection}; use crate::key; use crate::notifications::NotificationService; use crate::render::draw::CursorState; use crate::scrollback::render::ScratchBuffer; use crate::views::prompt_widget::PromptWidget; use crate::views::welcome::WelcomePromptFocus; use agent_client_protocol as acp; use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEventKind}; use indexmap::IndexMap; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use xai_acp_lib::AcpAgentTx; /// State for the "New Worktree" popup dialog on the welcome screen. #[derive(Debug, Default)] pub struct NewWorktreeDialogState { /// Text input for the worktree label (empty = auto-generated name). label: LineEditor, } const MAX_WORKTREE_LABEL_BYTES: usize = 100; impl NewWorktreeDialogState { pub fn new() -> Self { Self { label: LineEditor::default(), } } pub fn label(&self) -> &str { self.label.text() } pub(crate) fn viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport { self.label.viewport(width) } #[cfg(test)] pub(crate) fn set_label(&mut self, label: impl Into) { self.label.set_text(label); } #[cfg(test)] pub(crate) fn set_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome { self.label.set_cursor_byte(cursor_byte) } pub fn insert_paste(&mut self, text: &str) -> NewWorktreeDialogOutcome { Self::from_line_edit( self.label .insert_paste_with_byte_limit(text, MAX_WORKTREE_LABEL_BYTES), ) } /// Handle a key event. Returns the dialog outcome. pub fn handle_key(&mut self, key: &crossterm::event::KeyEvent) -> NewWorktreeDialogOutcome { use crossterm::event::{KeyCode, KeyModifiers}; if crate::input::key::is_paste_key(key) { return crate::clipboard::system_clipboard_get() .map_or(NewWorktreeDialogOutcome::Unchanged, |text| { self.insert_paste(&text) }); } if key.modifiers.contains(KeyModifiers::CONTROL) && !crate::input::key::is_altgr(key.modifiers) && matches!(key.code, KeyCode::Char('c' | 'd' | 'q')) { return NewWorktreeDialogOutcome::Cancelled; } if key.code == KeyCode::Enter && !key.modifiers.is_empty() { return NewWorktreeDialogOutcome::Unchanged; } match key.code { KeyCode::Enter if key.modifiers.is_empty() => { let label = self.label().trim().to_string(); NewWorktreeDialogOutcome::Submitted(if label.is_empty() { None } else { Some(label) }) } KeyCode::Esc => NewWorktreeDialogOutcome::Cancelled, _ => { let remaining = MAX_WORKTREE_LABEL_BYTES.saturating_sub(self.label().len()); let outcome = self.label.handle_key_with_insert_policy(key, |character| { character.len_utf8() <= remaining }); Self::from_line_edit(outcome) } } } fn from_line_edit(outcome: LineEditOutcome) -> NewWorktreeDialogOutcome { match outcome { LineEditOutcome::TextChanged | LineEditOutcome::CursorChanged | LineEditOutcome::HandledNoChange => NewWorktreeDialogOutcome::Changed, LineEditOutcome::Unhandled => NewWorktreeDialogOutcome::Unchanged, } } } /// Per-visit announcement UI state on the welcome screen. Reset on every /// return-to-welcome transition (see `show_welcome`) so a previously expanded /// announcement can't leak into a freshly shown screen; the non-`expanded` /// fields are recomputed each frame, so resetting them is harmless. #[derive(Debug, Default)] pub struct WelcomeAnnouncementState { /// Whether a long announcement is expanded inline (default: 2 lines + `…`). pub expanded: bool, /// Mouse last over the announcement block (drives hover color + redraws). pub on_cta: bool, /// Whether the announcement overflowed (the "expandable" signal). pub truncated: bool, /// Hit-test rect for the full announcement block (click anywhere to toggle). pub rect: Option, } /// Outcome of handling input in the new-worktree dialog. #[derive(Debug)] pub enum NewWorktreeDialogOutcome { /// User pressed Enter — create the worktree. /// `None` means auto-generate the name. Submitted(Option), /// User pressed Esc — close without creating. Cancelled, /// Input changed (redraw needed). Changed, /// Nothing happened. Unchanged, } /// Persisted worktree preference for `/new` and `/fork`. /// /// Controls whether the worktree question popup is shown when starting a /// new session or forking. Each command has its own config key: /// - `[hints] new_session_worktree_mode` (default: `ask`) /// - `[hints] fork_worktree_mode` (default: `ask`) /// /// The legacy `[hints] worktree_mode` key is read as a fallback when /// neither per-command key is set. /// /// Startup resolution lives in /// [`xai_grok_shell::util::config::resolve_hints`]; this type is the pager's /// in-memory mirror. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorktreeMode { /// Always show the popup. Ask, /// Always create a worktree, skip the popup. Always, /// Never create a worktree, skip the popup. Never, } impl From for WorktreeMode { fn from(mode: xai_grok_shell::util::config::WorktreeHintMode) -> Self { use xai_grok_shell::util::config::WorktreeHintMode; match mode { WorktreeHintMode::Ask => Self::Ask, WorktreeHintMode::Always => Self::Always, WorktreeHintMode::Never => Self::Never, } } } impl WorktreeMode { /// Parse from a TOML string value. Unrecognised values fall back to /// [`WorktreeMode::Never`] with a debug-level log. pub fn from_config_str(s: &str) -> Self { xai_grok_shell::util::config::WorktreeHintMode::from_config_str(s).into() } /// Serialise to the TOML string representation. pub fn as_config_str(self) -> &'static str { match self { Self::Ask => "ask", Self::Always => "always", Self::Never => "never", } } /// Resolve per-command worktree modes from a parsed TOML document. /// /// Returns `(new_session_worktree_mode, fork_worktree_mode)`. /// /// Resolution order: /// - `/new`: `new_session_worktree_mode` key, else legacy `worktree_mode`, else `Never` (no popup). /// - `/fork`: `fork_worktree_mode` key, else legacy `worktree_mode`, else `Ask`. pub fn resolve_from_hints(hints: Option<&toml_edit::Item>) -> (Self, Self) { let get_str = |key: &str| -> Option { hints .and_then(|h| h.get(key)) .and_then(|v| v.as_str()) .map(Self::from_config_str) }; Self::resolve_from_hint_strings(get_str) } /// Same as [`Self::resolve_from_hints`], for merged effective config (`toml::Value`). pub fn resolve_from_hints_value(hints: Option<&toml::Value>) -> (Self, Self) { let (new_session, fork) = xai_grok_shell::util::config::WorktreeHintMode::resolve_pair(hints); (new_session.into(), fork.into()) } fn resolve_from_hint_strings(get_str: impl Fn(&str) -> Option) -> (Self, Self) { let legacy = get_str("worktree_mode"); let new_session = get_str("new_session_worktree_mode") .or(legacy) .unwrap_or(Self::Never); let fork = get_str("fork_worktree_mode") .or(legacy) .unwrap_or(Self::Ask); (new_session, fork) } } use super::PagerTerminal; use super::actions::Action; use super::agent::AgentId; use super::agent_view::{AgentView, AppRenderParams, McpInitProgress}; use super::bundle::BundleState; /// Which view is currently displayed. /// /// Note: `AgentDashboard` does not carry state directly because /// `DashboardState` is not `Copy`. The dashboard view-state lives on /// `AppView::dashboard` and is only "active" when `active_view == AgentDashboard`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActiveView { Welcome, Agent(AgentId), /// The top-level Agent Dashboard. State lives in `AppView::dashboard`. AgentDashboard, } /// Target restored when leaving the dashboard (Ctrl+\ / Esc). /// Consumed by `dispatch_exit_dashboard`; dead agents fall back to /// insertion-order first / Welcome. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DashboardReturn { /// Plain agent view (no session-overlay chrome). Agent(AgentId), /// Session overlay: re-set `attached_agent` on the way back. Overlay(AgentId), } impl DashboardReturn { pub fn agent_id(self) -> AgentId { match self { Self::Agent(id) | Self::Overlay(id) => id, } } pub fn is_overlay(self) -> bool { matches!(self, Self::Overlay(_)) } } /// Tick cadence demanded by the current view state — see /// [`AppView::tick_demand`]. Ordered: `None < Slow < Fast`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum TickDemand { /// Nothing animates or polls: the event loop parks (zero wakeups). None, /// Only low-frequency work is pending (welcome logo shimmer at ~12fps, /// the macOS Cmd link-hover poll): tick at [`SLOW_TICK_INTERVAL`]. Slow, /// Real animation is on screen: tick at the configured animation fps. Fast, } /// Tick cadence for [`TickDemand::Slow`] (~12fps). Matches the welcome logo's /// `SHIMMER_FPS` so slow ticks sample every shimmer frame, and bounds the /// latency of the macOS Cmd link-hover underline. pub const SLOW_TICK_INTERVAL: Duration = Duration::from_millis(83); /// Welcome toast lifetime (wall clock, so the duration holds whether the /// event loop is ticking Slow or Fast). const WELCOME_TOAST_DURATION: Duration = Duration::from_secs(4); /// Which prompt box in-flight voice dictation appends its finalized text to. /// Captured when recording **starts** so a trailing STT final still lands where /// the user was dictating, even if they navigate away — or toggle a dashboard /// row's peek panel — mid-utterance. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VoiceTarget { /// A live agent session's prompt box. Agent(AgentId), /// The dashboard's new-agent dispatch input (no row peek was open at start). DashboardDispatch, /// The dashboard's peek reply input, bound to the agent whose peek was open /// at start. The id pins the row: selecting a different row mid-utterance /// stops capture (the reply widget is shared and clears on row change), so a /// final can't land on the wrong agent's reply. DashboardPeekReply(AgentId), } /// The voice-dictation lifecycle. Exactly one state holds at a time, so the /// "is the mic live / is a start queued / does a Ctrl+Space hold own it / which /// box receives finals" facts can never disagree (they once lived as separate /// booleans and repeatedly drifted apart). All transitions go through the /// `AppView::voice_*` methods. /// /// `hold` marks a session begun by a Ctrl+Space hold-press: its matching /// Ctrl+Space release ends it (and only it), while `/voice` / toggle sessions /// leave `hold` false so a Ctrl+Space release can't touch them. `target` is the /// prompt box bound at /// **start**, so a trailing final lands where the user was dictating. `interim` /// (the live partial transcript) lives inside the recording states so it can't /// linger as a stale overlay once dictation ends. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum VoiceState { /// No dictation in flight. #[default] Idle, /// A start was requested before the lazy pipeline existed; the event loop /// spawns it once and then opens the mic. ColdStart { hold: bool, target: VoiceTarget }, /// Mic is open and streaming audio to STT. Recording { hold: bool, target: VoiceTarget, interim: Option, }, /// Capture was explicitly stopped (Esc / Ctrl+Space / [stop] / Ctrl+Space /// release), but the target — and the last interim — are kept so a trailing /// STT final still lands without the overlay flickering in the meantime. Stopping { target: VoiceTarget, interim: Option, }, } impl VoiceState { /// Mic is live (the `Recording` state). pub fn listening(&self) -> bool { matches!(self, Self::Recording { .. }) } /// A start is queued for the lazy pipeline (the `ColdStart` state). pub fn pending_cold_start(&self) -> bool { matches!(self, Self::ColdStart { .. }) } /// The prompt box that owns this session's dictation, if any. pub fn target(&self) -> Option { match self { Self::ColdStart { target, .. } | Self::Recording { target, .. } | Self::Stopping { target, .. } => Some(*target), Self::Idle => None, } } /// The live partial transcript shown in the prompt overlay, if any. pub fn interim(&self) -> Option<&str> { match self { Self::Recording { interim, .. } | Self::Stopping { interim, .. } => interim.as_deref(), _ => None, } } /// Whether a hold-press owns the current session (so its key release ends /// it). `/voice` and toggle-style starts leave this false. pub(crate) fn hold(&self) -> bool { matches!(self, Self::ColdStart { hold, .. } | Self::Recording { hold, .. } if *hold) } } /// Entry in the session picker list on the welcome screen. #[derive(Debug, Clone)] pub struct SessionPickerEntry { pub id: String, pub summary: String, pub updated_at: chrono::DateTime, pub created_at: chrono::DateTime, pub cwd: String, pub hostname: Option, pub source: String, pub model_id: Option, pub num_messages: usize, /// When the session last had content added (most recent of local and remote). pub last_active_at: Option>, /// Git branch associated with the session (if available from the server response). pub branch: Option, /// Repo display name derived from the CWD path (last 2 path components joined by `-`). pub repo_name: String, /// Human-readable worktree label (if the session was created in a named worktree). pub worktree_label: Option, /// Lazy-loaded detail for the expanded card view. pub card_detail: Option, } /// Detail loaded on-demand when a session card is expanded. #[derive(Debug, Clone)] pub struct CardDetail { pub turn_count: usize, pub tool_call_count: usize, pub first_prompt_preview: String, } /// Authentication state for the welcome screen. /// /// Drives the login flow UI and input routing on the welcome screen. #[derive(Debug)] pub enum AuthState { /// No login required (API key, cached token, or already authenticated). Done, /// Login required -- show login menu on welcome screen. /// `error` is set after a failed auth attempt so the user sees what went wrong. Pending { error: Option }, /// Auth flow is in progress. Authenticating { /// Sequence number for this auth attempt (stale results are ignored). request_seq: u64, /// Abort handle for the in-flight Authenticate task. handle: Option, /// Auth URL from the provider (populated by AuthUrlReady). auth_url: Option, /// How the auth flow presents itself to the user. mode: AuthMode, }, } /// How the auth flow presents itself to the user. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthMode { /// Mode not yet determined (waiting for auth URL response). Pending, /// Browser opened automatically by external provider. Command, /// Manual: user must visit URL and paste token. Loopback, /// RFC 8628 device flow: device code + copyable URL, no paste box. Device, } /// Folder-trust state for the welcome screen. /// /// Mirrors [`AuthState`]: a welcome sub-state that drives the "Do you trust the /// contents of this directory?" question and gates session creation until it is /// answered. Seeded once before the first render from the pure /// [`xai_grok_workspace::folder_trust::decide`] verdict; when the feature flag /// is off `decide` returns trusted, so this is always [`TrustState::Done`]. #[derive(Debug)] pub enum TrustState { /// No question needed (feature off, already trusted, nothing to gate) or /// the question has been answered. Session creation may proceed. Done, /// An untrusted folder with repo-local code-exec config: show the trust /// question and defer session creation until the user answers. Pending { /// The resolved workspace root (git root) that is trusted on accept and /// is shown in the question. workspace: std::path::PathBuf, }, } /// Result of `handle_input`. Tells the event loop what to do next. #[derive(Debug)] #[allow(clippy::large_enum_variant)] pub enum InputOutcome { /// Dispatch this action, then redraw. Action(Action), /// Dispatch this action, then re-process the same event through the /// (now-changed) active view. Used when the welcome screen creates a /// new session on the first keystroke so the character lands in the /// session's prompt instead of being consumed. ActionThenForward(Action), /// Dispatch both actions in order, then redraw (e.g. revert preview /// + open reset confirm). ActionPair(Action, Action), /// Arm a double-press pending action (e.g. idle Esc clear/rewind). /// AppView installs [`PendingAction`]; second press within `ttl` fires /// `action`. `label: None` arms silently (no shortcuts-bar hint). ArmPending { action: Action, shortcut: KeyShortcut, label: Option<&'static str>, ttl: Duration, }, /// Something changed visually (prompt text, scroll). Redraw needed. Changed, /// Nothing happened. Skip redraw to preserve cursor blink. Unchanged, } /// Immutable origin carried beside a paste event until its target consumes it. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PasteProvenance { /// Terminal bracketed paste or pager key-coalesced paste. Terminal, /// Linux X11 PRIMARY read triggered by one unmodified middle-button down. #[allow(dead_code)] X11Primary, } impl PasteProvenance { pub(crate) fn may_probe_clipboard_attachments(self) -> bool { matches!(self, Self::Terminal) } } /// A pending action awaiting double-press confirmation. /// /// Set when a `requires_confirmation` action is triggered. The shortcuts bar /// shows "press again to {label}" when [`Self::label`] is `Some`. If the same /// key is pressed within the TTL, the action fires. Any other key or expiry /// clears it. `label: None` = silent arm (idle-empty Esc→rewind). pub struct PendingAction { /// The action to fire on second press. pub action: Action, /// The specific key that was pressed (narrowed from the binding). pub shortcut: KeyShortcut, /// When `Some`, shortcuts bar shows "press again to {label}". pub label: Option<&'static str>, /// When this pending action expires. pub expires_at: Instant, } impl PendingAction { pub const TTL: Duration = Duration::from_millis(1000); /// Double-press timeout for idle Esc clear / rewind arms. pub const ESC_DOUBLE_PRESS_TTL: Duration = Duration::from_millis(800); pub fn new(action: Action, shortcut: KeyShortcut, label: &'static str) -> Self { Self::with_ttl(action, shortcut, Some(label), Self::TTL) } /// Like [`Self::new`] but with an explicit confirm window. Used by /// the dashboard-overlay stop (Ctrl+X), which mirrors the /// dashboard's [`crate::views::dashboard::state::STOP_CONFIRM_WINDOW`] /// rather than the default double-press TTL. pub fn with_ttl( action: Action, shortcut: KeyShortcut, label: Option<&'static str>, ttl: Duration, ) -> Self { Self { action, shortcut, label, expires_at: Instant::now() + ttl, } } pub fn expired(&self) -> bool { Instant::now() >= self.expires_at } } /// Cap for the `GROK_ESC_DOUBLE_PRESS_MS` override; the pty_e2e suite sets /// exactly this value. pub const ESC_DOUBLE_PRESS_TEST_MS: u64 = 60_000; /// Idle-Esc double-press confirm window, `GROK_ESC_DOUBLE_PRESS_MS`-overridable /// (read once, bounded). Test seam: a loaded pty_e2e shard's render round-trip /// between the two presses can outlast the 800ms default and expire the arm. pub(crate) fn esc_double_press_ttl() -> Duration { use std::sync::OnceLock; static TTL: OnceLock = OnceLock::new(); *TTL.get_or_init(|| parse_esc_ttl(std::env::var("GROK_ESC_DOUBLE_PRESS_MS").ok())) } /// Extracted pure (no `OnceLock`) so the bounds — zero/garbage → default, /// oversized → clamp — are unit-testable. fn parse_esc_ttl(raw: Option) -> Duration { raw.and_then(|v| v.trim().parse::().ok()) .filter(|&ms| ms > 0) .map(|ms| Duration::from_millis(ms.min(ESC_DOUBLE_PRESS_TEST_MS))) .unwrap_or(PendingAction::ESC_DOUBLE_PRESS_TTL) } /// Slash commands unavailable on the free and X Basic subscription tiers. /// /// To restrict another command for these tiers, add its canonical name /// (no leading `/`) here — matching covers aliases automatically via /// [`crate::slash::registry::CommandRegistry::set_restricted_commands`]. /// /// Current set: /// - `usage` — coding credit / billing UI (alias: `/cost`) /// - `imagine` — image generation entry point /// - `imagine-video` — video generation entry point /// - `voice` — voice dictation entry point (the Ctrl+Space / F8 keybinding is /// gated separately in [`crate::app::dispatch::voice`], since it bypasses the /// slash registry) pub(crate) const TIER_RESTRICTED_COMMANDS: &[&str] = &["usage", "imagine", "imagine-video", "voice"]; /// Whether a subscription-tier display name is a tier with restricted /// commands: the free tier (no subscription ⇒ `None`, or an explicit /// "Free") and X Basic (CCP display name "X Basic"; JWT claim fallback /// "x_basic"). Everything else — paid tiers and unknown future names — /// is unrestricted (fail-open). /// /// The string classification is shared with the shell's capability /// (toolset) gate via [`xai_grok_shell::tier::is_restricted_tier_name`] so /// the two can't drift. The pager's *cosmetic* slash-command gate treats an /// absent tier (`None`) as restricted (it recovers live on the next settings /// update); the shell's capability gate treats absence as unrestricted. fn is_restricted_tier(tier: Option<&str>) -> bool { match tier { None => true, Some(t) => xai_grok_shell::tier::is_restricted_tier_name(t), } } /// True for API-key labels from shell/CCP: `"ApiKey"`, `"API Key"`, `"api_key"`. pub(crate) fn is_api_key_label(s: &str) -> bool { s.trim().to_ascii_lowercase().replace([' ', '_', '-'], "") == "apikey" } /// Pending re-exec into another screen mode (see `/minimal` / `/fullscreen`). #[derive(Debug, Clone)] pub struct ScreenModeRelaunch { /// `true` → `--minimal`; `false` → fullscreen (non-minimal). pub minimal: bool, /// Active session to reopen via `--resume`. pub session_id: String, } /// Root view component — owns all application state. pub struct AppView { /// Which view is currently active. pub active_view: ActiveView, /// View to return to after a mid-session login flow completes or is /// cancelled. `Some` only while a `/login` (or 401-triggered re-auth) /// initiated from an active session is in progress — it lets the auth /// UI take over the `Welcome` screen and then restore the caller's view /// (e.g. `Agent`) afterwards. `None` at startup so the normal /// login-then-load flow is preserved. pub auth_return_view: Option, /// Per-agent views (keyed by AgentId). pub agents: IndexMap, /// Monotonically increasing counter for agent ID allocation. /// Never reuse IDs after `shift_remove` to avoid collisions. pub next_agent_id: usize, /// Available/selected models (shared across agents). pub models: ModelState, /// Keybinding definitions. pub registry: ActionRegistry, /// Settings registry — canonical metadata for user-tunable preferences. pub settings_registry: Arc, /// In-memory snapshot of the effective `UiConfig`. Seeded once at /// startup; updated synchronously by `set_X_inner` so dispatch /// stays sans-IO. pub current_ui: xai_grok_shell::agent::config::UiConfig, /// Working directory. pub cwd: PathBuf, /// Whether the project picker question has already been shown this session. pub project_picker_shown: bool, /// "Don't ask me again" opt-out from [`xai_grok_shell::util::config::resolve_hints`]; /// TUI writes to user `config.toml` only. pub project_picker_disabled: bool, /// Whether the cwd is inside a git repository (any ancestor has `.git`). /// Pre-computed at startup so dispatch stays free of filesystem I/O. pub cwd_has_git_ancestor: bool, /// ACP channel for sending requests (shared resource, cloned into agents). pub acp_tx: AcpAgentTx, /// Local cache of bundle sync/status state from the shell. pub(crate) bundle_state: BundleState, /// Reusable scratch buffer for rendering. pub scratch: ScratchBuffer, /// Cursor state for blink-preserving cursor management. /// See [`crate::render::draw`] for the full rationale. pub cursor: CursorState, /// Pending double-press confirmation (quit, etc.). pub pending_action: Option, /// Pending exit-session confirmation for slash command path. /// Set when `/home` is first typed; confirmed on second invocation within TTL. pub exit_session_pending: Option, /// Mouse scroll normalization state (wheel/trackpad detection, acceleration). /// App-level because scroll is a physical input property, not per-agent. pub scroll_state: MouseScrollState, /// Scroll config derived from terminal detection. pub scroll_config: ScrollConfig, /// Current appearance config (hot-reloadable from ~/.grok/pager.toml). /// Stored here so new agents inherit the current config. pub appearance: AppearanceConfig, /// Notification service (terminal bell, OSC sequences, title updates). pub notification_service: NotificationService, /// Escape sequences (title, progress bar) accumulated by the last /// `update_notifications()` tick. Consumed by `draw()` and appended /// to the frame's `post_flush_escapes` so they are written inside the /// synchronized output block. pub(crate) pending_notification_escapes: Option, /// Notification deferred by several ticks so the terminal has time to /// process the idle title escape before the notification fires. /// /// The idle title goes through the frame pipeline (writer thread /// channel), then Ghostty must read it from the PTY and apply it. /// Ghostty debounces `setTitle()` by 75 ms, so we need >75 ms /// before the notification reads `self.title` for the subtitle. /// 3 ticks × 33 ms ≈ 99 ms covers the debounce comfortably. /// /// The `u8` counts remaining ticks; the notification fires when it /// reaches 0. pub(crate) deferred_notification: Option<(crate::notifications::NotificationEvent, u8)>, /// Tracing log channel receiver. Set by the event loop after /// `init_tracing()`. Drained into `tracing_pane` each tick in debug/dev /// builds; otherwise drained-and-discarded. pub tracing_rx: Option, /// Scroll-diagnostics HUD (`GROK_SCROLL_DEBUG` env / `/scroll-debug`). /// Release-compiled behind its runtime gate — see the module doc. pub scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud, /// Release-safe FPS HUD (`/debug fps`; `GROK_FPS` env on release /// builds, where the dev overlay is compiled out) — see the module doc. pub fps_hud: crate::views::fps_hud::FpsHud, pub active_announcements: Vec, /// Persisted hide keys, filtered at the banner selection gate — hiding one /// critical reveals the next unhidden one, and a NEW id re-arms the banner. pub hidden_announcement_ids: std::collections::BTreeSet, pub announcements_last_gen: u64, /// Selected welcome announcement for this pager launch. pub announcement: Option, /// Cached changelog markdown (for `/release-notes`). Populated by /// `FetchChangelog` at startup; `None` until the fetch completes. pub changelog_markdown: Option, /// Cached changelog bullets (for welcome screen). Populated by /// `FetchChangelog` at startup; empty until the fetch completes. pub changelog_bullets: Vec, /// Resolved tip list from config layers. pub tips: Vec, /// Selected tip for the current launch/session. pub tip: Option, /// Whether to show the resolved model ID in /session-info output. pub show_resolved_model: bool, /// Whether the `/share` slash command is available. Gated by /// `RemoteSettings.sharing_enabled`; defaults to `false` when remote /// settings are unavailable or the field is absent. pub sharing_enabled: bool, /// Whether the plugin marketplace CTA is enabled. Env `GROK_PLUGIN_CTA` /// overrides `RemoteSettings.plugin_cta` (remote settings); defaults to `false`. pub plugin_cta_enabled: bool, /// Consumer billing surface (credit fetches / warnings). False for team /// and API-key auth. `/usage` itself stays available for session token/cost. pub usage_visible: bool, /// Slash commands denied for the current subscription tier /// ([`TIER_RESTRICTED_COMMANDS`] when the user is on the free / X Basic /// tier, empty otherwise). Recomputed by [`Self::apply_tier_restrictions`] /// and fanned out to every slash registry (welcome prompt, agents, /// dashboard); deny wins over all other visibility gates. pub tier_restricted_commands: Vec, /// Whether the pager is connected via a leader (leader mode). The Agent /// Dashboard entry points (`/dashboard`, `Ctrl+\`, `grok dashboard`, the /// startup hook) are only meaningful when a leader is coordinating a /// fleet of sessions, so they are gated on this flag. Set in /// `event_loop::run` from `connection.leader_status_rx.is_some()`; /// defaults to `false` (non-leader, dashboard hidden). pub leader_mode: bool, /// App-level credit balance used to show the usage warning on the /// welcome screen before any agent session exists. pub credit_balance: Option, /// App-level auto top-up rule paired with `credit_balance` for the warning. pub auto_topup: Option, /// Periodic billing poll requested (credits >= 99%). pub billing_poll_wanted: bool, /// Leader-mode session roster (FleetView dashboard). Populated from /// `x.ai/sessions/list` polls and `x.ai/sessions/changed` broadcasts. /// Empty in non-leader mode, which naturally gates roster rendering. pub leader_roster: Vec, /// Local on-disk session list (dormant/idle sessions) surfaced on the /// dashboard when NOT in leader mode. There is no live leader roster to /// poll outside leader mode, so we fetch the same `x.ai/session/list` the /// resume picker uses and render those as idle rows. Entries are stored as /// [`crate::app::roster::RosterEntry`] (activity `Dormant`) so they reuse /// the existing roster-row rendering / attach path. Empty in leader mode. pub dashboard_local_sessions: Vec, /// Whether the dashboard is currently loading local sessions (non-leader mode). pub dashboard_sessions_loading: bool, /// Server-authoritative shared prompt queues, keyed by `sessionId` /// Reconciled from `x.ai/queue/changed` broadcasts so /// every client renders the same ordered queue (including prompts queued /// by other clients). Empty in non-leader mode. pub shared_prompt_queues: std::collections::HashMap>, /// Optimistic echo rows for prompts the pager sent server-authoritatively /// (plain prompt typed while a turn is running) but for which the /// confirming `x.ai/queue/changed` broadcast has not yet arrived. Keyed by /// `sessionId`. Pinned into `shared_prompt_queues` on reconcile so the row /// doesn't flicker, and dropped once the authoritative broadcast reflects /// the id (or it starts running). Never persisted. pub optimistic_prompt_echoes: std::collections::HashMap>, /// Server-authoritative running prompts that drained into the running slot /// while the previous turn was still finishing locally (handoff race). /// Keyed by `AgentId`. Consumed by the `PromptResponse` handler after /// `finish_turn` clears `current_prompt_id`, which then adopts the prompt /// and runs the turn-start shim. Never persisted. pub(crate) pending_running_adoptions: std::collections::HashMap, /// Whether the session picker groups entries by repo name with /// non-selectable headers. Gated by `GROK_SESSION_PICKER_GROUPED` env var /// or remote settings `session_picker_grouped`; defaults to `false`. pub session_picker_grouped: bool, /// Whether Ctrl+C before first server activity rewinds the prompt /// back into the input box. Gated by `GROK_CANCEL_REWIND` env / /// `[features] cancel_rewind` config / remote settings flag. pub cancel_rewind_enabled: bool, /// Whether session recap (`/recap` + automatic away recap) is rolled out, /// resolved by the shell and advertised on ACP initialize (`sessionRecap`). /// When false, the pager must not request recaps (zero `x.ai/recap` traffic). pub session_recap_available: bool, /// Stateful prompt widget rendered on the welcome screen (persists input across frames). pub welcome_prompt: PromptWidget, /// The single slash-command MRU/recency store. Owned here and injected /// into every agent prompt and the dashboard dispatch via /// [`PromptWidget::adopt_slash_mru`] so command recency is shared across /// surfaces (single-threaded UI; no process-global singleton). pub(crate) slash_mru: std::rc::Rc>, /// The single resolved per-command tag map (canonical name → free-form tag). /// Owned here and injected into every agent prompt and the dashboard dispatch /// via [`PromptWidget::adopt_command_tags`] so slash-dropdown tags are shared /// across surfaces. Populated from remote settings + local config; updated /// in place so adopters see refreshes without re-adopting. pub(crate) command_tags: std::rc::Rc>>, /// Whether the welcome screen prompt is currently capturing focus (user typed in it). /// When true, menu shortcuts like n/w/q are disabled and Escape unfocuses the prompt. pub welcome_prompt_focused: bool, /// Sticky flag: set once the user types in the welcome prompt, hides the /// tip for the rest of the session (even if the input is cleared). pub welcome_tip_typing_dismissed: bool, /// Effects queued by notification handlers (drained by the event loop). pub pending_effects: Vec, /// Typed `$EDITOR` work consumed by the event loop after the current cycle. /// Both configuration-file and prompt-draft edits share the existing /// leave-raw-mode / child / restore handoff. pub(crate) pending_editor: Option, /// Path to open in `$PAGER` (default `less`) after the current event cycle. /// Set by `Action::OpenTranscriptPager` (`/transcript`); consumed by the /// event loop which suspends the inline TUI, spawns the pager, then restores /// and deletes the temp file. Primarily for minimal mode (no interactive /// scrollback pane), but works in every mode. pub pending_pager_path: Option, /// Whether [`pending_pager_path`](Self::pending_pager_path) holds an /// ANSI-colored file (the minimal "full view" transcript). When true the /// event loop ensures the pager renders raw control codes (`less -R`) so the /// colors show instead of literal escapes. Plain-text transcripts (`/export` /// markdown) leave this false. pub pending_pager_ansi: bool, /// Minimal mode only: the Ctrl+T **force-show** pin for the todo panel. /// Minimal-mode-only per-session state, consolidated into a single field so /// the central `AppView` isn't peppered with loose minimal flags. Default- /// empty and inert outside `--minimal`; the `xai-grok-pager-minimal` crate /// reads/mutates it through the `crate::minimal_api` accessors. See /// [`crate::minimal_api::MinimalState`]. pub(crate) minimal_state: crate::minimal_api::MinimalState, /// Currently highlighted menu item on the welcome screen (arrow keys / hover). pub welcome_menu_index: Option, /// Hit-test rects for welcome menu items (populated during render). pub welcome_menu_rects: Vec, /// Whether the welcome menu currently includes a "Changelog" row (above /// Quit). Set during render; the input handler uses it to size the menu and /// map the extra row to the release-notes action. pub welcome_show_changelog_action: bool, /// Hit-test rect for the import-claude banner on the welcome screen. pub welcome_import_banner_rect: Option, /// Last known mouse position (column, row), updated on every Mouse event. /// Used by the welcome screen to render fine-grained hover effects (e.g. /// brighter red on the import row's `[x]` when the mouse is exactly on /// those cells). pub last_mouse_pos: Option<(u16, u16)>, /// Origin (column, row) of the in-progress scroll gesture. Reused by /// `update_tick`'s residual flush so sub-line carry / stream-gap flushes /// route via `hit_test` to the originating pane instead of leaking into /// scrollback. Cleared on the finalize-transition tick. pub last_scroll_pos: Option<(u16, u16)>, /// Last off-screen render-cache eviction sweep (see /// [`Self::maybe_evict_offscreen_caches`]). pub(super) last_cache_evict_at: Option, /// Hit-test rect for welcome prompt input (populated during render). pub welcome_prompt_rect: Option, /// Hit-test rect for the auth URL (click-to-open during Authenticating). pub welcome_auth_url_rect: Option, /// Whether the mouse pointer was last over the auth URL (for OSC 22 cursor shape). pub welcome_on_auth_url: bool, /// Mouse last over the changelog block (drives hover color + redraws). pub welcome_on_changelog_cta: bool, /// Per-visit announcement UI state on the welcome screen (expansion, hover, /// overflow flag, hit-rect). pub welcome_announcement: WelcomeAnnouncementState, /// Hit-test rect for the "show full URL" fallback link. pub welcome_auth_fallback_rect: Option, /// Hit-test rect for the "[Refresh]" button on the paywall tier line. pub welcome_refresh_rect: Option, /// Hit-test rect for the gate URL link on the paywall CTA. pub welcome_gate_url_rect: Option, /// Hit-test rect for the welcome hero upgrade CTA `[label]` button /// (click → `AnnouncementsOpenCta(Welcome)`). pub welcome_upgrade_cta_rect: Option, pub welcome_privacy_banner_accept_rect: Option, pub welcome_privacy_banner_customize_rect: Option, pub welcome_privacy_banner_legal_rect: Option, /// Transient welcome toast: (message, wall-clock expiry). pub welcome_toast: Option<(String, std::time::Instant)>, /// Sticky hover flag for the privacy banner buttons (redraw on enter/leave). pub welcome_on_privacy_banner: bool, /// Sticky hover flag for the welcome upgrade CTA (redraw on enter/leave). pub welcome_on_upgrade_cta: bool, /// Hit-test rect for the clickable changelog info block (opens release notes). pub welcome_changelog_cta_rect: Option, /// Show the raw auth URL with mouse capture disabled for manual copy. pub auth_show_raw_url: bool, /// Whether mouse capture is currently disabled for raw URL mode. pub auth_mouse_disabled: bool, /// Fetched session list for the session picker (None = not yet fetched). pub session_picker_entries: Option>, /// Whether the session list is currently being fetched. pub session_picker_loading: bool, /// Unified picker state for the session picker. pub session_picker_state: crate::views::picker::PickerState, /// Source filter for the welcome-screen session picker. pub session_picker_source_filter: crate::views::session_picker::SourceFilter, /// Directory whose relaxed-scope notice has fired, keyed by the browse cwd /// (`app.cwd`); a cwd-scoped browse clears it so a later relax re-notifies. pub session_picker_relaxed_notified_for: Option, /// Content-based (deep search) results from ACP session search. pub session_picker_content_results: Option>, /// Whether a deep search is currently in flight. pub session_picker_content_loading: bool, /// Monotonically increasing sequence number for deep search requests. pub session_picker_deep_search_seq: u64, /// Monotonically increasing sequence number for session list fetches /// (`Effect::FetchSessionList`): only the seq-current response is /// applied, so a stale completion can't clobber newer results. Bumped /// only under chat mode (server-search supersede); in Build mode it /// stays 0 so plain list responses keep their pre-existing /// last-write-wins behavior. pub session_picker_list_seq: u64, /// Resolved compat-session cells used before checking resume-skill paths. pub(crate) foreign_session_compat: xai_grok_workspace::foreign_sessions::EnabledForeignSessionSources, /// Monotonic picker scan sequence, bumped on every open and close. pub(crate) foreign_session_scan_seq: u64, /// Coalesces obsolete foreign scans across welcome and modal pickers. pub(crate) foreign_scan_coordinator: crate::app::ForeignScanCoordinator, /// Foreign lane completion and deferred native-lane notice. pub(crate) session_picker_lanes: crate::views::session_picker::SessionPickerLanes, /// Invalidates detail reads when picker rows or filters change. pub(crate) session_picker_detail_generation: u64, /// The search query `session_picker_entries` were server-fetched with /// (`None` = unfiltered fetch). Via /// [`crate::views::session_picker::effective_filter_query`], skips the /// local fuzzy re-filter for server search results. pub session_picker_entries_query: Option, /// Tick counter for welcome screen spinner animation. pub welcome_tick: u64, /// Last shimmer frame drawn on the welcome screen. Lets `tick` throttle the /// wall-clock logo animation to a few fps instead of the full tick rate. pub welcome_shimmer_frame: u64, /// CLI model override (`-m` / `--model`). Seeded into every new /// `AgentSession.deferred_model_switch` so the model is applied once /// the session is created. pub cli_model_override: Option, /// CLI effort token (`--reasoning-effort` / `--effort`). Applied on session create. pub cli_effort_token: Option, /// Default YOLO for new sessions, seeded at startup from `effective_yolo_for_launch`. pub default_yolo: bool, /// Soft-default still owns the mode: settings/update may rewrite UI + /// `default_yolo`. Cleared on user Shift+Tab / settings / CLI claim. /// Not inferred from the rendered permission string. pub permission_mode_from_soft_default: bool, /// Whether the **auto** permission-mode feature gate is enabled (resolved at /// startup from env / `[auto_mode] enabled` / remote settings, default OFF). When /// `false`, the Shift+Tab cycle skips Auto. See /// `xai_grok_shell::util::config::resolve_auto_permission_mode_enabled`. pub auto_mode_gate: bool, /// Managed-policy pin (set at startup); gates every runtime always-approve enable. pub yolo_policy_block: Option<&'static str>, /// One-shot notice that a launch `--yolo` was pinned off; shown on the first agent view. pub yolo_launch_block_notice: Option<&'static str>, /// One-shot switch-back toast after a screen-mode re-exec. pub screen_mode_switch_hint: Option<&'static str>, /// Require explicit plan approval via the plan viewer UI even in /// always-approve (YOLO) mode. Loaded from `[ui] require_plan_approval` /// in config.toml at startup. pub require_plan_approval: bool, /// Enable plan mode for new sessions (`--plan`). /// Adds `enter_plan_mode`, `exit_plan_mode` tools; implies `ask_user`. pub plan_mode: bool, /// Enable subagent spawning for new sessions (`--subagents`). /// Adds the `TaskTool` for spawning subagents. pub subagents: bool, /// Enable the ask-user-question tool for new sessions (`--ask-user`). /// Automatically enabled by `plan_mode`. pub ask_user: bool, /// Process-wide gateway light-frontend from CLI `--chat` only. /// Stamps `_meta["x.ai/session"].kind = "chat"` and omits Build agent /// profiles on create/load while set. `/chat` does **not** set this /// (uses [`Self::deferred_startup`] one-shot state instead). pub chat_mode: bool, /// Whether mouse capture is currently enabled. Disabled during the /// Authenticating state so the terminal handles native text selection. pub mouse_captured: bool, /// Active "New Worktree" dialog on the welcome screen. pub new_worktree_dialog: Option, /// Resolved per-tip gates for the contextual ephemeral hints (undo tip, /// plan nudge, clipboard-image tip, send-now tip). Default all ON; resolved /// at startup and on settings toggles from `GROK_CONTEXTUAL_HINTS` (master) /// > `[ui.contextual_hints]` user config > remote tier > default. pub contextual_hints: xai_grok_shell::util::config::ResolvedContextualHints, /// Remote tier for the contextual hints, kept so a settings toggle can /// re-resolve the untouched tips against the same remote defaults. pub remote_contextual_hints: Option, /// Per-key seen counts that gate seen-capped ephemeral tips; the single /// copy of this state. Passed to `show_ephemeral_tip`, which increments the /// matching key in place. In-memory only and per-session — never persisted /// to disk, so each pager run starts fresh (count 0). pub tip_seen_counts: std::collections::HashMap<&'static str, u32>, /// Terminal height (rows) from startup / the last `Event::Resize`. Feeds /// the auto-compact derivation (`views::agent::effective_compact`): the /// render-value compact flag is forced on while the terminal is /// `AUTO_COMPACT_MAX_ROWS` or shorter. 0 = unknown (never forces compact). pub last_known_terminal_rows: u16, /// One-shot gate for the small-screen `/compact-mode` tip: set after the /// first evaluation at a stable agent-view draw (regardless of outcome), /// so later resizes can never re-trigger the tip within this run. pub small_screen_tip_evaluated: bool, /// One-shot gate for the SSH `grok wrap` tip: set after the first /// evaluation at a stable agent-view draw (the environment gates are /// process-constant, so one evaluation decides the run). pub ssh_wrap_tip_evaluated: bool, /// Focus-scoped, opportunistically-polled clipboard-image tip state: poll /// throttle, changeCount delta-detection, fire cooldown, and changeCount /// dedup (macOS-only at the probe layer). pub clipboard_focus_tip: crate::tips::clipboard_focus::ClipboardFocusTipState, /// Persisted worktree preference for `/new`. /// Defaults to [`WorktreeMode::Never`] (no popup). pub new_session_worktree_mode: WorktreeMode, /// Persisted worktree preference for `/fork`. /// Defaults to [`WorktreeMode::Ask`] (show popup). pub fork_worktree_mode: WorktreeMode, /// Restore code state on resume (`--restore-code`). pub restore_code: Option, /// Startup resume target that missed local id/title resolution and was /// deferred to the worktree resume handler (set from materialization). /// Worktree failure messages append the no-match hint only for this /// exact target. pub resume_local_miss: Option, pub agent_override: Option, /// ACP-advertised commands seeded into every new `AgentSession` so /// autocomplete has shell builtins and skills before any runtime /// `AvailableCommandsUpdate` arrives. /// /// Initially populated from `InitializeResponse.meta.availableCommands` /// (AlwaysOn builtins only). Updated whenever the active agent receives /// an `AvailableCommandsUpdate` that includes skills, so subsequent /// sessions start with the full command catalog immediately. pub bootstrap_acp_commands: Vec, /// Auth methods from the ACP connection (preserved for re-login after logout). pub auth_methods: Vec, /// Authentication state for the welcome screen login flow. pub auth_state: AuthState, /// Folder-trust state for the welcome screen. Mirrors [`AppView::auth_state`]: /// when `Pending`, the welcome screen shows the trust question and session /// creation is deferred (gated after auth) until it is answered. pub trust_state: TrustState, /// Login button label from `AuthMethod.name` (e.g., "grok.com", "Acme Corp"). pub login_label: Option, /// The auth method ID to use for login. pub login_method_id: Option, /// Initial auth mode hint from method metadata. pub auth_start_mode: AuthMode, /// Text buffer for manual auth token paste (loopback mode). pub(crate) auth_code_input: LineEditor, /// Monotonically increasing sequence number for auth requests. pub next_auth_request_seq: u64, /// Abort handle for the in-flight `PollAuthUrl` task (with its request_seq). /// Aborted alongside the Authenticate task in single-flight re-login. pub auth_url_poll_handle: Option<(u64, tokio::task::AbortHandle)>, /// Every session/chat/worktree/prompt action deferred behind startup gates. pub deferred_startup: crate::app::session_startup::DeferredStartupActions, /// Whether deferred welcome-screen login should force OAuth. pub auth_use_oauth: bool, /// Delivery state from the last clipboard copy during auth. pub auth_clipboard_delivery: Option, /// Generation of the current auth copy feedback and its clear timer. pub auth_clipboard_feedback_generation: u64, /// Team principal UUID from auth (`None` for personal sessions). pub team_id: Option, /// Team name from auth (displayed in the shortcuts bar). pub team_name: Option, /// Whether the user's team has enterprise Zero Data Retention enabled. pub is_zdr: bool, /// Team role (e.g. "Admin", "Member", "Read Only") for access-control checks. pub team_role: Option, /// Whether the user has opted out of coding data retention. pub coding_data_retention_opt_out: bool, /// Remote settings `privacy_notice_rollout` (cohort on for this user). pub privacy_notice_rollout: bool, /// Remote `privacy_banner_reshow_days`. None/0 = never re-show after ack. pub privacy_banner_reshow_days: Option, /// Local `[privacy].privacy_banner_acked` (RFC 3339 UTC). pub privacy_banner_acked: Option, /// Accept awaits ACP success before ack. pub privacy_banner_accept_inflight: bool, /// Persisted `[cli].show_tips` mirror. `None` = no override (default `true`). pub show_tips: Option, /// Persisted `[cli].auto_update` mirror. `None` = no override (default `true`). pub auto_update: Option, /// Persisted `[toolset.ask_user_question].timeout_enabled` mirror, seeded /// from the effective TOML merge like `show_tips`. `None` = unset in TOML /// (default `true`); toggles write the user layer. pub ask_user_question_timeout_enabled: Option, /// Whether ZDR users are allowed to use the product. /// Server-controlled via RemoteSettings (remote settings). Default `false` (blocked) during beta. pub zdr_access_enabled: bool, /// When set, `/usage` shows a link to this URL instead of fetching billing /// data from the backend. Server-controlled via RemoteSettings (remote settings /// `grok_build_usage_redirect_url`, targeted at personal-team users). /// `None` (default) fetches usage from the backend. pub usage_billing_redirect_url: Option, pub access_gate_shown_logged: bool, /// (hide-key, surface) pairs whose `AnnouncementCtaShown` impression was /// already logged — once per pager process, cleared on logout. Keyed by /// `announcement_hide_key` (stable even for id-less items, unlike the /// event's `id`). pub announcement_cta_impressions_logged: std::collections::BTreeSet<(String, xai_grok_telemetry::events::AnnouncementCtaSurface)>, /// Access gate from `grok_build_access_gate`. `Some` = blocked. pub gate: Option, /// User-friendly subscription tier name (e.g. "SuperGrok", "Free"). pub subscription_tier: Option, /// When the pager started auto-checking subscriptions (for 10-min timeout). pub paywall_check_started: Option, /// Debounce stamp for watch/focus subscription checks (see /// [`super::subscription`]). pub last_subscription_check_at: Option, /// Server override (seconds) for the subscription-watch cadence. pub subscription_watch_interval_secs: Option, /// A stale-source gate held out of `gate` while a live check verifies /// it (see [`super::subscription`]). pub pending_gate_verification: Option, /// Generation stamp of the current gate verification. pub gate_verify_gen: u64, /// Whether a leader reconnect is in progress (blocks prompt submission). pub reconnect_pending: bool, /// Structured startup warnings collected from the terminal diagnostics /// engine at launch. Empty when the environment is healthy. pub startup_warnings: Vec, /// Whether the user authenticated with an API key (shown in the version badge). pub is_api_key_auth: bool, /// Latest version string from a background update check. Set when /// a newer version is detected; rendered as a notification on the /// welcome screen. pub pending_update_version: Option, /// When true, the event loop should exit so the user can relaunch /// to pick up the downloaded update. pub quit_for_update: bool, /// Generation and state for the one launch-scoped foreign resume detection. pub(crate) foreign_resume_launch_generation: u64, pub(crate) foreign_resume_launch: Option, /// When set, the event loop should exit and the process re-exec into the /// other screen mode. Driven by `/minimal` and `/fullscreen`. Captures the /// session id at action time so a later teardown cannot drop `--resume`. pub relaunch: Option, /// Whether importable `.claude/` settings were detected at startup. pub has_claude_import: bool, /// When set, the welcome screen renders an interactive import modal instead of normal content. pub import_claude_modal: Option, /// Doc viewer overlay for the welcome screen (release notes via Ctrl+L). pub welcome_doc_viewer: Option, /// Whether the pager uses fullscreen (alt-screen) or inline mode. /// Set from the resolved terminal state at startup. pub(crate) screen_mode: super::ScreenMode, /// Onboarding tutorial overlay, if open. Top-level (not per-agent) so it /// works over both the welcome screen and an agent session. Opened by /// `/tutorial` (also in the command palette). pub tutorial: Option, /// Agent Dashboard state. `Some(_)` only when the dashboard view /// is active (`active_view == AgentDashboard`) or recently closed. /// Held outside the `ActiveView` discriminant because `DashboardState` /// is not `Copy` (owns its prompt widget, peek panel, etc.). pub dashboard: Option, /// Where to return when leaving the dashboard. See [`DashboardReturn`]. pub dashboard_return: Option, /// Persisted dashboard configuration (pinned rows, reorderings, /// grouping). Loaded once on startup from /// `~/.grok/config.toml`. `None` when the file/section is absent /// or contained malformed data — falls back to in-memory defaults. pub dashboard_persisted: Option, /// Per-platform key event normalizer. /// /// NOTE: new event consumers that bypass `AppView::handle_input` /// will not get rescued modifiers unless also normalizing. pub(crate) keyboard_normalizer: KeyboardNormalizer, /// Voice gate (GA default on at startup resolution). When false — remote /// kill switch or `GROK_VOICE_MODE=0` — the STT pipeline is not started and /// session voice mode cannot turn on. Unit tests leave this false until /// they call [`Self::apply_voice_mode_enabled`]. pub voice_mode_enabled: bool, /// Session UI mode from `/voice` (this CLI process only — not in config.toml). /// When true and the pipeline is up, the in-prompt dictation overlay can show /// and capture may start. Cleared on exit or when the remote flag turns off. pub voice_ui_active: bool, /// Optional `[voice]` overrides from config (`api_base`, `language`, …). pub voice_config: xai_grok_voice::VoiceConfig, /// Auth for STT (OAuth session via shell `AuthManager`, or `XAI_API_KEY`). /// `None` until the pipeline is first started (lazy on `/voice`). pub voice_auth: Option, /// Commands into the voice pipeline (start/stop capture — toggle, not hold). pub voice_cmd_tx: Option>, /// The dictation lifecycle (idle / queued / recording / stopping), including /// the live interim transcript. One state at a time, so inconsistent /// combinations are unrepresentable; production mutates it only through the /// `AppView::voice_*` transition methods. pub voice_state: VoiceState, } /// Reshow window elapsed? None/0 = never. Unparseable ack fails open (show). fn privacy_banner_reshow_elapsed(acked_at: &str, reshow_days: Option) -> bool { let Some(days) = reshow_days.filter(|d| *d > 0) else { return false; }; let Ok(acked) = chrono::DateTime::parse_from_rfc3339(acked_at) else { return true; }; let acked_utc = acked.with_timezone(&chrono::Utc); let Some(next) = acked_utc.checked_add_signed(chrono::Duration::days(days as i64)) else { return false; }; chrono::Utc::now() >= next } /// Bottom-right toast overlay on the welcome screen (mirrors agent toast style). fn paint_welcome_toast(buf: &mut ratatui::buffer::Buffer, area: ratatui::layout::Rect, msg: &str) { let theme = crate::theme::Theme::current(); let max_msg = (area.width as usize).saturating_sub(4); if max_msg == 0 || area.height == 0 { return; } let toast = if msg.chars().count() <= max_msg { format!(" {msg} ") } else { let truncated: String = msg.chars().take(max_msg.saturating_sub(1)).collect(); format!(" {}… ", truncated.trim_end()) }; let w = toast.chars().count() as u16; let x = area.right().saturating_sub(w + 1); let y = area.bottom().saturating_sub(1); for (i, ch) in toast.chars().enumerate() { if let Some(cell) = buf.cell_mut((x + i as u16, y)) { cell.set_char(ch); cell.fg = theme.accent_user; cell.bg = theme.bg_base; cell.modifier = ratatui::prelude::Modifier::BOLD; } } } impl AppView { pub fn is_zdr_blocked(&self) -> bool { self.is_zdr && !self.zdr_access_enabled } /// User is not gated (no gate from remote settings or subscription fallback). pub fn has_access(&self) -> bool { self.gate.is_none() } /// True when the user should not see the prompt (gate, subscription, or ZDR). pub fn is_access_blocked(&self) -> bool { !self.has_access() || self.is_zdr_blocked() } /// Coding-data preference is team-admin-owned for non-admin members. pub fn is_team_non_admin(&self) -> bool { self.team_name.is_some() && !self .team_role .as_deref() .is_some_and(|r| r.eq_ignore_ascii_case("admin")) } /// Why `coding_data_sharing` is locked for this user (`None` = editable). /// Mirrors the dispatch guards in `set_coding_data_sharing`. pub fn coding_data_sharing_lock(&self) -> Option { if self.is_zdr { Some(crate::settings::CodingDataSharingLock::Zdr) } else if self.is_team_non_admin() { Some(crate::settings::CodingDataSharingLock::TeamManaged) } else { None } } /// Welcome privacy banner visibility gates. pub fn privacy_banner_should_show(&self) -> bool { if self.screen_mode.is_minimal() { return false; } if !self.privacy_notice_rollout { return false; } if self.is_zdr || self.is_team_non_admin() { return false; } if !self.coding_data_retention_opt_out { return false; } if !matches!(self.auth_state, AuthState::Done) || !self.has_access() || self.is_zdr_blocked() || !matches!(self.trust_state, TrustState::Done) { return false; } match self.privacy_banner_acked.as_deref() { None => true, Some(acked_at) => { privacy_banner_reshow_elapsed(acked_at, self.privacy_banner_reshow_days) } } } /// Whether deferred session-startup actions may run: both auth AND folder /// trust must be resolved. Mirrors the auth gate at the session-creating /// startup sites; trust is gated AFTER auth so a pending trust question /// defers session creation until answered. pub fn session_startup_allowed(&self) -> bool { matches!(self.auth_state, AuthState::Done) && matches!(self.trust_state, TrustState::Done) } /// Extract `GateInfo` from `RemoteSettings`. pub fn gate_from_settings( rs: &xai_grok_shell::util::config::RemoteSettings, ) -> Option { let msg = rs.gate_message.as_ref()?; if msg.is_empty() { return None; } Some(xai_grok_shell::auth::GateInfo { message: msg.clone(), url: rs.gate_url.clone(), label: rs.gate_label.clone(), }) } /// Apply typed auth metadata from the shell. pub fn apply_auth_meta(&mut self, meta: &xai_grok_shell::auth::AuthMeta) { self.pending_gate_verification = None; let was_gated = self.gate.is_some(); self.team_id = meta.team_id.clone(); self.team_name = meta.team_name.clone(); self.is_zdr = meta.is_zdr; self.team_role = meta.team_role.clone(); self.coding_data_retention_opt_out = meta.coding_data_retention_opt_out; self.gate = meta.gate.clone(); if was_gated && self.gate.is_none() { self.paywall_check_started = None; xai_grok_telemetry::session_ctx::log_event( xai_grok_telemetry::events::SubscriptionActivated { auth_method: self.login_method_id.as_ref().map(|id| id.0.to_string()), upsell_shown_this_session: self.access_gate_shown_logged, }, ); } self.subscription_tier = meta.subscription_tier.clone(); let was_api_key = self.is_api_key_auth; self.is_api_key_auth = meta.auth_mode.as_deref().is_some_and(is_api_key_label) || meta .subscription_tier .as_deref() .is_some_and(is_api_key_label); self.usage_visible = meta.team_name.is_none() && !self.is_api_key_auth; self.sync_billing_surface_to_agents(); self.apply_tier_restrictions(); if self.is_api_key_auth { self.ensure_voice_for_api_key(); } else if was_api_key && is_restricted_tier(self.subscription_tier.as_deref()) { self.voice_reset(); self.voice_ui_active = false; self.apply_voice_mode_enabled(false); } if let Some(show) = meta.show_resolved_model { self.show_resolved_model = show; } } /// Mirror [`Self::usage_visible`] onto every slash surface that can run /// `/usage` (agents, welcome, dashboard dispatch / peek-reply). pub(crate) fn sync_billing_surface_to_agents(&mut self) { let visible = self.usage_visible; for agent in self.agents.values_mut() { agent.set_billing_surface_visible(visible); } self.welcome_prompt .slash_controller .set_billing_surface_visible(visible); if let Some(dash) = self.dashboard.as_mut() { dash.dispatch .slash_controller .set_billing_surface_visible(visible); dash.peek_reply .slash_controller .set_billing_surface_visible(visible); } } /// Force voice on for API-key sessions when only a remote rule left it off. /// Requirement / env / config pins still win. pub(crate) fn ensure_voice_for_api_key(&mut self) { if !self.is_api_key_auth || self.voice_mode_enabled { return; } if crate::app::resolve_voice_mode_live(None, false) { self.apply_voice_mode_enabled(true); } } /// Create a new AppView with the given ACP connection details. pub fn new( acp_tx: AcpAgentTx, models: ModelState, bootstrap_acp_commands: Vec, ) -> Self { let slash_mru = std::rc::Rc::new(std::cell::RefCell::new(crate::slash::mru::SlashMru::new())); let command_tags = std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())); let mut welcome_prompt = PromptWidget::new(); welcome_prompt.adopt_slash_mru(slash_mru.clone()); welcome_prompt.adopt_command_tags(command_tags.clone()); Self { active_view: ActiveView::Welcome, auth_return_view: None, agents: IndexMap::new(), next_agent_id: 0, models, registry: ActionRegistry::defaults(), settings_registry: Arc::new(crate::settings::SettingsRegistry::defaults()), current_ui: xai_grok_shell::agent::config::UiConfig::default(), cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), project_picker_shown: false, project_picker_disabled: false, cwd_has_git_ancestor: std::env::current_dir() .ok() .is_some_and(|c| c.ancestors().any(|p| p.join(".git").exists())), acp_tx, bundle_state: BundleState::default(), scratch: ScratchBuffer::new(), cursor: CursorState::new(), pending_action: None, exit_session_pending: None, scroll_state: MouseScrollState::default(), scroll_config: ScrollConfig::from_settings(), appearance: AppearanceConfig::default(), notification_service: NotificationService::new(Default::default()), pending_notification_escapes: None, deferred_notification: None, tracing_rx: None, scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(), fps_hud: crate::views::fps_hud::FpsHud::new(), active_announcements: Vec::new(), hidden_announcement_ids: Default::default(), announcements_last_gen: 0, announcement: None, changelog_markdown: None, changelog_bullets: Vec::new(), tips: Vec::new(), tip: None, welcome_prompt, slash_mru, command_tags, welcome_prompt_focused: true, welcome_tip_typing_dismissed: false, pending_effects: Vec::new(), pending_editor: None, pending_pager_path: None, pending_pager_ansi: false, minimal_state: crate::minimal_api::MinimalState::default(), welcome_menu_index: None, welcome_menu_rects: Vec::new(), welcome_show_changelog_action: false, welcome_import_banner_rect: None, last_mouse_pos: None, last_scroll_pos: None, last_cache_evict_at: None, welcome_prompt_rect: None, welcome_auth_url_rect: None, welcome_on_auth_url: false, welcome_on_changelog_cta: false, welcome_announcement: WelcomeAnnouncementState::default(), welcome_auth_fallback_rect: None, welcome_refresh_rect: None, welcome_gate_url_rect: None, welcome_upgrade_cta_rect: None, welcome_privacy_banner_accept_rect: None, welcome_privacy_banner_customize_rect: None, welcome_privacy_banner_legal_rect: None, welcome_toast: None, welcome_on_privacy_banner: false, welcome_on_upgrade_cta: false, welcome_changelog_cta_rect: None, auth_show_raw_url: false, auth_mouse_disabled: false, session_picker_entries: None, session_picker_loading: false, session_picker_state: crate::views::picker::PickerState::with_mode( crate::views::picker::PickerMode::FullScreen, ), session_picker_source_filter: crate::views::session_picker::SourceFilter::default(), session_picker_relaxed_notified_for: None, session_picker_content_results: None, session_picker_content_loading: false, session_picker_deep_search_seq: 0, session_picker_list_seq: 0, foreign_session_compat: Default::default(), foreign_session_scan_seq: 0, foreign_scan_coordinator: Default::default(), session_picker_lanes: Default::default(), session_picker_detail_generation: 0, session_picker_entries_query: None, welcome_tick: 0, welcome_shimmer_frame: 0, cli_model_override: None, cli_effort_token: None, default_yolo: false, permission_mode_from_soft_default: true, auto_mode_gate: xai_grok_shell::util::config::auto_permission_mode_enabled_from_disk(), yolo_policy_block: None, yolo_launch_block_notice: None, screen_mode_switch_hint: None, require_plan_approval: false, plan_mode: false, subagents: false, ask_user: false, chat_mode: false, mouse_captured: true, new_worktree_dialog: None, contextual_hints: Default::default(), remote_contextual_hints: None, tip_seen_counts: Default::default(), last_known_terminal_rows: 0, small_screen_tip_evaluated: false, ssh_wrap_tip_evaluated: false, clipboard_focus_tip: Default::default(), new_session_worktree_mode: WorktreeMode::Never, fork_worktree_mode: WorktreeMode::Ask, restore_code: None, resume_local_miss: None, agent_override: None, bootstrap_acp_commands, auth_methods: Vec::new(), auth_state: AuthState::Done, trust_state: TrustState::Done, login_label: None, login_method_id: None, auth_start_mode: AuthMode::Pending, auth_code_input: LineEditor::default(), next_auth_request_seq: 1, auth_url_poll_handle: None, deferred_startup: Default::default(), auth_use_oauth: false, auth_clipboard_delivery: None, auth_clipboard_feedback_generation: 0, team_id: None, team_name: None, is_zdr: false, team_role: None, coding_data_retention_opt_out: true, privacy_notice_rollout: false, privacy_banner_reshow_days: None, privacy_banner_acked: None, privacy_banner_accept_inflight: false, show_tips: None, auto_update: None, ask_user_question_timeout_enabled: None, zdr_access_enabled: false, usage_billing_redirect_url: None, access_gate_shown_logged: false, announcement_cta_impressions_logged: Default::default(), gate: None, subscription_tier: None, paywall_check_started: None, last_subscription_check_at: None, subscription_watch_interval_secs: None, pending_gate_verification: None, gate_verify_gen: 0, reconnect_pending: false, startup_warnings: Vec::new(), is_api_key_auth: false, pending_update_version: None, foreign_resume_launch_generation: 0, foreign_resume_launch: None, quit_for_update: false, relaunch: None, has_claude_import: false, import_claude_modal: None, welcome_doc_viewer: None, screen_mode: ScreenMode::Inline, show_resolved_model: true, sharing_enabled: false, plugin_cta_enabled: false, usage_visible: true, tier_restricted_commands: Vec::new(), leader_mode: false, credit_balance: None, auto_topup: None, billing_poll_wanted: false, leader_roster: Vec::new(), dashboard_local_sessions: Vec::new(), dashboard_sessions_loading: false, shared_prompt_queues: std::collections::HashMap::new(), optimistic_prompt_echoes: std::collections::HashMap::new(), pending_running_adoptions: std::collections::HashMap::new(), session_picker_grouped: false, cancel_rewind_enabled: true, session_recap_available: false, tutorial: None, dashboard: None, dashboard_return: None, dashboard_persisted: None, keyboard_normalizer: KeyboardNormalizer::from_terminal_context(), voice_mode_enabled: false, voice_ui_active: false, voice_config: xai_grok_voice::VoiceConfig::default(), voice_auth: None, voice_cmd_tx: None, voice_state: VoiceState::Idle, } } /// Seed `deferred_model_switch` from CLI `-m`. The CLI effort token is /// resolved later against the authoritative session catalog in /// [`take_deferred_model_switch`](crate::app::dispatch::session::lifecycle::take_deferred_model_switch); /// resolving it here would use the pre-session dashboard catalog and a /// remapped menu id could resolve differently. pub fn deferred_model_switch_from_cli( &self, ) -> Option<( acp::ModelId, Option, )> { Some((self.cli_model_override.clone()?, None)) } /// Voice capture is armed: the in-prompt dictation overlay can show and /// Ctrl+Space can start capture. /// /// Requires the voice gate, session `/voice` mode, and a live pipeline. /// Stopping capture remains allowed when the kill switch flips mid-record /// (see `dispatch_voice_toggle`). pub fn voice_available(&self) -> bool { self.voice_mode_enabled && self.voice_ui_active && self.voice_cmd_tx.is_some() } /// Whether launch may spawn the background STT pipeline (independent of /// `/voice`). Gated on the voice gate + a build that compiled in audio /// capture. Free-tier upsell is separate ([`Self::is_voice_tier_restricted`]). pub fn voice_can_start_pipeline(&self) -> bool { self.voice_mode_enabled && xai_grok_voice::AUDIO_SUPPORTED } /// Sync voice availability into slash surfaces, cheatsheet, and settings. /// Mirrors `apply_session_recap_available` for `/recap`. pub fn apply_voice_mode_enabled(&mut self, enabled: bool) { self.voice_mode_enabled = enabled; crate::app::VOICE_MODE_ENABLED.store(enabled, std::sync::atomic::Ordering::Release); for agent in self.agents.values_mut() { agent.set_voice_mode_available(enabled); match agent.active_modal.as_mut() { Some(crate::views::modal::ActiveModal::Settings { state }) => { state.rebuild_rows(); } Some(crate::views::modal::ActiveModal::ResetSettingsConfirm { settings_state, .. }) => { settings_state.rebuild_rows(); } _ => {} } } self.welcome_prompt.set_voice_visible(enabled); if let Some(dashboard) = self.dashboard.as_mut() { dashboard.set_voice_visible(enabled); } } /// Sync the auto permission-mode feature gate into every slash surface. /// `/auto` is hard-hidden when `self.auto_mode_gate` is off; otherwise both /// `/always-approve` and `/auto` stay offered as true toggles. Mirrors /// [`Self::apply_voice_mode_enabled`]. Call after gate flips, startup, /// reconnect, and session create/switch (so new agents inherit the gate). pub fn sync_permission_mode_slash_gate(&mut self) { let available = self.auto_mode_gate; for agent in self.agents.values_mut() { agent.prompt.set_auto_mode_available(available); } self.welcome_prompt.set_auto_mode_available(available); if let Some(dashboard) = self.dashboard.as_mut() { dashboard.set_auto_mode_available(available); } } /// Recompute the tier-restricted slash commands from the current auth /// state and sync the deny list into every slash surface (welcome /// prompt, all agents, dashboard) so restricted commands hide/show in /// lockstep. Mirrors [`Self::apply_voice_mode_enabled`]. /// /// Called from [`Self::apply_auth_meta`] (startup / login) and from the /// `x.ai/settings/update` handler when the subscription tier changes, so /// a mid-session upgrade lifts the restrictions without a restart. pub fn apply_tier_restrictions(&mut self) { let restricted = self.team_name.is_none() && !self.is_api_key_auth && is_restricted_tier(self.subscription_tier.as_deref()); let names: Vec = if restricted { TIER_RESTRICTED_COMMANDS .iter() .map(|n| (*n).to_string()) .collect() } else { Vec::new() }; for agent in self.agents.values_mut() { agent.set_restricted_commands(&names); } self.welcome_prompt.set_restricted_commands(&names); if let Some(dashboard) = self.dashboard.as_mut() { dashboard.set_restricted_commands(&names); } self.tier_restricted_commands = names; } /// Whether voice mode is withheld for the current subscription tier /// (free / X Basic personal accounts). Derived from the computed /// [`Self::tier_restricted_commands`] deny list so it stays in lockstep /// with the slash-command gate. Used to gate the Ctrl+Space / F8 voice /// keybinding, which bypasses the slash registry entirely (see /// [`crate::app::dispatch::voice`]). pub fn is_voice_tier_restricted(&self) -> bool { self.tier_restricted_commands.iter().any(|c| c == "voice") } /// Draw-time expiry can flip the live-announcement predicate between /// pushes; resync the slash gate only when it diverges from the stored /// flags (checked per frame, fan-out runs only on change). pub fn resync_announcement_slash_gate_on_divergence(&mut self) { let has = crate::views::announcements::has_session_announcements(&self.active_announcements); if self .agents .values() .any(|a| a.prompt.slash_controller.has_session_announcements() != has) { self.sync_session_announcement_slash_gate(); } } /// Offer `/announcements` only when session items (critical or promo) /// exist (even if currently hidden — user may still run `/announcements /// show`). pub fn sync_session_announcement_slash_gate(&mut self) { let has = crate::views::announcements::has_session_announcements(&self.active_announcements); for agent in self.agents.values_mut() { agent .prompt .slash_controller .set_has_session_announcements(has); for child in agent.subagent_views.values_mut() { child.set_has_session_announcements(has); } } } /// Mic is live (the [`VoiceState::Recording`] state). pub fn voice_listening(&self) -> bool { self.voice_state.listening() } /// Whether the in-flight session is owned by a hold-press (so its key /// release ends it). `/voice` and toggle-style starts leave this false. pub fn voice_hold_owned(&self) -> bool { self.voice_state.hold() } /// The prompt box that owns in-flight dictation, if any. pub fn voice_recording_target(&self) -> Option { self.voice_state.target() } /// The live partial transcript shown in the prompt overlay, if any. pub fn voice_interim(&self) -> Option<&str> { self.voice_state.interim() } /// Best-effort one-shot command into the voice pipeline (no-op if it isn't up). fn voice_send(&self, cmd: xai_grok_voice::VoiceCommand) { if let Some(tx) = &self.voice_cmd_tx && tx.try_send(cmd).is_err() { tracing::trace!("voice command dropped: pipeline channel full or closed"); } } /// Open the mic now (pipeline already up) and enter [`VoiceState::Recording`] /// bound to `target`. `hold` marks a Ctrl+Space hold-press start. pub(crate) fn voice_begin_recording(&mut self, target: VoiceTarget, hold: bool) { self.voice_send(xai_grok_voice::VoiceCommand::PttPress); self.voice_state = VoiceState::Recording { hold, target, interim: None, }; } /// Set the live interim transcript. No-op unless recording, so a late event /// after a stop can't repopulate the overlay. pub(crate) fn voice_set_interim(&mut self, text: String) -> bool { if let VoiceState::Recording { interim, .. } = &mut self.voice_state { *interim = Some(text); true } else { false } } /// Clear the interim in place, keeping the current state. Called when a final /// commits (or yields empty) so the overlay drops the partial without a /// teardown. pub(crate) fn voice_clear_interim(&mut self) { match &mut self.voice_state { VoiceState::Recording { interim, .. } | VoiceState::Stopping { interim, .. } => { *interim = None; } VoiceState::Idle | VoiceState::ColdStart { .. } => {} } } /// Explicit stop (Esc / Ctrl+Space / `[stop]`): release the mic but keep /// the target and last interim so a trailing STT final still lands. Always /// allowed — never leaves a hot mic. No-op unless recording. pub(crate) fn voice_stop_keeping_final(&mut self) { let VoiceState::Recording { target, interim, .. } = &mut self.voice_state else { return; }; let target = *target; let interim = interim.take(); self.voice_send(xai_grok_voice::VoiceCommand::PttRelease); self.voice_state = VoiceState::Stopping { target, interim }; } /// Hard teardown (submit / error / kill-switch / navigate-away): release the /// mic and forget the session — no trailing final, no queued start. pub(crate) fn voice_reset(&mut self) { if self.voice_state.listening() { self.voice_send(xai_grok_voice::VoiceCommand::PttRelease); } self.voice_state = VoiceState::Idle; } /// Ctrl+Space hold release: end only a session a Ctrl+Space hold started — /// cancel a queued hold cold-start, or stop a live hold recording (keeping /// its trailing final). A `/voice` / toggle session (`hold` false) is left /// untouched, so a Ctrl+Space release can neither cancel nor stop it. pub(crate) fn voice_hold_release(&mut self) { match self.voice_state { VoiceState::ColdStart { hold: true, .. } => self.voice_reset(), VoiceState::Recording { hold: true, .. } => self.voice_stop_keeping_final(), _ => {} } } /// Whether the active view still owns the bound dictation `target` — i.e. the /// box dictation started in is the one currently on screen and selected. The /// target is bound at capture start; on the dashboard that means dispatch /// requires no peek open, a peek reply requires the *same* top-level row still /// peeked (the shared reply widget clears on row change), and any open /// attached-agent popup (which occludes the dashboard inputs) disqualifies it. /// `false` when no dictation is bound. fn voice_target_on_active_surface(&self) -> bool { let Some(target) = self.voice_recording_target() else { return false; }; if matches!(self.active_view, ActiveView::AgentDashboard) && self .dashboard .as_ref() .is_some_and(|d| d.attached_agent.is_some()) { return false; } let peeked_top_level = self .dashboard .as_ref() .and_then(|d| match d.peek.as_ref()?.row { crate::views::dashboard::DashboardRowId::TopLevel(id) => Some(id), _ => None, }); match (self.active_view, target) { (ActiveView::Agent(active), VoiceTarget::Agent(rec)) => active == rec, (ActiveView::AgentDashboard, VoiceTarget::DashboardDispatch) => { self.dashboard.as_ref().is_none_or(|d| d.peek.is_none()) } (ActiveView::AgentDashboard, VoiceTarget::DashboardPeekReply(rec)) => { peeked_top_level == Some(rec) } _ => false, } } /// Auto-release the mic if the user navigates away from the box that started /// recording (another agent / dashboard popup / a changed peek row). Keeps /// stop controls and the recording session aligned. Event-loop each tick; /// no-op unless recording. pub fn enforce_voice_session_bound(&mut self) { if !self.voice_state.listening() || self.voice_target_on_active_surface() { return; } self.voice_reset(); } /// Esc handling shared by the agent and dashboard surfaces: while voice is /// active, Esc aborts it (and consumes the key) rather than falling into the /// surface's own Esc behaviour. Gated on voice state only (not the remote /// flag) so Esc can always abort. `None` means Esc isn't ours — the caller /// continues its normal routing. fn voice_esc_outcome( &mut self, key_event: Option<&crossterm::event::KeyEvent>, ) -> Option { let key = key_event?; if key.code != KeyCode::Esc || !key.modifiers.is_empty() { return None; } if self.voice_listening() { Some(InputOutcome::Action(Action::VoiceToggle)) } else if self.voice_state.pending_cold_start() { self.voice_reset(); Some(InputOutcome::Changed) } else { None } } /// App-level Esc owners that consume the key BEFORE any agent input /// routing — the render-boundary decision handed to the agent hint path /// (`AgentView::draw` → `esc_would_cancel_turn`) so a hint bar rendered /// beneath one of these never advertises `Esc cancel`. /// /// Mirrors `handle_input`'s intercepts, in their order: the focused dev /// tracing pane (step 1a consumes all non-global keys), the cloud modal /// (step 1d), the import-Claude modal (agent-arm intercept), /// [`Self::voice_esc_outcome`] — listening OR pending cold-start, the /// handler's actual condition, not the render-only recording flag — and /// the dashboard's attached-agent popup (dashboard-arm intercept). Keep /// this list in lockstep with those intercepts when adding a top-level /// Esc owner. pub(crate) fn esc_owned_before_agent(&self) -> bool { if matches!(self.active_view, ActiveView::AgentDashboard) && self .dashboard .as_ref() .and_then(|d| d.attached_agent) .is_some_and(|id| self.agents.contains_key(&id)) { return true; } self.import_claude_modal.is_some() || self.voice_listening() || self.voice_state.pending_cold_start() } /// Commit interim on real send keys only (not multiline bare Enter). fn maybe_commit_voice_interim_before_submit_key(&mut self, key: &crossterm::event::KeyEvent) { if self.registry.matches_id(ActionId::InterjectPrompt, key) { let _ = crate::voice::commit_interim_into_prompt(self); return; } let multiline = match self.active_view { ActiveView::Agent(id) => self.agents.get(&id).is_some_and(|a| a.multiline_mode), ActiveView::AgentDashboard => self.dashboard.as_ref().is_some_and(|d| d.multiline_mode), _ => false, }; let is_send = if multiline { crate::input::is_mod_enter(key) } else { matches!(key.code, KeyCode::Enter) || self.registry.matches_id(ActionId::SendPrompt, key) }; if is_send { let _ = crate::voice::commit_interim_into_prompt(self); } } /// The active agent's view, when an agent tab is focused. /// /// Always the root agent, even when a subagent view is focused within the /// tab; for subagent-aware resolution use `dispatch::ctx::get_active_agent`. pub fn active_agent(&self) -> Option<&AgentView> { match self.active_view { ActiveView::Agent(id) => self.agents.get(&id), _ => None, } } /// Session ID of the active agent, if one exists and has an established session. pub fn active_session_id(&self) -> Option<&str> { match self.active_view { ActiveView::Agent(id) => self .agents .get(&id) .and_then(|a| a.session.session_id.as_ref()) .map(|sid| sid.0.as_ref()), _ => None, } } /// Whether the project picker should intercept the next prompt. pub fn needs_project_picker(&self) -> bool { !self.project_picker_shown && !self.project_picker_disabled && !crate::project_picker::detection::is_project_dir(&self.cwd) } /// Mark the project picker as resolved so it won't fire again. pub fn mark_project_picker_done(&mut self) { self.project_picker_shown = true; } /// Show a toast on the currently active view. /// /// From the dashboard, toasts route into the dispatch input's inline /// error slot. From an agent view the existing per-agent toast machinery /// fires. On welcome, a bottom-right overlay for /// [`WELCOME_TOAST_DURATION`]. pub fn show_toast(&mut self, msg: &str) { match self.active_view { ActiveView::Agent(id) => { if let Some(agent) = self.agents.get_mut(&id) { if let Some(child_sid) = agent.active_subagent.clone() && let Some(child) = agent.subagent_views.get_mut(&child_sid) { child.show_toast(msg); } else { agent.show_toast(msg); } } } ActiveView::AgentDashboard => { if let Some(d) = self.dashboard.as_mut() { d.error_toast = Some(crate::glyphs::legacy_glyph_fallback(msg).into_owned()); } } ActiveView::Welcome => { let msg = crate::glyphs::legacy_glyph_fallback(msg).into_owned(); self.welcome_toast = Some((msg, std::time::Instant::now() + WELCOME_TOAST_DURATION)); } } } /// Insert or replace a leader roster entry, keyed by `session_id`. pub fn upsert_roster_entry(&mut self, entry: crate::app::roster::RosterEntry) { if let Some(existing) = self .leader_roster .iter_mut() .find(|e| e.session_id == entry.session_id) { *existing = entry; } else { self.leader_roster.push(entry); } } /// Remove a leader roster entry by `session_id`. pub fn remove_roster_entry(&mut self, sid: &str) { self.leader_roster.retain(|e| e.session_id != sid); } /// The roster source the dashboard renders alongside locally-hosted /// agents. In leader mode this is the live leader roster (FleetView). With /// no leader there is nothing to poll, so we fall back to the local /// on-disk session list ([`Self::dashboard_local_sessions`]) so the /// dashboard still shows idle/dormant sessions instead of being empty. pub fn dashboard_roster(&self) -> &[crate::app::roster::RosterEntry] { if self.leader_mode { &self.leader_roster } else { &self.dashboard_local_sessions } } /// Reconcile the shared prompt queue for a session from a /// `x.ai/queue/changed` broadcast. The broadcast is /// authoritative: it fully replaces the previously-known queue for that /// session. An empty list clears the entry. /// /// Returns `(old_id, new_id)` for echoes retired via the kind+text /// fallback (re-keyed: the old id never appears in any broadcast). The /// caller routes these through `AgentView::note_queue_echo_rekeyed` so /// per-agent state moves with the message instead of leaking. pub fn apply_queue_changed( &mut self, changed: crate::app::prompt_queue::QueueChanged, ) -> Vec<(String, String)> { let crate::app::prompt_queue::QueueChanged { session_id, mut entries, running_prompt_id, running_text: _, running_kind: _, running_combined_texts: _, } = changed; let mut rekeyed_echo_ids: Vec<(String, String)> = Vec::new(); let running_row: Option<(String, String)> = running_prompt_id.as_ref().and_then(|pid| { self.shared_prompt_queues .get(&session_id) .and_then(|q| q.iter().find(|e| &e.id == pid)) .map(|e| (e.kind.clone(), e.text.clone())) }); if let Some(opt) = self.optimistic_prompt_echoes.get_mut(&session_id) { opt.retain(|e| { let id_matches_running = running_prompt_id.as_deref() == Some(e.id.as_str()); let id_matches_entry = entries.iter().any(|x| x.id == e.id); let content_match_id = running_row .as_ref() .filter(|(kind, text)| *kind == e.kind && *text == e.text) .and_then(|_| running_prompt_id.clone()) .or_else(|| { entries .iter() .find(|x| x.kind == e.kind && x.text == e.text) .map(|x| x.id.clone()) }); let retired = id_matches_running || id_matches_entry || content_match_id.is_some(); if retired && !id_matches_running && !id_matches_entry && let Some(new_id) = content_match_id { rekeyed_echo_ids.push((e.id.clone(), new_id)); } !retired }); for e in opt.iter() { if !entries.iter().any(|x| x.id == e.id) { let mut pinned = e.clone(); pinned.position = entries.len(); entries.push(pinned); } } if opt.is_empty() { self.optimistic_prompt_echoes.remove(&session_id); } } if entries.is_empty() { self.shared_prompt_queues.remove(&session_id); } else { self.shared_prompt_queues.insert(session_id, entries); } rekeyed_echo_ids } /// Push an optimistic echo row for a server-authoritative prompt the pager /// just sent (a plain prompt or agent-bound kind typed while a turn is /// running). The row is keyed by `prompt_id` so the authoritative /// `x.ai/queue/changed` broadcast replaces it (matched by `id`) rather than /// duplicating it. `kind` (`"prompt"`/`"bash"`/…) drives the row's display /// and, on adoption, the turn-start shim's block + focus flag. pub fn push_optimistic_prompt_echo( &mut self, session_id: &str, prompt_id: &str, text: &str, kind: &str, ) { let entry = crate::app::prompt_queue::QueueEntryWire { id: prompt_id.to_string(), version: 0, owner: None, last_editor: None, kind: kind.to_string(), text: text.to_string(), combined_texts: None, position: 0, }; let opt = self .optimistic_prompt_echoes .entry(session_id.to_string()) .or_default(); if !opt.iter().any(|e| e.id == entry.id) { opt.push(entry.clone()); } let shared = self .shared_prompt_queues .entry(session_id.to_string()) .or_default(); if !shared.iter().any(|e| e.id == entry.id) { let mut e = entry; e.position = shared.len(); shared.push(e); } } /// The shared (server-authoritative) prompt queue for a session, if any. pub fn shared_prompt_queue( &self, session_id: &str, ) -> Option<&Vec> { self.shared_prompt_queues.get(session_id) } /// Apply a (possibly hot-reloaded) appearance config to all agents. pub fn set_appearance(&mut self, config: AppearanceConfig) { for agent in self.agents.values_mut() { agent.scrollback.set_appearance(config.clone()); for child in agent.subagent_views.values_mut() { child.scrollback.set_appearance(config.clone()); child.prompt.sync_tab_width_from_appearance(); } agent .prompt .slash_controller .registry_mut() .set_plugins_visible(!config.disable_plugins); agent.prompt.sync_tab_width_from_appearance(); } self.welcome_prompt.sync_tab_width_from_appearance(); self.appearance = config; } /// Recompute the render-value compact flag from the user setting + /// terminal height (`views::agent::effective_compact`) and propagate it /// to the appearance fan-out and every agent's prompt widget when it /// changed. In-memory only: never touches the user setting /// (`current_ui.compact_mode`), the render cache, or disk — auto-compact /// is derived, so growing the window restores the user's choice. pub(crate) fn apply_effective_compact(&mut self) { let derived = crate::views::agent::effective_compact( self.current_ui.compact_mode, self.last_known_terminal_rows, ); if self.appearance.prompt.compact == derived { return; } let mut config = self.appearance.clone(); config.prompt.compact = derived; self.set_appearance(config); for agent in self.agents.values_mut() { agent.prompt.set_compact(derived); } } /// Viewport height (rows) of the surface a scroll would move — the /// active agent's (or its fullscreen subagent's) scrollback pane, as /// measured at the last draw. 0 = unknown (welcome/dashboard views), /// which keeps the trackpad per-flush cap at its floor. fn scroll_viewport_height(&self) -> u16 { match self.active_view { ActiveView::Agent(id) => self.agents.get(&id).map_or(0, |agent| { let scrollback = agent .active_subagent .as_ref() .and_then(|sid| agent.subagent_views.get(sid)) .map_or(&agent.scrollback, |child| &child.scrollback); scrollback.scroll_info().1 }), _ => 0, } } /// Assemble the scroll-debug HUD's per-frame params (`None` unless /// enabled). Called by `draw()` BEFORE the frame closure: all scroll /// state updates for this frame already happened (input/ticks run before /// draw), and the snapshot is read-only, so the HUD observes exactly the /// state the frame renders without perturbing it. fn scroll_debug_panel(&self) -> Option { if !self.scroll_debug_hud.enabled() { return None; } let config = self .scroll_config .with_viewport_height(self.scroll_viewport_height()); let snapshot = self .scroll_state .debug_snapshot(&config, std::time::Instant::now()); let view = match self.active_view { ActiveView::Agent(id) => self.agents.get(&id).map(|agent| { let scrollback = agent .active_subagent .as_ref() .and_then(|sid| agent.subagent_views.get(sid)) .map_or(&agent.scrollback, |child| &child.scrollback); let (scroll_offset, viewport, total_height) = scrollback.scroll_info(); let max_offset = total_height.saturating_sub(viewport as usize); crate::views::scroll_debug_hud::ViewportDebug { scroll_offset, max_offset, total_height, follow_mode: scrollback.is_follow_mode(), at_bottom: scroll_offset >= max_offset, } }), _ => None, }; let top_offset = self.dev_fps_rows() + self.fps_hud.overlay_height(); Some(crate::views::scroll_debug_hud::ScrollDebugPanel { snapshot, view, top_offset, }) } /// Rows the dev `GROK_FPS` overlay occupies (0 in non-dev builds), so /// runtime debug overlays stack below instead of overpainting it. fn dev_fps_rows(&self) -> u16 { 0 } /// Route a scroll delta to the active view. fn dispatch_scroll(&mut self, lines: i32, column: u16, row: u16) { match self.active_view { ActiveView::Agent(id) => { if let Some(agent) = self.agents.get_mut(&id) { if let Some(child_sid) = agent.active_subagent.clone() && let Some(child) = agent.subagent_views.get_mut(&child_sid) { child.handle_scroll(lines, column, row); return; } agent.handle_scroll(lines, column, row); } } ActiveView::Welcome => { if let Some(crate::views::modal::ActiveModal::DocViewer { scroll, .. }) = self.welcome_doc_viewer.as_mut() { crate::views::modal::apply_doc_scroll_delta(scroll, lines); } } ActiveView::AgentDashboard => { let popup_target = self.dashboard.as_ref().and_then(|d| { d.attached_agent .zip(d.popup_outer_rect) .filter(|(_, outer)| { column >= outer.x && column < outer.x + outer.width && row >= outer.y && row < outer.y + outer.height }) }); if let Some((agent_id, _outer)) = popup_target { if let Some(agent) = self.agents.get_mut(&agent_id) { if let Some(child_sid) = agent.active_subagent.clone() && let Some(child) = agent.subagent_views.get_mut(&child_sid) { child.handle_scroll(lines, column, row); return; } agent.handle_scroll(lines, column, row); } return; } let in_file_search_dropdown = self .dashboard .as_ref() .and_then(|d| d.file_search_dropdown_items_area) .is_some_and(|dd| { column >= dd.x && column < dd.x + dd.width && row >= dd.y && row < dd.y + dd.height }); if in_file_search_dropdown { if let Some(ref mut dashboard) = self.dashboard { dashboard .dropdown_file_search_mut() .move_selection(lines.signum() as isize); } return; } let in_slash_dropdown = self .dashboard .as_ref() .and_then(|d| d.slash_dropdown_items_area) .is_some_and(|dd| { column >= dd.x && column < dd.x + dd.width && row >= dd.y && row < dd.y + dd.height }); if in_slash_dropdown { if let Some(ref mut dashboard) = self.dashboard { dashboard .dispatch .slash_scroll_selection(lines.signum() as isize); } return; } if let Some(ref mut dashboard) = self.dashboard { dashboard.handle_scroll(lines); } } } } } impl AppView { /// Handle a terminal event. Routes through the input layer stack: /// /// 1. Pending action check (double-press confirmation) /// 2. Active view (Welcome or Agent — agent does pane + agent-level) /// 3. Global actions (quit with confirmation) /// /// Quit always goes through double-press confirmation, even when /// escalated from agent-level (e.g., Ctrl-C while cancelling). pub fn handle_input(&mut self, ev: &Event) -> InputOutcome { self.handle_input_at_with_paste_provenance(ev, Instant::now(), PasteProvenance::Terminal) } pub(crate) fn handle_input_at_with_paste_provenance( &mut self, ev: &Event, arrived_at: Instant, paste_provenance: PasteProvenance, ) -> InputOutcome { debug_assert!( matches!(ev, Event::Paste(_)) || paste_provenance == PasteProvenance::Terminal, "non-paste events cannot carry paste provenance" ); let normalized = self.keyboard_normalizer.rescue(ev); let ev: &Event = &normalized; let key_event = match ev { Event::Key(k) if k.kind != KeyEventKind::Release => Some(k), _ => None, }; if let Event::Resize(_, rows) = ev { for agent in self.agents.values_mut() { agent.note_terminal_resize(); for child in agent.subagent_views.values_mut() { child.note_terminal_resize(); } } self.last_known_terminal_rows = *rows; self.apply_effective_compact(); } if let Some(key) = key_event && let Some(pending) = &self.pending_action { let stale_idle_arm_while_busy = matches!( pending.action, Action::ClearPrompt | Action::RewindShowPicker ) && matches!( self.active_view, ActiveView::Agent(id) if self.agents.get(&id).is_some_and(|a| { a.session.state.is_turn_running() || a.session.state.is_cancelling() }) ); if !stale_idle_arm_while_busy && !pending.expired() && pending.shortcut.matches(key) { let action = self.pending_action.take().unwrap().action; return InputOutcome::Action(action); } self.pending_action = None; } let modal_open = self.is_scroll_blocking_modal_open(); if let Event::Mouse(mouse) = ev && let Some(direction) = ScrollDirection::from_mouse_event(mouse) && !modal_open { let config = self .scroll_config .with_viewport_height(self.scroll_viewport_height()); let update = self .scroll_state .on_scroll_event_at(arrived_at, direction, config); let pos = (mouse.column, mouse.row); self.last_scroll_pos = Some(pos); if update.lines != 0 { self.dispatch_scroll(update.lines, pos.0, pos.1); return InputOutcome::Changed; } return InputOutcome::Unchanged; } if let Event::Mouse(mouse) = ev { self.last_mouse_pos = Some((mouse.column, mouse.row)); let is_mouse_action = matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) | MouseEventKind::Moved ); if is_mouse_action {} } if let Some(tutorial) = self.tutorial.as_mut() && matches!(ev, Event::Key(_) | Event::Mouse(_) | Event::Paste(_)) { match crate::views::tutorial::handle_tutorial_input(ev, tutorial) { crate::views::tutorial::TutorialOutcome::Closed => { self.tutorial = None; } crate::views::tutorial::TutorialOutcome::Consumed => {} } return InputOutcome::Changed; } let zdr_blocked = self.is_zdr_blocked(); let has_access = self.has_access(); let welcome_pinned_upgrade_cta = crate::views::announcements::promo_cta( &self.active_announcements, &self.hidden_announcement_ids, ) .is_some_and(|(owner, _, _)| !crate::views::announcements::is_dismissible(owner)); let has_foreign_resume = self.foreign_resume_hint().is_some(); let sp_loading = crate::views::session_picker::loading_spinner_active( self.session_picker_entries.as_deref(), self.session_picker_source_filter, self.session_picker_loading, &self.session_picker_lanes, ); let outcome = match self.active_view { ActiveView::Welcome => handle_welcome_input( ev, &mut WelcomeInputCtx { auth_state: &self.auth_state, trust_state: &self.trust_state, cwd: &self.cwd, mid_session_login: self.auth_return_view.is_some(), auth_code_input: &mut self.auth_code_input, prompt: &mut self.welcome_prompt, prompt_focused: &mut self.welcome_prompt_focused, new_worktree_dialog: &mut self.new_worktree_dialog, menu_index: &mut self.welcome_menu_index, menu_rects: &self.welcome_menu_rects, menu_count: if zdr_blocked { 2 } else { 3 + if self.has_claude_import { 1 } else { 0 } + if self.welcome_show_changelog_action { 1 } else { 0 } }, prompt_rect: self.welcome_prompt_rect.as_ref(), import_banner_rect: self.welcome_import_banner_rect.as_ref(), auth_url_rect: self.welcome_auth_url_rect.as_ref(), auth_fallback_rect: self.welcome_auth_fallback_rect.as_ref(), refresh_rect: self.welcome_refresh_rect.as_ref(), gate_url_rect: self.welcome_gate_url_rect.as_ref(), upgrade_cta_rect: self.welcome_upgrade_cta_rect.as_ref(), privacy_banner_accept_rect: self.welcome_privacy_banner_accept_rect.as_ref(), privacy_banner_customize_rect: self .welcome_privacy_banner_customize_rect .as_ref(), privacy_banner_legal_rect: self.welcome_privacy_banner_legal_rect.as_ref(), on_privacy_banner: &mut self.welcome_on_privacy_banner, on_upgrade_cta: &mut self.welcome_on_upgrade_cta, upgrade_cta_keyboard: welcome_pinned_upgrade_cta, changelog_cta_rect: self.welcome_changelog_cta_rect.as_ref(), on_changelog_cta: &mut self.welcome_on_changelog_cta, announcement_truncated: self.welcome_announcement.truncated, announcement_rect: self.welcome_announcement.rect.as_ref(), on_announcement_cta: &mut self.welcome_announcement.on_cta, announcement_expanded: &mut self.welcome_announcement.expanded, show_raw_url: &mut self.auth_show_raw_url, has_access, is_zdr_blocked: zdr_blocked, sp_entries: &mut self.session_picker_entries, sp_loading, sp_state: &mut self.session_picker_state, sp_content_results: &self.session_picker_content_results, sp_content_loading: self.session_picker_content_loading, sp_entries_query: &self.session_picker_entries_query, has_claude_import: self.has_claude_import, import_claude_modal: &mut self.import_claude_modal, welcome_doc_viewer: &mut self.welcome_doc_viewer, changelog_markdown: &self.changelog_markdown, show_changelog_action: self.welcome_show_changelog_action, has_pending_update: self.pending_update_version.is_some(), has_foreign_resume, cwd_has_git_ancestor: self.cwd_has_git_ancestor, session_picker_grouped: self.session_picker_grouped, sp_source_filter: &mut self.session_picker_source_filter, chat_mode: self.chat_mode, }, ), ActiveView::Agent(id) => { let overlay_active = self .dashboard .as_ref() .is_some_and(|d| d.attached_agent == Some(id)); if !overlay_active && let Event::Key(key) = ev && key.kind != KeyEventKind::Release { match self .registry .lookup(key, crate::actions::When::DashboardOverlay) { Some(crate::actions::ActionId::DashboardOverlayPrev) => { return InputOutcome::Action(Action::DashboardOverlayPrev); } Some(crate::actions::ActionId::DashboardOverlayNext) => { return InputOutcome::Action(Action::DashboardOverlayNext); } _ => {} } } if overlay_active { if let Event::Key(key) = ev && key.kind != KeyEventKind::Release { let lookup = self .registry .lookup(key, crate::actions::When::DashboardOverlay) .or_else(|| self.registry.lookup(key, crate::actions::When::Always)); match lookup { Some(crate::actions::ActionId::OpenDashboard) | Some(crate::actions::ActionId::DashboardOverlayExit) => { return InputOutcome::Action(Action::DashboardOverlayExit); } Some(crate::actions::ActionId::DashboardOverlayPrev) => { return InputOutcome::Action(Action::DashboardOverlayPrev); } Some(crate::actions::ActionId::DashboardOverlayNext) => { return InputOutcome::Action(Action::DashboardOverlayNext); } Some(crate::actions::ActionId::DashboardOverlayStop) => { if self .agents .get(&id) .is_some_and(|a| a.session.state.is_turn_running()) { return InputOutcome::Action(Action::CancelTurn); } self.pending_action = Some(PendingAction::with_ttl( Action::DashboardOverlayStop, KeyShortcut::from(*key), Some("close this session"), crate::views::dashboard::state::STOP_CONFIRM_WINDOW, )); return InputOutcome::Changed; } _ => {} } if key.code == KeyCode::Left && key.modifiers.is_empty() && self .agents .get(&id) .is_some_and(|a| a.is_empty_focused_prompt()) { return InputOutcome::Action(Action::DashboardOverlayExit); } if key.code == KeyCode::Esc && key.modifiers.is_empty() && self .agents .get(&id) .is_some_and(|a| a.overlay_esc_backs_out_from_prompt()) { return InputOutcome::Action(Action::DashboardOverlayExit); } if key.modifiers.is_empty() && self.agents.get(&id).is_some_and(|a| match key.code { KeyCode::Esc => a.overlay_esc_backs_out(), KeyCode::Left => a.overlay_left_backs_out(), _ => false, }) { return InputOutcome::Action(Action::DashboardOverlayExit); } let neutral = self.agents.get(&id).is_some_and(|a| { a.is_bare_scrollback() && a.no_input_overlay_pending() }); if key.code == KeyCode::Char('q') && key.modifiers.is_empty() && neutral { return InputOutcome::Action(Action::DashboardOverlayExit); } if key.code == KeyCode::Esc && key.modifiers.is_empty() && neutral && self.agents.get(&id).is_some_and(|a| { a.no_esc_consumer_pending() && !a.session.state.is_turn_running() && !a.session.state.is_cancelling() }) { return InputOutcome::Action(Action::DashboardOverlayExit); } } if let Event::Mouse(mouse) = ev { use crossterm::event::{MouseButton, MouseEventKind}; match mouse.kind { MouseEventKind::Moved => { let mut changed = false; if let Some(d) = self.dashboard.as_mut() { changed |= d.overlay_close_hit.update_hover(mouse.column, mouse.row); changed |= d.overlay_prev_hit.update_hover(mouse.column, mouse.row); changed |= d.overlay_next_hit.update_hover(mouse.column, mouse.row); } if changed { return InputOutcome::Changed; } } MouseEventKind::Down(MouseButton::Left) => { if let Some(d) = self.dashboard.as_ref() { if d.overlay_close_hit.contains(mouse.column, mouse.row) { return InputOutcome::Action(Action::DashboardOverlayExit); } if d.overlay_prev_hit.contains(mouse.column, mouse.row) { return InputOutcome::Action(Action::DashboardOverlayPrev); } if d.overlay_next_hit.contains(mouse.column, mouse.row) { return InputOutcome::Action(Action::DashboardOverlayNext); } } } _ => {} } } } if let Some(modal) = self.import_claude_modal.as_mut() { use crate::views::import_claude_modal::ImportClaudeModalOutcome; let outcome_to_input = |o: ImportClaudeModalOutcome| match o { ImportClaudeModalOutcome::Confirmed => { InputOutcome::Action(Action::ImportClaudeConfirm) } ImportClaudeModalOutcome::Cancelled => { InputOutcome::Action(Action::ImportClaudeCancel) } ImportClaudeModalOutcome::Changed => InputOutcome::Changed, ImportClaudeModalOutcome::Unchanged => InputOutcome::Unchanged, }; if let Event::Key(key) = ev { if key.kind == KeyEventKind::Release { return InputOutcome::Unchanged; } return outcome_to_input(modal.handle_key(key)); } if let Event::Mouse(mouse) = ev { return outcome_to_input(modal.handle_mouse( mouse.kind, mouse.column, mouse.row, )); } return InputOutcome::Unchanged; } if let Some(outcome) = self.voice_esc_outcome(key_event) { return outcome; } if let Event::Key(key) = ev && key.kind != KeyEventKind::Release { self.maybe_commit_voice_interim_before_submit_key(key); } if self.screen_mode.is_minimal() && let Event::Key(key) = ev && key.kind != KeyEventKind::Release && let Some(outcome) = self.minimal_key_intercept(key) { return outcome; } let prompt_paging = !overlay_active && !self.screen_mode.is_minimal(); let outcome = match self.agents.get_mut(&id) { Some(agent) => { let transcript_before = agent.active_subagent.clone(); let workflows_before = agent.show_workflows; let outcome = if self.screen_mode.is_minimal() { agent.handle_minimal_input(ev, &self.registry) } else if prompt_paging { agent.handle_input_with_prompt_paging(ev, &self.registry) } else { agent.handle_input(ev, &self.registry) }; let transcript_opened = transcript_before.is_none() && agent.active_subagent.is_some(); let workflows_opened = !workflows_before && agent.show_workflows; if let Event::Key(key) = ev { agent.record_input(key, &outcome); } self.pending_effects.append(&mut agent.pending_effects); if transcript_opened || workflows_opened { self.scroll_state.cancel_stream(); self.last_scroll_pos = None; } outcome } None => InputOutcome::Unchanged, }; if self.pending_editor.is_some() && matches!(outcome, InputOutcome::Action(Action::EditPromptExternal)) { InputOutcome::Unchanged } else { outcome } } ActiveView::AgentDashboard => { if let Some(outcome) = self.voice_esc_outcome(key_event) { return outcome; } if let Event::Key(key) = ev && key.kind != KeyEventKind::Release { self.maybe_commit_voice_interim_before_submit_key(key); } let attached_raw = self.dashboard.as_ref().and_then(|d| d.attached_agent); let attached = attached_raw.filter(|id| self.agents.contains_key(id)); if attached_raw.is_some() && attached.is_none() && let Some(d) = self.dashboard.as_mut() { d.close_popup(); } if let Some(agent_id) = attached { if let Event::Key(key) = ev && key.kind != KeyEventKind::Release { let close_via_esc = key.code == KeyCode::Esc; let close_via_action = self .registry .lookup(key, crate::actions::When::Always) .is_some_and(|id| { matches!( id, crate::actions::ActionId::OpenDashboard | crate::actions::ActionId::DashboardExit ) }); if close_via_action || close_via_esc { if let Some(d) = self.dashboard.as_mut() { d.close_popup(); } if let Some(agent) = self.agents.get_mut(&agent_id) { agent.active_subagent = None; } return InputOutcome::Changed; } } if let Event::Mouse(mouse) = ev && matches!( mouse.kind, crossterm::event::MouseEventKind::Down( crossterm::event::MouseButton::Left ) ) { let (close_rect, outer_rect, row_target) = { let dash = self.dashboard.as_ref(); let close_rect = dash.and_then(|d| d.popup_close_rect); let outer_rect = dash.and_then(|d| d.popup_outer_rect); let row_target = dash.and_then(|d| { d.row_rects .iter() .find(|(_, r)| { mouse.column >= r.x && mouse.column < r.x + r.width && mouse.row >= r.y && mouse.row < r.y + r.height }) .map(|(id, _)| id.clone()) }); (close_rect, outer_rect, row_target) }; let in_close = close_rect.is_some_and(|r| { mouse.column >= r.x && mouse.column < r.x + r.width && mouse.row >= r.y && mouse.row < r.y + r.height }); let in_outer = outer_rect.is_some_and(|r| { mouse.column >= r.x && mouse.column < r.x + r.width && mouse.row >= r.y && mouse.row < r.y + r.height }); if in_close { if let Some(d) = self.dashboard.as_mut() { d.close_popup(); } if let Some(agent) = self.agents.get_mut(&agent_id) { agent.active_subagent = None; } return InputOutcome::Changed; } if !in_outer && let Some(target) = row_target { return InputOutcome::Action(Action::DashboardAttach(target)); } if !in_outer { return InputOutcome::Unchanged; } } match self.agents.get_mut(&agent_id) { Some(agent) => { let transcript_before = agent.active_subagent.clone(); let workflows_before = agent.show_workflows; let outcome = agent.handle_input(ev, &self.registry); let transcript_opened = transcript_before.is_none() && agent.active_subagent.is_some(); let workflows_opened = !workflows_before && agent.show_workflows; if let Event::Key(key) = ev { agent.record_input(key, &outcome); } self.pending_effects.append(&mut agent.pending_effects); if transcript_opened || workflows_opened { self.scroll_state.cancel_stream(); self.last_scroll_pos = None; } if matches!(outcome, InputOutcome::Action(Action::ExitSession)) { if let Some(d) = self.dashboard.as_mut() { d.close_popup(); } if let Some(agent) = self.agents.get_mut(&agent_id) { agent.active_subagent = None; } return InputOutcome::Changed; } outcome } None => InputOutcome::Unchanged, } } else if let Some(ref mut dashboard) = self.dashboard { let outcome = dashboard.handle_input_with_paste_provenance( ev, &self.registry, paste_provenance, ); self.pending_effects.append(&mut dashboard.pending_effects); outcome } else { InputOutcome::Unchanged } } }; if let InputOutcome::Action(Action::Quit) = &outcome { return self.apply_quit_confirmation(key_event); } if let InputOutcome::Action(Action::QuitConfirmed) = &outcome { return InputOutcome::Action(Action::Quit); } if let InputOutcome::Action(Action::ExitSessionConfirmed) = &outcome { return InputOutcome::Action(Action::ExitSession); } if let InputOutcome::ArmPending { action, shortcut, label, ttl, } = outcome { self.pending_action = Some(PendingAction::with_ttl(action, shortcut, label, ttl)); return InputOutcome::Changed; } if let InputOutcome::Action(Action::ExitSession) = &outcome && matches!(self.active_view, ActiveView::Agent(_)) { return self.apply_exit_session_confirmation(key_event); } if matches!( outcome, InputOutcome::Action(Action::NewSession | Action::NewWorktreeSession { .. }) ) && matches!(self.active_view, ActiveView::Agent(_)) && let Some(key) = key_event { let (action, action_id) = match &outcome { InputOutcome::Action(Action::NewSession) => { (Action::NewSession, ActionId::NewSession) } InputOutcome::Action(Action::NewWorktreeSession { load_session_id, label, git_ref, }) => { let action = Action::NewWorktreeSession { load_session_id: load_session_id.clone(), label: label.clone(), git_ref: git_ref.clone(), }; let shortcut = KeyShortcut::from(*key); self.pending_action = Some(PendingAction::new(action, shortcut, "new in worktree")); return InputOutcome::Changed; } _ => unreachable!(), }; if let Some(def) = self.registry.find(action_id) && def.requires_confirmation { let shortcut = if def.default_key.matches(key) || def.alt_keys.iter().any(|alt| alt.matches(key)) { KeyShortcut::from(*key) } else { def.default_key }; self.pending_action = Some(PendingAction::new(action, shortcut, def.label)); return InputOutcome::Changed; } } if !matches!(outcome, InputOutcome::Unchanged) { return outcome; } if matches!(ev, Event::Resize(_, _)) { return InputOutcome::Changed; } if let Some(key) = key_event && let Some(action_id) = self.registry.lookup(key, When::Always) { return self.handle_global_action(action_id, key); } if let Some(key) = key_event && (key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key)) && matches!( self.active_view, ActiveView::Agent(_) | ActiveView::AgentDashboard ) { self.pending_action = Some(PendingAction::new( Action::Quit, KeyShortcut::from(*key), "quit", )); return InputOutcome::Changed; } InputOutcome::Unchanged } /// Handle a global-level action. Applies confirmation if required. fn handle_global_action( &mut self, action_id: ActionId, key: &crossterm::event::KeyEvent, ) -> InputOutcome { let Some(def) = self.registry.find(action_id) else { return InputOutcome::Unchanged; }; let action = match action_id { ActionId::Quit => Action::Quit, ActionId::NewSession => Action::NewSession, ActionId::NewSessionInWorktree => Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, ActionId::OpenDashboard => Action::OpenDashboard, ActionId::VoiceToggle => { if !self.current_ui.voice_keybind_enabled.unwrap_or(true) { return InputOutcome::Unchanged; } Action::VoiceToggle } _ => return InputOutcome::Unchanged, }; if def.requires_confirmation { let shortcut = KeyShortcut::from(*key); let action = if action_id == ActionId::NewSession && matches!(self.active_view, ActiveView::Agent(_)) && self.new_session_worktree_mode == WorktreeMode::Ask { Action::ChooseNewSessionMode } else { action }; self.pending_action = Some(PendingAction::new(action, shortcut, def.label)); InputOutcome::Changed } else { InputOutcome::Action(action) } } /// Apply quit confirmation (double-press). Used both for direct global /// quit and for escalated quit from agent-level cancel. fn apply_quit_confirmation( &mut self, key_event: Option<&crossterm::event::KeyEvent>, ) -> InputOutcome { let Some(key) = key_event else { return InputOutcome::Action(Action::Quit); }; let Some(def) = self.registry.find(ActionId::Quit) else { return InputOutcome::Action(Action::Quit); }; if def.requires_confirmation { let shortcut = KeyShortcut::from(*key); self.pending_action = Some(PendingAction::new(Action::Quit, shortcut, def.label)); InputOutcome::Changed } else { InputOutcome::Action(Action::Quit) } } /// Apply exit-session confirmation (double-press). Works like quit confirmation /// but transitions to the welcome screen instead of quitting. fn apply_exit_session_confirmation( &mut self, key_event: Option<&crossterm::event::KeyEvent>, ) -> InputOutcome { let Some(key) = key_event else { return InputOutcome::Action(Action::ExitSession); }; let Some(def) = self.registry.find(ActionId::ExitSession) else { return InputOutcome::Action(Action::ExitSession); }; if def.requires_confirmation { let shortcut = KeyShortcut::from(*key); self.pending_action = Some(PendingAction::new(Action::ExitSession, shortcut, def.label)); InputOutcome::Changed } else { InputOutcome::Action(Action::ExitSession) } } } pub(crate) use crate::views::session_picker::filter_session_entries; use crate::views::session_picker::{ CONTENT_EXPAND_OFFSET, PickerItem, SessionPickerWorktreeSelection, build_entry_map, session_picker_worktree_selection, sync_session_picker_query_expansion, }; /// Context for welcome-view input handling. struct WelcomeInputCtx<'a> { auth_state: &'a AuthState, /// Folder-trust state. When `Pending` (and auth is `Done`), the trust /// question intercepts keys and swallows the rest so no session starts. trust_state: &'a TrustState, /// Live working directory (tracks `Effect::SetWorkingDir`), used to pin /// the current repo's group to the top of the session picker. cwd: &'a std::path::Path, /// `true` when the welcome screen is showing only to host a login flow /// that was started from inside a session. Esc / `q` then cancel the /// login and return to the session rather than quitting the app. mid_session_login: bool, auth_code_input: &'a mut LineEditor, prompt: &'a mut PromptWidget, prompt_focused: &'a mut bool, new_worktree_dialog: &'a mut Option, menu_index: &'a mut Option, menu_rects: &'a [ratatui::layout::Rect], menu_count: usize, prompt_rect: Option<&'a ratatui::layout::Rect>, import_banner_rect: Option<&'a ratatui::layout::Rect>, auth_url_rect: Option<&'a ratatui::layout::Rect>, auth_fallback_rect: Option<&'a ratatui::layout::Rect>, refresh_rect: Option<&'a ratatui::layout::Rect>, gate_url_rect: Option<&'a ratatui::layout::Rect>, /// Hit-test rect for the welcome hero upgrade CTA `[label]` button /// (click → open the promo url). upgrade_cta_rect: Option<&'a ratatui::layout::Rect>, privacy_banner_accept_rect: Option<&'a ratatui::layout::Rect>, privacy_banner_customize_rect: Option<&'a ratatui::layout::Rect>, privacy_banner_legal_rect: Option<&'a ratatui::layout::Rect>, /// Sticky hover flag for the privacy banner buttons (redraw on /// enter/leave/crossing so they brighten/dim). on_privacy_banner: &'a mut bool, /// Sticky hover flag for the upgrade CTA (redraw on enter/leave so the /// button brightens/dims). on_upgrade_cta: &'a mut bool, /// A pinned (non-dismissible) promo CTA is live, so `Ctrl+O` opens it /// (the welcome screen has no YOLO toggle to preserve). upgrade_cta_keyboard: bool, /// Hit-test rect for the clickable changelog info block (opens release notes). changelog_cta_rect: Option<&'a ratatui::layout::Rect>, /// Sticky hover flag for the changelog block (redraw on enter/leave). on_changelog_cta: &'a mut bool, /// Whether the announcement overflowed — the "expandable" signal for click-to-toggle. announcement_truncated: bool, /// Hit-test rect for the full announcement block (click anywhere to toggle). announcement_rect: Option<&'a ratatui::layout::Rect>, /// Sticky hover flag for the announcement block (redraw on enter/leave). on_announcement_cta: &'a mut bool, /// Whether the long announcement is currently expanded inline. announcement_expanded: &'a mut bool, show_raw_url: &'a mut bool, has_access: bool, is_zdr_blocked: bool, sp_entries: &'a mut Option>, /// Mirrors the render's `session_picker_loading` param: the spinner-only /// picker still owns input (Esc must dismiss it, not hit the hidden menu). sp_loading: bool, sp_state: &'a mut crate::views::picker::PickerState, sp_content_results: &'a Option>, sp_content_loading: bool, /// The query `sp_entries` were server-fetched with (see /// [`crate::views::session_picker::effective_filter_query`]). sp_entries_query: &'a Option, has_claude_import: bool, import_claude_modal: &'a mut Option, welcome_doc_viewer: &'a mut Option, changelog_markdown: &'a Option, /// Whether the welcome menu currently includes a "Changelog" row (above /// Quit), so index→action mapping accounts for it. show_changelog_action: bool, has_pending_update: bool, /// A recent foreign session is available to resume when no update is pending. has_foreign_resume: bool, cwd_has_git_ancestor: bool, session_picker_grouped: bool, sp_source_filter: &'a mut crate::views::session_picker::SourceFilter, /// Process-wide `--chat`: the session picker hides its source filter /// (conversations-only list), so `f` must not cycle it. chat_mode: bool, } /// Welcome view input -- auth-state-aware routing. fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutcome { if let Some(modal) = ctx.import_claude_modal.as_mut() { use crate::views::import_claude_modal::ImportClaudeModalOutcome; let outcome_to_input = |o: ImportClaudeModalOutcome| match o { ImportClaudeModalOutcome::Confirmed => { InputOutcome::Action(Action::ImportClaudeConfirm) } ImportClaudeModalOutcome::Cancelled => InputOutcome::Action(Action::ImportClaudeCancel), ImportClaudeModalOutcome::Changed => InputOutcome::Changed, ImportClaudeModalOutcome::Unchanged => InputOutcome::Unchanged, }; if let Event::Key(key) = ev { if key.kind == crossterm::event::KeyEventKind::Release { return InputOutcome::Unchanged; } return outcome_to_input(modal.handle_key(key)); } if let Event::Mouse(mouse) = ev { return outcome_to_input(modal.handle_mouse(mouse.kind, mouse.column, mouse.row)); } return InputOutcome::Unchanged; } if let Some(modal) = ctx.welcome_doc_viewer { if let Event::Key(key) = ev { if key.kind == crossterm::event::KeyEventKind::Release { return InputOutcome::Unchanged; } use crate::views::modal_window as mw; if let crate::views::modal::ActiveModal::DocViewer { window, scroll, .. } = modal { let chrome_cfg = mw::ModalWindowConfig { title: "", tabs: None, shortcuts: &[], sizing: mw::ModalSizing::default(), fold_info: None, }; match mw::handle_modal_key(window, key, &chrome_cfg) { mw::ModalWindowOutcome::CloseRequested => { *ctx.welcome_doc_viewer = None; return InputOutcome::Changed; } mw::ModalWindowOutcome::Unhandled => { if crate::views::modal::apply_doc_scroll(key.code, scroll) { return InputOutcome::Changed; } return InputOutcome::Unchanged; } _ => return InputOutcome::Changed, } } } if let Event::Mouse(mouse) = ev { use crate::views::modal_window as mw; if let crate::views::modal::ActiveModal::DocViewer { window, scroll, .. } = modal { match mw::handle_modal_mouse(window, mouse.kind, mouse.column, mouse.row) { mw::ModalWindowOutcome::CloseRequested => { *ctx.welcome_doc_viewer = None; return InputOutcome::Changed; } mw::ModalWindowOutcome::Unhandled => { if crate::views::modal::apply_doc_mouse_scroll(mouse.kind, scroll) { return InputOutcome::Changed; } } _ => return InputOutcome::Changed, } } } return InputOutcome::Unchanged; } if let Some(dialog) = ctx.new_worktree_dialog.as_mut() { let outcome = match ev { Event::Key(key) if key.kind != crossterm::event::KeyEventKind::Release => { dialog.handle_key(key) } Event::Paste(text) => dialog.insert_paste(text), Event::Resize(_, _) => return InputOutcome::Changed, _ => NewWorktreeDialogOutcome::Unchanged, }; match outcome { NewWorktreeDialogOutcome::Submitted(label) => { *ctx.new_worktree_dialog = None; return InputOutcome::Action(Action::NewWorktreeSession { load_session_id: None, label, git_ref: None, }); } NewWorktreeDialogOutcome::Cancelled => { *ctx.new_worktree_dialog = None; return InputOutcome::Changed; } NewWorktreeDialogOutcome::Changed => return InputOutcome::Changed, NewWorktreeDialogOutcome::Unchanged => return InputOutcome::Unchanged, } } if matches!(ctx.auth_state, AuthState::Done) && ctx.has_access && !ctx.is_zdr_blocked && matches!(ctx.trust_state, TrustState::Pending { .. }) { if let Event::Key(key) = ev { if key.kind == KeyEventKind::Release { return InputOutcome::Unchanged; } if key!('y').matches(key) || key!('Y').matches(key) || key!(Enter).matches(key) { return InputOutcome::Action(Action::TrustFolder); } if key!('n').matches(key) || key!('N').matches(key) || key!(Esc).matches(key) { return InputOutcome::Action(Action::QuitConfirmed); } if key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key) { return InputOutcome::Action(Action::Quit); } return InputOutcome::Unchanged; } if let Event::Mouse(mouse) = ev && matches!( mouse.kind, crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) ) { for (i, rect) in ctx.menu_rects.iter().enumerate() { if rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { return InputOutcome::Action(if i == 0 { Action::TrustFolder } else { Action::QuitConfirmed }); } } return InputOutcome::Unchanged; } if matches!(ev, Event::Resize(_, _)) { return InputOutcome::Changed; } return InputOutcome::Unchanged; } if (ctx.sp_entries.is_some() || ctx.sp_loading) && matches!(ctx.auth_state, AuthState::Done) { use crate::views::picker::{PickerConfig, PickerOutcome, handle_picker_input}; let source_filter = *ctx.sp_source_filter; let current_repo = crate::views::session_picker::repo_name_from_cwd(&ctx.cwd.to_string_lossy()); let entry_map = build_entry_map( ctx.sp_entries.as_deref(), ctx.sp_content_results.as_deref(), crate::views::session_picker::effective_filter_query( ctx.sp_state.query(), ctx.sp_entries_query.as_deref(), ), ctx.session_picker_grouped, ctx.sp_content_loading, source_filter, Some(current_repo.as_str()), ); let entry_count = entry_map.len(); let non_selectable_flags: Vec = entry_map.iter().map(|e| e.is_none()).collect(); let config = PickerConfig { title: Some("Resume session"), show_search_hint: true, expandable: true, esc_clears_query: true, shortcuts: Some(crate::views::picker::picker_shortcuts()), pending_hint: None, non_selectable: &non_selectable_flags, non_selectable_clickable: &[], shortcuts_area: None, tabs: None, active_tab: 0, filter_label: (!ctx.chat_mode).then(|| source_filter.label()), filter_key_hint: (!ctx.chat_mode).then_some("f"), filter_active: !ctx.chat_mode && source_filter.is_active(), header_note: None, action_keys: &[], disable_search: false, compact_bottom_bar: false, search_only_on_slash: false, vim_normal_first: crate::appearance::cache::load_vim_mode(), }; if let Event::Key(key) = ev { if key.kind == KeyEventKind::Press && (key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key)) { return InputOutcome::Action(Action::Quit); } if let Some(selection) = session_picker_worktree_selection( key, ctx.sp_state, &entry_map, &non_selectable_flags, ctx.sp_entries.as_deref(), ctx.sp_content_results.as_deref(), ) { return InputOutcome::Action(match selection { SessionPickerWorktreeSelection::Fuzzy(original_index) => { Action::PickSessionInWorktree(original_index) } SessionPickerWorktreeSelection::Content { session_id, cwd } => { Action::PickContentSessionInWorktree { session_id, cwd } } SessionPickerWorktreeSelection::Unavailable => { return InputOutcome::Changed; } }); } } let outcome = handle_picker_input(ev, ctx.sp_state, entry_count, &config); match outcome { PickerOutcome::Selected(i) => match entry_map.get(i).and_then(|e| e.as_ref()) { Some(PickerItem::Fuzzy { original_index }) => { return InputOutcome::Action(Action::PickSession(*original_index)); } Some(PickerItem::Content { hit_index }) => { if let Some(hits) = ctx.sp_content_results.as_ref() && let Some(hit) = hits.get(*hit_index) { return InputOutcome::Action(Action::PickContentSession { session_id: hit.session_id.clone(), cwd: hit.cwd.clone(), }); } return InputOutcome::Changed; } None => return InputOutcome::Changed, }, PickerOutcome::SubmitQuery => { let query = ctx.sp_state.query().trim().to_string(); if !query.is_empty() { return InputOutcome::Action(Action::LoadSession(query, None, false)); } return InputOutcome::Unchanged; } PickerOutcome::Closed => { *ctx.sp_entries = None; ctx.sp_state.reset(); *ctx.sp_source_filter = crate::views::session_picker::SourceFilter::default(); return InputOutcome::Action(Action::SessionPickerClosed); } PickerOutcome::Expand(i) => { match entry_map.get(i).and_then(|e| e.as_ref()) { Some(PickerItem::Fuzzy { original_index }) => { if let Some(ents) = ctx.sp_entries.as_ref() && let Some(entry) = ents.get(*original_index) && !crate::app::foreign_sessions::is_foreign_picker_source( &entry.source, ) { return InputOutcome::Action(Action::ExpandSessionCard { source: entry.source.clone(), session_id: entry.id.clone(), }); } } Some(PickerItem::Content { hit_index }) => { if let Some(hits) = ctx.sp_content_results.as_ref() && let Some(hit) = hits.get(*hit_index) { return InputOutcome::Action(Action::ExpandSessionCard { source: "local".into(), session_id: hit.session_id.clone(), }); } } None => {} } return InputOutcome::Changed; } PickerOutcome::Collapse(i) => { match entry_map.get(i).and_then(|e| e.as_ref()) { Some(PickerItem::Fuzzy { original_index }) => { if ctx.sp_state.expanded.contains(original_index) && let Some(ents) = ctx.sp_entries.as_ref() && let Some(entry) = ents.get(*original_index) { return InputOutcome::Action(Action::ExpandSessionCard { source: entry.source.clone(), session_id: entry.id.clone(), }); } } Some(PickerItem::Content { hit_index }) => { let key = CONTENT_EXPAND_OFFSET + hit_index; if ctx.sp_state.expanded.contains(&key) && let Some(hits) = ctx.sp_content_results.as_ref() && let Some(hit) = hits.get(*hit_index) { return InputOutcome::Action(Action::ExpandSessionCard { source: "local".into(), session_id: hit.session_id.clone(), }); } } None => {} } return InputOutcome::Changed; } PickerOutcome::Copy(i) => { if let Some(Some(PickerItem::Fuzzy { original_index })) = entry_map.get(i) { return InputOutcome::Action(Action::CopySessionId(*original_index)); } return InputOutcome::Changed; } PickerOutcome::QueryChanged => { sync_session_picker_query_expansion( ctx.sp_entries.as_deref(), ctx.sp_content_results.as_deref(), ctx.sp_entries_query.as_deref(), ctx.sp_state, ctx.session_picker_grouped, ctx.sp_content_loading, source_filter, Some(current_repo.as_str()), ); return InputOutcome::Action(Action::TriggerDeepSearch); } PickerOutcome::Changed => return InputOutcome::Changed, PickerOutcome::Unchanged => { if let Event::Key(key) = ev && key.kind == KeyEventKind::Press && key!('/', CONTROL).matches(key) && !ctx.sp_state.query().trim().is_empty() { return InputOutcome::Action(Action::ForceDeepSearch); } return InputOutcome::Unchanged; } PickerOutcome::FilterCycled => { return InputOutcome::Action(Action::CycleSessionSourceFilter); } PickerOutcome::NonSelectableClick(_) | PickerOutcome::TabChanged(_) | PickerOutcome::Action(_) => { return InputOutcome::Changed; } } } if let Event::Key(key) = ev { if key.kind == KeyEventKind::Release { return InputOutcome::Unchanged; } if ctx.is_zdr_blocked && matches!(ctx.auth_state, AuthState::Done) { return handle_menu_shortcuts( key, ctx.menu_index, &['l', 'q'], dispatch_zdr_menu_action, ); } if !ctx.has_access && matches!(ctx.auth_state, AuthState::Done) { return handle_menu_shortcuts( key, ctx.menu_index, &['g', 'l', 'q'], dispatch_access_gate_menu_action, ); } if matches!(ctx.auth_state, AuthState::Done) && key!(Enter).matches(key) && key.modifiers.is_empty() { return InputOutcome::Action(Action::NewSession); } if matches!(ctx.auth_state, AuthState::Done) { if ctx.upgrade_cta_keyboard && key!('o', CONTROL).matches(key) { return InputOutcome::Action(Action::AnnouncementsOpenCta( xai_grok_telemetry::events::AnnouncementCtaSurface::Keyboard, )); } if key!('w', CONTROL).matches(key) && ctx.cwd_has_git_ancestor { return InputOutcome::Action(Action::OpenNewWorktreeDialog); } if key!('s', CONTROL).matches(key) { return InputOutcome::Action(Action::FetchSessionList); } if ctx.has_pending_update && key!('u', CONTROL).matches(key) { return InputOutcome::Action(Action::QuitForUpdate); } if ctx.has_foreign_resume && key!('u', CONTROL).matches(key) { return InputOutcome::Action(Action::ResumeForeignSession); } if ctx.has_claude_import && key!('i', CONTROL).matches(key) { return InputOutcome::Action(Action::ImportClaudeSettings); } if ctx.has_claude_import && key!('I', CONTROL | SHIFT).matches(key) { return InputOutcome::Action(Action::DismissClaudeImport); } } if matches!(ctx.auth_state, AuthState::Done) && crate::input::key::is_shift_tab(key) { return InputOutcome::ActionThenForward(Action::NewSession); } if *ctx.prompt_focused && matches!(ctx.auth_state, AuthState::Done) && let KeyCode::Char(ch) = key.code && (crate::input::key::is_text_input_key(key) || (ch == 'v' && crate::input::key::is_paste_key(key))) { return InputOutcome::ActionThenForward(Action::NewSession); } if *ctx.prompt_focused { match ctx.prompt.handle_key(key) { crate::views::prompt_widget::PromptEvent::Edited => { return InputOutcome::Changed; } crate::views::prompt_widget::PromptEvent::Ignored => { if key!(Esc).matches(key) { *ctx.prompt_focused = false; return InputOutcome::Changed; } } } } if !*ctx.prompt_focused && matches!(ctx.auth_state, AuthState::Done) { if let Some(outcome) = handle_menu_nav(key, ctx.menu_index, ctx.menu_count) { return outcome; } if key!(Enter).matches(key) && let Some(idx) = *ctx.menu_index { return dispatch_menu_action( idx, ctx.has_claude_import, ctx.show_changelog_action, ctx.changelog_markdown.as_deref(), ); } if crate::input::key::is_text_input_key(key) { *ctx.prompt_focused = true; *ctx.menu_index = None; return InputOutcome::ActionThenForward(Action::NewSession); } } match ctx.auth_state { AuthState::Done => { if key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key) { return InputOutcome::Action(Action::Quit); } } AuthState::Pending { .. } => { if key!('q').matches(key) || key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key) { if ctx.mid_session_login { return InputOutcome::Action(Action::CancelLogin); } return InputOutcome::Action(Action::QuitConfirmed); } if key!('l').matches(key) || key!(Enter).matches(key) { return InputOutcome::Action(Action::Login); } } AuthState::Authenticating { .. } if *ctx.show_raw_url => { if key!('q', CONTROL).matches(key) || key!('c', CONTROL).matches(key) { return InputOutcome::Action(Action::HideRawAuthUrl); } return InputOutcome::Unchanged; } AuthState::Authenticating { mode: AuthMode::Loopback, .. } => { if key!(Esc).matches(key) || key!('q', CONTROL).matches(key) || key!('c', CONTROL).matches(key) { if ctx.mid_session_login { return InputOutcome::Action(Action::CancelLogin); } return InputOutcome::Action(Action::QuitConfirmed); } if key!(Enter).matches(key) { let trimmed = ctx.auth_code_input.text().trim().to_string(); if !trimmed.is_empty() { return InputOutcome::Action(Action::SubmitAuthCode(trimmed)); } return InputOutcome::Unchanged; } let outcome = if crate::input::key::is_paste_key(key) { let Some(text) = crate::clipboard::system_clipboard_get() else { return InputOutcome::Unchanged; }; ctx.auth_code_input.insert_paste(&text) } else if key.modifiers.intersects( crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT | crossterm::event::KeyModifiers::SUPER, ) && !crate::input::key::is_altgr(key.modifiers) { return InputOutcome::Changed; } else { ctx.auth_code_input .handle_key_with_insert_policy(key, |character| !character.is_control()) }; return match outcome { LineEditOutcome::TextChanged | LineEditOutcome::CursorChanged | LineEditOutcome::HandledNoChange => InputOutcome::Changed, LineEditOutcome::Unhandled => InputOutcome::Unchanged, }; } AuthState::Authenticating { .. } => { if key!(Esc).matches(key) || key!('q', CONTROL).matches(key) || key!('c', CONTROL).matches(key) { if ctx.mid_session_login { return InputOutcome::Action(Action::CancelLogin); } return InputOutcome::Action(Action::QuitConfirmed); } } } } if let Event::Paste(text) = ev { match ctx.auth_state { AuthState::Done => { if !ctx.has_access || ctx.is_zdr_blocked { return InputOutcome::Unchanged; } return InputOutcome::ActionThenForward(Action::NewSession); } AuthState::Authenticating { mode: AuthMode::Loopback, .. } => { let _ = ctx.auth_code_input.insert_paste(text); return InputOutcome::Changed; } _ => {} } } if matches!(ev, Event::Resize(_, _)) { return InputOutcome::Changed; } if let Event::Mouse(mouse) = ev { use crossterm::event::{MouseButton, MouseEventKind}; match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { for (i, rect) in ctx.menu_rects.iter().enumerate() { if mouse.column >= rect.x && mouse.column < rect.x + rect.width && mouse.row >= rect.y && mouse.row < rect.y + rect.height { if matches!(ctx.auth_state, AuthState::Pending { .. }) { return dispatch_pending_menu_action(i); } if ctx.is_zdr_blocked { return dispatch_zdr_menu_action(i); } if !ctx.has_access { return dispatch_access_gate_menu_action(i); } if ctx.has_claude_import && i == 0 && mouse.column >= rect.x + rect.width.saturating_sub(4) && mouse.column < rect.x + rect.width.saturating_sub(1) { return InputOutcome::Action(Action::DismissClaudeImport); } return dispatch_menu_action( i, ctx.has_claude_import, ctx.show_changelog_action, ctx.changelog_markdown.as_deref(), ); } } if let Some(rect) = ctx.refresh_rect && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { return InputOutcome::Action(Action::CheckSubscription); } if let Some(rect) = ctx.gate_url_rect && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { return InputOutcome::Action(Action::OpenSupergrokUrl); } if let Some(rect) = ctx.upgrade_cta_rect && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { return InputOutcome::Action(Action::AnnouncementsOpenCta( xai_grok_telemetry::events::AnnouncementCtaSurface::Welcome, )); } if let Some(rect) = ctx.privacy_banner_accept_rect && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { return InputOutcome::Action(Action::PrivacyBannerAccept); } if let Some(rect) = ctx.privacy_banner_customize_rect && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { return InputOutcome::Action(Action::PrivacyBannerCustomize); } if let Some(rect) = ctx.privacy_banner_legal_rect && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { return InputOutcome::Action(Action::OpenUrl( crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL.to_string(), )); } if let Some(rect) = ctx.changelog_cta_rect && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) && let Some(md) = ctx.changelog_markdown.as_deref() { return InputOutcome::Action(Action::ShowReleaseNotes { title: "Release Notes".to_string(), content: md.trim().to_string(), }); } if let Some(rect) = ctx.announcement_rect && (ctx.announcement_truncated || *ctx.announcement_expanded) && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { *ctx.announcement_expanded = !*ctx.announcement_expanded; return InputOutcome::Changed; } if let Some(rect) = ctx.auth_url_rect && matches!(ctx.auth_state, AuthState::Authenticating { .. }) && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { return InputOutcome::Action(Action::CopyAuthUrl); } if let Some(rect) = ctx.auth_fallback_rect && matches!(ctx.auth_state, AuthState::Authenticating { .. }) && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { return InputOutcome::Action(Action::ShowRawAuthUrl); } if let Some(rect) = ctx.import_banner_rect && matches!(ctx.auth_state, AuthState::Done) && mouse.column >= rect.x && mouse.column < rect.x + rect.width && mouse.row >= rect.y && mouse.row < rect.y + rect.height { return InputOutcome::Action(Action::ImportClaudeSettings); } if let Some(rect) = ctx.prompt_rect && matches!(ctx.auth_state, AuthState::Done) && mouse.column >= rect.x && mouse.column < rect.x + rect.width && mouse.row >= rect.y && mouse.row < rect.y + rect.height { *ctx.prompt_focused = true; return InputOutcome::Changed; } } MouseEventKind::Moved => { let mut new_index = None; for (i, rect) in ctx.menu_rects.iter().enumerate() { if mouse.column >= rect.x && mouse.column < rect.x + rect.width && mouse.row >= rect.y && mouse.row < rect.y + rect.height { new_index = Some(i); break; } } if new_index != *ctx.menu_index { *ctx.menu_index = new_index; return InputOutcome::Changed; } if ctx.has_claude_import && new_index == Some(0) { return InputOutcome::Changed; } let pos = ratatui::layout::Position::new(mouse.column, mouse.row); let over_cta = ctx.changelog_cta_rect.is_some_and(|r| r.contains(pos)); if over_cta != *ctx.on_changelog_cta { *ctx.on_changelog_cta = over_cta; return InputOutcome::Changed; } let over_upgrade = ctx.upgrade_cta_rect.is_some_and(|r| r.contains(pos)); if over_upgrade != *ctx.on_upgrade_cta { *ctx.on_upgrade_cta = over_upgrade; return InputOutcome::Changed; } let over_banner = ctx .privacy_banner_accept_rect .is_some_and(|r| r.contains(pos)) || ctx .privacy_banner_customize_rect .is_some_and(|r| r.contains(pos)) || ctx .privacy_banner_legal_rect .is_some_and(|r| r.contains(pos)); if over_banner || *ctx.on_privacy_banner { *ctx.on_privacy_banner = over_banner; return InputOutcome::Changed; } let over_ann = (ctx.announcement_truncated || *ctx.announcement_expanded) && ctx.announcement_rect.is_some_and(|r| r.contains(pos)); if over_ann != *ctx.on_announcement_cta { *ctx.on_announcement_cta = over_ann; return InputOutcome::Changed; } if matches!(ctx.auth_state, AuthState::Authenticating { .. }) && ctx.auth_url_rect.is_some() { return InputOutcome::Changed; } } _ => {} } } InputOutcome::Unchanged } /// Handle Up/Down arrow key cycling through a menu of `count` items. fn is_quit_signal(key: &crossterm::event::KeyEvent) -> bool { key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key) } /// `shortcuts[i]` triggers `menu_dispatch(i)`. fn handle_menu_shortcuts( key: &crossterm::event::KeyEvent, menu_index: &mut Option, shortcuts: &[char], menu_dispatch: fn(usize) -> InputOutcome, ) -> InputOutcome { if is_quit_signal(key) { return InputOutcome::Action(Action::Quit); } for (i, &ch) in shortcuts.iter().enumerate() { if key.code == KeyCode::Char(ch) { return menu_dispatch(i); } } if key!(Enter).matches(key) { return menu_dispatch(menu_index.unwrap_or(0)); } if let Some(outcome) = handle_menu_nav(key, menu_index, shortcuts.len()) { return outcome; } InputOutcome::Unchanged } fn handle_menu_nav( key: &crossterm::event::KeyEvent, index: &mut Option, count: usize, ) -> Option { match key.code { KeyCode::Down => { *index = Some(match *index { Some(i) if i + 1 < count => i + 1, Some(_) | None => 0, }); Some(InputOutcome::Changed) } KeyCode::Up => { *index = Some(match *index { Some(0) | None => count.saturating_sub(1), Some(i) => i - 1, }); Some(InputOutcome::Changed) } _ => None, } } /// Dispatch an action for a welcome menu item when not yet authenticated. /// Menu layout: 0 = Login, 1 = Quit. fn dispatch_pending_menu_action(index: usize) -> InputOutcome { match index { 0 => InputOutcome::Action(Action::Login), 1 => InputOutcome::Action(Action::Quit), _ => InputOutcome::Unchanged, } } /// Dispatch an action for a welcome menu item when ZDR-blocked. /// Menu layout: 0 = Switch account, 1 = Quit. fn dispatch_zdr_menu_action(index: usize) -> InputOutcome { match index { 0 => InputOutcome::Action(Action::SwitchAccount), 1 => InputOutcome::Action(Action::Quit), _ => InputOutcome::Unchanged, } } /// Menu actions when user is access-gated: 0 = Subscribe CTA, 1 = Logout, 2 = Quit. /// "Refresh" (ctrl-r) is handled as a direct key shortcut, not a menu item. fn dispatch_access_gate_menu_action(index: usize) -> InputOutcome { match index { 0 => InputOutcome::Action(Action::OpenSupergrokUrl), 1 => InputOutcome::Action(Action::Logout), 2 => InputOutcome::Action(Action::Quit), _ => InputOutcome::Unchanged, } } /// Dispatch an action for a welcome menu item by index. /// /// Menu order: `[Import]`, New worktree, Resume session, `[Changelog]`, Quit. /// `show_changelog_action` is true when the Changelog row is rendered; release /// notes open only once `changelog_md` is available. fn dispatch_menu_action( index: usize, has_claude_import: bool, show_changelog_action: bool, changelog_md: Option<&str>, ) -> InputOutcome { let base = if has_claude_import { 1 } else { 0 }; let worktree_idx = base; let resume_idx = base + 1; let (changelog_idx, quit_idx) = if show_changelog_action { (Some(base + 2), base + 3) } else { (None, base + 2) }; if has_claude_import && index == 0 { return InputOutcome::Action(Action::ImportClaudeSettings); } if index == worktree_idx { return InputOutcome::Action(Action::OpenNewWorktreeDialog); } if index == resume_idx { return InputOutcome::Action(Action::FetchSessionList); } if Some(index) == changelog_idx { if let Some(md) = changelog_md { return InputOutcome::Action(Action::ShowReleaseNotes { title: "Release Notes".to_string(), content: md.trim().to_string(), }); } return InputOutcome::Unchanged; } if index == quit_idx { return InputOutcome::Action(Action::Quit); } InputOutcome::Unchanged } impl AppView { /// Merge notification escape sequences with render-produced post-flush /// escapes. Both inputs are optional; returns `None` only when both are /// `None`. fn merge_escapes( notif: Option, render: Option, ) -> Option { Self::merge_post_flush( notif.map(crate::terminal::overlay::PostFlush::plain), render, ) } fn merge_post_flush( first: Option, second: Option, ) -> Option { match (first, second) { (Some(mut first), Some(second)) => { first.append(second); Some(first) } (Some(first), None) => Some(first), (None, second) => second, } } /// Build the Kitty delete escapes that remove image placements left /// behind by agent views that are not drawn this frame. /// /// Kitty graphics survive cell redraws until explicitly deleted, and /// every regular clear lives inside `AgentView::draw` / the prompt /// widget's per-frame self-heal. Once the dashboard takes over the /// frame those paths stop running, so an image overlay (or inline /// scrollback media) the user left open in the agent view would float /// above the dashboard forever. Called from the /// `ActiveView::AgentDashboard` draw branch every frame: /// /// - Placement id 1 is cleared only when no popup agent is drawn; a popup /// owns and reuses that slot across consecutive dashboard frames. /// - Inline scrollback media ids (2+) are drained per agent via /// `AgentView::take_inline_media_clear_escapes`, which resets the /// agent's placement tracking — a one-shot sweep per transition, /// not a per-frame cost. The popup-attached agent is skipped: it /// just drew and manages its own placements. The clears-before-popup /// ordering also means a drained id that collides with one the popup /// re-places this frame ends up displayed, not deleted. fn dashboard_stale_image_clears( agents: &mut IndexMap, drawn_agent: Option, ) -> Option { if crate::terminal::image::detect_graphics_protocol() == crate::terminal::image::GraphicsProtocol::None { return None; } let mut clears = crate::terminal::overlay::PostFlush::default(); let mut has_escapes = false; for (id, agent) in agents.iter_mut() { if Some(*id) == drawn_agent { continue; } if let Some(esc) = agent.take_inline_media_clear_escapes() { clears.append_plain(&esc); has_escapes = true; } } if drawn_agent.is_none() { clears.append(crate::terminal::overlay::clear_kitty().into()); has_escapes = true; } has_escapes.then_some(clears) } /// Minimal mode: queue the most-recently committed folded block (collapsed /// reasoning / truncated tool output) to be re-printed fully expanded below /// the conversation on the next draw (design decision K10). Returns whether /// something was queued. No-op when nothing folded remains to expand. pub(crate) fn minimal_expand_last(&mut self) -> bool { let ActiveView::Agent(id) = &self.active_view else { return false; }; let id = *id; let found = match self.agents.get_mut(&id) { Some(agent) => agent.scrollback.take_expandable_committed(), None => None, }; if let Some(eid) = found { self.minimal_state.pending_expand.push(eid); true } else { false } } /// Minimal-mode key overrides, handled inline instead of by /// `agent.handle_input`. Returns `Some` when the key was consumed here. /// Callers gate on `is_minimal()` + non-release before dispatching. /// /// These keys carry full-TUI meanings that don't apply to the /// scrollback-native mode, so minimal remaps them: /// - `Ctrl+T` pins/unpins the todo panel (force-show). It otherwise /// auto-hides once all todos are done (`minimal::live::todo_panel_visible`); /// the pin keeps a finished list visible for review. The full-TUI /// Ctrl+T toggles the todo overlay pane, which minimal never renders. /// - `Ctrl+E` re-prints the most-recently committed folded block fully /// expanded below the conversation (K10) — committed terminal text can't be /// mutated, so expansion is an honest re-print. The full-TUI Ctrl+E toggles /// the scrollback-pane fold. /// - `Ctrl+O` opens the whole conversation fully expanded in `$PAGER` (the /// "expand everything" view, the honest equivalent of a full /// transcript mode for a static native scrollback). The full-TUI Ctrl+O is /// interject, which keeps its Ctrl+Enter / Ctrl+I alt bindings — /// **except on Apple Terminal**, where Ctrl+O *is* the interject chord /// (kitty keyboard protocol unavailable → Ctrl+Enter doesn't arrive and /// Ctrl+I aliases to Tab, see `default_actions`'s terminal-aware /// `InterjectPrompt` binding). There the remap yields to interject only /// while an interject would actually consume the press (turn running with /// a non-empty composer, turn running with a queued follow-up on an empty /// composer, or editing a queued row) — otherwise minimal on Apple /// Terminal would have no working interject key at all. At idle / with an /// empty composer and no queue the interject path is a silent no-op, so /// the remap keeps the key and the transcript opens (it looked simply /// dead before); see `minimal_api::minimal_ctrl_o_opens_transcript`, which /// the info-row hint shares so it always advertises what a press would do. /// - The `ToggleQueue` chord (Ctrl+; by default; registry-resolved because /// it is remappable and terminal-dependent) commits the read-only /// `/queue` snapshot instead of toggling the full-TUI queue pane: the /// pane never renders in minimal, so the toggle focused an *invisible* /// pane that ate every keystroke (the same class of trap as the /// never-rendered `/mcps` modal). Queue edits stay full-TUI-only; K13's /// panes-become-committed-blocks rule applies. fn minimal_key_intercept(&mut self, key: &crossterm::event::KeyEvent) -> Option { if key!('t', CONTROL).matches(key) { self.minimal_state.show_todos = !self.minimal_state.show_todos; } else if self .registry .matches_id(crate::actions::ActionId::ToggleQueue, key) { return Some(InputOutcome::Action(crate::app::actions::Action::ShowQueue)); } else if key!('e', CONTROL).matches(key) { self.minimal_expand_last(); } else if key!('o', CONTROL).matches(key) { if crate::minimal_api::minimal_ctrl_o_opens_transcript(self) { return Some(InputOutcome::Action( crate::app::actions::Action::OpenTranscriptPager, )); } if let ActiveView::Agent(id) = &self.active_view { let id = *id; if let Some(agent) = self.agents.get_mut(&id) { return Some(agent.handle_prompt_key(key, &self.registry, false)); } } return None; } else { return None; } Some(InputOutcome::Changed) } /// Render the current view to the terminal. /// /// Delegates to [`crate::render::draw::draw_frame`] which handles the /// low-level terminal interaction (bypassing ratatui's `try_draw`, /// synchronized output, cursor blink preservation). See that module's /// docs for the full rationale. pub fn draw(&mut self, terminal: &mut PagerTerminal) { self.draw_inner(terminal); crate::memory_release::run_deferred_release(); } fn draw_inner(&mut self, terminal: &mut PagerTerminal) { self.resync_announcement_slash_gate_on_divergence(); if self.screen_mode.is_minimal() { if let Some(hooks) = crate::minimal_hook::hooks() { (hooks.draw)(self, terminal); } return; } if self.welcome_on_auth_url && !matches!( (&self.active_view, &self.auth_state), (ActiveView::Welcome, AuthState::Authenticating { .. }) ) { self.welcome_on_auth_url = false; if crate::terminal::terminal_context() .hyperlink_capabilities() .osc22_cursor { xai_grok_shell::util::with_locked_stderr(|stderr| { let _ = crossterm::execute!(stderr, crate::terminal::SetDefaultCursor); }); } } let want_mouse_off = self.auth_show_raw_url && !self.screen_mode.is_minimal() && matches!(self.active_view, ActiveView::Welcome) && matches!(self.auth_state, AuthState::Authenticating { .. }); if want_mouse_off && !self.auth_mouse_disabled { self.auth_mouse_disabled = true; xai_grok_shell::util::with_locked_stderr(|stderr| { let _ = crossterm::execute!(stderr, crossterm::event::DisableMouseCapture); }); #[cfg(windows)] super::win_native_selection::enable_native_selection(); super::MOUSE_CAPTURE_ENABLED.store(false, std::sync::atomic::Ordering::Release); } else if !want_mouse_off && self.auth_mouse_disabled { self.auth_mouse_disabled = false; xai_grok_shell::util::with_locked_stderr(|stderr| { let _ = crossterm::execute!(stderr, crossterm::event::EnableMouseCapture); }); super::MOUSE_CAPTURE_ENABLED.store(true, std::sync::atomic::Ordering::Release); for agent in self.agents.values_mut() { agent.set_sticky_toast_recursive(None); } } self.maybe_trigger_small_screen_tip(); self.maybe_trigger_ssh_wrap_tip(); let compact = self.appearance.prompt.compact; let (header_pad_left, header_pad_right, header_pad_top) = { let layout_cfg = &self.appearance.scrollback.layout; ( layout_cfg.eff_hpad_left(compact), layout_cfg.eff_hpad_right(compact), layout_cfg.eff_outer_vpad(compact), ) }; let zdr_blocked_for_draw = self.is_zdr_blocked(); let has_access = self.has_access(); let privacy_banner = self.privacy_banner_should_show(); let voice_available = self.voice_available(); let voice_on_surface = self.voice_target_on_active_surface(); let voice_listening = voice_on_surface && self.voice_listening(); let voice_interim = voice_on_surface .then(|| self.voice_interim().map(str::to_owned)) .flatten(); let esc_owned_before_agent = self.esc_owned_before_agent(); let scroll_debug_panel = self.scroll_debug_panel(); let dev_fps_rows = self.dev_fps_rows(); let fps_overlay = self.fps_hud.overlay(dev_fps_rows); let foreign_resume_hint = self.foreign_resume_hint().cloned(); let privacy_banner_agent = self.privacy_banner_should_show() && !crate::views::announcements::has_critical_session_announcement( &self.active_announcements, &self.hidden_announcement_ids, ); let agent_mouse_pos = self.last_mouse_pos; let Self { active_view, agents, registry, scratch, cursor, pending_action, pending_notification_escapes, .. } = self; let notif_escapes = pending_notification_escapes.take(); let pending_hint = pending_action .as_ref() .filter(|p| !p.expired()) .and_then(|p| { p.label .map(|label| crate::views::shortcuts_bar::PendingHint { shortcut: p.shortcut, label, }) }); let fps_frame_started = fps_overlay.as_ref().map(|_| std::time::Instant::now()); crate::render::draw::draw_frame(terminal, cursor, |f, link_spans| { let full_area = f.area(); let tracing_height = 0u16; #[allow(unused_variables)] let (tracing_area, view_area) = if tracing_height > 0 && tracing_height < full_area.height { let tracing = ratatui::layout::Rect { x: full_area.x, y: full_area.y, width: full_area.width, height: tracing_height, }; let view = ratatui::layout::Rect { x: full_area.x, y: full_area.y + tracing_height, width: full_area.width, height: full_area.height.saturating_sub(tracing_height), }; (Some(tracing), view) } else if tracing_height >= full_area.height { let tracing = full_area; (Some(tracing), ratatui::layout::Rect::default()) } else { (None, full_area) }; if view_area.height > 0 { match *active_view { ActiveView::Welcome => { let mut flags_vec: Vec> = Vec::new(); if self.default_yolo { flags_vec.push(crate::views::prompt_widget::PromptFlag { text: "always-approve", color: None, bold: false, }); } if !self.welcome_prompt.text().is_empty() { self.welcome_tip_typing_dismissed = true; } let tip = if self.welcome_tip_typing_dismissed { None } else { self.tip.as_deref() }; let model_name_base = self.models.current_model_name().unwrap_or_default(); let model_name = match self.models.reasoning_effort { Some(eff) => format!("{model_name_base} ({eff})"), None => model_name_base, }; let hero_cta = crate::views::announcements::promo_cta( &self.active_announcements, &self.hidden_announcement_ids, ); let hero_announcement = hero_cta .map(|(owner, _, _)| owner) .or_else(|| { crate::views::announcements::first_session_announcement( &self.active_announcements, &self.hidden_announcement_ids, ) }) .or(self.announcement.as_ref()); let welcome_params = crate::views::welcome::WelcomeRenderParams { prompt_focus: if self.welcome_prompt_focused { WelcomePromptFocus::Focused } else { WelcomePromptFocus::Unfocused }, cwd: &self.cwd, auth_state: &self.auth_state, trust_state: &self.trust_state, login_label: self.login_label.as_deref(), auth_code_input: self.auth_code_input.text(), auth_code_cursor_byte: self.auth_code_input.cursor_byte(), clipboard_delivery: self.auth_clipboard_delivery, show_raw_url: self.auth_show_raw_url, announcement: hero_announcement, tip, model_name: &model_name, flags: &flags_vec, selected: self.welcome_menu_index, team_name: self.team_name.as_deref(), has_access, has_claude_import: self.has_claude_import, mouse_pos: self.last_mouse_pos, is_zdr_blocked: zdr_blocked_for_draw, session_picker: self.session_picker_entries.as_deref(), session_picker_loading: crate::views::session_picker::loading_spinner_active( self.session_picker_entries.as_deref(), self.session_picker_source_filter, self.session_picker_loading, &self.session_picker_lanes, ), compact, pending_hint, startup_warnings: &self.startup_warnings, pending_update_version: self.pending_update_version.as_deref(), foreign_resume_hint: foreign_resume_hint.as_ref(), session_picker_content_results: self .session_picker_content_results .as_deref(), session_picker_content_loading: self.session_picker_content_loading, session_picker_entries_query: self .session_picker_entries_query .as_deref(), welcome_tick: self.welcome_tick, gate: self.gate.as_ref(), subscription_tier: self.subscription_tier.as_deref(), session_picker_grouped: self.session_picker_grouped, session_picker_source_filter: self.session_picker_source_filter, chat_mode: self.chat_mode, credit_balance: self.credit_balance.as_ref(), auto_topup: self.auto_topup.as_ref(), usage_visible: self.usage_visible, is_api_key_auth: self.is_api_key_auth, changelog_bullets: &self.changelog_bullets, changelog_has_full_notes: self.changelog_markdown.is_some(), welcome_announcement_expanded: self.welcome_announcement.expanded, upgrade_cta: hero_cta.map(|(_owner, label, _)| label), privacy_banner, }; let result = crate::views::welcome::render_welcome( view_area, f.buffer_mut(), &welcome_params, &mut self.welcome_prompt, &mut self.session_picker_state, ); self.welcome_menu_rects = result.menu_rects; self.welcome_show_changelog_action = result.changelog_action_present; self.welcome_prompt_rect = result.prompt_rect; self.welcome_import_banner_rect = result.import_banner_rect; self.welcome_auth_url_rect = result.auth_url_rect; self.welcome_auth_fallback_rect = result.auth_fallback_rect; self.welcome_refresh_rect = result.refresh_rect; self.welcome_gate_url_rect = result.gate_url_rect; self.welcome_upgrade_cta_rect = result.upgrade_cta_rect; self.welcome_privacy_banner_accept_rect = result.privacy_banner_accept_rect; self.welcome_privacy_banner_customize_rect = result.privacy_banner_customize_rect; self.welcome_privacy_banner_legal_rect = result.privacy_banner_legal_rect; self.welcome_changelog_cta_rect = result.changelog_cta_rect; if let Some((ref msg, _)) = self.welcome_toast { paint_welcome_toast(f.buffer_mut(), view_area, msg); } self.welcome_announcement.truncated = result.announcement_truncated; self.welcome_announcement.rect = result.announcement_rect; self.session_picker_state.hit_areas = result.session_picker_hit_areas; if let Some(modal) = self.import_claude_modal.as_mut() { let theme = crate::theme::Theme::current(); crate::views::import_claude_modal::render_import_claude_modal( f.buffer_mut(), view_area, modal, &theme, compact, ); } if let Some(dialog) = self.new_worktree_dialog.as_ref() { crate::views::new_worktree_dialog::render_new_worktree_dialog( view_area, f.buffer_mut(), dialog, ); } if let Some(crate::views::modal::ActiveModal::DocViewer { ref title, ref content, ref mut scroll, ref mut window, ref mut cached_lines, .. }) = self.welcome_doc_viewer { let theme = crate::theme::Theme::current(); crate::views::modal::render_doc_viewer_overlay( f.buffer_mut(), view_area, window, title, content, scroll, cached_lines, compact, &theme, ); } if !has_access && !self.access_gate_shown_logged { self.access_gate_shown_logged = true; xai_grok_telemetry::session_ctx::log_event( xai_grok_telemetry::events::SuperGrokUpsellShown { source: xai_grok_telemetry::events::SuperGrokUpsell::WelcomeScreen, auth_method: self .login_method_id .as_ref() .map(|id| id.0.to_string()), }, ); } if let Some(tutorial) = self.tutorial.as_mut() { crate::views::tutorial::render_tutorial( f.buffer_mut(), view_area, tutorial, compact, ); } if let Some(fps) = &fps_overlay { fps.render(full_area, f.buffer_mut()); } if let Some(panel) = &scroll_debug_panel { panel.render(full_area, f.buffer_mut()); } let has_cloud_modal = false; let cursor = if has_cloud_modal || self.tutorial.is_some() { None } else { result.cursor_pos }; let on_url = self.welcome_auth_url_rect.as_ref().is_some_and(|r| { matches!(self.auth_state, AuthState::Authenticating { .. }) && self.last_mouse_pos.is_some_and(|(mx, my)| { mx >= r.x && mx < r.x + r.width && my >= r.y && my < r.y + r.height }) }); let mut post_flush = result.post_flush_escapes; if crate::terminal::terminal_context() .hyperlink_capabilities() .osc22_cursor && on_url != self.welcome_on_auth_url { use crossterm::Command; let mut buf = String::new(); if on_url { let _ = crate::terminal::SetPointerCursor.write_ansi(&mut buf); } else { let _ = crate::terminal::SetDefaultCursor.write_ansi(&mut buf); } match post_flush.as_mut() { Some(existing) => existing.append_plain(&buf), None => { post_flush = Some(crate::terminal::overlay::PostFlush::plain(buf)); } } } self.welcome_on_auth_url = on_url; return (cursor, post_flush); } ActiveView::Agent(id) => { let overlay_focused = false; let overlay_active = self .dashboard .as_ref() .is_some_and(|d| d.attached_agent == Some(id)); let position: Option<(usize, usize)> = if overlay_active && let Some(d) = self.dashboard.as_ref() { let order = crate::views::dashboard::overlay_cycle_order(d, agents); order .iter() .position(|i| *i == id) .map(|idx| (idx + 1, order.len())) } else { None }; let (agent_area, header) = if overlay_active { let theme = crate::theme::Theme::current(); let title = agents .get(&id) .map(crate::views::session_title::entry_title) .unwrap_or_else(|| "(session)".to_string()); let (hover_prev, hover_next, hover_close) = self .dashboard .as_ref() .map(|d| { ( d.overlay_prev_hit.hovered, d.overlay_next_hit.hovered, d.overlay_close_hit.hovered, ) }) .unwrap_or((false, false, false)); let header = crate::views::dashboard::render_dashboard_session_header( f.buffer_mut(), view_area, &theme, &title, position, hover_prev, hover_next, hover_close, header_pad_left, header_pad_right, header_pad_top, ); match header { Some(chrome) => (chrome.content, Some(chrome)), None => (view_area, None), } } else { (view_area, None) }; if let Some(d) = self.dashboard.as_mut() { d.overlay_close_hit.set(header.and_then(|c| c.close_rect)); d.overlay_prev_hit.set(header.and_then(|c| c.prev_rect)); d.overlay_next_hit.set(header.and_then(|c| c.next_rect)); } if let Some(d) = self.dashboard.as_mut() && d.peek_viewport.is_some() { d.restore_peek_viewport(agents); } if let Some(agent) = agents.get_mut(&id) { let announcement_banner_h = crate::views::announcements::session_banner_height( &self.active_announcements, &self.hidden_announcement_ids, ); let privacy_banner = privacy_banner_agent; let show_session_tip = !privacy_banner && self.tip.is_some() && agent.should_show_tip(); let has_mode_banner = agent.mode_switch_banner.is_some(); let banner_height = if privacy_banner { 2 } else if has_mode_banner { 1 } else if announcement_banner_h > 0 { announcement_banner_h } else if show_session_tip { 1 } else { 0 }; let result = agent.draw( agent_area, f.buffer_mut(), registry, scratch, pending_hint, overlay_focused, crate::app::agent_view::BannerSlotParams { height: banner_height, announcements: &self.active_announcements, hidden_ids: &self.hidden_announcement_ids, privacy_banner, mouse_pos: agent_mouse_pos, tip: if show_session_tip { self.tip.as_deref() } else { None }, }, &self.bundle_state, overlay_active, link_spans, AppRenderParams { voice_available, voice_listening, voice_interim: voice_interim.as_deref(), esc_owned_before_agent, }, ); if let Some(modal) = self.import_claude_modal.as_mut() { let theme = crate::theme::Theme::current(); crate::views::import_claude_modal::render_import_claude_modal( f.buffer_mut(), view_area, modal, &theme, compact, ); } if let Some(tutorial) = self.tutorial.as_mut() { crate::views::tutorial::render_tutorial( f.buffer_mut(), view_area, tutorial, compact, ); } if let Some(fps) = &fps_overlay { fps.render(full_area, f.buffer_mut()); } if let Some(panel) = &scroll_debug_panel { panel.render(full_area, f.buffer_mut()); } let (cursor_pos, post_flush) = result; let has_cloud = false; if has_cloud || self.import_claude_modal.is_some() || self.tutorial.is_some() { link_spans.clear(); } let cursor = if has_cloud || self.tutorial.is_some() { None } else { cursor_pos }; return (cursor, Self::merge_escapes(notif_escapes, post_flush)); } } ActiveView::AgentDashboard => { if let Some(dashboard) = self.dashboard.as_mut() { dashboard.voice_listening = voice_listening; dashboard.voice_interim = voice_interim.clone(); if let Some(id) = dashboard.attached_agent && !agents.contains_key(&id) { dashboard.close_popup(); if dashboard.error_toast.is_none() { dashboard.error_toast = Some(format!( "{} Session closed", crate::glyphs::check_mark() )); } } let dashboard_roster: &[crate::app::roster::RosterEntry] = if self.leader_mode { &self.leader_roster } else { &self.dashboard_local_sessions }; let dash_upgrade_cta = crate::views::announcements::promo_cta( &self.active_announcements, &self.hidden_announcement_ids, ) .map( |(owner, label, _)| crate::views::dashboard::HeaderUpgradeCta { label, pinned: !crate::views::announcements::is_dismissible(owner), caption: crate::views::announcements::usable_cta_caption(owner), }, ); let dash_cursor = crate::views::dashboard::render_dashboard( f.buffer_mut(), view_area, dashboard, agents, registry, pending_hint, dashboard_roster, self.dashboard_sessions_loading, dash_upgrade_cta, ); let (popup_cursor, popup_post_flush, drawn_popup_agent) = if let Some(agent_id) = dashboard.attached_agent { let theme = crate::theme::Theme::current(); let popup_area = crate::views::dashboard::popup_rect(view_area); let title = agents .get(&agent_id) .map(crate::views::session_title::entry_title) .unwrap_or_else(|| "(session)".to_string()); let bundle_state = &self.bundle_state; let (cursor, post_flush, drawn) = crate::views::dashboard::render_popup_overlay( f.buffer_mut(), popup_area, &theme, &title, dashboard, |inner, buf| { if let Some(agent) = agents.get_mut(&agent_id) { agent.draw( inner, buf, registry, scratch, None, false, crate::app::agent_view::BannerSlotParams::none( ), bundle_state, false, link_spans, AppRenderParams { esc_owned_before_agent, ..Default::default() }, ) } else { (None, None) } }, ); (cursor, post_flush, drawn.then_some(agent_id)) } else { (None, None, None) }; let stale_clears = Self::dashboard_stale_image_clears(agents, drawn_popup_agent); let popup_post_flush = Self::merge_post_flush(stale_clears, popup_post_flush); let tutorial_open = self.tutorial.is_some(); if let Some(tutorial) = self.tutorial.as_mut() { crate::views::tutorial::render_tutorial( f.buffer_mut(), view_area, tutorial, compact, ); } if let Some(fps) = &fps_overlay { fps.render(full_area, f.buffer_mut()); } if let Some(panel) = &scroll_debug_panel { panel.render(full_area, f.buffer_mut()); } let cursor = if tutorial_open { None } else if dashboard.attached_agent.is_some() { popup_cursor } else { dash_cursor }; return (cursor, Self::merge_escapes(notif_escapes, popup_post_flush)); } } } } if let Some(fps) = &fps_overlay { fps.render(full_area, f.buffer_mut()); } if let Some(panel) = &scroll_debug_panel { panel.render(full_area, f.buffer_mut()); } (None, Self::merge_escapes(notif_escapes, None)) }); if let Some(started) = fps_frame_started { self.fps_hud.record(started.elapsed()); } self.log_announcement_cta_impressions(); self.maybe_evict_offscreen_caches(); } /// Log [`xai_grok_telemetry::events::AnnouncementCtaShown`] for each /// surface whose CTA button is painted this frame (armed hit rect, not /// covered by a frame occluder — the click/OSC 8 truth the impression /// pairs with), once per (announcement, surface) per pager process /// (cleared on logout). The owner resolves through the same slot gate as /// the click dispatch, so a critical preempting the slot or a hidden /// promo emits nothing. pub(crate) fn log_announcement_cta_impressions(&mut self) { use xai_grok_telemetry::events::AnnouncementCtaSurface; let (banner, welcome, header, dashboard) = match self.active_view { ActiveView::Welcome => (false, self.welcome_upgrade_cta_rect.is_some(), false, false), ActiveView::Agent(agent_id) => match self.agents.get(&agent_id) { Some(a) => { let cta_rect = a.hit_announcement_cta.rect; let header_rect = a.hit_upgrade_cta.rect; ( cta_rect.is_some_and(|r| !a.rect_occluded(r)), false, header_rect.is_some_and(|r| !a.rect_occluded(r)), false, ) } None => return, }, ActiveView::AgentDashboard => ( false, false, false, self.dashboard .as_ref() .is_some_and(|d| d.upgrade_cta_hit.rect.is_some()), ), }; if !(banner || welcome || header || dashboard) { return; } let Some((owner, _label, _url)) = crate::views::announcements::promo_cta( &self.active_announcements, &self.hidden_announcement_ids, ) else { return; }; let key = xai_grok_announcements::announcement_hide_key(owner); let id = owner.id.clone(); let surfaces = [ (AnnouncementCtaSurface::Banner, banner), (AnnouncementCtaSurface::Welcome, welcome), (AnnouncementCtaSurface::Header, header), (AnnouncementCtaSurface::Dashboard, dashboard), ]; for (surface, _) in surfaces.into_iter().filter(|(_, painted)| *painted) { if self .announcement_cta_impressions_logged .insert((key.clone(), surface)) { xai_grok_telemetry::session_ctx::log_event( xai_grok_telemetry::events::AnnouncementCtaShown { id: id.clone(), source: surface, }, ); } } } /// Interval between off-screen render-cache eviction sweeps. const CACHE_EVICT_INTERVAL: Duration = Duration::from_secs(5); /// Throttled sweep of off-screen render caches for the active view's /// scrollback (parent agent, or the open fullscreen subagent child). /// A sweep is an O(entries) walk of pointer-sized cache slots — trivial /// next to a frame render — but there's no reason to run it per frame. fn maybe_evict_offscreen_caches(&mut self) { let ActiveView::Agent(id) = self.active_view else { return; }; let now = Instant::now(); if self .last_cache_evict_at .is_some_and(|t| now.duration_since(t) < Self::CACHE_EVICT_INTERVAL) { return; } self.last_cache_evict_at = Some(now); if let Some(agent) = self.agents.get_mut(&id) { let evicted = if let Some(child_sid) = agent.active_subagent.clone() { agent .subagent_views .get(&child_sid) .map(|child| child.scrollback.evict_offscreen_render_caches()) .unwrap_or(0) } else { agent.scrollback.evict_offscreen_render_caches() }; if evicted > 0 { tracing::debug!(evicted, "scrollback.evicted_offscreen_render_caches"); } } } } impl AppView { /// True when any modal that should swallow scroll input is open. fn is_scroll_blocking_modal_open(&self) -> bool { let cloud_modal_open = false; matches!(self.active_view, ActiveView::Agent(id) if self.agents.get(&id).is_some_and(|a| a.extensions_modal.is_some() || a.active_modal.is_some())) || self.import_claude_modal.is_some() || self.new_worktree_dialog.is_some() || self.welcome_doc_viewer.is_some() || self.tutorial.is_some() || matches!(self.active_view, ActiveView::AgentDashboard if self.dashboard.as_ref().is_some_and(|d| d.shortcuts_modal.is_some())) || cloud_modal_open } /// Store the resolved per-tip gates and propagate the prompt-relevant tips /// (undo + plan nudge) to every agent's prompt. Reused by startup and the /// settings live-apply path so a runtime toggle reaches existing agents. pub fn apply_contextual_hints( &mut self, resolved: xai_grok_shell::util::config::ResolvedContextualHints, ) { self.contextual_hints = resolved; for agent in self.agents.values_mut() { agent .prompt .set_contextual_hints(resolved.undo, resolved.plan_mode); } } /// One-shot small-screen `/compact-mode` tip trigger, run at the top of /// every `draw`. Waits (without consuming the one-shot) until the active /// AGENT view has a stable, draw-measured size — so a welcome screen, an /// undrawn agent, or a pending post-resize re-measure defer it. An /// out-of-band (or user-compact-on) first measure consumes the one-shot, /// so later resizes can never re-trigger. An in-band measure whose banner /// row is occluded (permission ask, modal, open dropdown, session banner) /// defers instead of consuming: the show gate would refuse it anyway, and /// spending the run's only evaluation on an invisible frame would kill /// the hint for the run. pub(crate) fn maybe_trigger_small_screen_tip(&mut self) { if self.small_screen_tip_evaluated { return; } let ActiveView::Agent(id) = self.active_view else { return; }; let Some(agent) = self.agents.get(&id) else { return; }; if agent.terminal_size_stale || agent.last_terminal_size == (0, 0) { return; } if !crate::tips::small_screen::small_screen_band_contains(agent.last_terminal_size.1) || self.current_ui.compact_mode { self.small_screen_tip_evaluated = true; return; } if !agent.ephemeral_tip_can_render() { return; } self.small_screen_tip_evaluated = true; super::dispatch::show_small_screen_tip(self); } /// One-shot SSH `grok wrap` tip trigger, run at the top of every `draw` /// right after [`Self::maybe_trigger_small_screen_tip`]. The welcome /// screen has no ephemeral-tip row, so the first stable agent-view draw /// is the earliest surface that can paint a session-load tip. Reads the /// live environment (cached statics) and delegates to the injectable /// inner so tests never depend on the host's SSH shape. pub(crate) fn maybe_trigger_ssh_wrap_tip(&mut self) { if self.ssh_wrap_tip_evaluated { return; } static ENV_RECOMMENDS_WRAP: std::sync::OnceLock = std::sync::OnceLock::new(); let env_recommends_wrap = *ENV_RECOMMENDS_WRAP.get_or_init(|| { let ctx = crate::terminal::terminal_context(); crate::diagnostics::ssh_wrap_hint( ctx.is_ssh, crate::diagnostics::probes::osc52_sink_active(), ctx.is_official_vscode_remote, ) .is_some() }); self.maybe_trigger_ssh_wrap_tip_inner(env_recommends_wrap); } /// Inner trigger with the environment verdict injected /// (`diagnostics::ssh_wrap_hint` on the live path). Same /// defer-vs-consume rules as the small-screen trigger above, with two /// deltas: the environment gates are process-constant, so a failing /// verdict consumes the one-shot; and a busy tip slot defers instead of /// replacing — both session-load tips can qualify on the same first /// draw, and replacing would burn the other tip's once-per-session show, /// while this one loses nothing by waiting for a later draw. pub(crate) fn maybe_trigger_ssh_wrap_tip_inner(&mut self, env_recommends_wrap: bool) { if self.ssh_wrap_tip_evaluated { return; } let ActiveView::Agent(id) = self.active_view else { return; }; let Some(agent) = self.agents.get(&id) else { return; }; if agent.terminal_size_stale || agent.last_terminal_size == (0, 0) { return; } if !env_recommends_wrap { self.ssh_wrap_tip_evaluated = true; return; } if !agent.ephemeral_tip_can_render() || agent.ephemeral_tip.is_active() { return; } self.ssh_wrap_tip_evaluated = true; super::dispatch::show_ssh_wrap_tip(self); } /// Whether the clipboard-image tip may poll right now — the single in-window /// gate. Outside it the poll touches the pasteboard ZERO times: contextual /// hints on, the probe supported (macOS), past the fire cooldown, the /// terminal focused, and the active agent eligible (the tip row can paint, /// no image chips attached, an image-capable model). Cooldown is part of the /// gate so a recently-fired tip suppresses even the cheap changeCount read. fn clipboard_tip_in_poll_window(&self, now: std::time::Instant) -> bool { self.contextual_hints.image_input && crate::clipboard::clipboard_image_probe_supported() && !self.clipboard_focus_tip.in_cooldown(now) && self.notification_service.focus_tracker.is_focused() && match self.active_view { ActiveView::Agent(id) => self .agents .get(&id) .is_some_and(AgentView::clipboard_image_tip_eligible), _ => false, } } /// Opportunistic, throttled clipboard-image poll. Driven from event-loop /// iterations that already run for another reason (input, FocusGained, /// resize, an animation tick) — never from a timer and never by forcing /// `needs_animation`, so an idle/hibernating/unfocused app polls zero times. /// In-window it does at most one cheap `changeCount` read per `POLL_INTERVAL` /// and pays for the heavier type classification ONLY on a changeCount delta. /// Returns true when the tip was shown (needs redraw). pub(crate) fn poll_clipboard_focus_tip(&mut self) -> bool { let now = std::time::Instant::now(); if !self.clipboard_tip_in_poll_window(now) { return false; } let outcome = self.clipboard_focus_tip.poll( now, crate::clipboard::clipboard_change_count, crate::tips::clipboard_focus::run_clipboard_check, ); match outcome { Some(outcome) => self.apply_clipboard_probe(outcome, now), None => false, } } /// Decide + show for a probe outcome, committing the cooldown/dedup only /// when the show actually lands. Split out so the show/commit logic is /// unit-testable with a synthetic [`CheckOutcome`] (the native probe reads /// real hardware). fn apply_clipboard_probe( &mut self, outcome: crate::tips::clipboard_focus::CheckOutcome, now: std::time::Instant, ) -> bool { if !self.clipboard_focus_tip.should_fire(&outcome, now) { return false; } let ActiveView::Agent(id) = self.active_view else { return false; }; let Some(agent) = self.agents.get_mut(&id) else { return false; }; if agent.show_ephemeral_tip( crate::tips::clipboard_focus::clipboard_image_tip(), &mut self.tip_seen_counts, ) { self.clipboard_focus_tip.note_fired(&outcome, now); xai_grok_telemetry::session_ctx::log_event(xai_grok_telemetry::events::ContextualTip { tip: xai_grok_telemetry::events::ContextualTipKind::ImageInput, action: xai_grok_telemetry::events::ContextualTipAction::Shown, }); return true; } false } /// Advance animation timers and drain tracing channel. /// /// Called at a fixed rate (~30fps) from the event loop. Produces /// redraws when there are running entries with animated accents, /// when a pending action expires (to clear the "press again" hint), /// or when new tracing entries arrive via the channel. pub fn tick(&mut self) -> bool { let mut needs_redraw = false; needs_redraw |= self.minimal_state.transcript.is_some(); needs_redraw |= self.poll_clipboard_focus_tip(); if matches!(self.active_view, ActiveView::Welcome) { self.welcome_tick = self.welcome_tick.wrapping_add(1); if let Some(expires_at) = self.welcome_toast.as_ref().map(|(_, at)| *at) { if std::time::Instant::now() >= expires_at { self.welcome_toast = None; } needs_redraw = true; } if self.session_picker_content_loading || crate::views::session_picker::loading_spinner_active( self.session_picker_entries.as_deref(), self.session_picker_source_filter, self.session_picker_loading, &self.session_picker_lanes, ) { needs_redraw = true; } else { let frame = crate::views::welcome::shimmer_frame(); if frame != self.welcome_shimmer_frame { self.welcome_shimmer_frame = frame; needs_redraw = true; } } } if matches!(self.active_view, ActiveView::AgentDashboard) && let Some(d) = self.dashboard.as_mut() { d.spinner_tick = d.spinner_tick.wrapping_add(1); needs_redraw = true; d.dispatch.poll_file_search(); d.peek_reply.poll_file_search(); } if let Some(pending) = &self.pending_action && pending.expired() { self.pending_action = None; needs_redraw = true; } if let Some(rx) = &mut self.tracing_rx { while rx.try_recv().is_ok() {} } let mut bootstrap_commands_update: Option> = None; for agent in self.agents.values_mut() { needs_redraw |= agent.edit_hl_tick(); for child in agent.subagent_views.values_mut() { needs_redraw |= child.edit_hl_tick(); } } if let ActiveView::Agent(id) = self.active_view && let Some(agent) = self.agents.get_mut(&id) { needs_redraw |= agent.scrollback.tick(); needs_redraw |= agent.todo.list_state.tick(); needs_redraw |= agent.todo.badge_tick(); needs_redraw |= agent.tasks.tick(); for child_view in agent.subagent_views.values_mut() { needs_redraw |= child_view.scrollback.tick(); needs_redraw |= child_view.tick_toast(); needs_redraw |= child_view.tick_ephemeral_tip(); needs_redraw |= child_view.tick_mode_banner(); needs_redraw |= child_view.tick_selection_highlight(); needs_redraw |= child_view.tick_drag_autoscroll(); needs_redraw |= child_view.poll_link_modifier(); needs_redraw |= child_view.poll_scrollback_search(); needs_redraw |= child_view.mermaid_tick(); needs_redraw |= Self::tick_agent_image_load(child_view); needs_redraw |= Self::tick_agent_block_viewer(child_view); } let spinner_frame_tick = agent.scrollback.animation_tick() % crate::views::turn_status::SPINNER_DIVISOR == 0; needs_redraw |= !agent.session.state.is_idle() && spinner_frame_tick; needs_redraw |= agent .mcp_init_progress .as_ref() .is_some_and(McpInitProgress::is_visible) && spinner_frame_tick; needs_redraw |= matches!( agent.btw_state, Some(crate::views::btw_overlay::BtwOverlayState::Loading { .. }) ) && spinner_frame_tick; needs_redraw |= matches!( agent.active_modal.as_ref(), Some(crate::views::modal::ActiveModal::SessionPicker { entries, loading, lanes, source_filter, .. }) if crate::views::session_picker::loading_spinner_active( entries.as_deref(), *source_filter, *loading, lanes, ) ) && spinner_frame_tick; needs_redraw |= agent.drain_blocked(); agent.prompt.slash_controller.set_workflows_available( agent .session .available_commands .iter() .any(|c| c.name == "workflow") || !agent.workflow_runs.is_empty(), ); if agent.acp_synced_generation != agent.session.available_commands_generation { agent.prompt.sync_acp_commands( &agent.session.available_commands, agent.session.available_tools.as_ref(), &agent.session.models, ); agent.acp_synced_generation = agent.session.available_commands_generation; bootstrap_commands_update = Some(agent.session.available_commands.clone()); needs_redraw = true; } needs_redraw |= agent.prompt.poll_file_search(); needs_redraw |= agent.prompt.history_search.poll(); needs_redraw |= agent.poll_scrollback_search(); needs_redraw |= agent.tick_toast(); needs_redraw |= agent.tick_extensions_result_notice(); needs_redraw |= agent.tick_ephemeral_tip(); needs_redraw |= agent.tick_mode_banner(); needs_redraw |= agent.tick_selection_highlight(); needs_redraw |= agent.tick_drag_autoscroll(); needs_redraw |= agent.poll_link_modifier(); needs_redraw |= Self::tick_agent_image_load(agent); needs_redraw |= Self::tick_agent_block_viewer(agent); if let Some(ref mut viewer) = agent.video_viewer { needs_redraw |= viewer.tick(); } if let Some(ref mut gboom) = agent.gboom { gboom.tick(); needs_redraw = true; } if let Some(ref rx) = agent.video_load_rx && let Ok(result) = rx.try_recv() { agent.video_load_rx = None; match result { Some(video) => { agent.replace_inline_video(video); agent.toast = None; } None => { agent.show_toast("Video playback requires ffmpeg"); } } needs_redraw = true; } needs_redraw |= agent.mermaid_tick(); if let Some(ref mut video) = agent.inline_video && !video.finished && !video.frames.is_empty() { let elapsed = video.last_frame_time.elapsed(); let frame_dur = std::time::Duration::from_secs_f64(1.0 / video.fps); if elapsed >= frame_dur { if video.current_frame + 1 >= video.frames.len() { video.finished = true; } else { video.current_frame += 1; video.last_frame_time = std::time::Instant::now(); } needs_redraw = true; } } } if let Some(commands) = bootstrap_commands_update { self.welcome_prompt .sync_acp_commands(&commands, None, &self.models); if let Some(d) = self.dashboard.as_mut() { d.dispatch.sync_acp_commands(&commands, None, &self.models); } self.bootstrap_acp_commands = commands; } self.update_notifications(); if let Some((_, remaining)) = self.deferred_notification.as_mut() { if *remaining == 0 { let event = self.deferred_notification.take().unwrap().0; self.notification_service.notify(event); } else { *remaining -= 1; } } needs_redraw |= self.tick_scroll(); needs_redraw } /// Flush pending scroll lines (stream gap detection, redraw cadence). /// Without this, stale streams are never finalized after the user stops /// scrolling, and sub-line fractional remainders may not be flushed. /// /// Primarily driven by the event loop's scroll clock, armed from /// [`MouseScrollState::scroll_clock_deadline`] while a stream is active so /// residuals land on the 16ms redraw cadence (not the animation fps) and /// the 80ms stream-gap finalize fires on time. Returns true only when /// lines were dispatched — i.e. a draw would show real movement. pub(crate) fn tick_scroll(&mut self) -> bool { let mut needs_redraw = false; let had_scroll_stream = self.scroll_state.has_active_stream(); let scroll_update = self.scroll_state.on_tick(); if scroll_update.lines != 0 && let Some((col, row)) = self.last_scroll_pos && !self.is_scroll_blocking_modal_open() { self.dispatch_scroll(scroll_update.lines, col, row); needs_redraw = true; } if had_scroll_stream && !self.scroll_state.has_active_stream() { self.last_scroll_pos = None; } needs_redraw } /// Whether the `/gboom` easter egg is open on the active agent view. /// While active it owns input, so the event loop preserves key-release /// events for it and bypasses paste coalescing. pub(crate) fn gboom_active(&self) -> bool { matches!(self.active_view, ActiveView::Agent(id) if self.agents.get(&id).is_some_and(|a| a.gboom.is_some())) } /// Un-latch held movement on every open `/gboom` game. /// /// In release-aware (Kitty) mode a key stays latched until its release /// event arrives. On window focus loss the active game's release may be /// dropped, so clear all games' holds to stop runaway motion. pub(crate) fn gboom_release_all_games(&mut self) { for agent in self.agents.values_mut() { if let Some(gboom) = agent.gboom.as_mut() { gboom.release_all(); } } } /// Un-latch held movement on every `/gboom` game that is *not* the active /// input target. Only the active game receives release events; a key /// still held when the user switches agent tabs (or to any other view) /// would otherwise leave that backgrounded game walking or turning with /// no key down when it is next reopened. Reconciled every event-loop /// iteration while a game is open, so it holds regardless of which view /// becomes active or whether the shared keyboard layer stays pushed. pub(crate) fn gboom_release_backgrounded_games(&mut self) { let active = match self.active_view { ActiveView::Agent(id) => Some(id), _ => None, }; for (id, agent) in self.agents.iter_mut() { if Some(*id) != active && let Some(gboom) = agent.gboom.as_mut() { gboom.release_all(); } } } /// Tick-interval ceiling requested by the current view state, if any. /// /// The `/gboom` easter egg targets ~30 fps even when the user configured /// a lower `animation.fps`; the simulation steps with wall-clock `dt`, /// so this only affects smoothness, never game speed. pub fn tick_interval_ceiling(&self) -> Option { if self.gboom_active() { return Some(std::time::Duration::from_millis(33)); } if self.minimal_state.transcript.is_some() { return Some(std::time::Duration::from_millis(16)); } None } /// Deferred image viewer load (background thread). Shared by parent agent /// and fullscreen subagent children so gate/tick stay symmetric. fn tick_agent_image_load(agent: &mut AgentView) -> bool { if let Some(ref mut viewer) = agent.image_viewer && viewer.loading { if agent.image_load_rx.is_none() && let Some(path) = viewer.take_source_path() { let (tx, rx) = std::sync::mpsc::channel(); agent.image_load_rx = Some(rx); std::thread::spawn(move || { let _ = tx.send(crate::prompt_images::load_image_data(&path)); }); } if let Some(ref rx) = agent.image_load_rx { use crate::prompt_images::ImageLoadResult; match rx.try_recv() { Ok(ImageLoadResult::Loaded(data)) => { viewer.apply_loaded(data); agent.image_load_rx = None; } Ok(ImageLoadResult::Failed) | Err(std::sync::mpsc::TryRecvError::Disconnected) => { agent.image_viewer = None; agent.image_load_rx = None; agent.toast = Some(("Couldn't load image preview".into(), 6)); } Err(std::sync::mpsc::TryRecvError::Empty) => {} } } return true; } false } /// Block viewer streaming / follow-mode ticks (parent or subagent child). fn tick_agent_block_viewer(agent: &mut AgentView) -> bool { let mut needs_redraw = false; if let Some(ref mut viewer) = agent.block_viewer { if viewer.kind == crate::views::block_viewer::ViewerKind::BgTask && let Some(ref task_id) = viewer.bg_task_id.clone() && let Some(task) = agent.session.bg_tasks.get(task_id) { let is_running = task.status == crate::app::agent::BgTaskStatus::Running; needs_redraw |= viewer.tick_bg_task(&task.stdout, is_running); } else if let Some(entry) = agent.scrollback.get_by_id(viewer.entry_id) { needs_redraw |= viewer.tick(entry); } else { agent.block_viewer = None; needs_redraw = true; } } needs_redraw } /// Check if animation ticks should be scheduled. pub fn needs_animation(&self) -> bool { self.tick_demand() != TickDemand::None } /// What tick cadence the current view state demands. /// /// [`TickDemand::Fast`] runs at the configured animation fps (default /// 30). [`TickDemand::Slow`] runs at [`SLOW_TICK_INTERVAL`] and is used /// when the only reasons to tick are low-frequency by construction — /// the ~12fps welcome logo shimmer and the macOS Cmd link-hover poll — /// so an app that *looks* idle doesn't spin a 30fps loop for them. pub fn tick_demand(&self) -> TickDemand { if self.pending_action.is_some() { return TickDemand::Fast; } if self.minimal_state.transcript.is_some() { return TickDemand::Fast; } if self .agents .values() .any(|a| a.pending_turn_end_reconcile.is_some()) { return TickDemand::Fast; } if self.deferred_notification.is_some() { return TickDemand::Fast; } if self.voice_listening() { return TickDemand::Fast; } if self.session_picker_content_loading { return TickDemand::Fast; } if self.agents.values().any(|agent| { agent.edit_hl_needs_tick() || agent .subagent_views .values() .any(|c| c.edit_hl_needs_tick()) }) { return TickDemand::Fast; } match self.active_view { ActiveView::Agent(id) => { let Some(agent) = self.agents.get(&id) else { return TickDemand::None; }; let fast = agent.scrollback.needs_animation() || agent.todo.list_state.needs_tick() || agent.todo.badge_needs_tick() || agent.tasks.needs_tick() || agent.acp_synced_generation != agent.session.available_commands_generation || !agent.session.state.is_idle() || agent.session.loading_replay || agent .mcp_init_progress .as_ref() .is_some_and(McpInitProgress::is_visible) || agent.plugin_cta.phase.is_spinner() || matches!( agent.btw_state, Some(crate::views::btw_overlay::BtwOverlayState::Loading { .. }) ) || agent.drain_blocked() || agent.prompt.file_search.context().is_some() || agent.prompt.history_search.is_active() || agent.scrollback_search.is_some() || agent.line_viewer.is_some() || agent.toast.is_some() || agent .extensions_modal .as_ref() .is_some_and(|m| m.result_notice.is_some()) || agent.ephemeral_tip_needs_tick() || agent.mode_switch_banner.is_some() || agent.has_drag_autoscroll() || agent.selection_created_at.is_some() || agent.block_viewer.is_some() || agent.image_viewer.as_ref().is_some_and(|v| v.loading) || agent.image_load_rx.is_some() || agent.video_viewer.as_ref().is_some_and(|v| v.playing) || agent.gboom.is_some() || agent.inline_video.as_ref().is_some_and(|v| !v.finished) || agent.video_load_rx.is_some() || agent.mermaid_needs_tick() || !agent.permission_queue.is_empty() || matches!( agent.active_modal.as_ref(), Some(crate::views::modal::ActiveModal::SessionPicker { entries, loading, lanes, source_filter, .. }) if crate::views::session_picker::loading_spinner_active( entries.as_deref(), *source_filter, *loading, lanes, ) ) || agent.subagent_views.iter().any(|(sid, child)| { child.toast.is_some() || child.ephemeral_tip_needs_tick() || child.mode_switch_banner.is_some() || child.has_drag_autoscroll() || child.selection_created_at.is_some() || (agent.active_subagent.as_deref() == Some(sid.as_str()) && child.scrollback.needs_animation()) || child.scrollback_search.is_some() || child.block_viewer.is_some() || child.image_viewer.as_ref().is_some_and(|v| v.loading) || child.image_load_rx.is_some() || child.mermaid_needs_tick() }); if fast { return TickDemand::Fast; } if cfg!(target_os = "macos") && (agent.needs_link_modifier_poll() || agent .subagent_views .values() .any(|child| child.needs_link_modifier_poll())) { return TickDemand::Slow; } TickDemand::None } ActiveView::AgentDashboard => { let agents_need = self.agents.values().any(|agent| { !agent.session.state.is_idle() || !agent.permission_queue.is_empty() || agent.session.loading_replay || agent .subagent_sessions .values() .any(|info| !info.finished && info.workflow_run_id.is_none()) || agent.workflow_runs.iter().any(|run| run.is_active()) }); let dash_search = self.dashboard.as_ref().is_some_and(|d| { d.dispatch.file_search.context().is_some() || d.peek_reply.file_search.context().is_some() }); if agents_need || dash_search { TickDemand::Fast } else { TickDemand::None } } ActiveView::Welcome => TickDemand::Slow, } } /// Update the terminal tab title and OSC 9;4 progress bar. /// /// Stores any resulting escape sequences in `pending_notification_escapes` /// so that the next `draw()` can pipe them through the frame's /// `post_flush_escapes` (inside the synchronized output block). /// /// Also clears the permission notification flag when no permissions /// remain queued, so the next batch fires a fresh bell/popup. pub fn update_notifications(&mut self) { let (session_name, model, activity, has_perms, turn_elapsed, is_busy) = if let ActiveView::Agent(id) = self.active_view && let Some(agent) = self.agents.get(&id) { let name = agent .display_name .as_deref() .or(agent.generated_session_title.as_deref()); let model = agent.session.models.current_model_name(); let parked = agent.renders_parked(); let activity = if parked { None } else { agent.resolve_turn_activity() }; let has_perms = !agent.permission_queue.is_empty(); let elapsed = if parked { None } else { agent.turn_elapsed() }; let is_busy = agent.session.state.is_busy() && !parked; (name, model, activity, has_perms, elapsed, is_busy) } else { (None, None, None, false, None, false) }; let any_agent_has_perms = self.agents.values().any(|a| !a.permission_queue.is_empty()); if !any_agent_has_perms { self.notification_service.clear_permission_notification(); } let cwd_str = self.cwd.to_string_lossy(); let title_state = crate::notifications::TitleState { session_name, model: model.as_deref(), activity: activity.as_ref(), has_pending_permissions: has_perms, cwd: Some(&cwd_str), turn_elapsed, is_busy, focused: self.notification_service.focus_tracker.is_focused(), }; if let Some(esc) = self.notification_service.on_tick(&title_state) { self.pending_notification_escapes .get_or_insert_with(String::new) .push_str(&esc); } } } #[cfg(test)] pub(crate) mod tests { use super::*; use crate::acp::model_state::ModelState; use crate::acp::tracker::AcpUpdateTracker; use crate::app::agent::{AgentSession, AgentState}; use crate::app::agent_view::{AgentView, PromptMode}; use crate::app::bundle::BundleState; use crate::scrollback::state::ScrollbackState; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; #[test] fn parse_esc_ttl_bounds() { let default = PendingAction::ESC_DOUBLE_PRESS_TTL; assert_eq!(parse_esc_ttl(None), default); assert_eq!(parse_esc_ttl(Some("garbage".into())), default); assert_eq!(parse_esc_ttl(Some("".into())), default); assert_eq!(parse_esc_ttl(Some("0".into())), default); assert_eq!(parse_esc_ttl(Some("-5".into())), default); assert_eq!( parse_esc_ttl(Some(" 1200 ".into())), Duration::from_millis(1200) ); assert_eq!( parse_esc_ttl(Some(ESC_DOUBLE_PRESS_TEST_MS.to_string())), Duration::from_millis(ESC_DOUBLE_PRESS_TEST_MS) ); assert_eq!( parse_esc_ttl(Some(u64::MAX.to_string())), Duration::from_millis(ESC_DOUBLE_PRESS_TEST_MS) ); } /// `AppView::draw` is the ONLY drain point for the process-wide deferred /// release flag; if the wrapper loses its `run_deferred_release()` call, /// every draw/tick-path cliff (video scroll-off, takeover drain, /// frame-set replacement) silently stops purging. Drives the real /// `draw()` against a channel-backed terminal (no tty; same recipe as /// pager-render's `draw_frame` tests). Serialized: process-wide flag. #[test] #[serial_test::serial(MEMORY_RELEASE_DEFER)] fn app_draw_drains_deferred_release_after_flush() { use crate::memory_release::test_support; use ratatui::{TerminalOptions, Viewport}; test_support::install_counting_hook(); crate::memory_release::run_deferred_release(); let (frame_tx, _frame_rx) = std::sync::mpsc::channel::(); let writer = crate::render::draw::TermWriter::new(frame_tx, crate::render::draw::WriterSync::new()) .expect("single test writer"); let backend = ratatui::backend::CrosstermBackend::new(writer); let mut terminal = xai_ratatui_inline::Terminal::with_options( backend, TerminalOptions { viewport: Viewport::Fixed(ratatui::layout::Rect::new(0, 0, 80, 24)), }, ) .expect("channel-backed terminal requires no tty"); let mut app = test_app(); crate::memory_release::request_release_after_draw_with("unit-test-defer"); let before = test_support::calls(); app.draw(&mut terminal); assert_eq!( test_support::calls(), before + 1, "AppView::draw must drain the deferred release post-flush" ); let before = test_support::calls(); app.draw(&mut terminal); assert_eq!( test_support::calls(), before, "a draw without a pending request must not purge" ); } pub(crate) fn test_app() -> AppView { let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); AppView { active_view: ActiveView::Welcome, auth_return_view: None, agents: indexmap::IndexMap::new(), next_agent_id: 0, models: ModelState::default(), registry: ActionRegistry::defaults(), settings_registry: std::sync::Arc::new(crate::settings::SettingsRegistry::defaults()), current_ui: xai_grok_shell::agent::config::UiConfig::default(), cwd: std::path::PathBuf::from("/tmp"), project_picker_shown: true, project_picker_disabled: false, cwd_has_git_ancestor: false, acp_tx: tx, scratch: crate::scrollback::render::ScratchBuffer::new(), cursor: CursorState::new(), pending_action: None, exit_session_pending: None, scroll_state: MouseScrollState::default(), scroll_config: ScrollConfig::default(), appearance: AppearanceConfig::default(), notification_service: NotificationService::new(Default::default()), pending_notification_escapes: None, deferred_notification: None, tracing_rx: None, active_announcements: vec![], hidden_announcement_ids: Default::default(), announcements_last_gen: 0, announcement: None, changelog_markdown: None, changelog_bullets: Vec::new(), tips: Vec::new(), tip: None, cli_model_override: None, cli_effort_token: None, default_yolo: false, permission_mode_from_soft_default: true, auto_mode_gate: true, yolo_policy_block: None, yolo_launch_block_notice: None, screen_mode_switch_hint: None, require_plan_approval: false, plan_mode: false, subagents: false, ask_user: false, chat_mode: false, mouse_captured: true, new_worktree_dialog: None, contextual_hints: Default::default(), remote_contextual_hints: None, tip_seen_counts: Default::default(), last_known_terminal_rows: 0, small_screen_tip_evaluated: false, ssh_wrap_tip_evaluated: false, clipboard_focus_tip: Default::default(), new_session_worktree_mode: WorktreeMode::Never, fork_worktree_mode: WorktreeMode::Ask, restore_code: None, resume_local_miss: None, agent_override: None, bootstrap_acp_commands: Vec::new(), auth_methods: Vec::new(), auth_state: AuthState::Done, trust_state: TrustState::Done, login_label: None, login_method_id: None, auth_start_mode: AuthMode::Pending, auth_code_input: LineEditor::default(), next_auth_request_seq: 1, auth_url_poll_handle: None, deferred_startup: Default::default(), auth_use_oauth: false, auth_clipboard_delivery: None, auth_clipboard_feedback_generation: 0, team_id: None, team_name: None, is_zdr: false, team_role: None, coding_data_retention_opt_out: true, privacy_notice_rollout: false, privacy_banner_reshow_days: None, privacy_banner_acked: None, privacy_banner_accept_inflight: false, show_tips: None, auto_update: None, ask_user_question_timeout_enabled: None, zdr_access_enabled: false, usage_billing_redirect_url: None, access_gate_shown_logged: false, announcement_cta_impressions_logged: Default::default(), gate: None, subscription_tier: None, paywall_check_started: None, last_subscription_check_at: None, subscription_watch_interval_secs: None, pending_gate_verification: None, gate_verify_gen: 0, bundle_state: BundleState::default(), scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(), fps_hud: crate::views::fps_hud::FpsHud::new(), welcome_prompt: crate::views::prompt_widget::PromptWidget::new(), slash_mru: std::rc::Rc::new(std::cell::RefCell::new( crate::slash::mru::SlashMru::new_in_memory(), )), command_tags: std::rc::Rc::new(std::cell::RefCell::new( std::collections::HashMap::new(), )), welcome_prompt_focused: false, welcome_tip_typing_dismissed: false, welcome_menu_index: None, welcome_menu_rects: Vec::new(), welcome_show_changelog_action: false, welcome_import_banner_rect: None, last_mouse_pos: None, last_scroll_pos: None, last_cache_evict_at: None, welcome_prompt_rect: None, welcome_auth_url_rect: None, welcome_on_auth_url: false, welcome_on_changelog_cta: false, welcome_announcement: WelcomeAnnouncementState::default(), welcome_auth_fallback_rect: None, welcome_refresh_rect: None, welcome_gate_url_rect: None, welcome_upgrade_cta_rect: None, welcome_privacy_banner_accept_rect: None, welcome_privacy_banner_customize_rect: None, welcome_privacy_banner_legal_rect: None, welcome_toast: None, welcome_on_privacy_banner: false, welcome_on_upgrade_cta: false, welcome_changelog_cta_rect: None, auth_show_raw_url: false, auth_mouse_disabled: false, session_picker_entries: None, session_picker_loading: false, session_picker_state: crate::views::picker::PickerState::with_mode( crate::views::picker::PickerMode::FullScreen, ), session_picker_source_filter: crate::views::session_picker::SourceFilter::default(), session_picker_relaxed_notified_for: None, session_picker_content_results: None, session_picker_content_loading: false, session_picker_deep_search_seq: 0, session_picker_list_seq: 0, foreign_session_compat: Default::default(), foreign_session_scan_seq: 0, foreign_scan_coordinator: Default::default(), session_picker_lanes: Default::default(), session_picker_detail_generation: 0, session_picker_entries_query: None, welcome_tick: 0, welcome_shimmer_frame: 0, startup_warnings: Vec::new(), is_api_key_auth: false, pending_update_version: None, foreign_resume_launch_generation: 0, foreign_resume_launch: None, quit_for_update: false, relaunch: None, has_claude_import: false, import_claude_modal: None, welcome_doc_viewer: None, screen_mode: ScreenMode::Inline, pending_effects: Vec::new(), pending_editor: None, pending_pager_path: None, pending_pager_ansi: false, minimal_state: crate::minimal_api::MinimalState::default(), reconnect_pending: false, show_resolved_model: true, sharing_enabled: false, plugin_cta_enabled: false, usage_visible: true, tier_restricted_commands: Vec::new(), leader_mode: true, credit_balance: None, auto_topup: None, billing_poll_wanted: false, leader_roster: Vec::new(), dashboard_local_sessions: Vec::new(), dashboard_sessions_loading: false, shared_prompt_queues: std::collections::HashMap::new(), optimistic_prompt_echoes: std::collections::HashMap::new(), pending_running_adoptions: std::collections::HashMap::new(), session_picker_grouped: false, cancel_rewind_enabled: true, session_recap_available: false, tutorial: None, dashboard: None, dashboard_return: None, dashboard_persisted: None, keyboard_normalizer: KeyboardNormalizer::from_terminal_context(), voice_mode_enabled: false, voice_ui_active: false, voice_config: xai_grok_voice::VoiceConfig::default(), voice_auth: None, voice_cmd_tx: None, voice_state: VoiceState::Idle, } } pub(crate) fn test_app_with_agent() -> AppView { let mut app = test_app(); let id = super::super::agent::AgentId(0); let mut agent = AgentView::new( AgentSession { id, acp_tx: app.acp_tx.clone(), session_id: Some("test-session".into()), models: ModelState::default(), state: AgentState::Idle, tracker: AcpUpdateTracker::new(), cwd: std::path::PathBuf::from("/tmp"), is_worktree: false, forked_from: None, pending_prompts: std::collections::VecDeque::new(), next_queue_id: 0, yolo_mode: false, auto_mode: false, prompt_history: Vec::new(), prompt_history_loading: false, loading_replay: false, restore_degree: None, rate_limited: false, model_incompatible: false, credit_limit_blocked: false, free_usage_blocked: false, available_commands: Vec::new(), available_commands_generation: 0, available_tools: None, model_switch_pending: false, user_model_preference: None, deferred_model_switch: None, bg_tasks: std::collections::BTreeMap::new(), bg_tool_call_to_task: std::collections::HashMap::new(), scheduled_tasks: std::collections::HashMap::new(), in_flight_prompt: None, compact_held_prompt: None, current_prompt_id: None, created_via_new: false, }, ScrollbackState::new(), ); agent.active_pane = crate::views::agent::ActivePane::Scrollback; app.agents.insert(id, agent); super::super::dispatch::switch_to_agent( &mut app, id, super::super::dispatch::SwitchCause::Load, ); app } #[test] fn dashboard_x11_primary_provenance_bypasses_unrelated_clipboard_image() { const PRIMARY: &str = "PRIMARY selection text"; let clipboard_hook = || crate::clipboard::ClipboardProbeHook { text: Some("CLIPBOARD text".to_owned()), primary_text: Some(PRIMARY.to_owned()), x11_primary_available: true, ..crate::clipboard::ClipboardProbeHook::with_raster(Some(crate::clipboard::ImageData { data: vec![1, 2, 3], mime_type: "image/png".to_owned(), })) }; let mut bracketed = test_app(); bracketed.active_view = ActiveView::AgentDashboard; bracketed.dashboard = Some(crate::views::dashboard::DashboardState::new()); crate::clipboard::set_clipboard_probe_hook(clipboard_hook()); let _ = bracketed.handle_input(&Event::Paste(PRIMARY.to_owned())); crate::clipboard::clear_clipboard_probe_hook(); assert!( bracketed.pending_effects.iter().any(|effect| matches!( effect, crate::app::actions::Effect::ProbeClipboardAttachment { .. } )), "the distinct CLIPBOARD image must make ordinary bracketed paste probe" ); let mut primary = test_app(); primary.active_view = ActiveView::AgentDashboard; primary.dashboard = Some(crate::views::dashboard::DashboardState::new()); crate::clipboard::set_clipboard_probe_hook(clipboard_hook()); let outcome = primary.handle_input_at_with_paste_provenance( &Event::Paste(PRIMARY.to_owned()), Instant::now(), PasteProvenance::X11Primary, ); let probe_calls = crate::clipboard::clipboard_probe_call_count(); crate::clipboard::clear_clipboard_probe_hook(); assert!(matches!(outcome, InputOutcome::Changed)); let dashboard = primary.dashboard.as_ref().expect("dashboard state"); assert_eq!(dashboard.dispatch.text(), PRIMARY); assert!(!dashboard.dispatch.text().contains("CLIPBOARD")); assert!(dashboard.dispatch.images.is_empty()); assert_eq!(dashboard.paste_probe_in_flight, 0); assert!(primary.pending_effects.iter().all(|effect| !matches!( effect, crate::app::actions::Effect::ProbeClipboardAttachment { .. } ))); assert_eq!(probe_calls, 0); } /// With the image-input tip OFF, the poll short-circuits at the window gate /// before touching the pasteboard — the per-tip gate fails closed. #[test] fn clipboard_poll_no_op_when_flag_off() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().last_terminal_size = (80, 30); app.notification_service.focus_tracker.on_focus_gained(); app.contextual_hints.image_input = false; assert!(!app.poll_clipboard_focus_tip(), "tip-off poll is a no-op"); assert!(!app.agents[&id].ephemeral_tip.is_active()); } /// The in-window gate decides whether an already-running iteration may touch /// the pasteboard at all. It opens only when contextual hints are on, the /// probe is supported (macOS), the fire cooldown is clear, the terminal is /// focused, and the active agent is eligible; flipping any one closes it so /// the poll reads the clipboard zero times. (Probe support is macOS-only, so /// the in-window result tracks the platform.) #[test] fn clipboard_poll_window_gate() { let mut app = test_app_with_agent(); app.contextual_hints.image_input = true; let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().last_terminal_size = (80, 30); app.notification_service.focus_tracker.on_focus_gained(); let now = std::time::Instant::now(); let supported = crate::clipboard::clipboard_image_probe_supported(); assert_eq!( app.clipboard_tip_in_poll_window(now), supported, "in window" ); app.contextual_hints.image_input = false; assert!(!app.clipboard_tip_in_poll_window(now), "tip off"); app.contextual_hints.image_input = true; app.notification_service.focus_tracker.on_focus_lost(); assert!(!app.clipboard_tip_in_poll_window(now), "unfocused"); app.notification_service.focus_tracker.on_focus_gained(); let img = crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData { data: vec![1, 2, 3], mime_type: "image/png".into(), }); app.agents.get_mut(&id).unwrap().prompt.images.push(img); assert!(!app.clipboard_tip_in_poll_window(now), "image attached"); app.agents.get_mut(&id).unwrap().prompt.images.clear(); let fired = crate::tips::clipboard_focus::CheckOutcome { change_count: Some(1), has_image: true, }; app.clipboard_focus_tip.note_fired(&fired, now); assert!(!app.clipboard_tip_in_poll_window(now), "in cooldown"); } /// A positive, deduped, un-cooled-down outcome on a drawable agent shows the /// tip and commits the cooldown + changeCount dedup (same content won't /// re-fire). Drives `apply_clipboard_probe` with a synthetic outcome so it /// is independent of the real pasteboard. #[test] fn clipboard_probe_shows_and_commits_on_positive_outcome() { use crate::tips::clipboard_focus::CheckOutcome; let mut app = test_app_with_agent(); app.contextual_hints.image_input = true; let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().last_terminal_size = (80, 30); let now = std::time::Instant::now(); let outcome = CheckOutcome { change_count: Some(7), has_image: true, }; assert!(app.apply_clipboard_probe(outcome, now)); assert!(app.agents[&id].ephemeral_tip.is_active()); assert!( !app.clipboard_focus_tip.should_fire(&outcome, now), "fired content must commit the changeCount dedup" ); } /// A refused show (here: the renderability gate on a short terminal) must /// burn nothing — the same outcome stays fireable. #[test] fn clipboard_probe_refused_show_burns_nothing() { use crate::tips::clipboard_focus::CheckOutcome; let mut app = test_app_with_agent(); app.contextual_hints.image_input = true; let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().last_terminal_size = (80, 10); let now = std::time::Instant::now(); let outcome = CheckOutcome { change_count: Some(7), has_image: true, }; assert!(!app.apply_clipboard_probe(outcome, now)); assert!(!app.agents[&id].ephemeral_tip.is_active()); assert!( app.clipboard_focus_tip.should_fire(&outcome, now), "refused show must leave cooldown and dedup uncommitted" ); } /// Build an idle subagent child `AgentView` for child gate↔tick symmetry tests. fn idle_child_view(app: &AppView, id_n: usize, sid: &str) -> Box { let session = AgentSession { id: super::super::agent::AgentId(id_n), acp_tx: app.acp_tx.clone(), session_id: Some(sid.to_string().into()), models: ModelState::default(), state: AgentState::Idle, tracker: AcpUpdateTracker::new(), cwd: std::path::PathBuf::from("/tmp"), is_worktree: false, forked_from: None, pending_prompts: std::collections::VecDeque::new(), next_queue_id: 0, yolo_mode: false, auto_mode: false, prompt_history: Vec::new(), prompt_history_loading: false, loading_replay: false, restore_degree: None, rate_limited: false, model_incompatible: false, credit_limit_blocked: false, free_usage_blocked: false, available_commands: Vec::new(), available_commands_generation: 0, available_tools: None, model_switch_pending: false, user_model_preference: None, deferred_model_switch: None, bg_tasks: std::collections::BTreeMap::new(), bg_tool_call_to_task: std::collections::HashMap::new(), scheduled_tasks: std::collections::HashMap::new(), in_flight_prompt: None, compact_held_prompt: None, current_prompt_id: None, created_via_new: false, }; Box::new(AgentView::new(session, ScrollbackState::new())) } fn key_event(code: KeyCode, mods: KeyModifiers) -> Event { Event::Key(KeyEvent::new(code, mods)) } /// Build a registry pinned to the non-VSCode bindings so tests are /// deterministic regardless of the host terminal. fn pin_non_vscode_registry(app: &mut AppView) { let mut actions = crate::actions::default_actions(ScreenMode::Fullscreen, false); for def in actions.iter_mut() { if def.id == ActionId::Quit { def.default_key = key!('q', CONTROL); def.alt_keys = vec![key!('d', CONTROL)]; } if def.id == ActionId::HalfPageDown { def.default_key = key!('d', CONTROL); } } app.registry = ActionRegistry::new(actions); } fn ctrl_d() -> Event { key_event(KeyCode::Char('d'), KeyModifiers::CONTROL) } fn ctrl_q() -> Event { key_event(KeyCode::Char('q'), KeyModifiers::CONTROL) } fn ctrl_c() -> Event { key_event(KeyCode::Char('c'), KeyModifiers::CONTROL) } fn left_mouse(kind: MouseEventKind, column: u16, row: u16) -> Event { Event::Mouse(MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE, }) } #[test] fn needs_animation_ignores_tracing_rx_outside_dev_builds() { let mut app = test_app_with_agent(); let (_tx, rx) = tokio::sync::mpsc::channel::(4); app.tracing_rx = Some(rx); assert!( !app.needs_animation(), "release builds must not request animation ticks just because \ tracing_rx exists (always true after startup)" ); } #[test] fn needs_animation_gates_prompt_history_tick_delivery() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert!( !app.needs_animation(), "an idle agent with no history overlay must not request animation ticks" ); { let agent = app.agents.get_mut(&id).unwrap(); agent.session.prompt_history = vec!["first prompt".into(), "second prompt".into()]; let history = agent.combined_prompt_history(); agent.prompt.history_search.activate(&history, ""); } assert!( app.needs_animation(), "an open prompt history overlay must request animation ticks" ); let mut delivered = false; for _ in 0..1000 { if app.tick() && app.agents[&id].prompt.history_search.result_count() == 2 { delivered = true; break; } std::thread::sleep(std::time::Duration::from_millis(1)); } assert!( delivered, "tick() must poll the history daemon and deliver results" ); app.agents .get_mut(&id) .unwrap() .prompt .history_search .deactivate(); assert!( !app.needs_animation(), "closing the history overlay stops the animation ticks" ); } #[test] fn needs_animation_gates_scrollback_search_tick_delivery() { use crate::scrollback::ScrollbackSearchState; use crate::scrollback::block::RenderBlock; let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent .scrollback .push_block(RenderBlock::user_prompt("foo bar")); agent .scrollback .push_block(RenderBlock::user_prompt("baz foo")); agent.scrollback.prepare_layout(80, 24); } assert!( !app.needs_animation(), "an idle agent with no search open must not request animation ticks" ); { let agent = app.agents.get_mut(&id).unwrap(); agent.scrollback_search = Some(ScrollbackSearchState::open()); let search = agent.scrollback_search.as_mut().unwrap(); search.update_query("foo", &agent.scrollback); assert_eq!( search.current_index(), None, "matches are not computed synchronously on the input thread" ); } assert!( app.needs_animation(), "an open scrollback search must request animation ticks" ); let mut delivered = false; for _ in 0..1000 { app.tick(); if app.agents[&id] .scrollback_search .as_ref() .unwrap() .current_index() == Some(0) { delivered = true; break; } std::thread::sleep(std::time::Duration::from_millis(1)); } assert!(delivered, "tick() must poll the daemon and deliver results"); assert_eq!( app.agents[&id] .scrollback_search .as_ref() .unwrap() .match_count(), 2 ); app.agents.get_mut(&id).unwrap().scrollback_search = None; assert!( !app.needs_animation(), "closing the search stops the animation ticks" ); } /// The welcome screen shimmer only advances ~12fps, so a resting welcome /// screen must demand Slow ticks — not a 30fps loop; the deep-search /// spinner upgrades it to Fast while loading. #[test] fn tick_demand_welcome_is_slow_unless_loading() { let mut app = test_app(); assert_eq!(app.active_view, ActiveView::Welcome); assert_eq!(app.tick_demand(), TickDemand::Slow); assert!(app.needs_animation(), "slow still counts as animating"); app.session_picker_content_loading = true; assert_eq!(app.tick_demand(), TickDemand::Fast); } /// An open modal session picker that is still fetching keeps fast ticks /// alive on an otherwise-idle agent (its loading spinner must animate) — /// including after the fast foreign scan lands rows the default Grok /// filter hides; once the native list settles the demand parks again. #[test] fn tick_demand_fast_while_modal_session_picker_loads() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert_eq!(app.tick_demand(), TickDemand::None, "idle agent parks"); app.agents.get_mut(&id).unwrap().active_modal = Some(crate::views::modal::ActiveModal::SessionPicker { state: crate::views::picker::PickerState::default(), entries: None, loading: true, lanes: Default::default(), previous_palette: None, window: crate::views::modal_window::ModalWindowState::new(), content_results: None, content_loading: false, deep_search_seq: 0, entries_query: None, source_filter: crate::views::session_picker::SourceFilter::default(), pending_delete: None, }); assert_eq!( app.tick_demand(), TickDemand::Fast, "loading modal picker must keep the spinner animating" ); let foreign_entry = SessionPickerEntry { id: "claude-1".into(), summary: "claude".into(), updated_at: chrono::Utc::now(), created_at: chrono::Utc::now(), cwd: String::new(), hostname: None, source: "claude".into(), model_id: None, num_messages: 0, last_active_at: None, branch: None, repo_name: "r".into(), worktree_label: None, card_detail: None, }; if let Some(crate::views::modal::ActiveModal::SessionPicker { entries, .. }) = app.agents.get_mut(&id).unwrap().active_modal.as_mut() { *entries = Some(vec![foreign_entry]); } assert_eq!( app.tick_demand(), TickDemand::Fast, "foreign rows hidden by the Grok filter must not end the loading spinner" ); if let Some(crate::views::modal::ActiveModal::SessionPicker { loading, .. }) = app.agents.get_mut(&id).unwrap().active_modal.as_mut() { *loading = false; } assert_eq!( app.tick_demand(), TickDemand::None, "settled picker must not keep demanding ticks" ); } /// An idle agent view demands no ticks at all; the macOS Cmd link-hover /// poll (when it is the only pending work) demands Slow, never Fast. #[test] #[cfg(target_os = "macos")] fn tick_demand_link_poll_is_slow_only() { use crate::render::osc8::{LinkOverlay, OverlayLink}; use std::sync::Arc; let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert_eq!(app.tick_demand(), TickDemand::None, "idle agent parks"); { let agent = app.agents.get_mut(&id).unwrap(); let mut overlay = LinkOverlay::new(); overlay.push(OverlayLink { screen_row: 2, col_start: 0, col_end: 10, target: crate::render::osc8::LinkTarget::Url(Arc::from("https://example.com")), presentation: crate::render::osc8::LinkPresentation::Opaque, id: Some(1), }); agent.visible_link_map.rebuild(1, &overlay, vec![]); agent.hovered_entry = Some(0); agent.last_mouse_moved_at = Some(std::time::Instant::now()); } if !crate::app::agent_view::has_native_link_hover() { assert_eq!( app.tick_demand(), TickDemand::Slow, "link poll alone must not spin the fast loop" ); } } #[test] fn needs_animation_gates_mode_switch_banner_countdown() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert!(!app.needs_animation(), "idle agent must not request ticks"); app.agents .get_mut(&id) .unwrap() .show_mode_switch_banner("Plan"); assert!( app.needs_animation(), "mode_switch_banner must request ticks (tick_mode_banner countdown)" ); let mut cleared = false; for _ in 0..512 { app.tick(); if app.agents[&id].mode_switch_banner.is_none() { cleared = true; break; } } assert!( cleared, "tick() must decrement mode_switch_banner until it expires" ); assert!( !app.needs_animation(), "expired mode banner must stop requesting ticks" ); } /// Draw-entry resync: an `expires_at` crossing between pushes must close /// the `/announcements` gate on the next frame; a later live list re-opens /// it through the same divergence check. #[test] fn slash_gate_resyncs_when_critical_expires_between_pushes() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.agents .get_mut(&id) .unwrap() .set_has_session_announcements(true); app.active_announcements = vec![xai_grok_announcements::RemoteAnnouncement { id: Some("expired".into()), message: Some("gone".into()), severity: Some("critical".into()), expires_at: Some("2000-01-01T00:00:00Z".into()), ..Default::default() }]; app.resync_announcement_slash_gate_on_divergence(); assert!( !app.agents[&id] .prompt .slash_controller .has_session_announcements(), "expired-only list must close the gate on the next frame" ); app.active_announcements = vec![xai_grok_announcements::RemoteAnnouncement { id: Some("live".into()), message: Some("new outage".into()), severity: Some("critical".into()), ..Default::default() }]; app.resync_announcement_slash_gate_on_divergence(); assert!( app.agents[&id] .prompt .slash_controller .has_session_announcements(), "a live critical must re-open the gate" ); } /// Critical freezes tip TTL and must not arm needs_animation for a tip /// that is not counting down (session-long metronome heat). #[test] fn ephemeral_tip_frozen_under_critical_does_not_request_animation_or_burn_ttl() { use std::collections::HashMap; let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); let _ = agent.ephemeral_tip.show( crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("TIP")), &mut HashMap::new(), ); agent.session_banner_active = true; } let before = app.agents[&id] .ephemeral_tip .ticks_remaining() .expect("tip active"); assert!( !app.agents[&id].ephemeral_tip_needs_tick(), "critical must freeze tip tick policy" ); assert!( !app.needs_animation(), "frozen tip under critical must not arm the metronome on an idle agent" ); for _ in 0..10 { app.tick(); } assert_eq!( app.agents[&id].ephemeral_tip.ticks_remaining(), Some(before), "TTL must not burn while critical occludes" ); app.agents.get_mut(&id).unwrap().session_banner_active = false; assert!( app.needs_animation(), "unfreezing must re-arm tip countdown ticks" ); app.tick(); let after = app.agents[&id] .ephemeral_tip .ticks_remaining() .expect("tip still active"); assert!(after < before, "TTL must resume when critical clears"); } /// The word-select tip's long TTL is bounded by prompt divergence: ANY /// prompt change since the tip was shown (typed here; the snapshot guard /// covers paste/drop identically) refuses the chord immediately and /// retires the tip on the next tick, so Ctrl+Y goes back to yank. #[test] fn word_select_tip_retires_on_prompt_divergence_and_accepts_before() { use std::collections::HashMap; let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.last_terminal_size = (80, 30); agent.active_pane = crate::views::agent::ActivePane::Prompt; let _ = agent.ephemeral_tip.show( crate::tips::word_select::word_select_tip(), &mut HashMap::new(), ); agent.word_select_tip_prompt_snapshot = Some(agent.prompt.text().to_string()); } let out = app.handle_input(&key_event(KeyCode::Char('y'), KeyModifiers::CONTROL)); assert!( matches!(out, InputOutcome::Action(Action::AcceptWordSelectTip)), "Ctrl+Y with the tip up must route to accept, got {out:?}" ); let _ = app.handle_input(&key_event(KeyCode::Char('a'), KeyModifiers::NONE)); let out = app.handle_input(&key_event(KeyCode::Char('y'), KeyModifiers::CONTROL)); assert!( !matches!(out, InputOutcome::Action(Action::AcceptWordSelectTip)), "Ctrl+Y after a prompt edit must not accept, got {out:?}" ); app.tick(); assert!( !app.agents[&id].ephemeral_tip.is_active(), "prompt divergence must retire the word-select tip on tick" ); assert!( app.agents[&id].word_select_tip_prompt_snapshot.is_none(), "snapshot must drop with the tip" ); } #[test] fn needs_animation_gates_image_viewer_loading() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert!(!app.needs_animation()); let viewer = crate::prompt_images::ImageViewerState::open_from_path_deferred( std::path::Path::new("/nonexistent/image_gate_test.png"), ); assert!(viewer.loading, "deferred open must be in loading state"); app.agents.get_mut(&id).unwrap().image_viewer = Some(viewer); assert!( app.needs_animation(), "image_viewer.loading must request ticks (poll/spawn load path)" ); let mut terminal = false; for _ in 0..200 { app.tick(); let agent = &app.agents[&id]; if agent.image_viewer.is_none() || agent.toast.is_some() || agent.image_load_rx.is_some() || agent.image_viewer.as_ref().is_some_and(|v| !v.loading) { terminal = true; break; } std::thread::sleep(std::time::Duration::from_millis(2)); } assert!( terminal, "tick() must progress image load (spawn rx, fail toast, or clear loading)" ); app.agents.get_mut(&id).unwrap().image_viewer = None; app.agents.get_mut(&id).unwrap().image_load_rx = None; app.agents.get_mut(&id).unwrap().toast = None; assert!(!app.needs_animation()); } #[test] fn needs_animation_gates_loading_replay() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert!(!app.needs_animation()); app.agents.get_mut(&id).unwrap().session.loading_replay = true; assert!( app.needs_animation(), "loading_replay (attach/resume) must keep ticks alive" ); let _ = app.tick(); app.agents.get_mut(&id).unwrap().session.loading_replay = false; assert!(!app.needs_animation()); } #[test] fn active_scroll_stream_arms_scroll_clock_not_animation_ticks() { use crate::input::mouse::{ScrollConfig, ScrollDirection}; let mut app = test_app_with_agent(); assert!(!app.needs_animation()); let _ = app .scroll_state .on_scroll_event(ScrollDirection::Up, ScrollConfig::default()); assert!( app.scroll_state.has_active_stream(), "fixture: scroll event must arm an active stream" ); assert!( !app.needs_animation(), "scroll streams must not demand animation ticks (scroll clock owns pacing)" ); assert!( app.scroll_state .scroll_clock_deadline(std::time::Instant::now()) .is_some(), "active stream must expose a scroll-clock deadline to the event loop" ); let mut finalized = false; for _ in 0..200 { let _ = app.tick_scroll(); if !app.scroll_state.has_active_stream() { finalized = true; break; } std::thread::sleep(std::time::Duration::from_millis(5)); } assert!( finalized, "tick_scroll() must finalize the scroll stream without a metronome" ); assert!( app.scroll_state .scroll_clock_deadline(std::time::Instant::now()) .is_none(), "finalized stream must disarm the scroll clock (no idle wakeups)" ); assert!(!app.needs_animation()); } #[test] fn handle_input_scroll_suppressed_events_do_not_report_changed() { let mut app = test_app_with_agent(); let wheel = Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollUp, column: 5, row: 5, modifiers: KeyModifiers::NONE, }); std::thread::sleep(std::time::Duration::from_millis(20)); const EVENTS: u32 = 30; let start = std::time::Instant::now(); let mut changed = 0u32; for _ in 0..EVENTS { if matches!(app.handle_input(&wheel), InputOutcome::Changed) { changed += 1; } assert!( app.scroll_state.has_active_stream(), "wheel burst must keep the stream active" ); } let elapsed_ms = start.elapsed().as_millis() as u32; assert!( changed >= 1, "a flushing wheel event must still report Changed" ); let max_changed = elapsed_ms / 16 + 2; assert!( changed <= max_changed, "cadence-suppressed wheel events must not report Changed: got \ {changed} Changed outcomes from {EVENTS} events in {elapsed_ms}ms \ (bound {max_changed})" ); assert!( app.scroll_state .scroll_clock_deadline(std::time::Instant::now()) .is_some(), "armed stream must schedule a scroll-clock deadline" ); } #[test] fn needs_animation_gates_dashboard_file_search() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert!(app.agents[&id].session.state.is_idle()); app.active_view = ActiveView::AgentDashboard; app.dashboard = Some(crate::views::dashboard::DashboardState::new()); assert!( !app.needs_animation(), "idle AgentDashboard with no agents alive and no @-search must not request ticks" ); app.dashboard .as_mut() .unwrap() .dispatch .file_search .update_context("@a", 2); assert!( app.dashboard .as_ref() .unwrap() .dispatch .file_search .context() .is_some(), "fixture: dispatch @-context must be armed" ); assert!( app.needs_animation(), "dispatch file_search.context() on AgentDashboard must request ticks" ); let _ = app.tick(); assert!( app.dashboard .as_ref() .unwrap() .dispatch .file_search .context() .is_some(), "tick() must not clear dispatch @-context" ); assert!(app.needs_animation()); app.dashboard .as_mut() .unwrap() .dispatch .file_search .update_context("", 0); assert!( !app.needs_animation(), "clearing dispatch @-context stops ticks when agents stay idle" ); app.dashboard .as_mut() .unwrap() .peek_reply .file_search .update_context("@b", 2); assert!( app.needs_animation(), "peek_reply file_search.context() on AgentDashboard must request ticks" ); let _ = app.tick(); app.dashboard .as_mut() .unwrap() .peek_reply .file_search .update_context("", 0); assert!(!app.needs_animation()); } #[test] fn tick_drains_tracing_rx_and_does_not_metronome_on_channel() { let mut app = test_app_with_agent(); let (tx, rx) = tokio::sync::mpsc::channel::(8); for i in 0..5 { tx.try_send(format!("trace line {i}")) .expect("queue tracer line"); } app.tracing_rx = Some(rx); assert!( !app.needs_animation(), "non-dev: queued tracer lines must not request animation ticks" ); let _ = app.tick(); assert!( matches!( app.tracing_rx.as_mut().unwrap().try_recv(), Err(tokio::sync::mpsc::error::TryRecvError::Empty) ), "tick() must drain the tracer channel (bounded; cannot grow unbounded)" ); assert!( !app.needs_animation(), "non-dev: a present-but-drained tracer channel must not request ticks" ); drop(tx); } #[test] fn needs_animation_gates_btw_loading_spinner() { use crate::views::btw_overlay::BtwOverlayState; use crate::views::turn_status::SPINNER_DIVISOR; let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert!(!app.needs_animation()); app.agents.get_mut(&id).unwrap().btw_state = Some(BtwOverlayState::Loading { question: "what is X?".into(), }); assert!(app.needs_animation()); let saw_redraw = (0..SPINNER_DIVISOR).any(|_| app.tick()); assert!( saw_redraw, "Loading must redraw at spinner cadence while idle" ); app.agents.get_mut(&id).unwrap().btw_state = Some(BtwOverlayState::done("what is X?".into(), "X is …".into())); assert!(!app.needs_animation()); app.agents.get_mut(&id).unwrap().btw_state = Some(BtwOverlayState::Error { question: "what is X?".into(), error: "boom".into(), }); assert!(!app.needs_animation()); } #[test] fn needs_animation_gates_todo_badge_flash() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert!(!app.needs_animation(), "idle agent must not request ticks"); app.agents .get_mut(&id) .unwrap() .todo .update_todos(vec![xai_grok_shell::tools::TodoItem { content: "do the thing".into(), priority: Default::default(), status: xai_grok_shell::tools::TodoStatus::InProgress, meta: None, }]); assert!( app.agents[&id].todo.badge_needs_tick(), "fixture: a counts change must arm the badge flash" ); assert!( app.needs_animation(), "an active todo badge flash must request animation ticks" ); app.agents .get_mut(&id) .unwrap() .todo .expire_badge_flash_for_test(); let _ = app.tick(); assert!( !app.agents[&id].todo.badge_needs_tick(), "tick() must clear the expired badge flash (badge_tick)" ); assert!( !app.needs_animation(), "a cleared badge flash must stop requesting ticks" ); } #[test] fn needs_animation_gates_pending_acp_command_sync() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); assert!( !app.needs_animation(), "an idle, fully-synced agent must not request ticks" ); app.agents .get_mut(&id) .unwrap() .session .available_commands_generation += 1; assert!( app.agents[&id].acp_synced_generation != app.agents[&id].session.available_commands_generation, "fixture: a commands update must leave the catalog sync pending" ); assert!( app.needs_animation(), "a pending ACP command-catalog sync must request animation ticks" ); let _ = app.tick(); assert_eq!( app.agents[&id].acp_synced_generation, app.agents[&id].session.available_commands_generation, "tick() must reconcile the slash-command catalog generation" ); assert!( !app.needs_animation(), "a reconciled command catalog must stop requesting ticks" ); } #[test] fn needs_animation_gates_pending_turn_end_reconcile() { use super::super::dispatch::{TURN_END_RECONCILE_GRACE, reconcile_overdue_turn_ends}; let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::AgentDashboard; assert!(app.agents[&id].session.state.is_idle()); assert!( !app.needs_animation(), "idle background agent on the dashboard must not request ticks" ); app.agents.get_mut(&id).unwrap().pending_turn_end_reconcile = Some(super::super::agent_view::PendingTurnEnd { prompt_id: "pid-stuck".into(), stop_reason: Some("end_turn".into()), agent_result: None, cancel_trigger: None, received_at: std::time::Instant::now() - (TURN_END_RECONCILE_GRACE + std::time::Duration::from_secs(1)), }); assert!( app.needs_animation(), "an armed turn-end reconcile must request ticks even for a background agent" ); let _ = reconcile_overdue_turn_ends(&mut app); assert!( app.agents[&id].pending_turn_end_reconcile.is_none(), "reconcile must clear the overdue marker" ); assert!( !app.needs_animation(), "a cleared reconcile marker must stop requesting ticks" ); } #[test] fn needs_animation_gates_subagent_image_viewer_loading() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let child_sid = "child-img-gate"; let child = idle_child_view(&app, 1, child_sid); app.agents .get_mut(&id) .unwrap() .subagent_views .insert(child_sid.to_string(), child); assert!( !app.needs_animation(), "an idle agent with an idle subagent child must not request ticks" ); let viewer = crate::prompt_images::ImageViewerState::open_from_path_deferred( std::path::Path::new("/nonexistent/child_img_gate.png"), ); assert!(viewer.loading, "deferred open must be in loading state"); app.agents .get_mut(&id) .unwrap() .subagent_views .get_mut(child_sid) .unwrap() .image_viewer = Some(viewer); assert!( app.needs_animation(), "a loading image viewer on a subagent CHILD must request ticks (child arm)" ); let mut terminal = false; for _ in 0..200 { app.tick(); let child = &app.agents[&id].subagent_views[child_sid]; if child.image_viewer.is_none() || child.toast.is_some() || child.image_load_rx.is_some() || child.image_viewer.as_ref().is_some_and(|v| !v.loading) { terminal = true; break; } std::thread::sleep(std::time::Duration::from_millis(2)); } assert!( terminal, "tick() must progress the CHILD image load (shared tick_agent_image_load)" ); { let child = app .agents .get_mut(&id) .unwrap() .subagent_views .get_mut(child_sid) .unwrap(); child.image_viewer = None; child.image_load_rx = None; child.toast = None; } assert!( !app.needs_animation(), "a cleared child image viewer must stop requesting ticks" ); } #[test] fn gboom_backgrounded_game_drops_held_movement() { use crate::gboom::GboomState; let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let mut game = GboomState::new(); game.handle_key(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); game.handle_key(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); assert!( game.any_movement_held(), "press should latch a movement hold" ); app.agents.get_mut(&id).unwrap().gboom = Some(game); app.active_view = ActiveView::Agent(id); app.gboom_release_backgrounded_games(); assert!( app.agents[&id].gboom.as_ref().unwrap().any_movement_held(), "the active game must keep its holds" ); app.active_view = ActiveView::Welcome; app.gboom_release_backgrounded_games(); assert!( !app.agents[&id].gboom.as_ref().unwrap().any_movement_held(), "a backgrounded game must drop its holds" ); } /// `Event::Resize` must close the tip show gate of every agent view — /// parent AND fullscreen-capable subagent children — until the next draw /// re-measures: a trigger firing between the event and the (debounced) /// resize draw would otherwise act on the pre-resize measurement and burn /// a seen count on a tip the new layout can never paint. The event must /// NOT write the full terminal size into `last_terminal_size` — views can /// paint into chrome-shrunk rects, so the event height proves nothing /// about the banner row. #[test] fn resize_event_closes_tip_show_gate_until_redraw() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let child_sid = "child-session"; { let mut child = idle_child_view(&app, 1, child_sid); child.note_terminal_size((80, 28)); let agent = app.agents.get_mut(&id).unwrap(); agent.note_terminal_size((80, 30)); agent.subagent_views.insert(child_sid.to_string(), child); } let _ = app.handle_input(&Event::Resize(120, 50)); let agent = app.agents.get_mut(&id).unwrap(); assert_eq!( agent.last_terminal_size, (80, 30), "event must not overwrite the draw-measured rect size" ); let mut counts = std::collections::HashMap::new(); let tip = || { crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint")) .with_session_seen_cap("t_seen", 2) }; assert!(!agent.show_ephemeral_tip(tip(), &mut counts)); assert!(counts.is_empty(), "stale-size show must not burn a count"); let child = agent.subagent_views.get_mut(child_sid).unwrap(); assert!(!child.show_ephemeral_tip(tip(), &mut counts)); assert!(counts.is_empty(), "child stale-size show must not burn"); child.note_terminal_size((118, 46)); assert!(child.show_ephemeral_tip(tip(), &mut counts)); let agent = app.agents.get_mut(&id).unwrap(); agent.note_terminal_size((120, 50)); assert!(agent.show_ephemeral_tip(tip(), &mut counts)); assert_eq!(counts.get("t_seen"), Some(&2)); } #[test] fn apply_auth_meta_disables_billing_surface_for_team_users() { let mut app = test_app(); assert!(app.usage_visible); let meta = xai_grok_shell::auth::AuthMeta { team_id: Some("team-uuid".into()), team_name: Some("Acme Corp".into()), ..Default::default() }; app.apply_auth_meta(&meta); assert!(!app.usage_visible); assert_eq!(app.team_id.as_deref(), Some("team-uuid")); assert!( !app.welcome_prompt .slash_controller .billing_surface_visible() ); } #[test] fn apply_auth_meta_enables_billing_surface_for_personal_users() { let mut app = test_app(); app.usage_visible = false; let meta = xai_grok_shell::auth::AuthMeta::default(); app.apply_auth_meta(&meta); assert!(app.usage_visible); } #[test] fn apply_auth_meta_clears_api_key_flag_and_restores_billing_on_personal_login() { let mut app = test_app(); app.is_api_key_auth = true; app.usage_visible = false; app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta::default()); assert!(!app.is_api_key_auth); assert!(app.usage_visible); } #[test] fn apply_auth_meta_api_key_enables_voice_and_skips_tier_gate() { let mut app = test_app(); advertise_media_tools(&mut app); assert!(!app.voice_mode_enabled); app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta { auth_mode: Some("ApiKey".into()), subscription_tier: Some("API Key".into()), ..Default::default() }); assert!(app.is_api_key_auth); assert!(!app.usage_visible); assert!(app.tier_restricted_commands.is_empty()); assert_tier_restricted_commands_present(&app); assert!(!app.is_voice_tier_restricted()); assert!(app.voice_mode_enabled); let mut app = test_app(); app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta { subscription_tier: Some("api_key".into()), ..Default::default() }); assert!(app.is_api_key_auth); assert!(app.voice_mode_enabled); assert!(app.tier_restricted_commands.is_empty()); app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta { auth_mode: Some("Oidc".into()), subscription_tier: Some("Free".into()), ..Default::default() }); assert!(!app.is_api_key_auth); assert!(!app.voice_mode_enabled); assert!(app.usage_visible); assert!(!app.tier_restricted_commands.is_empty()); } fn expected_tier_restricted_commands() -> Vec { TIER_RESTRICTED_COMMANDS .iter() .map(|n| (*n).to_string()) .collect() } /// Make every tier-restricted command visible on the welcome prompt so the /// present/absent assertions exercise the deny list, not incidental /// fail-closed hiding: /// - `/imagine`, `/imagine-video` are `required_tools()`-gated, so advertise /// their tools (otherwise the registry fail-closes them). /// - `/voice` is fail-closed hidden until the remote flag turns it on, so /// reveal it via the registry directly. (We drive the prompt's registry /// rather than `apply_voice_mode_enabled`, which also flips a process-global /// atomic and would leak across parallel tests.) fn advertise_media_tools(app: &mut AppView) { app.welcome_prompt .slash_controller .registry_mut() .set_available_tools( ["image_gen", "image_to_video"] .into_iter() .map(str::to_string) .collect(), ); app.welcome_prompt.set_voice_visible(true); } fn assert_tier_restricted_commands_absent(app: &AppView) { let reg = app.welcome_prompt.slash_controller.registry(); for name in TIER_RESTRICTED_COMMANDS { assert!( reg.get(name).is_none(), "/{name} must be denied on a restricted tier" ); } assert!(reg.get("cost").is_none(), "/cost alias must be denied"); } fn assert_tier_restricted_commands_present(app: &AppView) { let reg = app.welcome_prompt.slash_controller.registry(); for name in TIER_RESTRICTED_COMMANDS { assert!( reg.get(name).is_some(), "/{name} must be available when not tier-restricted (tools advertised)" ); } } #[test] fn apply_auth_meta_restricts_usage_for_free_tier() { let mut app = test_app(); advertise_media_tools(&mut app); app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta::default()); assert_eq!( app.tier_restricted_commands, expected_tier_restricted_commands() ); assert_tier_restricted_commands_absent(&app); assert!(app.usage_visible); } #[test] fn apply_auth_meta_restricts_usage_for_x_basic_tier() { let mut app = test_app(); advertise_media_tools(&mut app); let meta = xai_grok_shell::auth::AuthMeta { subscription_tier: Some("X Basic".into()), ..Default::default() }; app.apply_auth_meta(&meta); assert_eq!( app.tier_restricted_commands, expected_tier_restricted_commands() ); assert_tier_restricted_commands_absent(&app); } #[test] fn apply_auth_meta_lifts_restrictions_for_paid_tiers_and_teams() { let mut app = test_app(); advertise_media_tools(&mut app); let meta = xai_grok_shell::auth::AuthMeta { subscription_tier: Some("SuperGrok".into()), ..Default::default() }; app.apply_auth_meta(&meta); assert!(app.tier_restricted_commands.is_empty()); assert_tier_restricted_commands_present(&app); let mut app = test_app(); advertise_media_tools(&mut app); app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta::default()); assert!(!app.tier_restricted_commands.is_empty()); app.subscription_tier = Some("SuperGrok".into()); app.apply_tier_restrictions(); assert!(app.tier_restricted_commands.is_empty()); assert_tier_restricted_commands_present(&app); let mut app = test_app(); let meta = xai_grok_shell::auth::AuthMeta { team_id: Some("team-uuid".into()), team_name: Some("Acme Corp".into()), ..Default::default() }; app.apply_auth_meta(&meta); assert!(app.tier_restricted_commands.is_empty()); } #[test] fn is_restricted_tier_classification() { assert!(is_restricted_tier(None)); assert!(is_restricted_tier(Some(""))); assert!(is_restricted_tier(Some("Free"))); assert!(is_restricted_tier(Some("X Basic"))); assert!(is_restricted_tier(Some("x_basic"))); assert!(!is_restricted_tier(Some("SuperGrok"))); assert!(!is_restricted_tier(Some("SuperGrok Heavy"))); assert!(!is_restricted_tier(Some("X Premium"))); assert!(!is_restricted_tier(Some("X Premium+"))); assert!(!is_restricted_tier(Some("SomeFutureTier"))); } #[test] fn voice_included_in_tier_restricted_commands() { assert!(TIER_RESTRICTED_COMMANDS.contains(&"voice")); } #[test] fn is_voice_tier_restricted_tracks_tier() { let mut app = test_app(); app.apply_auth_meta(&xai_grok_shell::auth::AuthMeta::default()); assert!(app.is_voice_tier_restricted()); let mut app = test_app(); let meta = xai_grok_shell::auth::AuthMeta { subscription_tier: Some("SuperGrok".into()), ..Default::default() }; app.apply_auth_meta(&meta); assert!(!app.is_voice_tier_restricted()); } #[test] fn apply_auth_meta_clears_gate_on_subscription() { let mut app = test_app(); app.gate = Some(xai_grok_shell::auth::GateInfo { message: "Subscribe to use Grok Build".into(), url: Some("https://grok.com/supergrok?referrer=grok-build".into()), label: None, }); assert!(app.is_access_blocked()); let meta = xai_grok_shell::auth::AuthMeta::default(); app.apply_auth_meta(&meta); assert!(app.gate.is_none()); assert!(app.has_access()); } #[test] fn apply_auth_meta_gate_unchanged_when_still_gated() { let mut app = test_app(); let gate = xai_grok_shell::auth::GateInfo { message: "Subscribe".into(), url: None, label: None, }; app.gate = Some(gate.clone()); let meta = xai_grok_shell::auth::AuthMeta { gate: Some(gate), ..Default::default() }; app.apply_auth_meta(&meta); assert!(app.gate.is_some()); assert!(app.is_access_blocked()); } #[test] fn welcome_ctrl_q_requires_confirmation() { let mut app = test_app(); let outcome = app.handle_input(&key_event(KeyCode::Char('q'), KeyModifiers::CONTROL)); assert!(matches!(outcome, InputOutcome::Changed)); let pending = app .pending_action .as_ref() .expect("expected pending action"); assert!(matches!(pending.action, Action::Quit)); assert_eq!( pending.shortcut, KeyShortcut::from(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)) ); } #[test] fn welcome_ctrl_u_update_keeps_priority_over_foreign_resume() { let mut app = test_app(); app.foreign_session_compat = xai_grok_workspace::foreign_sessions::EnabledForeignSessionSources { cursor: true, ..Default::default() }; let crate::app::actions::Effect::CanonicalizeForeignResumeCwd { requested_cwd, launch_token, } = app.begin_foreign_resume_detection().unwrap() else { panic!("expected canonicalization effect"); }; let canonical_cwd = dunce::canonicalize(&requested_cwd).unwrap(); assert!(app.accept_foreign_resume_canonical_cwd( launch_token, &requested_cwd, Some(canonical_cwd.clone()), )); app.apply_foreign_resume_detection( launch_token, &canonical_cwd, Some(xai_grok_workspace::foreign_sessions::RecentForeignSession { tool: xai_grok_workspace::foreign_sessions::ForeignSessionTool::Cursor, native_id: "cursor-session".into(), age: std::time::Duration::from_secs(30), }), ); let key = key_event(KeyCode::Char('u'), KeyModifiers::CONTROL); assert!(matches!( app.handle_input(&key), InputOutcome::Action(Action::ResumeForeignSession) )); app.pending_update_version = Some("9.9.9".into()); assert!(matches!( app.handle_input(&key), InputOutcome::Action(Action::QuitForUpdate) )); } #[test] fn minimal_ctrl_g_edits_prompt_while_full_tui_keeps_tasks() { let event = key_event(KeyCode::Char('g'), KeyModifiers::CONTROL); let mut minimal = test_app_with_agent(); minimal.screen_mode = ScreenMode::Minimal; minimal.registry = ActionRegistry::defaults_for(ScreenMode::Minimal); let id = super::super::agent::AgentId(0); minimal .agents .get_mut(&id) .unwrap() .prompt .set_screen_mode(ScreenMode::Minimal); minimal .agents .get_mut(&id) .unwrap() .set_input_mode(crate::views::agent::InputMode::Vim); assert_eq!( minimal.agents[&id].active_pane, crate::views::agent::ActivePane::Scrollback, "Vim startup leaves the legacy pane field on Scrollback" ); let out = minimal.handle_input(&event); assert!(matches!( out, InputOutcome::Action(Action::EditPromptExternal) )); assert!(!minimal.agents[&id].tasks.overlay.visible); assert!(!minimal.agents[&id].tasks.overlay.focused); minimal.pending_editor = Some( crate::app::external_editor::PendingEditorRequest::PromptDraft { agent_id: id, original_text: "already pending".to_owned(), }, ); assert!(matches!( minimal.handle_input(&event), InputOutcome::Unchanged )); let mut owned = test_app_with_agent(); owned.screen_mode = ScreenMode::Minimal; owned.registry = ActionRegistry::defaults_for(ScreenMode::Minimal); owned .agents .get_mut(&id) .unwrap() .prompt .suggestions .dropdown .open = true; assert!(matches!(owned.handle_input(&event), InputOutcome::Changed)); assert!(owned.pending_editor.is_none()); assert!(!owned.agents[&id].tasks.overlay.visible); assert!(!owned.agents[&id].tasks.overlay.focused); let mut full = test_app_with_agent(); full.screen_mode = ScreenMode::Fullscreen; let out = full.handle_input(&event); assert!(matches!(out, InputOutcome::Changed)); assert!(full.agents[&id].tasks.overlay.visible); assert!(full.agents[&id].tasks.overlay.focused); assert!(full.pending_editor.is_none()); } #[test] fn minimal_ctrl_backslash_is_inert_while_full_modes_open_dashboard() { let event = key_event(KeyCode::Char('\\'), KeyModifiers::CONTROL); let mut minimal = test_app_with_agent(); minimal.screen_mode = ScreenMode::Minimal; minimal.registry = ActionRegistry::defaults_for(ScreenMode::Minimal); assert!(matches!( minimal.handle_input(&event), InputOutcome::Unchanged )); assert!(minimal.dashboard.is_none()); for mode in [ScreenMode::Fullscreen, ScreenMode::Inline] { let mut app = test_app_with_agent(); app.screen_mode = mode; app.registry = ActionRegistry::defaults_for(mode); assert!(matches!( app.handle_input(&event), InputOutcome::Action(Action::OpenDashboard) )); } } #[test] fn minimal_ctrl_t_toggles_todo_panel() { let mut app = test_app_with_agent(); app.screen_mode = ScreenMode::Minimal; assert!(!app.minimal_state.show_todos); let out = app.handle_input(&key_event(KeyCode::Char('t'), KeyModifiers::CONTROL)); assert!(matches!(out, InputOutcome::Changed)); assert!( app.minimal_state.show_todos, "Ctrl+T pins the panel visible" ); let _ = app.handle_input(&key_event(KeyCode::Char('t'), KeyModifiers::CONTROL)); assert!( !app.minimal_state.show_todos, "Ctrl+T again unpins the panel" ); } #[test] fn non_minimal_ctrl_t_leaves_todo_panel_flag_untouched() { let mut app = test_app_with_agent(); app.screen_mode = ScreenMode::Inline; assert!(!app.minimal_state.show_todos); let _ = app.handle_input(&key_event(KeyCode::Char('t'), KeyModifiers::CONTROL)); assert!( !app.minimal_state.show_todos, "the minimal todo-panel flag must never flip outside minimal mode" ); } /// The minimal info-row transcript hint and the Ctrl+O key remap are gated /// on the same predicate. Ctrl+O opens the transcript pager unless it is /// the interject chord (Apple Terminal) AND an interject would actually /// consume the press (turn running + non-empty composer, turn running + /// queued follow-up with empty composer, or editing a queued row) — at /// idle / empty composer with no queue the interject path is a silent /// no-op, so the remap keeps the key (it looked simply dead before). #[test] fn minimal_ctrl_o_transcript_predicate_tracks_interject_binding() { let mut app = test_app_with_agent(); app.registry = ActionRegistry::non_vscode_for_mode_for_test(ScreenMode::Minimal); assert!( crate::minimal_api::minimal_ctrl_o_opens_transcript(&app), "Ctrl+O opens the transcript when interject doesn't own the chord" ); app.registry = ActionRegistry::apple_terminal_for_mode_for_test(ScreenMode::Minimal); assert!( crate::minimal_api::minimal_ctrl_o_opens_transcript(&app), "idle + empty composer: Ctrl+O must open the transcript, not no-op" ); let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; assert!( crate::minimal_api::minimal_ctrl_o_opens_transcript(&app), "running turn + empty composer + empty queue: still no interjection" ); { let agent = app.agents.get_mut(&id).unwrap(); agent.prompt.set_text(""); agent.session.enqueue_prompt("queued follow-up".into()); } assert!( !crate::minimal_api::minimal_ctrl_o_opens_transcript(&app), "running + empty composer + queue: Ctrl+O must yield to send-now" ); app.agents .get_mut(&id) .unwrap() .session .pending_prompts .clear(); app.agents.get_mut(&id).unwrap().prompt.set_text("steer it"); assert!( !crate::minimal_api::minimal_ctrl_o_opens_transcript(&app), "running turn + payload: Ctrl+O must yield to interject" ); { let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::Idle; agent.prompt_mode = PromptMode::EditingQueued { id: 1, original: String::new(), server_id: None, kind: crate::app::agent::QueueEntryKind::Prompt, }; } assert!( !crate::minimal_api::minimal_ctrl_o_opens_transcript(&app), "editing a queued row: Ctrl+O must stay the interject/save key" ); } /// In minimal mode Ctrl+O routes to `Action::OpenTranscriptPager` (unless /// interject owns the chord AND would consume the press — see the /// predicate test above). #[test] fn minimal_ctrl_o_opens_transcript_pager() { let mut app = test_app_with_agent(); app.screen_mode = ScreenMode::Minimal; app.registry = ActionRegistry::non_vscode_for_mode_for_test(ScreenMode::Minimal); let out = app.handle_input(&key_event(KeyCode::Char('o'), KeyModifiers::CONTROL)); assert!( matches!(out, InputOutcome::Action(Action::OpenTranscriptPager)), "expected OpenTranscriptPager, got {out:?}" ); } /// Apple Terminal (interject = Ctrl+O), minimal mode: at idle the interject /// path would silently no-op, so Ctrl+O must open the transcript — this was /// the "Ctrl+O appears dead on Mac" report. With a running turn and text in /// the composer the same key must send-now (cancel-and-send). With a running /// turn, empty composer, and a queued follow-up it must force-send that row /// (send-now). #[test] fn minimal_ctrl_o_on_apple_terminal_transcript_at_idle_interject_with_payload() { let mut app = test_app_with_agent(); app.screen_mode = ScreenMode::Minimal; app.registry = ActionRegistry::apple_terminal_for_mode_for_test(ScreenMode::Minimal); let out = app.handle_input(&key_event(KeyCode::Char('o'), KeyModifiers::CONTROL)); assert!( matches!(out, InputOutcome::Action(Action::OpenTranscriptPager)), "idle Apple-Terminal Ctrl+O must open the transcript, got {out:?}" ); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.prompt.set_text("steer it"); } let out = app.handle_input(&key_event(KeyCode::Char('o'), KeyModifiers::CONTROL)); assert!( matches!(out, InputOutcome::Action(Action::SendPromptNow { ref text, .. }) if text == "steer it"), "running Apple-Terminal Ctrl+O with payload must send-now, got {out:?}" ); { let agent = app.agents.get_mut(&id).unwrap(); agent.prompt.set_text(""); agent.session.enqueue_prompt("queued follow-up".into()); } let out = app.handle_input(&key_event(KeyCode::Char('o'), KeyModifiers::CONTROL)); assert!( matches!( out, InputOutcome::Action(Action::SendPromptNow { ref text, .. }) if text == "queued follow-up" ), "running + empty + queue: Apple-Terminal Ctrl+O must send-now, got {out:?}" ); assert!( app.agents[&id].session.pending_prompts.is_empty(), "queued row must be consumed by prompt-path send-now" ); } fn assert_background_routing_for_mode( mode: ScreenMode, pane: crate::app::agent_view::AgentPane, event: Event, ) { let mut app = test_app_with_agent(); app.screen_mode = mode; app.registry = ActionRegistry::defaults_for(mode); let ActiveView::Agent(id) = app.active_view else { panic!("test app must start on an agent"); }; app.agents.get_mut(&id).unwrap().set_active_pane(pane, true); let out = app.handle_input(&event); assert!(matches!(out, InputOutcome::Changed)); assert_eq!(app.agents[&id].active_pane, pane); assert!(!app.agents[&id].tasks.overlay.visible); assert!(!app.agents[&id].tasks.overlay.focused); crate::app::agent_view::test_fixtures::add_running_execute( app.agents.get_mut(&id).unwrap(), ); let out = app.handle_input(&event); assert!(matches!( out, InputOutcome::Action(Action::DemoteToBackground) )); assert_eq!(app.agents[&id].active_pane, pane); assert!(!app.agents[&id].tasks.overlay.visible); assert!(!app.agents[&id].tasks.overlay.focused); } #[test] fn raw_ctrl_b_routes_like_canonical_in_full_and_minimal_modes() { for mode in [ScreenMode::Fullscreen, ScreenMode::Minimal] { for pane in [ crate::app::agent_view::AgentPane::Prompt, crate::app::agent_view::AgentPane::Scrollback, ] { assert_background_routing_for_mode( mode, pane, crate::app::agent_view::test_fixtures::raw_ctrl_b_event(), ); } } } /// Minimal maps the full-TUI queue chord to `/queue` because the pane is absent. #[test] fn minimal_toggle_queue_chord_shows_queue_block() { let mut app = test_app_with_agent(); app.screen_mode = ScreenMode::Minimal; app.registry = ActionRegistry::non_vscode_for_mode_for_test(ScreenMode::Minimal); let out = app.handle_input(&key_event(KeyCode::Char(';'), KeyModifiers::CONTROL)); assert!( matches!(out, InputOutcome::Action(Action::ShowQueue)), "expected ShowQueue, got {out:?}" ); app.screen_mode = ScreenMode::Fullscreen; let out = app.handle_input(&key_event(KeyCode::Char(';'), KeyModifiers::CONTROL)); assert!( !matches!(out, InputOutcome::Action(Action::ShowQueue)), "full TUI must keep the queue-pane toggle, got {out:?}" ); } fn welcome_session_entry(id: &str) -> SessionPickerEntry { SessionPickerEntry { id: id.into(), summary: id.into(), updated_at: chrono::Utc::now(), created_at: chrono::Utc::now(), cwd: "/tmp/repo".into(), hostname: None, source: "local".into(), model_id: None, num_messages: 0, last_active_at: None, branch: None, repo_name: "tmp-repo".into(), worktree_label: None, card_detail: None, } } fn open_welcome_session_picker(app: &mut AppView) { crate::appearance::cache::set_vim_mode(false); app.session_picker_entries = Some(vec![welcome_session_entry("session-0")]); app.session_picker_state.search_active = true; } #[test] fn welcome_session_picker_ctrl_w_resumes_in_worktree_while_search_is_focused() { let mut app = test_app(); open_welcome_session_picker(&mut app); app.session_picker_state.set_query("session"); let outcome = app.handle_input(&key_event(KeyCode::Char('w'), KeyModifiers::CONTROL)); assert!(matches!( outcome, InputOutcome::Action(Action::PickSessionInWorktree(0)) )); assert_eq!(app.session_picker_state.query(), "session"); } #[test] fn welcome_session_picker_ctrl_d_keeps_global_quit_precedence() { let mut app = test_app(); open_welcome_session_picker(&mut app); app.session_picker_state.set_query("session"); let outcome = app.handle_input(&ctrl_d()); assert!(matches!(outcome, InputOutcome::Changed)); assert!(matches!( app.pending_action.as_ref().map(|pending| &pending.action), Some(Action::Quit) )); assert_eq!(app.session_picker_state.query(), "session"); } #[test] fn welcome_session_picker_cursor_motion_does_not_trigger_deep_search() { let mut app = test_app(); open_welcome_session_picker(&mut app); app.session_picker_state.set_query("session"); let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.session_picker_state.query(), "session"); } #[test] fn welcome_session_picker_ctrl_u_kills_to_cursor_and_triggers_deep_search() { let mut app = test_app(); open_welcome_session_picker(&mut app); app.session_picker_state.set_query("session"); let _ = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); let outcome = app.handle_input(&key_event(KeyCode::Char('u'), KeyModifiers::CONTROL)); assert!(matches!( outcome, InputOutcome::Action(Action::TriggerDeepSearch) )); assert_eq!(app.session_picker_state.query(), "n"); assert_eq!(app.session_picker_state.query_cursor(), 0); } #[test] fn welcome_ctrl_w_opens_new_worktree_dialog() { let mut app = test_app(); app.cwd_has_git_ancestor = true; let outcome = app.handle_input(&key_event(KeyCode::Char('w'), KeyModifiers::CONTROL)); assert!(matches!( outcome, InputOutcome::Action(Action::OpenNewWorktreeDialog) )); } #[test] fn welcome_ctrl_w_noop_outside_git_repo() { let mut app = test_app(); app.cwd_has_git_ancestor = false; let outcome = app.handle_input(&key_event(KeyCode::Char('w'), KeyModifiers::CONTROL)); assert!(matches!(outcome, InputOutcome::Unchanged)); } #[test] fn welcome_trust_decline_keys_quit() { for code in [KeyCode::Char('n'), KeyCode::Char('N'), KeyCode::Esc] { let mut app = test_app(); app.trust_state = TrustState::Pending { workspace: std::path::PathBuf::from("/tmp/x"), }; let outcome = app.handle_input(&key_event(code, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::Quit)), "{code:?} on the trust prompt must quit, got {outcome:?}" ); } let mut app = test_app(); app.trust_state = TrustState::Pending { workspace: std::path::PathBuf::from("/tmp/x"), }; let outcome = app.handle_input(&key_event(KeyCode::Char('y'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Action(Action::TrustFolder))); } #[test] fn welcome_ctrl_c_requires_confirmation() { let mut app = test_app(); let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Changed)); let pending = app .pending_action .as_ref() .expect("expected pending action"); assert!(matches!(pending.action, Action::Quit)); assert_eq!( pending.shortcut, KeyShortcut::from(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) ); } #[test] fn welcome_ctrl_c_double_press_quits() { let mut app = test_app(); let _ = app.handle_input(&ctrl_c()); assert!(app.pending_action.is_some()); let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Action(Action::Quit))); assert!(app.pending_action.is_none()); } #[test] fn welcome_ctrl_d_requires_confirmation() { let mut app = test_app(); let outcome = app.handle_input(&ctrl_d()); assert!(matches!(outcome, InputOutcome::Changed)); let pending = app .pending_action .as_ref() .expect("expected pending action"); assert!(matches!(pending.action, Action::Quit)); assert_eq!( pending.shortcut, KeyShortcut::from(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) ); } #[test] fn menu_action_indices_without_changelog() { assert!(matches!( dispatch_menu_action(0, false, false, None), InputOutcome::Action(Action::OpenNewWorktreeDialog) )); assert!(matches!( dispatch_menu_action(1, false, false, None), InputOutcome::Action(Action::FetchSessionList) )); assert!(matches!( dispatch_menu_action(2, false, false, None), InputOutcome::Action(Action::Quit) )); } #[test] fn menu_action_changelog_sits_above_quit() { let md = Some("# notes"); assert!(matches!( dispatch_menu_action(1, false, true, md), InputOutcome::Action(Action::FetchSessionList) )); assert!(matches!( dispatch_menu_action(2, false, true, md), InputOutcome::Action(Action::ShowReleaseNotes { .. }) )); assert!(matches!( dispatch_menu_action(3, false, true, md), InputOutcome::Action(Action::Quit) )); } #[test] fn menu_action_changelog_before_fetch_is_noop() { assert!(matches!( dispatch_menu_action(2, false, true, None), InputOutcome::Unchanged )); } #[test] fn menu_action_indices_with_import_and_changelog() { let md = Some("# notes"); assert!(matches!( dispatch_menu_action(0, true, true, md), InputOutcome::Action(Action::ImportClaudeSettings) )); assert!(matches!( dispatch_menu_action(1, true, true, md), InputOutcome::Action(Action::OpenNewWorktreeDialog) )); assert!(matches!( dispatch_menu_action(2, true, true, md), InputOutcome::Action(Action::FetchSessionList) )); assert!(matches!( dispatch_menu_action(3, true, true, md), InputOutcome::Action(Action::ShowReleaseNotes { .. }) )); assert!(matches!( dispatch_menu_action(4, true, true, md), InputOutcome::Action(Action::Quit) )); } #[test] fn welcome_pending_ctrl_c_quits_instantly() { let mut app = test_app(); app.auth_state = AuthState::Pending { error: None }; let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Action(Action::Quit))); assert!(app.pending_action.is_none()); } #[test] fn welcome_authenticating_ctrl_c_quits_instantly() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Command, }; let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Action(Action::Quit))); assert!(app.pending_action.is_none()); } #[test] fn page_keys_from_prompt_page_conversation_without_mutating_prompt() { let mut app = test_app_with_agent(); let ActiveView::Agent(id) = app.active_view else { panic!("test app must start on an agent"); }; { let agent = app.agents.get_mut(&id).unwrap(); agent.set_active_pane(crate::app::agent_view::AgentPane::Prompt, true); agent.prompt.set_text("draft text"); agent.prompt.textarea.set_selection(1, 5); } let prompt_before = { let agent = &app.agents[&id]; ( agent.prompt.text().to_owned(), agent.prompt.cursor(), agent.prompt.textarea.selection_range(), ) }; assert!( prompt_before.2.is_some(), "precondition: prompt selection is active" ); for (code, page_up) in [(KeyCode::PageUp, true), (KeyCode::PageDown, false)] { let outcome = app.handle_input(&key_event(code, KeyModifiers::NONE)); assert!( matches!( (&outcome, page_up), (InputOutcome::Action(Action::PageUp), true) | (InputOutcome::Action(Action::PageDown), false) ), "{code:?} must page the conversation, got {outcome:?}", ); let agent = &app.agents[&id]; assert_eq!(agent.active_pane, crate::app::agent_view::AgentPane::Prompt); assert_eq!(agent.prompt.text(), prompt_before.0); assert_eq!(agent.prompt.cursor(), prompt_before.1); assert_eq!(agent.prompt.textarea.selection_range(), prompt_before.2); } } #[test] fn prompt_paging_scope_matches_agent_surface() { fn focused_app(screen_mode: ScreenMode) -> (AppView, super::super::agent::AgentId) { let mut app = test_app_with_agent(); app.screen_mode = screen_mode; let ActiveView::Agent(id) = app.active_view else { panic!("test app must start on an agent"); }; app.agents .get_mut(&id) .unwrap() .set_active_pane(crate::app::agent_view::AgentPane::Prompt, true); (app, id) } #[derive(Clone, Copy)] enum Surface { Agent(ScreenMode), DashboardOverlay, DashboardPopup, } for (label, surface, paging_enabled) in [ ("inline agent", Surface::Agent(ScreenMode::Inline), true), ( "fullscreen agent", Surface::Agent(ScreenMode::Fullscreen), true, ), ("minimal agent", Surface::Agent(ScreenMode::Minimal), false), ( "dashboard session overlay", Surface::DashboardOverlay, false, ), ("dashboard attached popup", Surface::DashboardPopup, false), ] { let screen_mode = match surface { Surface::Agent(mode) => mode, Surface::DashboardOverlay | Surface::DashboardPopup => ScreenMode::Inline, }; let (mut app, id) = focused_app(screen_mode); match surface { Surface::DashboardOverlay => { app.dashboard = Some(crate::views::dashboard::DashboardState::new()); app.dashboard.as_mut().unwrap().attached_agent = Some(id); } Surface::DashboardPopup => assert_eq!(attach_popup(&mut app), id), Surface::Agent(_) => {} } let outcome = app.handle_input(&key_event(KeyCode::PageUp, KeyModifiers::NONE)); assert_eq!( matches!( &outcome, InputOutcome::Action(Action::PageUp | Action::PageDown) ), paging_enabled, "{label} prompt paging scope mismatch: {outcome:?}", ); } } #[test] fn prompt_page_actions_target_visible_fullscreen_child_scrollback() { fn make_pageable(agent: &mut AgentView) { for i in 0..16 { agent .scrollback .push_block(crate::scrollback::block::RenderBlock::agent_message( format!("message {i}\ncontinued"), )); } agent.scrollback.prepare_layout(40, 6); agent.scrollback.goto_bottom(); assert!( agent.scrollback.scroll_info().0 > 0, "precondition: scrollback must have a page above" ); } let mut app = test_app_with_agent(); app.screen_mode = ScreenMode::Fullscreen; let ActiveView::Agent(id) = app.active_view else { panic!("test app must start on an agent"); }; let child_sid = "page-target-child"; let mut child = idle_child_view(&app, 1, child_sid); child.set_active_pane(crate::app::agent_view::AgentPane::Prompt, true); make_pageable(&mut child); { let parent = app.agents.get_mut(&id).unwrap(); make_pageable(parent); parent.subagent_views.insert(child_sid.to_owned(), child); parent.active_subagent = Some(child_sid.to_owned()); } let offsets = |app: &AppView| { let parent = &app.agents[&id]; ( parent.scrollback.scroll_info().0, parent.subagent_views[child_sid].scrollback.scroll_info().0, ) }; let before = offsets(&app); let outcome = app.handle_input(&key_event(KeyCode::PageUp, KeyModifiers::NONE)); let InputOutcome::Action(action @ Action::PageUp) = outcome else { panic!("child prompt PageUp must emit PageUp, got {outcome:?}"); }; let _ = super::super::dispatch::dispatch(action, &mut app); let after_up = offsets(&app); assert_eq!(after_up.0, before.0, "parent scrollback must not move"); assert!( after_up.1 < before.1, "PageUp must move the visible child scrollback" ); let outcome = app.handle_input(&key_event(KeyCode::PageDown, KeyModifiers::NONE)); let InputOutcome::Action(action @ Action::PageDown) = outcome else { panic!("child prompt PageDown must emit PageDown, got {outcome:?}"); }; let _ = super::super::dispatch::dispatch(action, &mut app); let after_down = offsets(&app); assert_eq!(after_down.0, before.0, "parent scrollback must stay put"); assert!( after_down.1 > after_up.1, "PageDown must move the visible child scrollback" ); } #[test] fn ctrl_d_from_scrollback_is_half_page_down_not_quit() { let mut app = test_app_with_agent(); pin_non_vscode_registry(&mut app); let outcome = app.handle_input(&ctrl_d()); assert!(matches!( outcome, InputOutcome::Action(Action::HalfPageDown) )); assert!(app.pending_action.is_none()); } #[test] fn ctrl_d_double_press_quits_from_prompt() { let mut app = test_app_with_agent(); pin_non_vscode_registry(&mut app); let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().active_pane = crate::views::agent::ActivePane::Prompt; let outcome = app.handle_input(&ctrl_d()); assert!( matches!(outcome, InputOutcome::Changed), "first Ctrl+D should set pending quit, got: {outcome:?}", ); assert!(app.pending_action.is_some()); assert_eq!(app.pending_action.as_ref().unwrap().label, Some("quit")); let outcome = app.handle_input(&ctrl_d()); assert!(matches!(outcome, InputOutcome::Action(Action::Quit))); assert!(app.pending_action.is_none()); } #[test] fn ctrl_d_in_vscode_quits_from_scrollback() { let mut app = test_app_with_agent(); let mut actions = crate::actions::default_actions(ScreenMode::Fullscreen, false); for def in actions.iter_mut() { if def.id == ActionId::Quit { def.default_key = key!('d', CONTROL); def.alt_keys = vec![]; } if def.id == ActionId::HalfPageDown { def.default_key = key!('D'); } } app.registry = ActionRegistry::new(actions); let outcome = app.handle_input(&ctrl_d()); assert!( matches!(outcome, InputOutcome::Changed), "first Ctrl+D should set pending quit, got: {outcome:?}", ); assert!(app.pending_action.is_some()); assert_eq!(app.pending_action.as_ref().unwrap().label, Some("quit")); let outcome = app.handle_input(&ctrl_d()); assert!(matches!(outcome, InputOutcome::Action(Action::Quit))); assert!(app.pending_action.is_none()); } #[test] fn ctrl_q_sets_pending_action() { let mut app = test_app_with_agent(); let outcome = app.handle_input(&ctrl_q()); assert!(matches!(outcome, InputOutcome::Changed)); assert!(app.pending_action.is_some()); assert_eq!(app.pending_action.as_ref().unwrap().label, Some("quit")); } #[test] fn ctrl_q_double_press_quits() { let mut app = test_app_with_agent(); let _ = app.handle_input(&ctrl_q()); assert!(app.pending_action.is_some()); let outcome = app.handle_input(&ctrl_q()); assert!(matches!(outcome, InputOutcome::Action(Action::Quit))); assert!(app.pending_action.is_none()); } #[test] fn different_key_clears_pending() { crate::appearance::cache::set_simple_mode(false); let mut app = test_app_with_agent(); if let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get_mut(&id) { agent.vim_mode = true; } let _ = app.handle_input(&ctrl_q()); assert!(app.pending_action.is_some()); let outcome = app.handle_input(&key_event(KeyCode::Char('j'), KeyModifiers::NONE)); assert!(app.pending_action.is_none()); assert!(matches!(outcome, InputOutcome::Action(Action::SelectNext))); } #[test] fn ctrl_q_then_ctrl_d_does_not_confirm() { let mut app = test_app_with_agent(); pin_non_vscode_registry(&mut app); let _ = app.handle_input(&ctrl_q()); assert!(app.pending_action.is_some()); let outcome = app.handle_input(&ctrl_d()); assert!(app.pending_action.is_none()); assert!(matches!( outcome, InputOutcome::Action(Action::HalfPageDown) )); } fn ctrl_n() -> Event { key_event(KeyCode::Char('n'), KeyModifiers::CONTROL) } #[test] fn ctrl_n_sets_pending_new_session() { let mut app = test_app_with_agent(); let outcome = app.handle_input(&ctrl_n()); assert!(matches!(outcome, InputOutcome::Changed)); let pending = app.pending_action.as_ref().expect("pending action"); assert_eq!(pending.label, Some("new")); } #[test] fn second_ctrl_n_opens_new_session_mode_question_when_mode_is_ask() { let mut app = test_app_with_agent(); app.new_session_worktree_mode = WorktreeMode::Ask; let _ = app.handle_input(&ctrl_n()); let outcome = app.handle_input(&ctrl_n()); assert!(matches!( outcome, InputOutcome::Action(Action::ChooseNewSessionMode) )); assert!(app.pending_action.is_none()); } #[test] fn second_ctrl_n_respects_never_worktree_mode() { let mut app = test_app_with_agent(); app.new_session_worktree_mode = WorktreeMode::Never; let _ = app.handle_input(&ctrl_n()); let outcome = app.handle_input(&ctrl_n()); assert!(matches!(outcome, InputOutcome::Action(Action::NewSession))); assert!(app.pending_action.is_none()); } #[test] fn second_ctrl_n_respects_always_worktree_mode() { let mut app = test_app_with_agent(); app.new_session_worktree_mode = WorktreeMode::Always; let _ = app.handle_input(&ctrl_n()); let outcome = app.handle_input(&ctrl_n()); assert!(matches!(outcome, InputOutcome::Action(Action::NewSession))); assert!(app.pending_action.is_none()); } #[test] fn ctrl_c_running_cancels_turn() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Action(Action::CancelTurn))); } #[test] fn ctrl_c_cancelling_escalates_to_quit_pending() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnCancelling; let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Changed)); assert!(app.pending_action.is_some()); assert_eq!(app.pending_action.as_ref().unwrap().label, Some("quit")); } fn assert_pending_quit(app: &AppView) { let pending = app .pending_action .as_ref() .expect("expected pending action"); assert_eq!(pending.label, Some("quit")); assert!(matches!(pending.action, Action::Quit)); } #[test] fn ctrl_c_idle_empty_prompt_sets_pending_quit() { crate::appearance::cache::set_simple_mode(true); let mut app = test_app_with_agent(); let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Changed)); assert_pending_quit(&app); } #[test] fn ctrl_c_idle_empty_prompt_focused_sets_pending_quit() { crate::appearance::cache::set_simple_mode(true); let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().active_pane = crate::views::agent::ActivePane::Prompt; let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Changed)); assert_pending_quit(&app); } #[test] fn ctrl_c_double_press_idle_quits() { crate::appearance::cache::set_simple_mode(true); let mut app = test_app_with_agent(); let _ = app.handle_input(&ctrl_c()); assert!(app.pending_action.is_some()); let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Action(Action::Quit))); assert!(app.pending_action.is_none()); } #[test] fn ctrl_c_consumed_by_cancel_does_not_set_pending() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Action(Action::CancelTurn))); assert!(app.pending_action.is_none()); } #[test] fn ctrl_c_consumed_by_text_clear_does_not_set_pending() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt.textarea.set_text("some text"); let outcome = app.handle_input(&ctrl_c()); assert!(matches!(outcome, InputOutcome::Changed)); assert!(app.pending_action.is_none()); } #[test] fn ctrl_c_then_other_key_resets_pending() { crate::appearance::cache::set_simple_mode(true); let mut app = test_app_with_agent(); let _ = app.handle_input(&ctrl_c()); assert!(app.pending_action.is_some()); let _ = app.handle_input(&key_event(KeyCode::Char('j'), KeyModifiers::NONE)); assert!(app.pending_action.is_none()); } #[test] fn ctrl_c_idle_prompt_with_text_clears_text() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt.textarea.set_text("draft prompt"); assert!(agent.session.state.is_idle()); let outcome = app.handle_input(&ctrl_c()); assert!( matches!(outcome, InputOutcome::Changed), "Ctrl+C with text in idle prompt must Change (clear text), got: {outcome:?}", ); assert!( app.agents[&id].prompt.textarea.text().is_empty(), "Ctrl+C must clear prompt text when agent is idle; got: {:?}", app.agents[&id].prompt.textarea.text(), ); } #[test] fn ctrl_c_running_prompt_with_text_clears_text_and_preserves_turn() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt.textarea.set_text("draft prompt"); let outcome = app.handle_input(&ctrl_c()); assert!( matches!(outcome, InputOutcome::Changed), "Ctrl+C with text in a running prompt must clear the text, got: {outcome:?}", ); assert!( app.agents[&id].prompt.textarea.text().is_empty(), "Ctrl+C must clear prompt text first; got: {:?}", app.agents[&id].prompt.textarea.text(), ); assert!( app.agents[&id].session.state.is_turn_running(), "First Ctrl+C must NOT cancel the turn while a draft was present", ); let outcome = app.handle_input(&ctrl_c()); assert!( matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "Second Ctrl+C on empty running prompt must CancelTurn, got: {outcome:?}", ); } #[test] fn esc_from_prompt_pane_running_turn_cancels_in_non_vim_mode() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.vim_mode = false; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "1× Esc while running must cancel in non-vim mode, got {outcome:?}" ); assert!(app.pending_action.is_none()); assert_eq!( app.agents[&id].cancel_trigger_hint, Some(crate::app::actions::CancelTrigger::Esc) ); } #[test] fn esc_from_prompt_pane_running_turn_with_draft_cancels_preserving_draft() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.vim_mode = false; agent.prompt.textarea.set_text("draft while streaming"); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "mid-turn Esc with draft must cancel in non-vim mode, got {outcome:?}" ); assert!(app.pending_action.is_none(), "must not arm idle clear"); assert_eq!( app.agents[&id].prompt.textarea.text(), "draft while streaming", "Esc cancel must preserve the draft (not clear it like Ctrl+C)" ); assert_eq!( app.agents[&id].cancel_trigger_hint, Some(crate::app::actions::CancelTrigger::Esc) ); } #[test] fn esc_from_scrollback_pane_running_turn_cancels_in_non_vim_mode() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Scrollback; agent.vim_mode = false; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "1× Esc from scrollback while running must cancel in non-vim mode, got {outcome:?}" ); assert!(app.pending_action.is_none()); assert_eq!( app.agents[&id].cancel_trigger_hint, Some(crate::app::actions::CancelTrigger::Esc) ); } #[test] fn esc_from_prompt_pane_running_turn_vim_mode_is_swallowed() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.vim_mode = true; agent.prompt.textarea.set_text("draft while streaming"); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "1× Esc while running must swallow in vim mode, got {outcome:?}" ); assert!(app.pending_action.is_none()); assert!(app.agents[&id].cancel_trigger_hint.is_none()); assert_eq!( app.agents[&id].prompt.textarea.text(), "draft while streaming", "vim mid-turn Esc must not clear the draft or arm idle clear" ); assert!(app.agents[&id].session.state.is_turn_running()); } #[test] fn esc_from_scrollback_pane_running_turn_vim_mode_is_swallowed() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Scrollback; agent.vim_mode = true; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "1× Esc from scrollback while running must swallow in vim mode, got {outcome:?}" ); assert!(app.pending_action.is_none()); assert!(app.agents[&id].cancel_trigger_hint.is_none()); assert!(app.agents[&id].session.state.is_turn_running()); } #[test] fn esc_cancels_turn_gate_truth_table() { assert!(crate::app::esc_cancels_turn(true, true)); assert!(crate::app::esc_cancels_turn(true, false)); assert!(crate::app::esc_cancels_turn(false, false)); assert!(!crate::app::esc_cancels_turn(false, true)); } #[test] fn esc_running_turn_minimal_screen_mode_cancels_even_with_vim_on() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.vim_mode = true; agent .prompt .set_screen_mode(crate::app::ScreenMode::Minimal); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "minimal mode must Esc-cancel even with vim scrollback nav on, got {outcome:?}" ); assert_eq!( app.agents[&id].cancel_trigger_hint, Some(crate::app::actions::CancelTrigger::Esc) ); } #[test] fn esc_owned_before_agent_covers_app_level_owners() { let mut app = test_app_with_agent(); assert!(!app.esc_owned_before_agent()); app.voice_state = VoiceState::Recording { hold: false, target: VoiceTarget::DashboardDispatch, interim: None, }; assert!(app.esc_owned_before_agent(), "listening owns Esc"); app.voice_state = VoiceState::ColdStart { hold: false, target: VoiceTarget::DashboardDispatch, }; assert!(app.esc_owned_before_agent(), "pending cold-start owns Esc"); app.voice_state = VoiceState::Idle; assert!(!app.esc_owned_before_agent()); app.import_claude_modal = Some( crate::views::import_claude_modal::ImportClaudeModalState::new( xai_grok_shell::claude_import::ImportPlan::default(), std::path::PathBuf::from("/tmp"), ), ); assert!(app.esc_owned_before_agent(), "import-claude modal owns Esc"); app.import_claude_modal = None; app.active_view = ActiveView::AgentDashboard; app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(super::super::agent::AgentId(0)); } assert!(app.esc_owned_before_agent(), "dashboard popup owns Esc"); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(super::super::agent::AgentId(99)); } assert!(!app.esc_owned_before_agent()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = None; } assert!(!app.esc_owned_before_agent()); } #[test] fn esc_while_cancelling_retries_cancel() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnCancelling; agent.active_pane = crate::views::agent::ActivePane::Scrollback; agent.vim_mode = true; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "Esc while cancelling must retry CancelTurn, got {outcome:?}" ); assert!(app.pending_action.is_none()); assert_eq!( app.agents[&id].cancel_trigger_hint, Some(crate::app::actions::CancelTrigger::Esc) ); } #[test] fn esc_cancel_grace_holds_rewind_arm_then_expires() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.vim_mode = false; agent .scrollback .push_block(crate::scrollback::block::RenderBlock::user_prompt( "earlier", )); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Action(Action::CancelTurn))); assert!(app.agents[&id].rewind_suppress_deadline.is_some()); app.agents.get_mut(&id).unwrap().session.state = AgentState::Idle; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "Esc within the post-cancel grace must swallow, got {outcome:?}" ); assert!( app.pending_action.is_none(), "post-cancel Esc must not arm the rewind picker" ); app.agents.get_mut(&id).unwrap().rewind_suppress_deadline = Some(std::time::Instant::now()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!( matches!( app.pending_action.as_ref().map(|p| &p.action), Some(Action::RewindShowPicker) ), "expired grace must restore the idle rewind arm" ); assert!( app.agents[&id].rewind_suppress_deadline.is_none(), "the expired deadline must be cleared on the consult" ); } #[test] fn idle_non_empty_double_esc_clears_prompt() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt.textarea.set_text("draft to clear"); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); let pending = app.pending_action.as_ref().expect("arm clear"); assert_eq!(pending.label, Some("clear")); assert!(matches!(pending.action, Action::ClearPrompt)); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Action(Action::ClearPrompt))); assert!(app.pending_action.is_none()); let effects = crate::app::dispatch::dispatch(Action::ClearPrompt, &mut app); assert!(effects.is_empty()); assert!(app.agents[&id].prompt.textarea.text().is_empty()); assert_eq!( app.agents[&id] .session .prompt_history .first() .map(String::as_str), Some("draft to clear") ); } #[test] fn idle_empty_with_messages_double_esc_opens_rewind_silent() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent .scrollback .push_block(crate::scrollback::block::RenderBlock::user_prompt( "earlier", )); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); let pending = app.pending_action.as_ref().expect("arm rewind"); assert!( pending.label.is_none(), "first Esc for rewind must be silent" ); assert!(matches!(pending.action, Action::RewindShowPicker)); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!( outcome, InputOutcome::Action(Action::RewindShowPicker) )); assert!(app.pending_action.is_none()); } #[test] fn idle_empty_no_messages_esc_is_swallowed() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; assert!(agent.scrollback.is_empty()); assert!(agent.prompt.textarea.text().is_empty()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "idle empty with no messages must swallow Esc (not FocusScrollback), got {outcome:?}" ); assert!(app.pending_action.is_none()); assert_eq!( app.agents[&id].active_pane, crate::views::agent::ActivePane::Prompt ); } #[test] fn mouse_send_retires_armed_clear_so_next_esc_swallows() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt.textarea.set_text("draft to clear"); agent.vim_mode = true; } let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); let pending = app.pending_action.as_ref().expect("arm clear"); assert!(matches!(pending.action, Action::ClearPrompt)); let _ = crate::app::dispatch::dispatch(Action::SendPrompt("draft to clear".into()), &mut app); assert!( app.pending_action.is_none(), "submit must retire the stale ClearPrompt arm", ); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "Esc after a mouse-send must swallow mid-turn, got {outcome:?}", ); assert!( !matches!(outcome, InputOutcome::Action(Action::ClearPrompt)), "the retired ClearPrompt arm must not fire", ); assert!( !matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "Esc must not cancel mid-turn", ); assert!(app.agents[&id].cancel_trigger_hint.is_none()); assert!(app.pending_action.is_none()); } /// Arm an idle-Esc `ClearPrompt`, submit via `text`-carrying `action` (a /// turn-start path with no intervening key), assert the arm was retired, then /// with the turn running assert the next Esc swallows (never the stale clear). fn assert_submit_path_retires_clear_arm(action: Action) { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt.textarea.set_text("draft to clear"); agent.vim_mode = true; } let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!(matches!( app.pending_action.as_ref().expect("arm clear").action, Action::ClearPrompt )); let _ = crate::app::dispatch::dispatch(action, &mut app); assert!( app.pending_action.is_none(), "every submit path (inner funnel) must retire the stale ClearPrompt arm", ); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "Esc after a non-keyed submit must swallow mid-turn, got {outcome:?}", ); assert!( !matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "Esc must not cancel mid-turn", ); assert!(app.agents[&id].cancel_trigger_hint.is_none()); assert!(app.pending_action.is_none()); } #[test] fn submit_follow_up_retires_armed_clear_so_next_esc_swallows() { assert_submit_path_retires_clear_arm(Action::SubmitFollowUp("follow up".into())); } #[test] fn slash_preserving_send_retires_armed_clear_so_next_esc_swallows() { assert_submit_path_retires_clear_arm(Action::SendSlashCommandPreservingDraft( "/compact".into(), )); } #[test] fn stale_idle_clear_arm_never_fires_on_busy_agent() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt.textarea.set_text("draft to clear"); agent.vim_mode = true; } let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!(matches!( app.pending_action.as_ref().expect("arm clear").action, Action::ClearPrompt )); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "Esc on a busy agent must swallow, not fire the stale clear arm, got {outcome:?}", ); assert!( !matches!(outcome, InputOutcome::Action(Action::ClearPrompt)), "the stale ClearPrompt arm must not fire on a running turn", ); assert!( !matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "Esc must not cancel mid-turn", ); assert!(app.agents[&id].cancel_trigger_hint.is_none()); assert!( app.pending_action.is_none(), "the stale arm must be dropped" ); } #[test] fn stale_idle_rewind_arm_never_fires_on_busy_agent() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.vim_mode = true; agent .scrollback .push_block(crate::scrollback::block::RenderBlock::user_prompt( "earlier", )); } let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!(matches!( app.pending_action.as_ref().expect("arm rewind").action, Action::RewindShowPicker )); app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "Esc on a busy agent must swallow, not fire the stale rewind arm, got {outcome:?}", ); assert!( !matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "Esc must not cancel mid-turn", ); assert!( app.pending_action.is_none(), "the stale arm must be dropped" ); } #[test] fn esc_consumed_by_policy_disarms_esc_d_combo() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; assert!(agent.scrollback.is_empty()); assert!(agent.prompt.textarea.text().is_empty()); } let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!( app.agents[&id].esc_pressed_at.is_none(), "idle-empty swallow Esc must disarm the Esc→d combo", ); let mut app = test_app_with_agent(); { let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.vim_mode = true; } let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!( app.agents[&id].esc_pressed_at.is_none(), "mid-turn swallow Esc must disarm the Esc→d combo", ); } #[test] fn idle_non_empty_esc_ttl_expiry_re_arms_without_clearing() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt.textarea.set_text("still here"); let _ = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); if let Some(p) = app.pending_action.as_mut() { p.expires_at = std::time::Instant::now() - std::time::Duration::from_millis(1); } let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!( app.agents[&id].prompt.textarea.text(), "still here", "expired first Esc must not clear" ); let pending = app.pending_action.as_ref().expect("re-arm clear"); assert_eq!(pending.label, Some("clear")); } #[test] fn idle_images_only_double_esc_arms_clear() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; agent .prompt .images .push(crate::prompt_images::from_clipboard_data( &crate::clipboard::ImageData { data: vec![1, 2, 3], mime_type: "image/png".into(), }, )); assert!(agent.prompt.textarea.text().is_empty()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); let pending = app.pending_action.as_ref().expect("arm clear for images"); assert!(matches!(pending.action, Action::ClearPrompt)); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Action(Action::ClearPrompt))); assert!(app.pending_action.is_none()); let effects = crate::app::dispatch::dispatch(Action::ClearPrompt, &mut app); assert!(effects.is_empty()); assert!( app.agents[&id].prompt.images.is_empty(), "second Esc must clear the image chips" ); assert!( app.agents[&id].session.prompt_history.is_empty(), "an images-only (empty-text) clear records nothing in prompt history" ); } /// Scrollback-pane double-Esc, idle + empty prompt + messages: first Esc /// arms `RewindShowPicker` silently, second within the TTL opens the /// picker. Driven per scrollback nav mode because the routing differs — /// vim resolves through `lookup_with_mode(vim=true)`, non-vim adds the /// bare-letter forward-to-prompt fallback — and neither may consume Esc. fn assert_scrollback_double_esc_opens_rewind(vim: bool) { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.vim_mode = vim; agent.set_input_mode(if vim { crate::views::agent::InputMode::Vim } else { crate::views::agent::InputMode::Simple }); agent.active_pane = crate::views::agent::ActivePane::Scrollback; agent .scrollback .push_block(crate::scrollback::block::RenderBlock::user_prompt( "earlier", )); assert!(agent.prompt.textarea.text().is_empty()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "vim={vim}: first scrollback Esc must arm silently, got {outcome:?}" ); let pending = app .pending_action .as_ref() .expect("scrollback-pane idle Esc must arm rewind"); assert!( pending.label.is_none(), "vim={vim}: first Esc for rewind must be silent" ); assert!(matches!(pending.action, Action::RewindShowPicker)); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::RewindShowPicker)), "vim={vim}: second Esc from scrollback must open the rewind picker, got {outcome:?}" ); assert!(app.pending_action.is_none()); } /// Non-vim (simple) scrollback nav: double-Esc from scrollback opens rewind. #[test] fn idle_scrollback_pane_double_esc_opens_rewind() { assert_scrollback_double_esc_opens_rewind(false); } /// Vim scrollback nav consumes no plain Esc, so the same flow must work. #[test] fn idle_scrollback_pane_double_esc_opens_rewind_vim_mode() { assert_scrollback_double_esc_opens_rewind(true); } /// From the SCROLLBACK pane an idle Esc with a draft in the (unfocused) /// composer arms NOTHING and leaves the draft intact: clear is skipped by /// the prompt-pane gate, and rewind is skipped by the global /// empty-composer gate even with turns present — never clear or /// rewind-stash a draft the reader has scrolled past. The Esc is /// swallowed (no pending, no global quit/back-out). #[test] fn idle_scrollback_pane_esc_with_draft_and_messages_swallows() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Scrollback; agent .scrollback .push_block(crate::scrollback::block::RenderBlock::user_prompt( "earlier", )); agent .prompt .textarea .set_text("draft while reading scrollback"); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!( app.pending_action.is_none(), "scrollback-pane Esc with a draft must arm neither clear nor rewind" ); assert_eq!( app.agents[&id].prompt.textarea.text(), "draft while reading scrollback", "scrollback-pane Esc must leave the composer draft intact" ); } /// A pending needs-input overlay blocks the scrollback rewind arm: the /// overlay intercepts exempt the scrollback pane, so its Esc reaches the /// policy — which must swallow rather than arm a picker that would /// key-starve the pending overlay. The overlay must survive the Esc. #[test] fn idle_scrollback_pane_esc_with_pending_input_overlay_does_not_arm_rewind() { type OverlayInstaller = (&'static str, fn(&mut AgentView)); let installers: [OverlayInstaller; 2] = [ ("cancel_turn_view", |a| { a.cancel_turn_view = Some(crate::views::modal::CancelTurnViewState { active_idx: 0, running_count: 1, }); }), ("question_view", |a| { let stashed = a.prompt.stash(); a.question_view = Some(crate::views::question_view::QuestionViewState::new( "call-q".into(), vec![], stashed, )); }), ]; for (name, install) in installers { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Scrollback; agent .scrollback .push_block(crate::scrollback::block::RenderBlock::user_prompt( "earlier", )); assert!(agent.prompt.textarea.text().is_empty()); install(agent); assert!( !agent.no_input_overlay_pending(), "{name}: fixture must have a pending needs-input overlay" ); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "{name}: scrollback Esc under a pending overlay must swallow, got {outcome:?}" ); assert!( app.pending_action.is_none(), "{name}: must not arm rewind under a pending needs-input overlay" ); assert!( !app.agents[&id].no_input_overlay_pending(), "{name}: the pending overlay must survive the swallowed Esc" ); } } /// A latent Bash/Remember/Feedback composer mode blocks the scrollback /// rewind arm — a rewind restore must not drop conversation text into a /// still-armed `!` composer. The Esc must swallow WITHOUT exiting the /// mode: mode exit stays a prompt-pane (step 0e) affordance. #[test] fn idle_scrollback_pane_esc_in_bash_mode_does_not_arm_rewind() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Scrollback; agent.prompt_input_mode = crate::app::agent_view::PromptInputMode::Bash; agent .scrollback .push_block(crate::scrollback::block::RenderBlock::user_prompt( "earlier", )); assert!(agent.prompt.textarea.text().is_empty()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "scrollback Esc with a latent bash composer must swallow, got {outcome:?}" ); assert!( app.pending_action.is_none(), "must not arm rewind while the composer is in bash mode" ); assert_eq!( app.agents[&id].prompt_input_mode, crate::app::agent_view::PromptInputMode::Bash, "scrollback Esc must not exit the composer mode either" ); } /// An active prompt history search blocks the scrollback rewind arm — the /// step 0b intercept is prompt-pane-only, so a scrollback Esc reaches the /// policy while the search overlay is open and must swallow rather than /// stack a rewind arm under it. The search must survive the Esc. #[test] fn idle_scrollback_pane_esc_with_history_search_does_not_arm_rewind() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent .scrollback .push_block(crate::scrollback::block::RenderBlock::user_prompt( "earlier", )); assert!(agent.prompt.textarea.text().is_empty()); let history = agent.combined_prompt_history(); let current_text = agent.prompt.text().to_string(); agent .prompt .history_search .activate(&history, ¤t_text); agent.active_pane = crate::views::agent::ActivePane::Scrollback; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "scrollback Esc with an open history search must swallow, got {outcome:?}" ); assert!( app.pending_action.is_none(), "must not arm rewind while history search is open" ); assert!( app.agents[&id].prompt.history_search.is_active(), "scrollback Esc must not dismiss the search either" ); } #[test] fn running_slash_dropdown_esc_dismisses_not_cancel() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt.set_text("/he"); agent.prompt.refresh_slash(&agent.session.models); assert!( agent.prompt.slash_open(), "precondition: slash dropdown open" ); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!(app.pending_action.is_none()); assert!( !matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "slash Esc must steal, not cancel" ); assert!(app.agents[&id].session.state.is_turn_running()); assert!(!app.agents[&id].prompt.slash_open()); } #[test] fn running_bash_mode_empty_esc_exits_mode_not_cancel() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent.active_pane = crate::views::agent::ActivePane::Prompt; agent.prompt_input_mode = crate::app::agent_view::PromptInputMode::Bash; assert!(agent.prompt.textarea.text().is_empty()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!( !matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "empty bash Esc exits mode, does not cancel while running" ); assert_eq!( app.agents[&id].prompt_input_mode, crate::app::agent_view::PromptInputMode::Normal ); assert!(app.agents[&id].session.state.is_turn_running()); } #[test] fn tab_from_prompt_follows_screen_mode_registry() { let id = super::super::agent::AgentId(0); for mode in [ScreenMode::Fullscreen, ScreenMode::Inline] { let mut app = test_app_with_agent(); app.screen_mode = mode; app.registry = ActionRegistry::defaults_for(mode); app.agents.get_mut(&id).unwrap().active_pane = crate::views::agent::ActivePane::Prompt; let outcome = app.handle_input(&key_event(KeyCode::Tab, KeyModifiers::NONE)); assert!(matches!( outcome, InputOutcome::Action(Action::FocusScrollback) )); } let mut minimal = test_app_with_agent(); minimal.screen_mode = ScreenMode::Minimal; minimal.registry = ActionRegistry::defaults_for(ScreenMode::Minimal); minimal.agents.get_mut(&id).unwrap().active_pane = crate::views::agent::ActivePane::Prompt; let outcome = minimal.handle_input(&key_event(KeyCode::Tab, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Unchanged)); assert_eq!( minimal.agents[&id].active_pane, crate::views::agent::ActivePane::Prompt ); } #[test] fn prompt_focused_printable_chars_still_go_to_textarea() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; let _ = app.handle_input(&key_event(KeyCode::Char('a'), KeyModifiers::NONE)); let agent = app.agents.get(&id).unwrap(); assert_eq!(agent.prompt.textarea.text(), "a"); } #[test] fn prompt_focused_question_mark_with_shift_still_goes_to_textarea() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; let outcome = app.handle_input(&key_event(KeyCode::Char('?'), KeyModifiers::SHIFT)); assert!( !matches!(outcome, InputOutcome::Changed if app.agents.get(&id).unwrap().active_modal.is_some()), "?+SHIFT must not open the command palette when typing in the prompt; got {outcome:?}", ); let agent = app.agents.get(&id).unwrap(); assert!( agent.active_modal.is_none(), "?+SHIFT must not open any modal in the prompt", ); assert!( agent.prompt.textarea.text().contains('?'), "?+SHIFT must reach the textarea as `?`; got {:?}", agent.prompt.textarea.text(), ); } #[test] fn prompt_focused_bare_text_chars_promote_no_action() { for ch in ['p', 'b', '/', '?', '1', '5', 'm', 'o', 'c', 'h'] { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; let _ = app.handle_input(&key_event(KeyCode::Char(ch), KeyModifiers::NONE)); let agent = app.agents.get(&id).unwrap(); assert!( agent.active_modal.is_none(), "bare `{ch}` must not open any modal", ); assert!( agent.prompt.textarea.text().contains(ch), "bare `{ch}` must reach the textarea; got {:?}", agent.prompt.textarea.text(), ); let mut app = test_app_with_agent(); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::views::agent::ActivePane::Prompt; let _ = app.handle_input(&key_event(KeyCode::Char(ch), KeyModifiers::SHIFT)); let agent = app.agents.get(&id).unwrap(); assert!( agent.active_modal.is_none(), "shift+`{ch}` must not open any modal", ); } } #[test] fn welcome_pending_l_triggers_login() { let mut app = test_app(); app.auth_state = AuthState::Pending { error: None }; app.welcome_prompt_focused = false; let outcome = app.handle_input(&key_event(KeyCode::Char('l'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Action(Action::Login))); } #[test] fn welcome_pending_enter_triggers_login() { let mut app = test_app(); app.auth_state = AuthState::Pending { error: None }; app.welcome_prompt_focused = false; let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Action(Action::Login))); } #[test] fn welcome_pending_n_is_unchanged() { let mut app = test_app(); app.auth_state = AuthState::Pending { error: None }; app.welcome_prompt_focused = false; let outcome = app.handle_input(&key_event(KeyCode::Char('n'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Unchanged)); } #[test] fn welcome_done_n_starts_session() { let mut app = test_app(); app.auth_state = AuthState::Done; let outcome = app.handle_input(&key_event(KeyCode::Char('n'), KeyModifiers::NONE)); assert!(matches!( outcome, InputOutcome::ActionThenForward(Action::NewSession) )); } #[test] fn welcome_done_ctrl_w_opens_new_worktree_dialog() { let mut app = test_app(); app.auth_state = AuthState::Done; app.cwd_has_git_ancestor = true; let outcome = app.handle_input(&key_event(KeyCode::Char('w'), KeyModifiers::CONTROL)); assert!(matches!( outcome, InputOutcome::Action(Action::OpenNewWorktreeDialog) )); } #[test] fn welcome_ctrl_v_creates_normal_session() { let mut app = test_app(); app.auth_state = AuthState::Done; app.welcome_prompt_focused = true; let outcome = app.handle_input(&key_event(KeyCode::Char('v'), KeyModifiers::CONTROL)); assert!(matches!( outcome, InputOutcome::ActionThenForward(Action::NewSession) )); } #[test] fn welcome_cmd_v_creates_normal_session() { let mut app = test_app(); app.auth_state = AuthState::Done; app.welcome_prompt_focused = true; let outcome = app.handle_input(&key_event(KeyCode::Char('v'), KeyModifiers::SUPER)); assert!(matches!( outcome, InputOutcome::ActionThenForward(Action::NewSession) )); } #[test] fn worktree_dialog_enter_creates_worktree_session() { let mut app = test_app(); app.auth_state = AuthState::Done; app.new_worktree_dialog = Some(NewWorktreeDialogState::new()); let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!( outcome, InputOutcome::Action(Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }) )); assert!(app.new_worktree_dialog.is_none()); } #[test] fn worktree_dialog_modified_enter_is_ignored() { let mut app = test_app(); app.auth_state = AuthState::Done; app.new_worktree_dialog = Some(NewWorktreeDialogState::new()); let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::CONTROL)); assert!(matches!(outcome, InputOutcome::Unchanged)); assert!(app.new_worktree_dialog.is_some()); let outcome = app.handle_input(&key_event(KeyCode::Char('w'), KeyModifiers::SHIFT)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "W"); } #[test] fn worktree_dialog_enter_threads_label() { let mut app = test_app(); app.auth_state = AuthState::Done; app.new_worktree_dialog = Some(NewWorktreeDialogState::new()); for c in "wolves".chars() { app.handle_input(&key_event(KeyCode::Char(c), KeyModifiers::NONE)); } let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!( outcome, InputOutcome::Action(Action::NewWorktreeSession { load_session_id: None, label: Some(ref l), git_ref: None, }) if l == "wolves" )); assert!(app.new_worktree_dialog.is_none()); } #[test] fn worktree_dialog_esc_closes() { let mut app = test_app(); app.auth_state = AuthState::Done; app.new_worktree_dialog = Some(NewWorktreeDialogState::new()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!(app.new_worktree_dialog.is_none()); } #[test] fn worktree_dialog_typing_updates_label() { let mut app = test_app(); app.auth_state = AuthState::Done; app.new_worktree_dialog = Some(NewWorktreeDialogState::new()); let outcome = app.handle_input(&key_event(KeyCode::Char('h'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "h"); let outcome = app.handle_input(&key_event(KeyCode::Char('i'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "hi"); } #[test] fn worktree_dialog_backspace_removes_char() { let mut app = test_app(); app.auth_state = AuthState::Done; let mut dialog = NewWorktreeDialogState::new(); dialog.set_label("test"); app.new_worktree_dialog = Some(dialog); let outcome = app.handle_input(&key_event(KeyCode::Backspace, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "tes"); } #[test] fn worktree_dialog_enforces_byte_cap_for_typing_and_middle_paste() { let mut app = test_app(); app.auth_state = AuthState::Done; let mut dialog = NewWorktreeDialogState::new(); dialog.set_label("a".repeat(98)); let _ = dialog.set_cursor_byte(1); app.new_worktree_dialog = Some(dialog); let outcome = app.handle_input(&Event::Paste("éx".to_owned())); assert!(matches!(outcome, InputOutcome::Changed)); let dialog = app.new_worktree_dialog.as_ref().unwrap(); assert_eq!(dialog.label().len(), 100); assert_eq!(&dialog.label()[1.."aé".len()], "é"); let outcome = app.handle_input(&key_event(KeyCode::Char('中'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label().len(), 100); let mut dialog = NewWorktreeDialogState::new(); dialog.set_label("a".repeat(99)); app.new_worktree_dialog = Some(dialog); let _ = app.handle_input(&key_event(KeyCode::Char('é'), KeyModifiers::NONE)); assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label().len(), 99); } #[test] fn worktree_dialog_paste_is_scoped_away_from_welcome_prompt() { let mut app = test_app(); app.auth_state = AuthState::Done; let mut dialog = NewWorktreeDialogState::new(); dialog.set_label("ab"); let _ = dialog.set_cursor_byte(1); app.new_worktree_dialog = Some(dialog); let outcome = app.handle_input(&Event::Paste("中".to_owned())); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "a中b"); assert!(app.welcome_prompt.text().is_empty()); } #[test] fn authenticating_loopback_esc_quits() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Action(Action::Quit))); } #[test] fn authenticating_command_esc_quits() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Command, }; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Action(Action::Quit))); } /// Regression (user report): 'q' must type into the auth-code input, /// not quit. #[test] fn authenticating_loopback_q_types_into_code_input() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; let outcome = app.handle_input(&key_event(KeyCode::Char('q'), KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "typing 'q' must edit the auth code input, got {outcome:?}" ); assert_eq!(app.auth_code_input.text(), "q"); } /// Users reflex-type the displayed device code; bare 'q' must not abort. #[test] fn authenticating_device_and_command_q_does_not_quit() { for mode in [AuthMode::Device, AuthMode::Command] { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode, }; let outcome = app.handle_input(&key_event(KeyCode::Char('q'), KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Unchanged), "bare 'q' must not quit during {mode:?} auth, got {outcome:?}" ); } } /// Advertised cancel keys must survive the bare-'q' removal. #[test] fn authenticating_advertised_cancel_keys_still_quit() { for mode in [AuthMode::Loopback, AuthMode::Device, AuthMode::Command] { for (code, mods) in [ (KeyCode::Char('q'), KeyModifiers::CONTROL), (KeyCode::Char('c'), KeyModifiers::CONTROL), (KeyCode::Esc, KeyModifiers::NONE), ] { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode, }; let outcome = app.handle_input(&key_event(code, mods)); assert!( matches!(outcome, InputOutcome::Action(Action::Quit)), "{code:?}+{mods:?} must still quit during {mode:?} auth, got {outcome:?}" ); } } } #[test] fn authenticating_loopback_char_mutates_input() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; let outcome = app.handle_input(&key_event(KeyCode::Char('a'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.auth_code_input.text(), "a"); } #[test] fn authenticating_loopback_readline_control_chords_are_ignored() { for code in [KeyCode::Char('u'), KeyCode::Char('d')] { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; app.auth_code_input.set_text("token"); let outcome = app.handle_input(&key_event(code, KeyModifiers::CONTROL)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.auth_code_input.text(), "token"); } } #[cfg(target_os = "windows")] #[test] fn authenticating_loopback_altgr_char_mutates_input() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; let outcome = app.handle_input(&key_event( KeyCode::Char('@'), KeyModifiers::CONTROL | KeyModifiers::ALT, )); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.auth_code_input.text(), "@"); } #[test] fn authenticating_loopback_backspace_removes_char() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; app.auth_code_input.set_text("ab"); let outcome = app.handle_input(&key_event(KeyCode::Backspace, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.auth_code_input.text(), "a"); } #[test] fn authenticating_loopback_paste_appends_text() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; app.auth_code_input.set_text("tok"); let outcome = app.handle_input(&Event::Paste("en_value".to_string())); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.auth_code_input.text(), "token_value"); } #[test] fn authenticating_loopback_cursor_edit_and_paste_stay_scoped() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; app.auth_code_input.set_text("ab"); let _ = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); let _ = app.handle_input(&Event::Paste("中\r\n".to_owned())); assert_eq!(app.auth_code_input.text(), "a中b"); assert!(app.welcome_prompt.text().is_empty()); let _ = app.handle_input(&key_event(KeyCode::Delete, KeyModifiers::NONE)); assert_eq!(app.auth_code_input.text(), "a中"); } #[test] fn authenticating_loopback_uses_canonical_super_v_paste() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; crate::clipboard::set_clipboard_probe_hook( crate::clipboard::ClipboardProbeHook::no_raster(Some("secret\r\n")), ); let outcome = app.handle_input(&key_event(KeyCode::Char('v'), KeyModifiers::SUPER)); crate::clipboard::clear_clipboard_probe_hook(); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.auth_code_input.text(), "secret"); assert!(app.welcome_prompt.text().is_empty()); } #[test] fn authenticating_loopback_enter_empty_is_noop() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; app.auth_code_input.set_text(" "); let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Unchanged)); } #[test] fn authenticating_loopback_enter_with_content_submits() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Loopback, }; app.auth_code_input.set_text(" token123 "); let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); match outcome { InputOutcome::Action(Action::SubmitAuthCode(code)) => { assert_eq!(code, "token123"); } other => panic!("expected SubmitAuthCode, got {:?}", other), } } #[test] fn moved_with_button_held_promotes_pending_scrollback_drag() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent .scrollback .push_block(crate::scrollback::RenderBlock::agent_message( "hello world this should wrap across lines", )); agent.scrollback.prepare_layout(40, 10); let mut buf = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 40, 20)); let _ = agent.draw( ratatui::layout::Rect::new(0, 0, 40, 20), &mut buf, &ActionRegistry::defaults(), &mut crate::scrollback::render::ScratchBuffer::new(), None, false, crate::app::agent_view::BannerSlotParams::none(), &BundleState::default(), false, &mut Vec::new(), crate::app::agent_view::AppRenderParams::default(), ); let hit = agent .last_scrollback_selection_model .ranges .first() .and_then(|range| range.lines.first()) .cloned() .expect("expected selectable markdown line"); let down_col = hit.screen_x + hit.selectable_cols.start; let row = hit.screen_y.min(9); let move_col = down_col + 1; let down = left_mouse(MouseEventKind::Down(MouseButton::Left), down_col, row); let moved = left_mouse(MouseEventKind::Moved, move_col, row); assert!(matches!(app.handle_input(&down), InputOutcome::Changed)); let agent = app.agents.get(&id).unwrap(); assert!(agent.pending_text_drag.is_some()); assert!(agent.drag_selection.is_none()); assert!(matches!(app.handle_input(&moved), InputOutcome::Changed)); let agent = app.agents.get(&id).unwrap(); assert!(agent.pending_text_drag.is_some()); assert!(agent.drag_selection.is_some()); } #[test] fn moved_without_button_does_not_promote_pending_scrollback_drag() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent .scrollback .push_block(crate::scrollback::RenderBlock::agent_message( "hello world this should wrap across lines", )); agent.scrollback.prepare_layout(40, 10); let mut buf = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 40, 20)); let _ = agent.draw( ratatui::layout::Rect::new(0, 0, 40, 20), &mut buf, &ActionRegistry::defaults(), &mut crate::scrollback::render::ScratchBuffer::new(), None, false, crate::app::agent_view::BannerSlotParams::none(), &BundleState::default(), false, &mut Vec::new(), crate::app::agent_view::AppRenderParams::default(), ); let hit = agent .last_scrollback_selection_model .ranges .first() .and_then(|range| range.lines.first()) .cloned() .expect("expected selectable markdown line"); let down_col = hit.screen_x + hit.selectable_cols.start; let row = hit.screen_y.min(9); let move_col = down_col + 1; let down = left_mouse(MouseEventKind::Down(MouseButton::Left), down_col, row); let up = left_mouse(MouseEventKind::Up(MouseButton::Left), down_col, row); let moved = left_mouse(MouseEventKind::Moved, move_col, row); assert!(matches!(app.handle_input(&down), InputOutcome::Changed)); assert!(matches!(app.handle_input(&up), InputOutcome::Changed)); let agent = app.agents.get(&id).unwrap(); assert!(!agent.left_mouse_down); assert!(agent.pending_text_drag.is_none()); assert!(agent.drag_selection.is_none()); let outcome = app.handle_input(&moved); assert!(matches!( outcome, InputOutcome::Unchanged | InputOutcome::Changed )); let agent = app.agents.get(&id).unwrap(); assert!(agent.drag_selection.is_none()); } #[test] fn scrollback_click_still_selects_entry_on_mouse_up() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let agent = app.agents.get_mut(&id).unwrap(); agent .scrollback .push_block(crate::scrollback::RenderBlock::agent_message("hello world")); agent.scrollback.prepare_layout(40, 10); let mut buf = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 40, 20)); let _ = agent.draw( ratatui::layout::Rect::new(0, 0, 40, 20), &mut buf, &ActionRegistry::defaults(), &mut crate::scrollback::render::ScratchBuffer::new(), None, false, crate::app::agent_view::BannerSlotParams::none(), &BundleState::default(), false, &mut Vec::new(), crate::app::agent_view::AppRenderParams::default(), ); let hit = agent .last_scrollback_selection_model .ranges .first() .and_then(|range| range.lines.first()) .cloned() .expect("expected selectable markdown line"); let click_col = hit.screen_x + hit.selectable_cols.start; let click_row = hit.screen_y; let down = left_mouse( MouseEventKind::Down(MouseButton::Left), click_col, click_row, ); let up = left_mouse(MouseEventKind::Up(MouseButton::Left), click_col, click_row); assert!(matches!(app.handle_input(&down), InputOutcome::Changed)); assert!(matches!(app.handle_input(&up), InputOutcome::Changed)); let selected_after = app.agents.get(&id).unwrap().scrollback.selected(); assert_eq!(selected_after, Some(0)); } fn make_test_warning() -> crate::startup::StartupWarning { crate::startup::StartupWarning { severity: crate::startup::WarningSeverity::Warning, message: "test warning".to_string(), action: Some("run /terminal-setup".to_string()), } } #[test] fn welcome_d_starts_session_when_no_warnings() { let mut app = test_app(); app.welcome_prompt_focused = true; app.startup_warnings = vec![]; let outcome = app.handle_input(&key_event(KeyCode::Char('d'), KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::ActionThenForward(Action::NewSession)), "Expected NewSession when no warnings, got {outcome:?}" ); } #[test] fn welcome_other_char_starts_session_even_with_warnings() { let mut app = test_app(); app.welcome_prompt_focused = true; app.startup_warnings = vec![make_test_warning()]; let outcome = app.handle_input(&key_event(KeyCode::Char('a'), KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::ActionThenForward(Action::NewSession)), "Expected NewSession for 'a' even with warnings, got {outcome:?}" ); } #[test] fn merge_escapes_both_some_concatenates() { let result = AppView::merge_escapes( Some("notif".into()), Some(crate::terminal::overlay::PostFlush::plain("render".into())), ); assert_eq!( result.as_ref().map(|post| post.as_str()), Some("notifrender") ); } #[test] fn merge_escapes_only_notif() { let result = AppView::merge_escapes(Some("notif".into()), None); assert_eq!(result.as_ref().map(|post| post.as_str()), Some("notif")); } #[test] fn merge_escapes_only_render() { let result = AppView::merge_escapes( None, Some(crate::terminal::overlay::PostFlush::plain("render".into())), ); assert_eq!(result.as_ref().map(|post| post.as_str()), Some("render")); } #[test] fn merge_escapes_both_none() { let result = AppView::merge_escapes(None, None); assert!(result.is_none()); } #[test] fn dashboard_stale_clears_modal_placement_under_kitty() { use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test}; let _g = set_protocol_for_test(GraphicsProtocol::Kitty); let mut app = test_app_with_agent(); let clears = AppView::dashboard_stale_image_clears(&mut app.agents, None); let expected = crate::terminal::overlay::clear_kitty().into_string(); assert_eq!( clears.as_ref().map(|post| post.as_str()), Some(expected.as_str()), "the modal/preview placement (id 1) is deleted every dashboard frame" ); } #[test] fn dashboard_stale_clears_none_without_graphics_protocol() { use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test}; let _g = set_protocol_for_test(GraphicsProtocol::None); let mut app = test_app_with_agent(); let clears = AppView::dashboard_stale_image_clears(&mut app.agents, None); assert!(clears.is_none(), "text-only terminals never get escapes"); } #[test] fn dashboard_stale_clears_drain_undrawn_agent_inline_media() { use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test}; let _g = set_protocol_for_test(GraphicsProtocol::Kitty); let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent .inline_media_ids .insert(std::path::PathBuf::from("/tmp/media.png"), 5); agent.inline_media_active = true; } let clears = AppView::dashboard_stale_image_clears(&mut app.agents, None) .expect("kitty sweep always emits"); assert!( clears .as_str() .contains(&crate::terminal::image::clear_kitty_image(5)), "deletes the undrawn agent's inline placement: {clears:?}" ); let again = AppView::dashboard_stale_image_clears(&mut app.agents, None); let expected = crate::terminal::overlay::clear_kitty().into_string(); assert_eq!( again.as_ref().map(|post| post.as_str()), Some(expected.as_str()), ); } #[test] fn dashboard_stale_clears_skip_attached_popup_agent() { use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test}; let _g = set_protocol_for_test(GraphicsProtocol::Kitty); let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent .inline_media_ids .insert(std::path::PathBuf::from("/tmp/media.png"), 5); agent.inline_media_active = true; } crate::terminal::overlay::reset_owner(); let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']; let _ = crate::terminal::overlay::static_image(&png, 20, 10, 0, 0, 7) .unwrap() .commit(); for _ in 0..2 { assert!(AppView::dashboard_stale_image_clears(&mut app.agents, Some(id)).is_none()); let popup = crate::terminal::overlay::static_image(&png, 20, 10, 0, 0, 7).unwrap(); assert!(!popup.as_str().contains("a=t")); let _ = popup.commit(); } let agent = app.agents.get(&id).unwrap(); assert!(agent.inline_media_active, "drawn agent state is untouched"); assert_eq!(agent.inline_media_ids.len(), 1); } #[test] fn dashboard_too_small_popup_clears_shared_overlay_slot() { use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test}; let _guard = set_protocol_for_test(GraphicsProtocol::Kitty); crate::terminal::overlay::reset_owner(); let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']; let _ = crate::terminal::overlay::static_image(&png, 20, 10, 0, 0, 8) .unwrap() .commit(); let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); let mut dashboard = crate::views::dashboard::DashboardState::new(); let mut buf = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 40, 4)); let (_, _, drawn) = crate::views::dashboard::render_popup_overlay( &mut buf, ratatui::layout::Rect::new(0, 0, 40, 4), &crate::theme::Theme::current(), "Tiny", &mut dashboard, |_inner, _buf| panic!("tiny popup must not draw the agent"), ); assert!(!drawn); let clear = AppView::dashboard_stale_image_clears(&mut app.agents, drawn.then_some(id)).unwrap(); assert!(clear.as_str().contains("a=d")); assert!( !crate::terminal::overlay::static_image(&png, 20, 10, 0, 0, 8) .unwrap() .as_str() .contains("a=t") ); clear.write_to(&mut Vec::new()).unwrap(); assert!( crate::terminal::overlay::static_image(&png, 20, 10, 0, 0, 8) .unwrap() .as_str() .contains("a=t") ); } #[test] fn worktree_mode_round_trip_ask() { let mode = WorktreeMode::from_config_str("ask"); assert_eq!(mode, WorktreeMode::Ask); assert_eq!(mode.as_config_str(), "ask"); } #[test] fn worktree_mode_round_trip_always() { let mode = WorktreeMode::from_config_str("always"); assert_eq!(mode, WorktreeMode::Always); assert_eq!(mode.as_config_str(), "always"); } #[test] fn worktree_mode_round_trip_never() { let mode = WorktreeMode::from_config_str("never"); assert_eq!(mode, WorktreeMode::Never); assert_eq!(mode.as_config_str(), "never"); } #[test] fn worktree_mode_unrecognised_falls_back_to_never() { assert_eq!(WorktreeMode::from_config_str("alway"), WorktreeMode::Never); assert_eq!(WorktreeMode::from_config_str(""), WorktreeMode::Never); assert_eq!(WorktreeMode::from_config_str("ALWAYS"), WorktreeMode::Never); } /// Helper: parse a TOML string and return the document. fn parse_toml(s: &str) -> toml_edit::DocumentMut { s.parse::().expect("valid TOML") } #[test] fn resolve_from_hints_no_keys_returns_defaults() { let doc = parse_toml(""); let (new_s, fork) = WorktreeMode::resolve_from_hints(doc.get("hints")); assert_eq!(new_s, WorktreeMode::Never); assert_eq!(fork, WorktreeMode::Ask); } #[test] fn resolve_from_hints_legacy_key_sets_both() { let doc = parse_toml("[hints]\nworktree_mode = \"always\"\n"); let (new_s, fork) = WorktreeMode::resolve_from_hints(doc.get("hints")); assert_eq!(new_s, WorktreeMode::Always); assert_eq!(fork, WorktreeMode::Always); } #[test] fn resolve_from_hints_per_command_keys_override_legacy() { let doc = parse_toml( "[hints]\n\ worktree_mode = \"always\"\n\ new_session_worktree_mode = \"never\"\n\ fork_worktree_mode = \"ask\"\n", ); let (new_s, fork) = WorktreeMode::resolve_from_hints(doc.get("hints")); assert_eq!(new_s, WorktreeMode::Never); assert_eq!(fork, WorktreeMode::Ask); } #[test] fn resolve_from_hints_only_per_command_keys() { let doc = parse_toml( "[hints]\n\ new_session_worktree_mode = \"ask\"\n\ fork_worktree_mode = \"never\"\n", ); let (new_s, fork) = WorktreeMode::resolve_from_hints(doc.get("hints")); assert_eq!(new_s, WorktreeMode::Ask); assert_eq!(fork, WorktreeMode::Never); } #[test] fn resolve_from_hints_one_per_command_key_other_falls_back_to_legacy() { let doc = parse_toml( "[hints]\n\ worktree_mode = \"always\"\n\ fork_worktree_mode = \"never\"\n", ); let (new_s, fork) = WorktreeMode::resolve_from_hints(doc.get("hints")); assert_eq!(new_s, WorktreeMode::Always); assert_eq!(fork, WorktreeMode::Never); } #[test] fn resolve_from_hints_one_per_command_key_other_falls_back_to_default() { let doc = parse_toml("[hints]\nnew_session_worktree_mode = \"always\"\n"); let (new_s, fork) = WorktreeMode::resolve_from_hints(doc.get("hints")); assert_eq!(new_s, WorktreeMode::Always); assert_eq!(fork, WorktreeMode::Ask); } fn scroll_event(kind: MouseEventKind, column: u16, row: u16) -> Event { Event::Mouse(MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE, }) } #[test] fn opening_workflow_transcript_cancels_pending_scroll_stream() { use crate::input::mouse::{ScrollConfig, ScrollDirection}; let mut app = test_app_with_agent(); let ActiveView::Agent(id) = app.active_view else { panic!("test app must start on an agent"); }; let child_sid = "workflow-child"; let child = idle_child_view(&app, 1, child_sid); let agent = app.agents.get_mut(&id).unwrap(); agent.subagent_views.insert(child_sid.to_owned(), child); agent .workflow_runs .push(crate::views::workflows::WorkflowRunSnapshot { run_id: "wf_run".to_owned(), name: "deep-research".to_owned(), objective: "obj".to_owned(), status: "active".to_owned(), management_available: true, builtin: false, phases: vec![("Research".to_owned(), "active".to_owned())], current_phase: Some("Research".to_owned()), agents: vec![crate::views::workflows::WorkflowAgentRowView { agent_id: child_sid.to_owned(), label: "researcher".to_owned(), phase: Some("Research".to_owned()), model: None, state: "running".to_owned(), tokens_used: 0, duration_ms: 0, }], agent_budget: None, agents_used: 0, agents_reserved: 0, agents_remaining: None, agent_usage_incomplete: false, active_agents: 1, elapsed_ms: 0, received_at: std::time::Instant::now(), pause_message: None, result_summary: None, }); agent.show_workflows = true; agent.workflows_view.detail_run_id = Some("wf_run".to_owned()); let _ = app .scroll_state .on_scroll_event(ScrollDirection::Up, ScrollConfig::default()); app.last_scroll_pos = Some((30, 12)); assert!(app.scroll_state.has_active_stream()); let out = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(out, InputOutcome::Changed)); assert_eq!(app.agents[&id].active_subagent.as_deref(), Some(child_sid)); assert!(!app.scroll_state.has_active_stream()); assert_eq!(app.last_scroll_pos, None); } #[test] fn scroll_event_stashes_origin_for_residual_flush() { let mut app = test_app(); assert!(app.last_scroll_pos.is_none()); let _ = app.handle_input(&scroll_event(MouseEventKind::ScrollDown, 42, 17)); assert_eq!(app.last_scroll_pos, Some((42, 17))); let _ = app.handle_input(&scroll_event(MouseEventKind::ScrollUp, 7, 3)); assert_eq!(app.last_scroll_pos, Some((7, 3))); } #[test] fn scroll_event_does_not_stash_when_blocking_modal_open() { let mut app = test_app(); app.new_worktree_dialog = Some(NewWorktreeDialogState::new()); assert!(app.is_scroll_blocking_modal_open()); let _ = app.handle_input(&scroll_event(MouseEventKind::ScrollDown, 42, 17)); assert!( app.last_scroll_pos.is_none(), "scroll events must be ignored while a scroll-blocking modal is open", ); } #[test] fn welcome_privacy_banner_hover_triggers_redraw() { let mut app = test_app(); app.active_view = ActiveView::Welcome; app.welcome_privacy_banner_accept_rect = Some(ratatui::layout::Rect::new(50, 10, 8, 1)); app.welcome_privacy_banner_customize_rect = Some(ratatui::layout::Rect::new(25, 10, 24, 1)); app.welcome_privacy_banner_legal_rect = Some(ratatui::layout::Rect::new(2, 11, 45, 1)); let over = left_mouse(MouseEventKind::Moved, 52, 10); assert!(matches!(app.handle_input(&over), InputOutcome::Changed)); assert!(app.welcome_on_privacy_banner); let cross = left_mouse(MouseEventKind::Moved, 30, 10); assert!(matches!(app.handle_input(&cross), InputOutcome::Changed)); assert!(app.welcome_on_privacy_banner); let over_legal = left_mouse(MouseEventKind::Moved, 10, 11); assert!(matches!( app.handle_input(&over_legal), InputOutcome::Changed )); assert!(app.welcome_on_privacy_banner); let leave = left_mouse(MouseEventKind::Moved, 5, 5); assert!(matches!(app.handle_input(&leave), InputOutcome::Changed)); assert!(!app.welcome_on_privacy_banner); assert!(matches!(app.handle_input(&leave), InputOutcome::Unchanged)); } #[test] fn welcome_doc_viewer_is_scroll_blocking_and_wheel_scrolls_content() { let mut app = test_app(); app.active_view = ActiveView::Welcome; app.welcome_doc_viewer = Some(crate::views::modal::ActiveModal::DocViewer { title: "Release Notes".into(), content: "line\n".repeat(80), scroll: 0, window: crate::views::modal_window::ModalWindowState::new(), cached_lines: None, previous_palette: None, standalone: true, }); assert!( app.is_scroll_blocking_modal_open(), "welcome release-notes overlay must block background scroll", ); let outcome = app.handle_input(&scroll_event(MouseEventKind::ScrollDown, 40, 12)); assert!( matches!(outcome, InputOutcome::Changed), "wheel must be handled by the doc viewer", ); assert!( app.last_scroll_pos.is_none(), "wheel must not reach the background scroll path while release notes are open", ); let scroll = match app.welcome_doc_viewer.as_ref() { Some(crate::views::modal::ActiveModal::DocViewer { scroll, .. }) => *scroll, _ => panic!("expected DocViewer"), }; assert!(scroll > 0, "wheel must advance doc scroll, got {scroll}"); } #[test] fn tutorial_is_scroll_blocking_and_wheel_scrolls_topic() { let mut app = test_app(); app.active_view = ActiveView::Welcome; let mut tut = crate::views::tutorial::TutorialState::new(); let _ = crate::views::tutorial::handle_tutorial_input( &Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), &mut tut, ); app.tutorial = Some(tut); assert!( app.is_scroll_blocking_modal_open(), "tutorial overlay must block background scroll", ); let outcome = app.handle_input(&scroll_event(MouseEventKind::ScrollDown, 40, 12)); assert!(matches!(outcome, InputOutcome::Changed)); assert!( app.last_scroll_pos.is_none(), "wheel must not reach the background scroll path while the tutorial is open", ); let tut = app.tutorial.as_ref().expect("tutorial stays open"); assert!( tut.scroll > 0, "wheel must advance topic scroll, got {}", tut.scroll ); } #[test] fn tutorial_esc_on_list_closes_overlay() { let mut app = test_app(); app.active_view = ActiveView::Welcome; app.tutorial = Some(crate::views::tutorial::TutorialState::new()); let outcome = app.handle_input(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); assert!(matches!(outcome, InputOutcome::Changed)); assert!( app.tutorial.is_none(), "Esc on the list closes the tutorial" ); } #[test] fn dashboard_shortcuts_modal_is_scroll_blocking() { let mut app = test_app(); app.active_view = ActiveView::AgentDashboard; let mut d = crate::views::dashboard::DashboardState::new(); let entries = Vec::new(); let state = crate::views::shortcuts_help::build_initial_picker_state(&entries); d.shortcuts_modal = Some(Box::new(crate::views::dashboard::ShortcutsModalState { entries, state, window: Default::default(), filter_active: false, collapsed_sections: Default::default(), expanded_ids: std::collections::HashSet::new(), mode: crate::views::shortcuts_help::ShortcutsHelpMode::Browse, })); app.dashboard = Some(d); assert!( app.is_scroll_blocking_modal_open(), "an open dashboard cheatsheet must block background scroll", ); let _ = app.handle_input(&scroll_event(MouseEventKind::ScrollDown, 42, 17)); assert!( app.last_scroll_pos.is_none(), "wheel must not reach the background scroll path while the cheatsheet is open", ); } /// Ctrl+C on the session-less dashboard arms the quit confirmation /// (like the agent view) and a second press confirms. Regression for /// "Ctrl+C/D/Q do nothing on the dashboard prompt". #[test] fn ctrl_c_on_dashboard_arms_then_confirms_quit() { let mut app = test_app(); app.active_view = ActiveView::AgentDashboard; app.dashboard = Some(crate::views::dashboard::DashboardState::new()); let outcome = app.handle_input(&key_event(KeyCode::Char('c'), KeyModifiers::CONTROL)); assert!(matches!(outcome, InputOutcome::Changed)); assert!( app.pending_action.is_some(), "Ctrl+C on the dashboard must arm a pending quit confirmation" ); let outcome = app.handle_input(&key_event(KeyCode::Char('c'), KeyModifiers::CONTROL)); assert!( matches!(outcome, InputOutcome::Action(Action::Quit)), "second Ctrl+C must quit, got {outcome:?}" ); } /// Ctrl+Q on the dashboard arms quit via the global `When::Always` /// lookup (it's not bound to `When::DashboardFocused`). #[test] fn ctrl_q_on_dashboard_arms_quit() { let mut app = test_app(); app.active_view = ActiveView::AgentDashboard; app.dashboard = Some(crate::views::dashboard::DashboardState::new()); let outcome = app.handle_input(&key_event(KeyCode::Char('q'), KeyModifiers::CONTROL)); assert!(matches!(outcome, InputOutcome::Changed)); assert!( app.pending_action.is_some(), "Ctrl+Q on the dashboard must arm a pending quit confirmation" ); } /// Ctrl+Space on the dashboard resolves to `VoiceToggle` via the global /// `When::Always` fallthrough — the dispatch input ignores the chord, so it /// falls through to `handle_global_action`. (The event loop intercepts /// Ctrl+Space before this for hold-to-talk/toggle when voice is enabled; /// this registry route is the cheatsheet/command-palette fallback.) #[test] fn ctrl_space_on_dashboard_routes_to_voice_toggle() { let mut app = test_app(); pin_non_vscode_registry(&mut app); app.active_view = ActiveView::AgentDashboard; app.dashboard = Some(crate::views::dashboard::DashboardState::new()); let outcome = app.handle_input(&key_event(KeyCode::Char(' '), KeyModifiers::CONTROL)); assert!( matches!(outcome, InputOutcome::Action(Action::VoiceToggle)), "Ctrl+Space on the dashboard must route to VoiceToggle, got {outcome:?}" ); } /// With `[ui].voice_keybind_enabled = false` the global fallthrough must /// swallow the chord — otherwise Ctrl+Space would still start dictation via /// the registry route whenever the event-loop intercept skips it. #[test] fn ctrl_space_on_dashboard_ignored_when_keybind_disabled() { let mut app = test_app(); pin_non_vscode_registry(&mut app); app.active_view = ActiveView::AgentDashboard; app.dashboard = Some(crate::views::dashboard::DashboardState::new()); app.current_ui.voice_keybind_enabled = Some(false); let outcome = app.handle_input(&key_event(KeyCode::Char(' '), KeyModifiers::CONTROL)); assert!( !matches!(outcome, InputOutcome::Action(Action::VoiceToggle)), "Ctrl+Space must be inert with the voice shortcut disabled, got {outcome:?}" ); } /// Esc while voice is recording on the dashboard must STOP voice (route to /// `VoiceToggle`) rather than fall into the dashboard's Esc cascade /// (clear filter / unfocus / deselect / exit). #[test] fn esc_on_dashboard_while_listening_stops_voice() { let mut app = test_app(); pin_non_vscode_registry(&mut app); app.active_view = ActiveView::AgentDashboard; app.dashboard = Some(crate::views::dashboard::DashboardState::new()); app.voice_state = VoiceState::Recording { hold: false, target: VoiceTarget::DashboardDispatch, interim: None, }; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::VoiceToggle)), "Esc while recording on the dashboard must stop voice, got {outcome:?}" ); } /// Esc on the dashboard while NOT recording must keep its normal cascade /// behaviour (here: not a `VoiceToggle`). #[test] fn esc_on_dashboard_not_listening_does_not_toggle_voice() { let mut app = test_app(); pin_non_vscode_registry(&mut app); app.active_view = ActiveView::AgentDashboard; app.dashboard = Some(crate::views::dashboard::DashboardState::new()); app.voice_state = VoiceState::Idle; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::VoiceToggle)), "Esc must not toggle voice when not recording, got {outcome:?}" ); } /// Esc with a voice cold-start still queued (pipeline spawning, mic not yet /// open) must cancel it so the event loop doesn't open the mic after the user /// backed out — even though `voice_listening` is still false. #[test] fn esc_cancels_pending_voice_cold_start() { let mut app = test_app(); pin_non_vscode_registry(&mut app); app.active_view = ActiveView::AgentDashboard; app.dashboard = Some(crate::views::dashboard::DashboardState::new()); app.voice_state = VoiceState::ColdStart { hold: false, target: VoiceTarget::DashboardDispatch, }; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert!( !app.voice_state.pending_cold_start(), "Esc must cancel the queued cold-start" ); assert!( app.voice_recording_target().is_none(), "target dropped on cancel" ); } /// The dictation overlay must only render on the surface that owns the bound /// target. After an explicit stop the interim is kept (`Stopping`) for a /// trailing final, so navigating away must not flash it on the wrong box. #[test] fn voice_overlay_bound_to_target_surface() { let id = super::super::agent::AgentId(0); let mut app = test_app(); app.voice_state = VoiceState::Stopping { target: VoiceTarget::Agent(id), interim: Some("partial".into()), }; app.active_view = ActiveView::Agent(id); assert!( app.voice_target_on_active_surface(), "overlay shows on the agent that owns the dictation" ); app.active_view = ActiveView::AgentDashboard; assert!( !app.voice_target_on_active_surface(), "overlay hidden once the user navigates off the target surface" ); } /// Entering a session from the dashboard sets `active_view = Agent(id)` but /// leaves `attached_agent = Some(id)` as a return breadcrumb. The agent is /// fullscreen, so dictation into its prompt must stay on-surface and the /// bind-enforcer must not auto-stop it. Regression: recording bar missing /// after clicking into a session. (Popup-over-dashboard suppression is /// covered by `dispatch::tests::voice_suppressed_while_dashboard_popup_open`.) #[test] fn voice_target_on_agent_entered_from_dashboard() { let id = super::super::agent::AgentId(0); let mut app = test_app(); app.voice_state = VoiceState::Recording { hold: false, target: VoiceTarget::Agent(id), interim: None, }; app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); app.dashboard.as_mut().unwrap().attached_agent = Some(id); assert!(app.voice_target_on_active_surface()); app.enforce_voice_session_bound(); assert!( app.voice_listening(), "entering a session from the dashboard must not auto-stop the mic" ); } /// Attach a popup overlay onto a freshly-built `test_app_with_agent` /// and return the attached agent id. Convenience for the /// popup-handle-input tests. /// NOTE: this helper bypasses /// `dispatch_dashboard_attach`. The action-dispatcher path /// (which sets `attached_agent` via `Action::DashboardAttach(...)`) /// is pinned by tests in `dispatch.rs` /// (`dashboard_attach_top_level_opens_popup_overlay`, /// `dashboard_attach_subagent_opens_popup_with_subagent`). /// `attach_popup` exists so the `handle_input`/`dispatch_scroll` /// tests in this file can stand up a popup'd state in two lines /// without re-exercising the dispatcher each time. fn attach_popup(app: &mut AppView) -> super::super::agent::AgentId { app.active_view = ActiveView::AgentDashboard; let id = super::super::agent::AgentId(0); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(id)); } id } /// Esc keystroke closes the popup at the /// `AppView::handle_input` layer (not the dispatch layer the /// other tests exercise). #[test] fn handle_input_esc_closes_popup_overlay() { let mut app = test_app_with_agent(); let id = attach_popup(&mut app); assert_eq!(app.dashboard.as_ref().unwrap().attached_agent, Some(id)); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.dashboard.as_ref().unwrap().attached_agent, None); } /// Esc on a neutral overlay (scrollback focused, no modals /// or viewers, no text selection or link highlight, no /// question / goal / rewind / permission overlays) closes the /// dashboard session overlay — mirrors the `q` shortcut and /// gives users a single-key back-out from agent detail to /// the dashboard. The Esc cascade is preserved for non- /// neutral states: see `overlay_esc_passes_through_when_*`. #[test] fn overlay_esc_exits_when_agent_is_neutral() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc on a neutral overlay must request DashboardOverlayExit, got {outcome:?}", ); } /// In a dashboard overlay an empty, Normal-mode prompt-focused Esc backs /// out to the dashboard (attach lands on Prompt /// focus, so without this Esc would silently arm the agent's rewind policy /// instead of returning to the list). #[test] fn overlay_esc_backs_out_when_empty_normal_prompt() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; assert!(agent.prompt.text().is_empty()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "empty prompt Esc in an overlay must back out to the dashboard, got {outcome:?}", ); assert!(app.pending_action.is_none()); } /// Overlay + open `/btw` + empty Normal prompt: Esc dismisses `/btw`, not /// dashboard back-out; a follow-up Esc still exits when the guard holds. #[test] fn overlay_esc_dismisses_btw_before_dashboard_backout() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::done( "side question".into(), "side answer".into(), )); let first = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(first, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc with open /btw must not exit the overlay, got {first:?}", ); assert!(app.agents.get(&id).unwrap().btw_state.is_none()); let second = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(second, InputOutcome::Action(Action::DashboardOverlayExit)), "second Esc with no /btw must back out to the dashboard, got {second:?}", ); } /// Regression: in an overlay, a bare Esc while a turn is /// RUNNING must swallow (matching full-screen vim mode), NOT detach to the /// dashboard and NOT cancel. The empty-prompt back-out is idle-gated, so Esc /// falls through to `try_handle_esc_policy` → mid-turn swallow. #[test] fn overlay_esc_running_turn_empty_prompt_swallows_not_backout() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.session.state = AgentState::TurnRunning; agent.vim_mode = true; assert!(agent.prompt.text().is_empty()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "running-turn overlay Esc (empty prompt) must swallow, not detach/cancel, got {outcome:?}", ); assert!( !matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "Esc must not cancel mid-turn", ); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc must not detach mid-turn", ); assert!(app.agents[&id].cancel_trigger_hint.is_none()); assert!(app.pending_action.is_none()); } /// Regression: in an overlay, a bare Esc from the /// (neutral) bare-scrollback pane while a turn is RUNNING must swallow, NOT /// detach — the neutral back-out is idle-gated. The fixture is otherwise /// neutral (so the gate, not a missing-neutral, is what suppresses detach). #[test] fn overlay_esc_running_turn_scrollback_swallows_not_backout() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Scrollback; agent.session.state = AgentState::TurnRunning; agent.vim_mode = true; assert!(agent.is_bare_scrollback() && agent.no_input_overlay_pending()); assert!(agent.no_esc_consumer_pending()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Changed), "running-turn overlay Esc (scrollback) must swallow, not detach/cancel, got {outcome:?}", ); assert!( !matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "Esc must not cancel mid-turn", ); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc must not detach mid-turn", ); assert!(app.agents[&id].cancel_trigger_hint.is_none()); } /// Overlay + non-vim: mid-turn Esc CANCELS (matching full-screen), and /// still must not detach to the dashboard. #[test] fn overlay_esc_running_turn_non_vim_cancels_not_backout() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.session.state = AgentState::TurnRunning; agent.vim_mode = false; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "running-turn overlay Esc must cancel in non-vim mode, got {outcome:?}", ); assert_eq!( app.agents[&id].cancel_trigger_hint, Some(crate::app::actions::CancelTrigger::Esc) ); } /// Overlay + TurnCancelling: Esc retries cancel (does not detach). #[test] fn overlay_esc_cancelling_scrollback_retries_cancel_not_backout() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Scrollback; agent.session.state = AgentState::TurnCancelling; assert!(agent.is_bare_scrollback() && agent.no_input_overlay_pending()); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "cancelling overlay Esc must retry CancelTurn, got {outcome:?}", ); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc must not detach while cancelling", ); } /// Counterpart to the back-out: a NON-EMPTY draft Esc in an overlay must /// pass through to the agent's policy (arms "press again to clear"), never /// back out — so the user doesn't lose a draft by reaching for the dashboard. #[test] fn overlay_esc_with_draft_arms_clear_not_backout() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.prompt.textarea.set_text("draft in overlay"); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "a drafted overlay prompt Esc must NOT back out, got {outcome:?}", ); let pending = app.pending_action.as_ref().expect("clear arm"); assert_eq!(pending.label, Some("clear")); } /// A Bash/Remember/Feedback empty prompt keeps Esc as its mode-exit even in /// an overlay — the back-out is gated to `PromptInputMode::Normal`, so the /// special-mode Esc is not stolen as a dashboard back-out. #[test] fn overlay_esc_in_bash_mode_exits_mode_not_backout() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.prompt_input_mode = crate::app::agent_view::PromptInputMode::Bash; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "empty bash-mode Esc in an overlay must exit the mode, not back out, got {outcome:?}", ); assert_eq!( app.agents[&id].prompt_input_mode, crate::app::agent_view::PromptInputMode::Normal, "Esc must have exited bash mode", ); } /// A live highlighted link consumes Esc (the agent's scrollback /// handler clears it). We mustn't pre-empt that — the overlay /// closes only after the per-pane Esc work is drained. #[test] fn overlay_esc_passes_through_when_link_highlight_present() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } app.agents.get_mut(&id).unwrap().highlighted_link_idx = Some(0); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc with a highlighted link must clear the highlight first, got {outcome:?}", ); } /// Build an app with a neutral agent attached as the dashboard overlay. fn neutral_overlay_app() -> (AppView, super::super::agent::AgentId) { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); app.dashboard = Some(crate::views::dashboard::DashboardState::new()); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(id); } (app, id) } /// With a pending input overlay, neither `q` nor `Esc` is consumed as a /// dashboard-overlay exit — both fall through to the agent (the scrollback /// handler, not the overlay handler). #[test] fn overlay_q_esc_do_not_exit_while_input_overlay_pending() { let installers: [fn(&mut AgentView); 2] = [ |a| { a.cancel_turn_view = Some(crate::views::modal::CancelTurnViewState { active_idx: 0, running_count: 1, }); }, |a| { let request = crate::views::plan_approval_view::ExitPlanModeExtRequest { session_id: "s".into(), tool_call_id: "c".into(), plan_content: Some("p".into()), }; let stashed = crate::views::prompt_widget::StashedPrompt { text: String::new(), cursor: 0, images: Vec::new(), chip_elements: Vec::new(), image_counter: 0, image_undo_stash: Vec::new(), }; let view = crate::views::plan_approval_view::PlanApprovalViewState::new( request, stashed, tokio::sync::oneshot::channel().0, ); a.plan_approval_view = Some(view); }, ]; for key in [KeyCode::Char('q'), KeyCode::Esc] { let (mut app, id) = neutral_overlay_app(); assert!( app.agents.get(&id).unwrap().is_bare_scrollback() && app.agents.get(&id).unwrap().no_input_overlay_pending(), "fixture must start neutral", ); let bare = app.handle_input(&key_event(key, KeyModifiers::NONE)); assert!( matches!(bare, InputOutcome::Action(Action::DashboardOverlayExit)), "neutral {key:?} must exit, got {bare:?}", ); for &install in &installers { let (mut app, id) = neutral_overlay_app(); install(app.agents.get_mut(&id).unwrap()); let outcome = app.handle_input(&key_event(key, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "{key:?} with an input overlay pending must fall through, got {outcome:?}", ); } } } /// Left arrow on an empty, prompt-focused overlay backs out to the /// dashboard — the mirror of the dashboard's Right-arrow "open /// detail". Requires the prompt to be focused with an empty buffer. #[test] fn overlay_left_arrow_empty_prompt_exits_to_dashboard() { let (mut app, id) = neutral_overlay_app(); app.agents.get_mut(&id).unwrap().active_pane = crate::app::agent_view::AgentPane::Prompt; let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Left on an empty focused prompt must exit the overlay, got {outcome:?}", ); } /// `/gboom` is opened from an empty prompt — the exact state where the /// dashboard overlay steals Left/Esc as back-out. Both must reach the game. #[test] fn overlay_gboom_owns_left_and_esc() { let (mut app, id) = neutral_overlay_app(); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.gboom = Some(crate::gboom::GboomState::new()); } let left = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( !matches!(left, InputOutcome::Action(Action::DashboardOverlayExit)), "Left with /gboom open must reach the game, got {left:?}", ); assert!( app.agents.get(&id).unwrap().gboom.is_some(), "Left must not close /gboom", ); let esc = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(esc, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc with /gboom open must close the game, not the overlay, got {esc:?}", ); assert!( app.agents.get(&id).unwrap().gboom.is_none(), "Esc should close /gboom", ); } /// Left arrow with an active prompt history search (empty draft) is NOT /// an overlay exit — the search owns the key (Left moves its query caret), /// so it must reach the agent rather than backing out to the dashboard. #[test] fn overlay_left_arrow_history_search_active_does_not_exit() { let (mut app, id) = neutral_overlay_app(); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.prompt.history_search.activate(&[], ""); assert!( agent.prompt.text().is_empty(), "fixture draft must be empty" ); } let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Left with an active history search must reach the agent, got {outcome:?}", ); } /// Left arrow with the `@` file-search dropdown open is NOT an overlay exit /// — the prompt widget owns picker nav (Right drills in, Up/Down move the /// selection), so the key must reach the agent rather than backing out. In /// production an open dropdown implies a non-empty draft (the `@` token); /// we force the decoupled state to isolate the explicit file-search guard. #[test] fn overlay_left_arrow_file_search_open_does_not_exit() { let (mut app, id) = neutral_overlay_app(); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; let ctx = crate::views::file_search::context::detect("@", 1).expect("@-context must parse"); agent.prompt.file_search.set_test_state( ctx, vec![xai_grok_workspace::file_system::FuzzyMatchResult { path: nucleo::Utf32String::from("src"), score: 100, indices: Vec::new(), is_dir: true, }], 0, ); assert!( agent.prompt.file_search_visible(), "fixture must open the @ dropdown", ); assert!( agent.prompt.text().is_empty(), "fixture keeps the draft empty to isolate the file-search guard", ); assert!( !agent.is_empty_focused_prompt(), "an open @ dropdown must fail the empty-focused-prompt guard", ); } let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Left with the @ dropdown open must reach the agent, got {outcome:?}", ); } /// Left arrow with a non-empty prompt draft is NOT an overlay exit — /// it falls through to the prompt so it moves the caret within the /// text rather than closing the agent detail. #[test] fn overlay_left_arrow_with_draft_does_not_exit() { let (mut app, id) = neutral_overlay_app(); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.prompt.set_text("draft"); } let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Left with a non-empty prompt must NOT exit the overlay, got {outcome:?}", ); } /// Left arrow while the scrollback pane is focused is NOT an overlay /// exit — it must reach the agent so the scrollback's `Left=collapse` /// binding keeps working (the back-out is prompt-only). #[test] fn overlay_left_arrow_in_scrollback_does_not_exit() { let (mut app, _id) = neutral_overlay_app(); let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Left in scrollback must reach the agent, got {outcome:?}", ); } /// An open modal (extensions modal or `active_modal`) makes /// `is_empty_focused_prompt` false even on an empty, prompt-focused /// composer, so the modal — not the overlay back-out — owns Esc/Left. #[test] fn overlay_open_modal_fails_empty_focused_prompt_guard() { let (mut app, id) = neutral_overlay_app(); let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; assert!( agent.is_empty_focused_prompt(), "bare empty prompt must satisfy the guard", ); agent.extensions_modal = Some(crate::views::extensions_modal::ExtensionsModalState::new( crate::views::extensions_modal::ExtensionsTab::Plugins, )); assert!( !agent.is_empty_focused_prompt(), "an open extensions modal must fail the guard", ); agent.extensions_modal = None; agent.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette { entries: Vec::new(), state: crate::views::picker::PickerState::default(), window: crate::views::modal_window::ModalWindowState::new(), }); assert!( !agent.is_empty_focused_prompt(), "an open active_modal must fail the guard", ); agent.active_modal = None; assert!( agent.is_empty_focused_prompt(), "clearing the modals restores the guard", ); } /// With an agent attached (dashboard overlay) and the extensions modal /// open on the Prompt pane, Esc/Left must reach the modal rather than /// backing out to the dashboard. Esc closes the modal; Left folds / /// is consumed by the modal — neither yields `DashboardOverlayExit`. #[test] fn overlay_modal_open_esc_left_do_not_exit() { let (mut app, id) = neutral_overlay_app(); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.extensions_modal = Some(crate::views::extensions_modal::ExtensionsModalState::new( crate::views::extensions_modal::ExtensionsTab::Plugins, )); } let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc with the extensions modal open must not back out, got {outcome:?}", ); assert!( app.agents[&id].extensions_modal.is_none(), "Esc must reach the modal handler and close it", ); let (mut app, id) = neutral_overlay_app(); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.extensions_modal = Some(crate::views::extensions_modal::ExtensionsModalState::new( crate::views::extensions_modal::ExtensionsTab::Plugins, )); } let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Left with the extensions modal open must not back out, got {outcome:?}", ); assert!( app.agents[&id].extensions_modal.is_some(), "Left must reach the modal (fold), keeping it open", ); for code in [KeyCode::Esc, KeyCode::Left] { let (mut app, id) = neutral_overlay_app(); { let agent = app.agents.get_mut(&id).unwrap(); agent.active_pane = crate::app::agent_view::AgentPane::Prompt; agent.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette { entries: Vec::new(), state: crate::views::picker::PickerState::default(), window: crate::views::modal_window::ModalWindowState::new(), }); } let outcome = app.handle_input(&key_event(code, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "{code:?} with active_modal open must not back out, got {outcome:?}", ); } } /// The graduated plan/Q&A back-out also defers to an open modal: with a /// single-question Q&A overlay at its back-out top AND a modal open, both /// `overlay_esc_backs_out` and `overlay_left_backs_out` return false (a modal /// and a question view can coexist when the ACP handler installs the overlay /// without closing the modal). #[test] fn graduated_back_out_defers_to_open_modal() { let (mut app, id) = neutral_overlay_app(); install_question_overlay(&mut app, id, 1); { let a = app.agents.get(&id).unwrap(); assert!( a.overlay_esc_backs_out() && a.overlay_left_backs_out(), "fixture must be a back-out-top state without a modal", ); } app.agents.get_mut(&id).unwrap().extensions_modal = Some(crate::views::extensions_modal::ExtensionsModalState::new( crate::views::extensions_modal::ExtensionsTab::Plugins, )); { let a = app.agents.get(&id).unwrap(); assert!( !a.overlay_esc_backs_out() && !a.overlay_left_backs_out(), "an open extensions modal must suppress the graduated back-out", ); } app.agents.get_mut(&id).unwrap().extensions_modal = None; app.agents.get_mut(&id).unwrap().active_modal = Some(crate::views::modal::ActiveModal::CommandPalette { entries: Vec::new(), state: crate::views::picker::PickerState::default(), window: crate::views::modal_window::ModalWindowState::new(), }); { let a = app.agents.get(&id).unwrap(); assert!( !a.overlay_esc_backs_out() && !a.overlay_left_backs_out(), "an open active_modal must suppress the graduated back-out", ); } } /// Install a plan-approval overlay on the agent and put it in the /// "focused dashboard overlay, prompt pane" state the graduated /// back-out cares about. fn install_plan_overlay(app: &mut AppView, id: super::super::agent::AgentId) { let a = app.agents.get_mut(&id).unwrap(); a.in_dashboard_overlay = true; a.active_pane = crate::app::agent_view::AgentPane::Prompt; let request = crate::views::plan_approval_view::ExitPlanModeExtRequest { session_id: "s".into(), tool_call_id: "c".into(), plan_content: Some("p".into()), }; let stashed = crate::views::prompt_widget::StashedPrompt { text: String::new(), cursor: 0, images: Vec::new(), chip_elements: Vec::new(), image_counter: 0, image_undo_stash: Vec::new(), }; let mut view = crate::views::plan_approval_view::PlanApprovalViewState::new( request, stashed, tokio::sync::oneshot::channel().0, ); view.focus = crate::views::plan_approval_view::PlanApprovalFocus::Prompt; a.plan_approval_view = Some(view); } /// Install a Q&A overlay with `n_questions` single-select questions, /// focused in the dashboard overlay's Navigation surface. fn install_question_overlay( app: &mut AppView, id: super::super::agent::AgentId, n_questions: usize, ) { use crate::views::question_view::QuestionViewState; use xai_grok_tools::implementations::grok_build::ask_user_question::{ Question, QuestionOption, }; let questions: Vec = (0..n_questions) .map(|i| Question { question: format!("Q{i}?"), options: vec![QuestionOption { label: "opt".into(), description: String::new(), preview: None, id: None, }], multi_select: None, id: None, }) .collect(); let a = app.agents.get_mut(&id).unwrap(); a.in_dashboard_overlay = true; a.active_pane = crate::app::agent_view::AgentPane::Prompt; a.question_view = Some(QuestionViewState::new( "c".into(), questions, crate::views::prompt_widget::StashedPrompt::default(), )); } /// Graduated back-out: at the plan feedback top state (empty prompt, /// no pending comment) a bare Esc returns to the dashboard, leaving /// the plan overlay pending (no approve / reject is sent). #[test] fn overlay_esc_exits_at_plan_top_state() { let (mut app, id) = neutral_overlay_app(); install_plan_overlay(&mut app, id); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc at the plan top state must back out, got {outcome:?}", ); assert!( app.agents.get(&id).unwrap().plan_approval_view.is_some(), "backing out must leave the plan overlay pending (unanswered)", ); } /// A typed feedback draft is NOT a top state — Esc keeps its /// in-overlay meaning so the draft isn't lost to an accidental exit. #[test] fn overlay_esc_does_not_exit_with_plan_draft() { let (mut app, id) = neutral_overlay_app(); install_plan_overlay(&mut app, id); app.agents.get_mut(&id).unwrap().prompt.set_text("feedback"); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc with a plan feedback draft must NOT back out, got {outcome:?}", ); } /// Graduated back-out: in the Q&A Navigation surface with nothing /// selected, a bare Esc (whose only job there is to unselect) backs /// out to the dashboard instead of dead-ending. #[test] fn overlay_esc_exits_when_question_nav_unselected() { let (mut app, id) = neutral_overlay_app(); install_question_overlay(&mut app, id, 1); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc with nothing selected must back out, got {outcome:?}", ); assert!( app.agents.get(&id).unwrap().question_view.is_some(), "backing out must leave the question overlay pending", ); } /// Multi-question Q&A: on question 2+ a bare `Esc` must NOT back out — the /// flow isn't at its top, so `Esc` stays in-flow (the question view handles /// it) and `Left` can still walk back. Only `active_tab == 0` is the /// back-out top. #[test] fn overlay_esc_does_not_exit_on_later_multi_question() { let (mut app, id) = neutral_overlay_app(); install_question_overlay(&mut app, id, 2); app.agents .get_mut(&id) .unwrap() .question_view .as_mut() .unwrap() .next_question(); assert_eq!( app.agents .get(&id) .unwrap() .question_view .as_ref() .unwrap() .active_tab, 1, "fixture must be on the second question", ); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc on question 2+ of a multi-question Q&A must stay in-flow, got {outcome:?}", ); } /// ...but from question 1 (the top of a multi-question flow) with nothing /// selected, a bare `Esc` still backs out, leaving the Q&A pending. #[test] fn overlay_esc_exits_at_first_multi_question() { let (mut app, id) = neutral_overlay_app(); install_question_overlay(&mut app, id, 2); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc at question 1 of a multi-question Q&A must back out, got {outcome:?}", ); assert!( app.agents.get(&id).unwrap().question_view.is_some(), "backing out must leave the question overlay pending", ); } /// With an option selected, Esc has something to clear — it must NOT /// back out (the first Esc unselects; a second, now-unselected Esc /// would exit). #[test] fn overlay_esc_does_not_exit_when_question_option_selected() { let (mut app, id) = neutral_overlay_app(); install_question_overlay(&mut app, id, 1); app.agents .get_mut(&id) .unwrap() .question_view .as_mut() .unwrap() .select_option(0, 0); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc with a selection must unselect first, not back out, got {outcome:?}", ); } /// Left backs out of a single-question Q&A (Left has no prev question /// to step to), but with multiple questions Left switches question /// and must NOT exit. #[test] fn overlay_left_exits_single_question_only() { let (mut app, id) = neutral_overlay_app(); install_question_overlay(&mut app, id, 1); let single = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( matches!(single, InputOutcome::Action(Action::DashboardOverlayExit)), "Left in a single-question Q&A must back out, got {single:?}", ); let (mut app, id) = neutral_overlay_app(); install_question_overlay(&mut app, id, 2); let multi = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( !matches!(multi, InputOutcome::Action(Action::DashboardOverlayExit)), "Left in a multi-question Q&A must switch question, not back out, got {multi:?}", ); } /// Single-question Q&A back-out is key-specific: `Left` has no in-overlay /// behaviour there (only multi-question `Left` switches questions; `Esc` /// owns unselect), so a bare `Left` backs out even with an option selected. /// The exit is non-destructive — the Q&A and its selection stay pending — so /// nothing is lost. (Esc stays graduated, clearing the selection first: see /// `overlay_esc_does_not_exit_when_question_option_selected`.) #[test] fn overlay_left_exits_single_question_with_selection() { let (mut app, id) = neutral_overlay_app(); install_question_overlay(&mut app, id, 1); app.agents .get_mut(&id) .unwrap() .question_view .as_mut() .unwrap() .select_option(0, 0); assert!( app.agents .get(&id) .unwrap() .question_view .as_ref() .unwrap() .active_tab_has_selection(), "fixture must start with a live selection", ); let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); let InputOutcome::Action(action @ Action::DashboardOverlayExit) = outcome else { panic!( "Left in a single-question Q&A must back out even with a selection, got {outcome:?}", ); }; let _ = super::super::dispatch::dispatch(action, &mut app); let agent = app.agents.get(&id).unwrap(); assert!( agent .question_view .as_ref() .is_some_and(|qv| qv.active_tab_has_selection()), "the selection must survive the back-out (Q&A still pending)", ); } /// Install a plan-approval overlay showing the plan in the line /// viewer (`Preview` focus) — the default shape when the plan has /// content (`acp_handler` opens the preview). This is the state the /// user reported as stuck: `Esc` / `Left` are dead no-ops in the /// plan line viewer. fn install_plan_preview_overlay(app: &mut AppView, id: super::super::agent::AgentId) { let request = crate::views::plan_approval_view::ExitPlanModeExtRequest { session_id: "s".into(), tool_call_id: "c".into(), plan_content: Some("# Plan\n- step one\n- step two".into()), }; let view = crate::views::plan_approval_view::PlanApprovalViewState::new( request, crate::views::prompt_widget::StashedPrompt::default(), tokio::sync::oneshot::channel().0, ); let a = app.agents.get_mut(&id).unwrap(); a.in_dashboard_overlay = true; a.plan_approval_view = Some(view); a.show_plan_preview(); assert!( a.line_viewer.is_some(), "fixture must open the plan line viewer", ); } /// Regression for the reported bug: in plan approval shown via the /// line viewer (the common case), Esc was a dead no-op. It must now /// back out to the dashboard, leaving the plan pending (unanswered). #[test] fn overlay_esc_exits_at_plan_preview() { let (mut app, id) = neutral_overlay_app(); install_plan_preview_overlay(&mut app, id); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc in the plan line-viewer preview must back out, got {outcome:?}", ); assert!( app.agents.get(&id).unwrap().plan_approval_view.is_some(), "backing out must leave the plan overlay pending (unanswered)", ); } /// Left is likewise a no-op in the plan line viewer (the list pane /// ignores it), so it backs out too. #[test] fn overlay_left_exits_at_plan_preview() { let (mut app, id) = neutral_overlay_app(); install_plan_preview_overlay(&mut app, id); let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); assert!( matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Left in the plan line-viewer preview must back out, got {outcome:?}", ); } /// Backing out of the plan preview is non-destructive: dispatching the /// resulting `DashboardOverlayExit` switches to the dashboard but /// leaves BOTH the plan-approval view and its line-viewer preview /// intact, so re-opening the agent shows the plan exactly as before. #[test] fn overlay_exit_from_plan_preview_keeps_preview_intact() { let (mut app, id) = neutral_overlay_app(); install_plan_preview_overlay(&mut app, id); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); let InputOutcome::Action(action @ Action::DashboardOverlayExit) = outcome else { panic!("Esc must yield DashboardOverlayExit, got {outcome:?}"); }; let _ = super::super::dispatch::dispatch(action, &mut app); assert!( matches!(app.active_view, ActiveView::AgentDashboard), "exit must land on the dashboard", ); let agent = app.agents.get(&id).unwrap(); assert!( agent.plan_approval_view.is_some(), "plan approval must survive the back-out (still pending)", ); assert!( agent.line_viewer.is_some(), "the plan line-viewer preview must survive the back-out", ); } /// Graduated: while a visual selection is active in the plan viewer, /// Esc must clear it first (reach the viewer) rather than backing out. #[test] fn overlay_esc_does_not_exit_plan_preview_in_visual_mode() { let (mut app, id) = neutral_overlay_app(); install_plan_preview_overlay(&mut app, id); app.agents .get_mut(&id) .unwrap() .line_viewer .as_mut() .unwrap() .list_state .visual_mode = true; let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc with an active visual selection must reach the viewer, got {outcome:?}", ); } /// Graduated: while an accepted search matcher is active in the plan /// viewer (input bar closed, filter still applied), the first Esc must /// clear it (reach the viewer) rather than backing out; only once it's /// cleared does Esc exit to the dashboard. #[test] fn overlay_esc_clears_matcher_before_exiting_plan_preview() { use crate::views::list_pane::{ListMatcher, MatchMode, QueryKind}; let (mut app, id) = neutral_overlay_app(); install_plan_preview_overlay(&mut app, id); app.agents .get_mut(&id) .unwrap() .line_viewer .as_mut() .unwrap() .list_state .set_matcher(Some(ListMatcher::new( "step", QueryKind::Substring, MatchMode::Search, ))); let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( !matches!(outcome, InputOutcome::Action(Action::DashboardOverlayExit)), "Esc with an accepted matcher must reach the viewer, got {outcome:?}", ); assert!( app.agents .get(&id) .unwrap() .line_viewer .as_ref() .unwrap() .list_state .matcher() .is_none(), "the first Esc must clear the accepted search matcher", ); let outcome2 = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(outcome2, InputOutcome::Action(Action::DashboardOverlayExit)), "once the matcher is cleared, Esc must back out, got {outcome2:?}", ); } /// Overlay Ctrl+X on an agent with a RUNNING turn — routes to the /// agent view's existing cancel behaviour (`Action::CancelTurn`, /// same as Ctrl+C) and never arms the close confirm: mashing /// Ctrl+X to stop a turn must not be able to close the session. #[test] fn overlay_ctrl_x_busy_agent_cancels_turn_without_arming() { let (mut app, id) = neutral_overlay_app(); app.agents.get_mut(&id).unwrap().session.state = crate::app::agent::AgentState::TurnRunning; for _ in 0..2 { let outcome = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!( matches!(outcome, InputOutcome::Action(Action::CancelTurn)), "Ctrl+X on a busy agent must cancel the turn, got {outcome:?}", ); assert!( app.pending_action.is_none(), "Ctrl+X on a busy agent must not arm the close confirm", ); } assert!( app.agents.get(&id).unwrap().active_modal.is_none(), "Ctrl+X must be intercepted before the agent sees it", ); } /// Overlay Ctrl+X on a non-turn busy agent (command in flight, /// cancel pending) — `Action::CancelTurn` would no-op for these /// states, so the press arms the two-press close instead of /// being a dead key. #[test] fn overlay_ctrl_x_command_or_cancelling_agent_arms_close_confirm() { use crate::app::agent::{AgentCommand, AgentState}; let states = [ AgentState::CommandRunning { command: AgentCommand::Compact, started_at: std::time::Instant::now(), }, AgentState::TurnCancelling, AgentState::CommandCancelling { command: AgentCommand::Compact, }, ]; for state in states { let (mut app, id) = neutral_overlay_app(); app.agents.get_mut(&id).unwrap().session.state = state.clone(); let outcome = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!( matches!(outcome, InputOutcome::Changed), "Ctrl+X on a {state:?} agent must arm (not cancel / fire), got {outcome:?}", ); assert!( app.pending_action .as_ref() .is_some_and(|p| matches!(p.action, Action::DashboardOverlayStop)), "Ctrl+X on a {state:?} agent must arm the close confirm", ); } } /// Overlay Ctrl+X on an IDLE agent — arms the two-press close /// confirm (`pending_action` = `DashboardOverlayStop` so the /// shortcuts bar paints "press again to close this session"); /// there is no turn to cancel. #[test] fn overlay_ctrl_x_idle_agent_arms_close_confirm() { let (mut app, _id) = neutral_overlay_app(); let outcome = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!( matches!(outcome, InputOutcome::Changed), "first Ctrl+X on an idle agent must arm (not fire), got {outcome:?}", ); let pending = app.pending_action.as_ref().expect("confirm must be armed"); assert!( matches!(pending.action, Action::DashboardOverlayStop), "pending action must be the overlay stop", ); assert_eq!(pending.label, Some("close this session")); assert!( !pending.expired(), "the confirm window must still be live right after arming", ); assert!( app.pending_effects.is_empty(), "no CancelTurn for an idle agent", ); } /// Overlay Ctrl+X, second press inside the confirm window — the /// pending-action fast path consumes the key and fires /// `Action::DashboardOverlayStop` (close + back to dashboard). #[test] fn overlay_ctrl_x_second_press_fires_overlay_stop() { let (mut app, _id) = neutral_overlay_app(); let _ = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!(app.pending_action.is_some(), "first press must arm"); let outcome = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!( matches!(outcome, InputOutcome::Action(Action::DashboardOverlayStop)), "second Ctrl+X must fire the confirmed stop, got {outcome:?}", ); assert!( app.pending_action.is_none(), "firing must consume the pending confirm", ); } /// Overlay Ctrl+X then ANY other key — the pending-action fast /// path disarms the confirm (the dashboard's stop-confirm /// semantics: any other press cancels), and the other key is /// still processed normally. #[test] fn overlay_ctrl_x_other_key_disarms_confirm() { let (mut app, _id) = neutral_overlay_app(); let _ = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!(app.pending_action.is_some(), "first press must arm"); let _ = app.handle_input(&key_event(KeyCode::Char('a'), KeyModifiers::NONE)); assert!( app.pending_action.is_none(), "any other key must disarm the pending stop confirm", ); let outcome = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!( matches!(outcome, InputOutcome::Changed), "Ctrl+X after a disarm must re-arm, not fire, got {outcome:?}", ); } /// OUTSIDE the overlay (a plain agent view, no dashboard attach), /// Ctrl+X must keep its existing agent-screen behaviour — the /// overlay stop binding lives in `When::DashboardOverlay` only. #[test] fn plain_agent_ctrl_x_does_not_arm_overlay_stop() { let mut app = test_app_with_agent(); let id = super::super::agent::AgentId(0); app.active_view = ActiveView::Agent(id); let _ = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!( !app.pending_action .as_ref() .is_some_and(|p| matches!(p.action, Action::DashboardOverlayStop)), "overlay stop must not arm outside the dashboard overlay", ); } /// When the attached agent disappears externally, /// the `handle_input` filter must clear `attached_agent` /// immediately rather than waiting for the next draw frame. #[test] fn minimal_double_ctrl_c_arms_then_quits() { let prev = crate::app::minimal_mode_active(); crate::app::set_minimal_mode_active_for_test(true); let mut app = test_app_with_agent(); if let ActiveView::Agent(id) = app.active_view { app.agents.get_mut(&id).unwrap().active_pane = crate::views::agent::ActivePane::Prompt; } let o1 = app.handle_input(&key_event(KeyCode::Char('c'), KeyModifiers::CONTROL)); let armed = app.pending_action.is_some(); let o2 = app.handle_input(&key_event(KeyCode::Char('c'), KeyModifiers::CONTROL)); crate::app::set_minimal_mode_active_for_test(prev); assert!(armed, "first Ctrl+C should arm quit (o1={o1:?})"); assert!( matches!(o2, InputOutcome::Action(crate::app::actions::Action::Quit)), "second Ctrl+C should quit (o2={o2:?})" ); } #[test] fn handle_input_clears_stale_attached_agent_on_input() { let mut app = test_app_with_agent(); let id = attach_popup(&mut app); app.agents.shift_remove(&id); let _ = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::NONE)); assert_eq!( app.dashboard.as_ref().unwrap().attached_agent, None, "stale attached_agent must be cleared on input", ); } /// Click on the popup's `[✗]` close affordance /// closes the popup. The close-rect is registered into /// `state.popup_close_rect` by the renderer; we set it /// directly here since this test doesn't run a render pass. #[test] fn handle_input_mouse_click_on_close_affordance_closes_popup() { let mut app = test_app_with_agent(); let _ = attach_popup(&mut app); if let Some(d) = app.dashboard.as_mut() { d.popup_close_rect = Some(ratatui::layout::Rect::new(50, 1, 3, 1)); d.popup_outer_rect = Some(ratatui::layout::Rect::new(0, 0, 60, 20)); } let click = Event::Mouse(crossterm::event::MouseEvent { kind: MouseEventKind::Down(crossterm::event::MouseButton::Left), column: 51, row: 1, modifiers: KeyModifiers::NONE, }); let outcome = app.handle_input(&click); assert!(matches!(outcome, InputOutcome::Changed)); assert_eq!(app.dashboard.as_ref().unwrap().attached_agent, None); } /// A click on a dashboard row outside the popup's /// outer rect dispatches `DashboardAttach(clicked_row)` so the /// popup target switches. #[test] fn handle_input_mouse_click_outside_popup_on_row_switches_target() { let mut app = test_app_with_agent(); let _ = attach_popup(&mut app); let row_id = crate::views::dashboard::DashboardRowId::TopLevel(super::super::agent::AgentId(42)); if let Some(d) = app.dashboard.as_mut() { d.popup_outer_rect = Some(ratatui::layout::Rect::new(20, 5, 40, 10)); d.row_rects .push((row_id.clone(), ratatui::layout::Rect::new(0, 1, 10, 1))); } let click = Event::Mouse(crossterm::event::MouseEvent { kind: MouseEventKind::Down(crossterm::event::MouseButton::Left), column: 5, row: 1, modifiers: KeyModifiers::NONE, }); let outcome = app.handle_input(&click); match outcome { InputOutcome::Action(Action::DashboardAttach(target)) => { assert_eq!(target, row_id); } other => panic!("expected DashboardAttach, got {other:?}"), } } /// Scroll routing through the popup /// overlay. A scroll inside `popup_outer_rect` must NOT advance /// the dashboard's `viewport_offset` (it forwards to the /// attached agent). A scroll outside the popup falls through to /// the dashboard list pane and DOES advance `viewport_offset`. #[test] fn handle_input_scroll_inside_popup_forwards_to_agent() { let mut app = test_app_with_agent(); let _ = attach_popup(&mut app); let popup_outer = ratatui::layout::Rect::new(20, 5, 40, 10); if let Some(d) = app.dashboard.as_mut() { d.popup_outer_rect = Some(popup_outer); d.viewport_offset = 0; } let inside_x = popup_outer.x + 5; let inside_y = popup_outer.y + 3; app.dispatch_scroll(3, inside_x, inside_y); assert_eq!( app.dashboard.as_ref().unwrap().viewport_offset, 0, "scroll inside popup must not advance the dashboard viewport", ); let outside_x = 0; let outside_y = 0; app.dispatch_scroll(3, outside_x, outside_y); assert_eq!( app.dashboard.as_ref().unwrap().viewport_offset, 3, "scroll outside popup must advance the dashboard viewport", ); } /// When the attached agent emits /// `Action::ExitSession` (via the synchronous outcome path, /// e.g. user presses the keybind for ExitSession inside the /// popup), the popup is closed but the agent stays in /// `app.agents`. The `/exit` slash command takes a different /// path (emits an effect) — see the user-guide for the /// asymmetry; this test pins only the synchronous-outcome /// branch. /// /// We can't easily synthesize an `ExitSession` from /// `agent.handle_input` without a real prompt event sequence, /// so the test exercises the popup-close intercept by feeding a /// key that lands in the agent's prompt and observing the popup /// state after the intercept runs. Concretely: we drive an Esc /// key (which the popup-close fast-path catches BEFORE the /// agent intercept). To prove the `ExitSession` branch /// independently, we directly invoke the intercepted-outcome /// path with a stub: set `attached_agent`, then call the same /// close routine the intercept would call. This is the smallest /// behavioural pin available without a full prompt-mode setup. #[test] fn handle_input_exit_session_action_closes_popup() { let mut app = test_app_with_agent(); let id = attach_popup(&mut app); assert!(app.agents.contains_key(&id)); assert_eq!(app.dashboard.as_ref().unwrap().attached_agent, Some(id)); if let Some(d) = app.dashboard.as_mut() { d.close_popup(); } if let Some(agent) = app.agents.get_mut(&id) { agent.active_subagent = None; } assert_eq!(app.dashboard.as_ref().unwrap().attached_agent, None); assert!( app.agents.contains_key(&id), "ExitSession intercept must NOT remove the agent (it only closes the popup)", ); } #[test] fn needs_project_picker_false_when_disabled() { let mut app = test_app(); app.project_picker_shown = false; app.cwd = std::path::PathBuf::from("/tmp"); app.project_picker_disabled = true; assert!(!app.needs_project_picker()); } #[test] fn needs_project_picker_false_when_already_shown() { let mut app = test_app(); app.project_picker_shown = true; app.cwd = std::path::PathBuf::from("/tmp"); app.project_picker_disabled = false; assert!(!app.needs_project_picker()); } #[test] fn needs_project_picker_true_for_non_project_dir() { let mut app = test_app(); app.project_picker_shown = false; app.project_picker_disabled = false; app.cwd = std::path::PathBuf::from("/tmp"); assert!(app.needs_project_picker()); } /// Chat mode hides the welcome picker's source filter, so `f` must not /// cycle it; Build mode keeps the cycle. #[test] fn welcome_picker_f_cycle_disabled_under_chat_mode() { let conversation_entry = SessionPickerEntry { id: "conv-welcome-f".into(), summary: "chat".into(), updated_at: chrono::Utc::now(), created_at: chrono::Utc::now(), cwd: String::new(), hostname: None, source: "conversation".into(), model_id: None, num_messages: 0, last_active_at: None, branch: None, repo_name: "r".into(), worktree_label: None, card_detail: None, }; let f_key = Event::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); crate::appearance::cache::set_vim_mode(false); let mut app = test_app(); app.session_picker_entries = Some(vec![conversation_entry]); app.chat_mode = true; let _ = app.handle_input(&f_key); assert_eq!( app.session_picker_source_filter, crate::views::session_picker::SourceFilter::Grok, "f must not cycle the hidden source filter under chat mode" ); assert_eq!( app.session_picker_state.query(), "f", "under chat mode `f` keeps its normal typing/search meaning" ); app.session_picker_state.reset(); app.chat_mode = false; let outcome = app.handle_input(&f_key); assert!(matches!( outcome, InputOutcome::Action(Action::CycleSessionSourceFilter) )); } }