Synced from monorepo
Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
|
|
@ -770,6 +770,13 @@ pub struct AppView {
|
|||
/// [`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,
|
||||
|
|
@ -989,6 +996,11 @@ pub struct AppView {
|
|||
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
|
||||
|
|
@ -1115,6 +1127,10 @@ pub struct AppView {
|
|||
/// 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`
|
||||
|
|
@ -1339,8 +1355,11 @@ impl AppView {
|
|||
) -> 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,
|
||||
|
|
@ -1381,6 +1400,7 @@ impl AppView {
|
|||
tip: None,
|
||||
welcome_prompt,
|
||||
slash_mru,
|
||||
command_tags,
|
||||
welcome_prompt_focused: true,
|
||||
welcome_tip_typing_dismissed: false,
|
||||
pending_effects: Vec::new(),
|
||||
|
|
@ -1457,6 +1477,7 @@ impl AppView {
|
|||
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(),
|
||||
|
|
@ -1525,6 +1546,7 @@ impl AppView {
|
|||
session_picker_grouped: false,
|
||||
cancel_rewind_enabled: true,
|
||||
session_recap_available: false,
|
||||
tutorial: None,
|
||||
dashboard: None,
|
||||
dashboard_return: None,
|
||||
dashboard_persisted: None,
|
||||
|
|
@ -2341,6 +2363,17 @@ impl AppView {
|
|||
);
|
||||
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(
|
||||
|
|
@ -2349,6 +2382,12 @@ impl AppView {
|
|||
)
|
||||
.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,
|
||||
|
|
@ -2398,6 +2437,7 @@ impl AppView {
|
|||
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,
|
||||
|
|
@ -2867,7 +2907,12 @@ impl AppView {
|
|||
git_ref: None,
|
||||
},
|
||||
ActionId::OpenDashboard => Action::OpenDashboard,
|
||||
ActionId::VoiceToggle => Action::VoiceToggle,
|
||||
ActionId::VoiceToggle => {
|
||||
if !self.current_ui.voice_keybind_enabled.unwrap_or(true) {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
Action::VoiceToggle
|
||||
}
|
||||
_ => return InputOutcome::Unchanged,
|
||||
};
|
||||
if def.requires_confirmation {
|
||||
|
|
@ -2990,6 +3035,9 @@ struct WelcomeInputCtx<'a> {
|
|||
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>>,
|
||||
|
|
@ -3010,8 +3058,8 @@ struct WelcomeInputCtx<'a> {
|
|||
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 Local/Remote
|
||||
/// source filter (conversations-only list), so `f` must not cycle it.
|
||||
/// 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.
|
||||
|
|
@ -3153,7 +3201,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if ctx.sp_entries.is_some() && matches!(ctx.auth_state, AuthState::Done) {
|
||||
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 =
|
||||
|
|
@ -3187,6 +3235,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||
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,
|
||||
|
|
@ -3612,7 +3661,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||
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()));
|
||||
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))
|
||||
|
|
@ -4080,6 +4131,12 @@ impl AppView {
|
|||
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,
|
||||
|
|
@ -4190,9 +4247,13 @@ impl AppView {
|
|||
mouse_pos: self.last_mouse_pos,
|
||||
is_zdr_blocked: zdr_blocked_for_draw,
|
||||
session_picker: self.session_picker_entries.as_deref(),
|
||||
session_picker_loading: self.session_picker_entries.is_none()
|
||||
&& (self.session_picker_loading
|
||||
|| self.session_picker_lanes.foreign_loading),
|
||||
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,
|
||||
|
|
@ -4300,6 +4361,14 @@ impl AppView {
|
|||
},
|
||||
);
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
|
@ -4307,7 +4376,7 @@ impl AppView {
|
|||
panel.render(full_area, f.buffer_mut());
|
||||
}
|
||||
let has_cloud_modal = false;
|
||||
let cursor = if has_cloud_modal {
|
||||
let cursor = if has_cloud_modal || self.tutorial.is_some() {
|
||||
None
|
||||
} else {
|
||||
result.cursor_pos
|
||||
|
|
@ -4414,9 +4483,13 @@ impl AppView {
|
|||
&self.active_announcements,
|
||||
&self.hidden_announcement_ids,
|
||||
);
|
||||
let show_session_tip = self.tip.is_some() && agent.should_show_tip();
|
||||
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 has_mode_banner {
|
||||
let banner_height = if privacy_banner {
|
||||
2
|
||||
} else if has_mode_banner {
|
||||
1
|
||||
} else if announcement_banner_h > 0 {
|
||||
announcement_banner_h
|
||||
|
|
@ -4432,13 +4505,17 @@ impl AppView {
|
|||
scratch,
|
||||
pending_hint,
|
||||
overlay_focused,
|
||||
banner_height,
|
||||
&self.active_announcements,
|
||||
&self.hidden_announcement_ids,
|
||||
if show_session_tip {
|
||||
self.tip.as_deref()
|
||||
} else {
|
||||
None
|
||||
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,
|
||||
|
|
@ -4460,6 +4537,14 @@ impl AppView {
|
|||
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());
|
||||
}
|
||||
|
|
@ -4468,10 +4553,17 @@ impl AppView {
|
|||
}
|
||||
let (cursor_pos, post_flush) = result;
|
||||
let has_cloud = false;
|
||||
if has_cloud || self.import_claude_modal.is_some() {
|
||||
if has_cloud
|
||||
|| self.import_claude_modal.is_some()
|
||||
|| self.tutorial.is_some()
|
||||
{
|
||||
link_spans.clear();
|
||||
}
|
||||
let cursor = if has_cloud { None } else { cursor_pos };
|
||||
let cursor = if has_cloud || self.tutorial.is_some() {
|
||||
None
|
||||
} else {
|
||||
cursor_pos
|
||||
};
|
||||
return (cursor, Self::merge_escapes(notif_escapes, post_flush));
|
||||
}
|
||||
}
|
||||
|
|
@ -4537,24 +4629,22 @@ impl AppView {
|
|||
|inner, buf| {
|
||||
if let Some(agent) = agents.get_mut(&agent_id) {
|
||||
agent.draw(
|
||||
inner,
|
||||
buf,
|
||||
registry,
|
||||
scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
bundle_state,
|
||||
false,
|
||||
link_spans,
|
||||
AppRenderParams {
|
||||
esc_owned_before_agent,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
|
@ -4568,13 +4658,24 @@ impl AppView {
|
|||
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 dashboard.attached_agent.is_some() {
|
||||
let cursor = if tutorial_open {
|
||||
None
|
||||
} else if dashboard.attached_agent.is_some() {
|
||||
popup_cursor
|
||||
} else {
|
||||
dash_cursor
|
||||
|
|
@ -4704,6 +4805,7 @@ impl AppView {
|
|||
|| 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
|
||||
|
|
@ -4900,7 +5002,14 @@ impl AppView {
|
|||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
if self.session_picker_content_loading {
|
||||
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();
|
||||
|
|
@ -4967,6 +5076,21 @@ impl AppView {
|
|||
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
|
||||
|
|
@ -5277,6 +5401,21 @@ impl AppView {
|
|||
|| 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()
|
||||
|
|
@ -5518,6 +5657,7 @@ pub(crate) mod tests {
|
|||
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(),
|
||||
|
|
@ -5563,6 +5703,9 @@ pub(crate) mod tests {
|
|||
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,
|
||||
|
|
@ -5644,6 +5787,7 @@ pub(crate) mod tests {
|
|||
session_picker_grouped: false,
|
||||
cancel_rewind_enabled: true,
|
||||
session_recap_available: false,
|
||||
tutorial: None,
|
||||
dashboard: None,
|
||||
dashboard_return: None,
|
||||
dashboard_persisted: None,
|
||||
|
|
@ -6055,6 +6199,72 @@ pub(crate) mod tests {
|
|||
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]
|
||||
|
|
@ -9293,10 +9503,7 @@ pub(crate) mod tests {
|
|||
&mut crate::scrollback::render::ScratchBuffer::new(),
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&BundleState::default(),
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -9342,10 +9549,7 @@ pub(crate) mod tests {
|
|||
&mut crate::scrollback::render::ScratchBuffer::new(),
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&BundleState::default(),
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -9395,10 +9599,7 @@ pub(crate) mod tests {
|
|||
&mut crate::scrollback::render::ScratchBuffer::new(),
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&BundleState::default(),
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -9820,6 +10021,46 @@ pub(crate) mod tests {
|
|||
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;
|
||||
|
|
@ -9897,6 +10138,22 @@ pub(crate) mod tests {
|
|||
"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).
|
||||
|
|
@ -11361,7 +11618,7 @@ pub(crate) mod tests {
|
|||
let _ = app.handle_input(&f_key);
|
||||
assert_eq!(
|
||||
app.session_picker_source_filter,
|
||||
crate::views::session_picker::SourceFilter::All,
|
||||
crate::views::session_picker::SourceFilter::Grok,
|
||||
"f must not cycle the hidden source filter under chat mode"
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
|
|||
Loading…
Reference in a new issue