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:
grokkybara[bot] 2026-07-23 17:12:33 +00:00
commit 69f0ba880a
286 changed files with 22939 additions and 9624 deletions

View file

@ -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!(