Synced from monorepo

Changes:
- Non-blocking coding-data sharing upsell banner
- Consolidate remediation in Doctor
- Auto mode defers fail-closed gate asks to the classifier
- Coalesce marketplace list fetches
- Allow removing a marketplace source by name
- Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal)
- Label failed workspace RPCs with error_kind
- Drop redundant explicit tonic/prost deps from xai-grok-shell
- Report real exit codes for completed background shells
- Narrow the date-rollover reminder to date-bearing templates
- Wire toolOverrides through the session and agent
- Security: Bash(git:*) allowlist matches whole command chain by prefix
- Split prompt-trigger telemetry and record classifier provenance
- Raise connectors-manager timeout to 60s
- Auto classifier honors recorded approvals for repeat actions
- Apply doctor fixes in the TUI
- Auto-mode classifier timeouts prompt instead of silently denying
- Scope subagent completion drains to the owning session
- Add the toolOverrides wire types
- Set client_identifier=grok-agent-sdk
- Accept both spellings of the workspace-teleport kill switch
- Persist one-shot occurrence journal
- Stop turns that poll the exact same tool call 16x in a row
- Copy compaction checkpoint files when forking sessions
- Auto-focus permission prompt from scrollback
- Esc cancels the running turn in non-vim and minimal modes
- List Ctrl+Z undo and redo in keyboard shortcuts
- Out-of-process macOS mic capture
- Show active auth mode on session-info
- Install the npm binary under $GROK_HOME
- Remove hover/click dead zones between dashboard items
- Route startup warnings to doctor
- Document [feedback.user] author identity config
- Extend bang command timeout
- Close combine-queued edit-hold race
- Integrate relocation recovery
- Expose privacy notice rollout flag
- Break harness discovery ref cycle so connections can idle-evict
- Shift/Alt+Enter inserts newline when editing a queued prompt
- Gate project Claude permissions on folder trust
- Echo response.create.event_id on response.created
- Toast when session creation fails from disk full
- Add shared test process lifecycle
- Enable dynamic workflows by default
- Add relocation transaction state machine
- Add shared test sandbox
- Surface auth failures on model-switch compact
- Persist durable scheduler expiry
- Confirm before removing extensions-modal items
- Re-run compact and prompt after login when compact hit expired auth
- Recap sends hosted tools under backend search
This commit is contained in:
grokkybara[bot] 2026-07-22 19:18:53 +01:00
commit a5727c5960
482 changed files with 37627 additions and 13402 deletions

View file

