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:
parent
3af4d5d398
commit
a5727c5960
482 changed files with 37627 additions and 13402 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue