Synced from monorepo Changes: - Report invalid MCP server config instead of failing startup - Keep completed terminal output when the gateway connection is lost - Show a duration-only detail view for single-task task output - Don't let a stale registry turn counter hide local sessions - Raise the file-descriptor soft limit on Linux and log effective limits at startup - Stop aborting when HTTP client construction fails - Make session thread and runtime spawn failures recoverable - Fix main-prompt paste parity in the question freeform input - Fire SessionEnd hooks on /exit and headless quit - Embed the deployment-config signing public key - Repaint paste-chip background on inline panel inputs - Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root - Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker - Parse hooks from config files - Add a remote kill-switch for managed-config signature verification - Security: fix workspace file-reference resolution bypassing workspace filesystem confinement Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
11680 lines
519 KiB
Rust
11680 lines
519 KiB
Rust
//! 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<String>) {
|
||
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<ratatui::layout::Rect>,
|
||
}
|
||
/// 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<String>),
|
||
/// 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<xai_grok_shell::util::config::WorktreeHintMode> 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<Self> {
|
||
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, 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<String>,
|
||
},
|
||
/// 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<String>,
|
||
},
|
||
}
|
||
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<VoiceTarget> {
|
||
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<chrono::Utc>,
|
||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||
pub cwd: String,
|
||
pub hostname: Option<String>,
|
||
pub source: String,
|
||
pub model_id: Option<String>,
|
||
pub num_messages: usize,
|
||
/// When the session last had content added (most recent of local and remote).
|
||
pub last_active_at: Option<chrono::DateTime<chrono::Utc>>,
|
||
/// Git branch associated with the session (if available from the server response).
|
||
pub branch: Option<String>,
|
||
/// 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<String>,
|
||
/// Lazy-loaded detail for the expanded card view.
|
||
pub card_detail: Option<CardDetail>,
|
||
}
|
||
/// 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<String> },
|
||
/// 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<tokio::task::AbortHandle>,
|
||
/// Auth URL from the provider (populated by AuthUrlReady).
|
||
auth_url: Option<String>,
|
||
/// 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<Duration> = 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<String>) -> Duration {
|
||
raw.and_then(|v| v.trim().parse::<u64>().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<ActiveView>,
|
||
/// Per-agent views (keyed by AgentId).
|
||
pub agents: IndexMap<AgentId, AgentView>,
|
||
/// 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<crate::settings::SettingsRegistry>,
|
||
/// 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<PendingAction>,
|
||
/// 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<Instant>,
|
||
/// 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<String>,
|
||
/// 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<crate::tracing::LogRx>,
|
||
/// 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<xai_grok_announcements::RemoteAnnouncement>,
|
||
/// 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<String>,
|
||
pub announcements_last_gen: u64,
|
||
/// Selected welcome announcement for this pager launch.
|
||
pub announcement: Option<xai_grok_announcements::RemoteAnnouncement>,
|
||
/// Cached changelog markdown (for `/release-notes`). Populated by
|
||
/// `FetchChangelog` at startup; `None` until the fetch completes.
|
||
pub changelog_markdown: Option<String>,
|
||
/// Cached changelog bullets (for welcome screen). Populated by
|
||
/// `FetchChangelog` at startup; empty until the fetch completes.
|
||
pub changelog_bullets: Vec<String>,
|
||
/// Resolved tip list from config layers.
|
||
pub tips: Vec<String>,
|
||
/// Selected tip for the current launch/session.
|
||
pub tip: Option<String>,
|
||
/// 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<String>,
|
||
/// 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<crate::views::credit_bar::CreditBalance>,
|
||
/// App-level auto top-up rule paired with `credit_balance` for the warning.
|
||
pub auto_topup: Option<crate::views::credit_bar::AutoTopupInfo>,
|
||
/// 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<crate::app::roster::RosterEntry>,
|
||
/// 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<crate::app::roster::RosterEntry>,
|
||
/// 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<String, Vec<crate::app::prompt_queue::QueueEntryWire>>,
|
||
/// 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<String, Vec<crate::app::prompt_queue::QueueEntryWire>>,
|
||
/// 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<AgentId, crate::app::acp_handler::PendingRunningAdoption>,
|
||
/// 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<std::cell::RefCell<crate::slash::mru::SlashMru>>,
|
||
/// 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<std::cell::RefCell<std::collections::HashMap<String, String>>>,
|
||
/// 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<crate::app::actions::Effect>,
|
||
/// 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<crate::app::external_editor::PendingEditorRequest>,
|
||
/// 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<std::path::PathBuf>,
|
||
/// 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<usize>,
|
||
/// Hit-test rects for welcome menu items (populated during render).
|
||
pub welcome_menu_rects: Vec<ratatui::layout::Rect>,
|
||
/// 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<ratatui::layout::Rect>,
|
||
/// 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<Instant>,
|
||
/// Hit-test rect for welcome prompt input (populated during render).
|
||
pub welcome_prompt_rect: Option<ratatui::layout::Rect>,
|
||
/// Hit-test rect for the auth URL (click-to-open during Authenticating).
|
||
pub welcome_auth_url_rect: Option<ratatui::layout::Rect>,
|
||
/// 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<ratatui::layout::Rect>,
|
||
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
|
||
pub welcome_refresh_rect: Option<ratatui::layout::Rect>,
|
||
/// Hit-test rect for the gate URL link on the paywall CTA.
|
||
pub welcome_gate_url_rect: Option<ratatui::layout::Rect>,
|
||
/// Hit-test rect for the welcome hero upgrade CTA `[label]` button
|
||
/// (click → `AnnouncementsOpenCta(Welcome)`).
|
||
pub welcome_upgrade_cta_rect: Option<ratatui::layout::Rect>,
|
||
pub welcome_privacy_banner_accept_rect: Option<ratatui::layout::Rect>,
|
||
pub welcome_privacy_banner_customize_rect: Option<ratatui::layout::Rect>,
|
||
pub welcome_privacy_banner_legal_rect: Option<ratatui::layout::Rect>,
|
||
/// 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<ratatui::layout::Rect>,
|
||
/// 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<Vec<SessionPickerEntry>>,
|
||
/// 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<std::path::PathBuf>,
|
||
/// Content-based (deep search) results from ACP session search.
|
||
pub session_picker_content_results:
|
||
Option<Vec<xai_grok_shell::extensions::session_search::SearchSessionHit>>,
|
||
/// 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<String>,
|
||
/// 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<acp::ModelId>,
|
||
/// CLI effort token (`--reasoning-effort` / `--effort`). Applied on session create.
|
||
pub cli_effort_token: Option<String>,
|
||
/// 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<NewWorktreeDialogState>,
|
||
/// 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<xai_grok_shell::util::config::ContextualHintsRemote>,
|
||
/// 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<bool>,
|
||
/// 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<String>,
|
||
pub agent_override: Option<serde_json::Value>,
|
||
/// 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<agent_client_protocol::AvailableCommand>,
|
||
/// Auth methods from the ACP connection (preserved for re-login after logout).
|
||
pub auth_methods: Vec<acp::AuthMethod>,
|
||
/// 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<String>,
|
||
/// The auth method ID to use for login.
|
||
pub login_method_id: Option<acp::AuthMethodId>,
|
||
/// 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<crate::clipboard::ClipboardDelivery>,
|
||
/// 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<String>,
|
||
/// Team name from auth (displayed in the shortcuts bar).
|
||
pub team_name: Option<String>,
|
||
/// 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<String>,
|
||
/// 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<u64>,
|
||
/// Local `[privacy].privacy_banner_acked` (RFC 3339 UTC).
|
||
pub privacy_banner_acked: Option<String>,
|
||
/// 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<bool>,
|
||
/// Persisted `[cli].auto_update` mirror. `None` = no override (default `true`).
|
||
pub auto_update: Option<bool>,
|
||
/// 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<bool>,
|
||
/// 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<String>,
|
||
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<xai_grok_shell::auth::GateInfo>,
|
||
/// User-friendly subscription tier name (e.g. "SuperGrok", "Free").
|
||
pub subscription_tier: Option<String>,
|
||
/// When the pager started auto-checking subscriptions (for 10-min timeout).
|
||
pub paywall_check_started: Option<std::time::Instant>,
|
||
/// Debounce stamp for watch/focus subscription checks (see
|
||
/// [`super::subscription`]).
|
||
pub last_subscription_check_at: Option<std::time::Instant>,
|
||
/// Server override (seconds) for the subscription-watch cadence.
|
||
pub subscription_watch_interval_secs: Option<u64>,
|
||
/// A stale-source gate held out of `gate` while a live check verifies
|
||
/// it (see [`super::subscription`]).
|
||
pub pending_gate_verification: Option<xai_grok_shell::auth::GateInfo>,
|
||
/// 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<crate::startup::StartupWarning>,
|
||
/// 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<String>,
|
||
/// 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<crate::app::foreign_sessions::ForeignResumeLaunch>,
|
||
/// 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<ScreenModeRelaunch>,
|
||
/// 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<crate::views::import_claude_modal::ImportClaudeModalState>,
|
||
/// Doc viewer overlay for the welcome screen (release notes via Ctrl+L).
|
||
pub welcome_doc_viewer: Option<crate::views::modal::ActiveModal>,
|
||
/// 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<crate::views::tutorial::TutorialState>,
|
||
/// 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<crate::views::dashboard::DashboardState>,
|
||
/// Where to return when leaving the dashboard. See [`DashboardReturn`].
|
||
pub dashboard_return: Option<DashboardReturn>,
|
||
/// 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<crate::views::dashboard::PersistedDashboard>,
|
||
/// 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<xai_grok_voice::SharedVoiceAuth>,
|
||
/// Commands into the voice pipeline (start/stop capture — toggle, not hold).
|
||
pub voice_cmd_tx: Option<tokio::sync::mpsc::Sender<xai_grok_voice::VoiceCommand>>,
|
||
/// 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<u64>) -> 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<crate::settings::CodingDataSharingLock> {
|
||
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<xai_grok_shell::auth::GateInfo> {
|
||
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<agent_client_protocol::AvailableCommand>,
|
||
) -> 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<xai_grok_shell::sampling::types::ReasoningEffort>,
|
||
)> {
|
||
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<String> = 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<VoiceTarget> {
|
||
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<InputOutcome> {
|
||
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<crate::app::prompt_queue::QueueEntryWire>> {
|
||
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<crate::views::scroll_debug_hud::ScrollDebugPanel> {
|
||
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<NewWorktreeDialogState>,
|
||
menu_index: &'a mut Option<usize>,
|
||
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<Vec<SessionPickerEntry>>,
|
||
/// 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<Vec<xai_grok_shell::extensions::session_search::SearchSessionHit>>,
|
||
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<String>,
|
||
has_claude_import: bool,
|
||
import_claude_modal: &'a mut Option<crate::views::import_claude_modal::ImportClaudeModalState>,
|
||
welcome_doc_viewer: &'a mut Option<crate::views::modal::ActiveModal>,
|
||
changelog_markdown: &'a Option<String>,
|
||
/// 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<bool> = 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<usize>,
|
||
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<usize>,
|
||
count: usize,
|
||
) -> Option<InputOutcome> {
|
||
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<String>,
|
||
render: Option<crate::terminal::overlay::PostFlush>,
|
||
) -> Option<crate::terminal::overlay::PostFlush> {
|
||
Self::merge_post_flush(
|
||
notif.map(crate::terminal::overlay::PostFlush::plain),
|
||
render,
|
||
)
|
||
}
|
||
fn merge_post_flush(
|
||
first: Option<crate::terminal::overlay::PostFlush>,
|
||
second: Option<crate::terminal::overlay::PostFlush>,
|
||
) -> Option<crate::terminal::overlay::PostFlush> {
|
||
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<AgentId, AgentView>,
|
||
drawn_agent: Option<AgentId>,
|
||
) -> Option<crate::terminal::overlay::PostFlush> {
|
||
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<InputOutcome> {
|
||
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<crate::views::prompt_widget::PromptFlag<'_>> =
|
||
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<bool> = 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<Vec<agent_client_protocol::AvailableCommand>> =
|
||
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<std::time::Duration> {
|
||
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::<crate::render::draw::WriterPayload>();
|
||
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<AgentView> {
|
||
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::<String>(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::<String>(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<String> {
|
||
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::<toml_edit::DocumentMut>().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<Question> = (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)
|
||
));
|
||
}
|
||
}
|