@ -213,7 +213,7 @@ impl WorktreeMode {
use super::PagerTerminal;
use super::actions::Action;
use super::agent::AgentId;
use super::agent_view::{AgentView, McpInitProgress};
use super::agent_view::{AgentView, AppRenderParams, McpInitProgress};
use super::bundle::BundleState;
/// Which view is currently displayed.
///
@ -263,6 +263,9 @@ pub enum TickDemand {
/// `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
@ -342,10 +345,7 @@ impl VoiceState {
/// 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
)
matches!(self, Self::ColdStart { hold, .. } | Self::Recording { hold, .. } if *hold)
}
}
/// Entry in the session picker list on the welcome screen.
@ -844,6 +844,13 @@ pub struct AppView {
/// 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).
@ -1031,6 +1038,14 @@ pub struct AppView {
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`).
@ -1139,6 +1154,45 @@ pub struct AppView {
/// `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
@ -1151,6 +1205,42 @@ impl AppView {
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"))
}
/// 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
@ -1314,6 +1404,11 @@ impl AppView {
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,
@ -1382,6 +1477,10 @@ impl AppView {
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,
@ -1733,6 +1832,33 @@ impl AppView {
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()
}
/// The active agent's view, when an agent tab is focused.
///
/// Always the root agent, even when a subagent view is focused within the
@ -1764,12 +1890,12 @@ impl AppView {
pub fn mark_project_picker_done(&mut self) {
self.project_picker_shown = true;
}
/// Show a toast on the currently active agent.
/// Show a toast on the currently active view.
///
/// No-op on the welcome screen. From the dashboard, toasts route
/// into the dispatch input's inline error slot so the user sees
/// the message at the bottom of the dashboard. From inside an
/// agent view the existing per-agent toast machinery fires.
/// 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) => {
@ -1788,7 +1914,11 @@ impl AppView {
d.error_toast = Some(crate::glyphs::legacy_glyph_fallback(msg).into_owned());
}
}
ActiveView::Welcome => {}
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`.
@ -2170,9 +2300,10 @@ impl AppView {
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() })
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;
@ -2249,6 +2380,12 @@ impl AppView {
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(),
@ -2825,6 +2962,12 @@ struct WelcomeInputCtx<'a> {
/// 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,
@ -3456,6 +3599,21 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
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("https://x.ai/legal".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()
@ -3534,6 +3692,19 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
*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 {
@ -3897,12 +4068,14 @@ impl AppView {
};
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);
@ -4046,6 +4219,7 @@ impl AppView {
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,
@ -4063,7 +4237,14 @@ impl AppView {
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;
@ -4262,9 +4443,12 @@ impl AppView {
&self.bundle_state,
overlay_active,
link_spans,
voice_available,
voice_listening,
voice_interim.as_deref(),
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();
@ -4366,9 +4550,10 @@ impl AppView {
bundle_state,
false,
link_spans,
false,
false,
None,
AppRenderParams {
esc_owned_before_agent,
..Default::default()
},
)
} else {
(None, None)
@ -4515,16 +4700,12 @@ 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()
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()
|| matches!(
self.active_view, ActiveView::AgentDashboard if self.dashboard.as_ref()
.is_some_and(| d | d.shortcuts_modal.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
@ -4713,6 +4894,12 @@ impl AppView {
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 {
needs_redraw = true;
} else {
@ -4900,10 +5087,8 @@ impl AppView {
/// 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())
)
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.
///
@ -5353,6 +5538,10 @@ pub(crate) mod tests {
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,
@ -5392,6 +5581,11 @@ pub(crate) mod tests {
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,
@ -5498,6 +5692,7 @@ pub(crate) mod tests {
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,
},
@ -5690,6 +5885,7 @@ pub(crate) mod tests {
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,
};
@ -7047,8 +7243,7 @@ pub(crate) mod tests {
}
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"),
matches!(out, InputOutcome::Action(Action::SendPromptNow { ref text, .. }) if text == "steer it"),
"running Apple-Terminal Ctrl+O with payload must send-now, got {out:?}"
);
{
@ -7058,8 +7253,11 @@ pub(crate) mod tests {
}
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"),
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!(
@ -7792,69 +7990,184 @@ pub(crate) mod tests {
);
}
#[test]
fn esc_from_prompt_pane_running_turn_is_swallowed() {
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::Changed),
"1× Esc while running must swallow (not cancel), got {outcome:?}"
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!(app.agents[&id].cancel_trigger_hint.is_none());
assert!(app.agents[&id].session.state.is_turn_running());
assert_eq!(
app.agents[&id].cancel_trigger_hint,
Some(crate::app::actions::CancelTrigger::Esc)
);
}
#[test]
fn esc_from_prompt_pane_running_turn_with_draft_is_swallowed_not_clear() {
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::Changed),
"mid-turn Esc with draft must swallow, got {outcome:?}"
);
assert!(app.pending_action.is_none());
assert!(
!matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
"Esc must not cancel mid-turn"
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",
"mid-turn Esc must not clear the draft or arm idle clear"
"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)
);
assert!(app.agents[&id].session.state.is_turn_running());
}
#[test]
fn esc_from_scrollback_pane_running_turn_is_swallowed() {
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 from scrollback while running must swallow, got {outcome:?}"
"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)),
@ -7867,6 +8180,47 @@ pub(crate) mod tests {
);
}
#[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);
@ -7946,6 +8300,7 @@ pub(crate) mod tests {
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));
@ -7984,6 +8339,7 @@ pub(crate) mod tests {
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));
@ -8027,6 +8383,7 @@ pub(crate) mod tests {
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));
@ -8061,6 +8418,7 @@ pub(crate) mod tests {
{
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(
@ -8109,6 +8467,7 @@ pub(crate) mod tests {
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));
@ -8467,8 +8826,7 @@ pub(crate) mod tests {
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()),
!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();
@ -8615,11 +8973,14 @@ pub(crate) mod tests {
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!(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]
@ -8939,9 +9300,7 @@ pub(crate) mod tests {
&BundleState::default(),
false,
&mut Vec::new(),
false,
false,
None,
crate::app::agent_view::AppRenderParams::default(),
);
let hit = agent
.last_scrollback_selection_model
@ -8990,9 +9349,7 @@ pub(crate) mod tests {
&BundleState::default(),
false,
&mut Vec::new(),
false,
false,
None,
crate::app::agent_view::AppRenderParams::default(),
);
let hit = agent
.last_scrollback_selection_model
@ -9045,9 +9402,7 @@ pub(crate) mod tests {
&BundleState::default(),
false,
&mut Vec::new(),
false,
false,
None,
crate::app::agent_view::AppRenderParams::default(),
);
let hit = agent
.last_scrollback_selection_model
@ -9409,6 +9764,30 @@ pub(crate) mod tests {
);
}
#[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;
@ -9733,9 +10112,9 @@ pub(crate) mod tests {
);
}
/// Regression: in an overlay, a bare Esc while a turn is
/// RUNNING must swallow (matching full-screen), 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.
/// 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();
@ -9748,6 +10127,7 @@ pub(crate) mod tests {
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!(
@ -9781,6 +10161,7 @@ pub(crate) mod tests {
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));
@ -9798,6 +10179,31 @@ pub(crate) mod tests {
);
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() {