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

@ -274,6 +274,16 @@ pub fn first_session_announcement<'a>(
first_session_announcement_at(announcements, hidden_ids, chrono::Utc::now())
}
/// Whether a live critical session announcement exists. Used by the banner
/// slot ranking: critical outranks the privacy upsell banner (an outage
/// notice must not be hidden by a persistent nag), promo does not.
pub fn has_critical_session_announcement(
announcements: &[xai_grok_announcements::RemoteAnnouncement],
hidden_ids: &BTreeSet<String>,
) -> bool {
first_critical_session_announcement_at(announcements, hidden_ids, chrono::Utc::now()).is_some()
}
/// [`first_session_announcement`] with an injectable clock.
pub fn first_session_announcement_at<'a>(
announcements: &'a [xai_grok_announcements::RemoteAnnouncement],

View file

@ -1144,6 +1144,7 @@ fn location_picker_config<'a>() -> crate::views::picker::PickerConfig<'a> {
filter_label: None,
filter_key_hint: None,
filter_active: false,
header_note: None,
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
@ -1411,6 +1412,17 @@ impl DashboardState {
self.peek_reply.adopt_slash_mru(mru);
}
/// Adopt the shared per-command tag map (owned by `AppView`) into both the
/// dispatch input and the peek-reply input so dashboard slash completion
/// renders the same tags as agent prompts.
pub(crate) fn adopt_command_tags(
&mut self,
command_tags: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>,
) {
self.dispatch.adopt_command_tags(command_tags.clone());
self.peek_reply.adopt_command_tags(command_tags);
}
pub(crate) fn set_screen_mode(&mut self, mode: crate::app::ScreenMode) {
self.dispatch.set_screen_mode(mode);
self.peek_reply.set_screen_mode(mode);

View file

@ -3568,6 +3568,7 @@ pub fn render_extensions_modal(
&non_selectable_clickable,
Some(theme.bg_base),
loading,
0,
inner_x + inner_width - 1,
);
(content_hit.item_rects, content_hit.entry_indices)

View file

@ -29,6 +29,7 @@ pub mod permission_view;
pub mod persona_detail;
pub mod picker;
pub mod plan_approval_view;
pub mod privacy_banner;
pub mod progress_bar;
pub mod prompt_suggestion;
pub mod prompt_widget;
@ -49,5 +50,6 @@ pub mod tasks_pane;
pub mod timeline;
pub mod todo_pane;
pub mod turn_status;
pub mod tutorial;
pub mod welcome;
pub mod workflows;

View file

@ -552,6 +552,11 @@ pub(crate) fn default_palette_entries(
shortcut: "/docs".into(),
command: PaletteCommand::HowTo,
},
PaletteEntry {
label: "Tutorial".into(),
shortcut: "/tutorial".into(),
command: PaletteCommand::SlashCommand("/tutorial".into()),
},
PaletteEntry {
label: "Quit".into(),
shortcut: "Ctrl+Q".into(),
@ -1160,8 +1165,7 @@ pub fn render_doc_viewer_overlay(
compact: bool,
theme: &Theme,
) {
use ratatui::widgets::{Paragraph, Widget, Wrap};
let doc_shortcuts = vec![
let doc_shortcuts = [
super::modal_window::Shortcut {
label: "\u{2191}/\u{2193} scroll",
clickable: false,
@ -1173,10 +1177,39 @@ pub fn render_doc_viewer_overlay(
id: 0,
},
];
render_doc_viewer_overlay_with_shortcuts(
buf,
area,
window,
title,
content,
scroll,
cached_lines,
compact,
theme,
&doc_shortcuts,
);
}
/// [`render_doc_viewer_overlay`] with caller-supplied footer shortcuts (the
/// tutorial adds a next-topic hint).
#[allow(clippy::too_many_arguments)]
pub fn render_doc_viewer_overlay_with_shortcuts(
buf: &mut ratatui::buffer::Buffer,
area: Rect,
window: &mut super::modal_window::ModalWindowState,
title: &str,
content: &str,
scroll: &mut u16,
cached_lines: &mut Option<(u16, Vec<ratatui::text::Line<'static>>)>,
compact: bool,
theme: &Theme,
shortcuts: &[super::modal_window::Shortcut<'_>],
) {
use ratatui::widgets::{Paragraph, Widget, Wrap};
let modal_config = super::modal_window::ModalWindowConfig {
title,
tabs: None,
shortcuts: &doc_shortcuts,
shortcuts,
sizing: super::modal_window::ModalSizing {
width_pct: 0.80,
max_width: 120,

View file

@ -1736,8 +1736,12 @@ pub struct PickerConfig<'a> {
pub filter_label: Option<&'a str>,
/// Key hint for the filter (e.g., "f").
pub filter_key_hint: Option<&'a str>,
/// Whether the filter is active (not in default/All state).
/// Whether the filter is active (not in its default state).
pub filter_active: bool,
/// Pinned single-line note rendered between the search/filter chrome and
/// the first entry (e.g. the hidden-external sessions hint). Render-only:
/// never part of the entry list, hit areas, or scrolling.
pub header_note: Option<&'a str>,
/// Custom action keys that produce `PickerOutcome::Action`.
/// Each entry is `(key_char, description)` shown in shortcuts.
pub action_keys: &'a [(char, &'a str)],
@ -1864,6 +1868,7 @@ pub fn render_picker_content(
non_selectable_clickable,
bg,
loading,
0,
None,
)
}
@ -1872,6 +1877,7 @@ pub fn render_picker_content(
/// x-position. When `scrollbar_x` is `Some(x)`, the scrollbar is
/// rendered at that column instead of `content_area.x + content_area.width - 1`.
/// Used by modals with h_pad to place the scrollbar flush against the border.
/// `loading_tick` animates the loading spinner (pass 0 for a static frame).
#[allow(clippy::too_many_arguments)]
pub fn render_picker_content_with_scrollbar_x(
buf: &mut Buffer,
@ -1883,6 +1889,7 @@ pub fn render_picker_content_with_scrollbar_x(
non_selectable_clickable: &[bool],
bg: Option<ratatui::style::Color>,
loading: bool,
loading_tick: u64,
scrollbar_x: u16,
) -> PickerContentHitAreas {
render_picker_content_inner(
@ -1895,6 +1902,7 @@ pub fn render_picker_content_with_scrollbar_x(
non_selectable_clickable,
bg,
loading,
loading_tick,
Some(scrollbar_x),
)
}
@ -1977,6 +1985,7 @@ pub fn render_picker_in_modal_inner(
&[],
Some(theme.bg_base),
loading,
0,
inner_x + inner_width - 1,
);
state.hit_areas = Some(PickerHitAreas {
@ -2000,6 +2009,7 @@ fn render_picker_content_inner(
non_selectable_clickable: &[bool],
bg: Option<ratatui::style::Color>,
loading: bool,
loading_tick: u64,
scrollbar_x_override: Option<u16>,
) -> PickerContentHitAreas {
// Cleared each paint; set below if a row underlines its last description line.
@ -2014,11 +2024,13 @@ fn render_picker_content_inner(
return empty_hit;
}
// Loading state — centered in the content area.
// Loading state — animated dot spinner centered in the content area.
if loading {
let msg = "Loading...";
let spinner_frames = crate::glyphs::dot_spinner_frames();
let frame = spinner_frames[(loading_tick / 4) as usize % spinner_frames.len()];
let msg = format!("{frame} Loading\u{2026}");
let msg_style = Style::default().fg(theme.gray);
let cx = content_area.x + content_area.width.saturating_sub(msg.len() as u16) / 2;
let cx = content_area.x + content_area.width.saturating_sub(msg.width() as u16) / 2;
let cy = content_area.y + content_area.height / 2;
buf.set_string(cx, cy, msg, msg_style);
return empty_hit;
@ -2198,6 +2210,7 @@ fn render_picker_content_inner(
///
/// Returns hit areas for mouse interaction. The caller stores these in
/// `state.hit_areas` for use by `handle_picker_input`.
/// `loading_tick` animates the loading spinner (pass 0 for a static frame).
#[allow(clippy::too_many_arguments)]
pub fn render_picker(
buf: &mut Buffer,
@ -2207,6 +2220,7 @@ pub fn render_picker(
entries: &[PickerEntry<'_>],
config: &PickerConfig<'_>,
loading: bool,
loading_tick: u64,
) -> PickerHitAreas {
let empty_hit = PickerHitAreas {
close_button: Rect::default(),
@ -2420,7 +2434,28 @@ pub fn render_picker(
render_divider(buf, content.x, sep_y, content.width, theme, bg);
}
let entries_start_y = sep_y + 1;
let mut entries_start_y = sep_y + 1;
// Pinned header note: reserve the first list row so it stays visible
// regardless of list scroll.
if let Some(note) = config.header_note
&& entries_start_y < content.y + content.height
{
let note_style = Style::default().fg(theme.gray_dim);
let note_style = if let Some(c) = bg {
note_style.bg(c)
} else {
note_style
};
buf.set_stringn(
content.x + 1,
entries_start_y,
note,
content.width.saturating_sub(1) as usize,
note_style,
);
entries_start_y += 1;
}
// Delegate entry rendering + scrollbar to render_picker_content.
let entries_area = Rect {
@ -2429,7 +2464,7 @@ pub fn render_picker(
width: content.width,
height: (content.y + content.height).saturating_sub(entries_start_y),
};
let content_hit = render_picker_content(
let content_hit = render_picker_content_inner(
buf,
entries_area,
theme,
@ -2439,6 +2474,8 @@ pub fn render_picker(
config.non_selectable_clickable,
bg,
loading,
loading_tick,
None,
);
let item_rects = content_hit.item_rects;
let entry_indices = content_hit.entry_indices;
@ -3222,6 +3259,7 @@ mod tests {
filter_label: None,
filter_key_hint: None,
filter_active: false,
header_note: None,
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
@ -3339,7 +3377,7 @@ mod tests {
let mut state = PickerState::with_mode(PickerMode::FullScreen);
state.search_active = search_active;
let mut buf = Buffer::empty(area);
let hit = render_picker(&mut buf, area, &theme, &mut state, &[], &config, false);
let hit = render_picker(&mut buf, area, &theme, &mut state, &[], &config, false, 0);
let y = hit.search_bar.y;
let mut has_cursor = false;
let mut text = String::new();

View file

@ -0,0 +1,169 @@
//! Coding-data sharing upsell banner (Figma "Data Sharing Upsell",
//! node 8698:3690). Shared by the welcome tip slot and the agent-view
//! banner slot; visibility is gated by `AppView::privacy_banner_should_show`.
use crate::theme::Theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
/// Legal line copy — used for both render spans and mouse hit width.
const PRIVACY_BANNER_LEGAL: &str = "Learn more and read Terms and Privacy Policy.";
/// Click target for the legal line links.
pub(crate) const PRIVACY_BANNER_LEGAL_URL: &str = "https://x.ai/legal";
/// Hit rects returned by [`render`] for mouse handling.
pub(crate) struct PrivacyBannerRects {
pub accept: Rect,
pub customize: Rect,
pub legal: Rect,
}
/// Render the banner: copy left, `[Customize in settings]` / `[Accept]`
/// right, legal links on the second row. Needs `area.height >= 2`.
/// Hover styling mirrors the plugin CTA buttons.
pub(crate) fn render(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
mouse_pos: Option<(u16, u16)>,
) -> PrivacyBannerRects {
let customize_label = "[Customize in settings]";
let accept_label = "[Accept]";
let right_w = (customize_label.len() + 1 + accept_label.len()) as u16;
// Buttons render whole or not at all: a clipped/overflowing [Accept]
// must never leave a click target in the blank margin (a stray click
// there would silently opt the user in).
let buttons_fit = area.width > right_w;
let left_w = if buttons_fit {
area.width - right_w - 1
} else {
area.width
};
let left = Rect {
x: area.x,
y: area.y,
width: left_w,
height: area.height.min(2),
};
let right = Rect {
x: area.x + left_w + 1,
y: area.y,
width: right_w,
height: 1,
};
let hovered = |r: Rect| {
mouse_pos.is_some_and(|(mx, my)| r.contains(ratatui::layout::Position::new(mx, my)))
};
let legal_w = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
PRIVACY_BANNER_LEGAL.len()
} else {
"Learn more".len().min(left.width as usize)
};
// The legal line only exists when the slot really has a second row —
// otherwise its rect would make the blank row below clickable.
let legal_rect = if area.height >= 2 {
Rect {
x: left.x,
y: left.y.saturating_add(1),
width: legal_w as u16,
height: 1,
}
} else {
Rect::default()
};
// Figma node 8698:3806: title fg/primary, description fg/secondary,
// legal line fg/tertiary with underlined links in the same color.
// The whole legal line is one click target, so its links brighten together.
let link_fg = if hovered(legal_rect) {
theme.gray_bright
} else {
theme.gray
};
let link = Style::default()
.fg(link_fg)
.add_modifier(Modifier::UNDERLINED);
let gray = Style::default().fg(theme.gray);
let title = Span::styled("Help improve Grok", Style::default().fg(theme.text_primary));
let desc = "Allow your sessions to improve SpaceXAI's models.";
// Drop trailing spans whole rather than clipping mid-word when narrow.
let line1 = if left.width as usize >= "Help improve Grok ".len() + desc.len() {
Line::from(vec![
title,
Span::raw(" "),
Span::styled(desc, Style::default().fg(theme.gray_bright)),
])
} else {
Line::from(title)
};
// Span pieces must reassemble to PRIVACY_BANNER_LEGAL.
let line2 = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
Line::from(vec![
Span::styled("Learn more", link),
Span::styled(" and read ", gray),
Span::styled("Terms", link),
Span::styled(" and ", gray),
Span::styled("Privacy Policy", link),
Span::styled(".", gray),
])
} else {
Line::from(Span::styled("Learn more", link))
};
Paragraph::new(vec![line1, line2]).render(left, buf);
if !buttons_fit {
return PrivacyBannerRects {
accept: Rect::default(),
customize: Rect::default(),
legal: legal_rect,
};
}
let customize_rect = Rect {
x: right.x,
y: right.y,
width: customize_label.len() as u16,
height: 1,
};
let accept_rect = Rect {
x: right.x + customize_label.len() as u16 + 1,
y: right.y,
width: accept_label.len() as u16,
height: 1,
};
let customize_style = if hovered(customize_rect) {
Style::default().fg(theme.text_primary).bg(theme.bg_hover)
} else {
Style::default().fg(theme.gray_bright)
};
let accept_style = if hovered(accept_rect) {
Style::default().fg(theme.link_fg).bg(theme.bg_hover)
} else {
Style::default().fg(theme.text_primary)
};
buf.set_stringn(
customize_rect.x,
customize_rect.y,
customize_label,
customize_rect.width as usize,
customize_style,
);
buf.set_stringn(
accept_rect.x,
accept_rect.y,
accept_label,
accept_rect.width as usize,
accept_style,
);
PrivacyBannerRects {
accept: accept_rect,
customize: customize_rect,
legal: legal_rect,
}
}

View file

@ -1092,6 +1092,16 @@ impl PromptWidget {
self.slash_controller.set_mru(mru);
}
/// Adopt the shared per-command tag map so this prompt's slash dropdown
/// renders the same tags as other agent prompts and the dashboard dispatch.
/// Injected by `AppView`, which owns the single process map.
pub(crate) fn adopt_command_tags(
&mut self,
command_tags: std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>,
) {
self.slash_controller.set_command_tags(command_tags);
}
pub(crate) fn set_recap_visible(&mut self, visible: bool) {
self.slash_controller
.registry_mut()

View file

@ -373,6 +373,79 @@ pub enum QueueEvent {
/// Maximum height (in lines) the queue pane will request.
const MAX_QUEUE_HEIGHT: u16 = 3;
/// Hit-test + hover state for one per-row action button (`[edit]`,
/// `[Send now]`, `[cancel]`). Rect and row binding are rebound on every
/// `QueuePane::render`; hover persists across frames so a stationary
/// pointer keeps its highlight.
#[derive(Default)]
struct RowActionButton {
/// Screen rect from the last render; `None` when the button didn't render.
rect: Option<Rect>,
/// Entry id the rendered button acts on.
entry_id: Option<u64>,
/// Entry id whose button is under the mouse, if any. Drives the button's
/// hover fg color.
hovered_id: Option<u64>,
}
impl RowActionButton {
/// Drop the previous frame's rect/row binding (start of each render).
fn reset(&mut self) {
self.rect = None;
self.entry_id = None;
}
/// Bind the just-rendered button to its screen rect and row.
fn bind(&mut self, rect: Rect, id: u64) {
self.rect = Some(rect);
self.entry_id = Some(id);
}
/// Row id the button acts on when `(col, row)` hits its rect. `fallback`
/// is the pane's selected row id.
fn hit(&self, col: u16, row: u16, fallback: Option<u64>) -> Option<u64> {
let rect = self.rect?;
if rect.contains((col, row).into()) {
self.entry_id.or(fallback)
} else {
None
}
}
/// Update the hover from the mouse position. Returns `true` if it
/// changed (caller redraws).
fn update_hover(&mut self, col: u16, row: u16, fallback: Option<u64>) -> bool {
let over = self.rect.is_some_and(|r| r.contains((col, row).into()));
let new_id = if over {
self.entry_id.or(fallback)
} else {
None
};
if new_id != self.hovered_id {
self.hovered_id = new_id;
true
} else {
false
}
}
/// Clear the hover (mouse left the queue pane). Returns `true` if it was
/// previously hovered (caller should redraw).
fn clear_hover(&mut self) -> bool {
if self.hovered_id.is_some() {
self.hovered_id = None;
true
} else {
false
}
}
/// Whether the button bound to row `id` is under the mouse.
fn is_hovered_for(&self, id: u64) -> bool {
self.hovered_id == Some(id)
}
}
/// Self-contained queue pane component.
///
/// Does NOT own the queue data — entries are rebuilt each frame from
@ -395,14 +468,12 @@ pub struct QueuePane {
pub overlay: OverlayState,
/// Previous queue length — used for auto-show detection.
prev_len: usize,
send_now_rect: Option<Rect>,
send_now_entry_id: Option<u64>,
/// Entry id whose `[Interject]` button is under the mouse, if any. Drives
/// the button's hover fg color (brightens like the `[Dashboard]` button).
hovered_send_now_id: Option<u64>,
delete_button_rect: Option<Rect>,
delete_button_entry_id: Option<u64>,
pub(crate) hovered_delete_id: Option<u64>,
/// `[Send now]` (force-interject) action button.
send_now: RowActionButton,
/// `[cancel]` (row delete) action button.
delete_button: RowActionButton,
/// `[edit]` (queued-row edit) action button.
edit_button: RowActionButton,
/// Entry id of the row currently under the mouse cursor, if any. Drives
/// the hover affordance: the hovered row reveals its action buttons just
/// like the selected row does when the pane is focused.
@ -440,12 +511,9 @@ impl QueuePane {
last_theme: Theme::current_kind(),
overlay: OverlayState::hidden(),
prev_len: 0,
send_now_rect: None,
send_now_entry_id: None,
hovered_send_now_id: None,
delete_button_rect: None,
delete_button_entry_id: None,
hovered_delete_id: None,
send_now: RowActionButton::default(),
delete_button: RowActionButton::default(),
edit_button: RowActionButton::default(),
hovered_row_id: None,
last_inner: None,
}
@ -687,73 +755,51 @@ impl QueuePane {
/// Hit-test the action-row `[Interject]` button. Returns the id of the row
/// the button belongs to (the hovered row, else the selected row).
pub fn send_now_click(&self, col: u16, row: u16) -> Option<u64> {
let rect = self.send_now_rect?;
if rect.contains((col, row).into()) {
self.send_now_entry_id.or_else(|| self.selected_id())
} else {
None
}
self.send_now.hit(col, row, self.selected_id())
}
/// Hit-test the action-row `[cancel]` button (removes the row).
pub fn delete_click(&self, col: u16, row: u16) -> Option<u64> {
let rect = self.delete_button_rect?;
if rect.contains((col, row).into()) {
self.delete_button_entry_id.or_else(|| self.selected_id())
} else {
None
}
self.delete_button.hit(col, row, self.selected_id())
}
/// Hit-test the action-row `[edit]` button (opens the queued-row edit).
pub fn edit_click(&self, col: u16, row: u16) -> Option<u64> {
self.edit_button.hit(col, row, self.selected_id())
}
/// Update the `[cancel]` hover. Returns `true` if it changed (caller redraws).
pub fn update_delete_hover(&mut self, col: u16, row: u16) -> bool {
let over = self
.delete_button_rect
.is_some_and(|r| r.contains((col, row).into()));
let new_id = if over {
self.delete_button_entry_id.or_else(|| self.selected_id())
} else {
None
};
if new_id != self.hovered_delete_id {
self.hovered_delete_id = new_id;
true
} else {
false
}
let selected = self.selected_id();
self.delete_button.update_hover(col, row, selected)
}
pub fn clear_delete_hover(&mut self) {
self.hovered_delete_id = None;
/// Clear the `[cancel]` hover. Returns `true` if it was previously hovered.
pub fn clear_delete_hover(&mut self) -> bool {
self.delete_button.clear_hover()
}
/// Update whether the mouse is over the `[Interject]` button. Drives the
/// button's hover fg color. Returns `true` if it changed (caller redraws).
/// Update the `[Interject]` hover (drives the brighten-on-hover fg, same
/// as the `[Dashboard]` button). Returns `true` if it changed.
pub fn update_send_now_hover(&mut self, col: u16, row: u16) -> bool {
let over = self
.send_now_rect
.is_some_and(|r| r.contains((col, row).into()));
let new_id = if over {
self.send_now_entry_id.or_else(|| self.selected_id())
} else {
None
};
if new_id != self.hovered_send_now_id {
self.hovered_send_now_id = new_id;
true
} else {
false
}
let selected = self.selected_id();
self.send_now.update_hover(col, row, selected)
}
/// Clear the `[Interject]` hover (mouse left the queue pane). Returns
/// `true` if it was previously hovered (caller should redraw).
/// Clear the `[Interject]` hover. Returns `true` if it was previously hovered.
pub fn clear_send_now_hover(&mut self) -> bool {
if self.hovered_send_now_id.is_some() {
self.hovered_send_now_id = None;
true
} else {
false
}
self.send_now.clear_hover()
}
/// Update the `[edit]` hover. Returns `true` if it changed (caller redraws).
pub fn update_edit_hover(&mut self, col: u16, row: u16) -> bool {
let selected = self.selected_id();
self.edit_button.update_hover(col, row, selected)
}
/// Clear the `[edit]` hover. Returns `true` if it was previously hovered.
pub fn clear_edit_hover(&mut self) -> bool {
self.edit_button.clear_hover()
}
/// Update which row the mouse is hovering over (the row, not just its
@ -910,14 +956,14 @@ impl QueuePane {
}
}
// Action buttons (optional [Send now] then [cancel], right-aligned). They
// render for the row under the mouse (hover affordance) or, when the
// pane is focused, the selected row. Hover takes precedence so mouse
// users can act on any row without focusing/selecting it first.
self.send_now_rect = None;
self.send_now_entry_id = None;
self.delete_button_rect = None;
self.delete_button_entry_id = None;
// Action buttons (optional [Send now], then [edit], then [cancel],
// right-aligned). They render for the row under the mouse (hover
// affordance) or, when the pane is focused, the selected row. Hover
// takes precedence so mouse users can act on any row without
// focusing/selecting it first.
self.send_now.reset();
self.delete_button.reset();
self.edit_button.reset();
let action_idx = self
.hovered_row_id
.and_then(|id| self.entries.iter().position(|e| e.id == id))
@ -941,42 +987,69 @@ impl QueuePane {
&& rel < inner.height as usize
{
let screen_y = inner.y + rel as u16;
let btn_style = Style::default().fg(theme.gray);
// Right-to-left walk. A button renders only when its whole
// label fits at or right of `inner.x`: saturating toward 0
// would paint into the left gutter and overlap already-placed
// buttons (checked_sub underflow = "doesn't fit", not x = 0).
let mut right = inner.x + inner.width;
let fits = |right: u16, w: u16| right.checked_sub(w).filter(|&x| x >= inner.x);
let cancel_label = "[cancel]";
let cancel_w = cancel_label.len() as u16;
// Compact action wording; same mouse hit-test as before (force-interject).
let interject_label = "[Send now]";
let interject_w = interject_label.len() as u16;
let btn_style = Style::default().fg(theme.gray);
let mut right = inner.x + inner.width;
right = right.saturating_sub(cancel_w);
let cancel_x = right;
let hovered = self.hovered_delete_id == Some(entry.id);
let cancel_style = if hovered {
Style::default().fg(theme.accent_error)
} else {
btn_style
};
buf.set_string_safe(cancel_x, screen_y, cancel_label, cancel_style);
self.delete_button_rect = Some(Rect::new(cancel_x, screen_y, cancel_w, 1));
self.delete_button_entry_id = Some(entry.id);
if is_turn_running {
// Place [Send now] flush against [cancel] (no gap) — a gap
// would let the queued message behind the row leak through
// the seam between the two buttons.
right = right.saturating_sub(interject_w);
let interject_x = right;
// Brighten the fg on hover (same hover color as the
// [Dashboard] button) so it reads as clickable.
let interject_style = if self.hovered_send_now_id == Some(entry.id) {
Style::default().fg(theme.text_primary)
if let Some(cancel_x) = fits(right, cancel_w) {
right = cancel_x;
let cancel_style = if self.delete_button.is_hovered_for(entry.id) {
Style::default().fg(theme.accent_error)
} else {
btn_style
};
buf.set_string_safe(interject_x, screen_y, interject_label, interject_style);
self.send_now_rect = Some(Rect::new(interject_x, screen_y, interject_w, 1));
self.send_now_entry_id = Some(entry.id);
buf.set_string_safe(cancel_x, screen_y, cancel_label, cancel_style);
self.delete_button
.bind(Rect::new(cancel_x, screen_y, cancel_w, 1), entry.id);
// [edit] sits flush against [cancel] (no gap) — a gap
// would let the queued message behind the row leak through
// the seam. Unlike [Send now] it renders regardless of
// turn state — the keyboard `e` edit works either way.
let edit_label = "[edit]";
let edit_w = edit_label.len() as u16;
if let Some(edit_x) = fits(right, edit_w) {
right = edit_x;
let edit_style = if self.edit_button.is_hovered_for(entry.id) {
Style::default().fg(theme.text_primary)
} else {
btn_style
};
buf.set_string_safe(edit_x, screen_y, edit_label, edit_style);
self.edit_button
.bind(Rect::new(edit_x, screen_y, edit_w, 1), entry.id);
}
if is_turn_running {
// Compact action wording; same mouse hit-test as before
// (force-interject). Leftmost in the chain, flush
// against [edit] for the same no-seam reason.
let interject_label = "[Send now]";
let interject_w = interject_label.len() as u16;
if let Some(interject_x) = fits(right, interject_w) {
// Brighten the fg on hover (same hover color as the
// [Dashboard] button) so it reads as clickable.
let interject_style = if self.send_now.is_hovered_for(entry.id) {
Style::default().fg(theme.text_primary)
} else {
btn_style
};
buf.set_string_safe(
interject_x,
screen_y,
interject_label,
interject_style,
);
self.send_now
.bind(Rect::new(interject_x, screen_y, interject_w, 1), entry.id);
}
}
}
}
}
@ -1521,9 +1594,9 @@ mod tests {
// -- Action-button rendering (hover + layout) ----------------------------
/// The `[Interject]` and `[cancel]` buttons render flush against each other
/// so the queued message behind the row can't leak through a seam between
/// them (no gap).
/// The `[Interject]`, `[edit]`, and `[cancel]` buttons render flush
/// against each other so the queued message behind the row can't leak
/// through a seam between them (no gap).
#[test]
fn action_buttons_render_flush_with_no_gap() {
let mut pane = QueuePane::new();
@ -1539,16 +1612,122 @@ mod tests {
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
let layout_cfg = crate::appearance::LayoutConfig::default();
// Focused + turn running → both buttons render for the selected row.
// Focused + turn running → all three buttons render for the selected row.
pane.render(area, &mut buf, true, &layout_cfg, None, true);
let interject = pane.send_now_rect.expect("interject button renders");
let delete = pane.delete_button_rect.expect("delete button renders");
let edit = pane.edit_button.rect.expect("edit button renders");
let interject = pane.send_now.rect.expect("interject button renders");
let delete = pane.delete_button.rect.expect("delete button renders");
assert_eq!(
interject.x + interject.width,
delete.x,
"[Interject] must sit flush against [cancel] (no gap to leak through)"
edit.x,
"[Interject] must sit flush against [edit] (no gap to leak through)"
);
assert_eq!(
edit.x + edit.width,
delete.x,
"[edit] must sit flush against [cancel] (no gap to leak through)"
);
}
/// `[edit]` renders even when no turn is running — the keyboard `e` edit
/// works regardless of turn state — while `[Send now]` stays hidden, and
/// the chain stays flush: [edit][cancel].
#[test]
fn edit_button_renders_when_turn_not_running() {
let mut pane = QueuePane::new();
let mut local = std::collections::VecDeque::new();
local.push_back(local_prompt(1, "msg"));
pane.sync_from_merged(&local, &[], None, None, &Default::default());
let ids = pane.entry_ids();
pane.list_state.select_by_id(ids[0]);
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
let layout_cfg = crate::appearance::LayoutConfig::default();
// Focused + turn NOT running → [edit] and [cancel], no [Send now].
pane.render(area, &mut buf, true, &layout_cfg, None, false);
assert!(
pane.send_now.rect.is_none(),
"[Send now] only renders mid-turn"
);
let edit = pane
.edit_button
.rect
.expect("edit button renders while idle");
let cancel = pane.delete_button.rect.expect("cancel button renders");
assert_eq!(pane.edit_button.entry_id, Some(ids[0]));
assert_eq!(
edit.x + edit.width,
cancel.x,
"[edit] must sit flush against [cancel] when [Send now] is hidden"
);
}
/// On panes too narrow for the full `[Send now][edit][cancel]` chain, a
/// button that can't fully fit at or right of the content area's left
/// edge is dropped instead of saturating toward x = 0 — otherwise rects
/// land outside `inner` and overlap, mis-routing clicks (send-now is
/// hit-tested before edit, so overlapped cells would fire it).
#[test]
fn narrow_pane_drops_buttons_that_do_not_fit() {
let layout_cfg = crate::appearance::LayoutConfig::default();
// Probe the left padding once so the width sweep spans inner widths
// from 1 (nothing fits) past 24 (the full chain fits).
let pad_left = {
let mut pane = QueuePane::new();
let mut local = std::collections::VecDeque::new();
local.push_back(local_prompt(1, "msg"));
pane.sync_from_merged(&local, &[], None, None, &Default::default());
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
pane.render(area, &mut buf, true, &layout_cfg, None, true);
pane.last_inner.expect("inner recorded").x
};
for is_running in [true, false] {
for width in (pad_left + 1)..=(pad_left + 26) {
let mut pane = QueuePane::new();
let mut local = std::collections::VecDeque::new();
local.push_back(local_prompt(1, "msg"));
pane.sync_from_merged(&local, &[], None, None, &Default::default());
let ids = pane.entry_ids();
pane.list_state.select_by_id(ids[0]);
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
pane.render(area, &mut buf, true, &layout_cfg, None, is_running);
let inner = pane.last_inner.expect("inner recorded");
let rects = [
("edit", pane.edit_button.rect),
("send_now", pane.send_now.rect),
("cancel", pane.delete_button.rect),
];
let mut placed: Vec<(&str, Rect)> = rects
.iter()
.filter_map(|&(name, rect)| rect.map(|r| (name, r)))
.collect();
for (name, r) in &placed {
assert!(
r.x >= inner.x && r.x + r.width <= inner.x + inner.width,
"{name} rect {r:?} must stay inside inner {inner:?} \
at width {width} (running={is_running})"
);
}
placed.sort_by_key(|(_, r)| r.x);
for pair in placed.windows(2) {
let (an, a) = pair[0];
let (bn, b) = pair[1];
assert!(
a.x + a.width <= b.x,
"{an} and {bn} rects must not overlap at width {width} \
(running={is_running}): {a:?} vs {b:?}"
);
}
}
}
}
/// Hovering a row reveals that row's action buttons even when the pane is
@ -1567,8 +1746,9 @@ mod tests {
// Unfocused with no hover → no action buttons at all.
pane.render(area, &mut buf, false, &layout_cfg, None, true);
assert!(pane.delete_button_rect.is_none());
assert!(pane.send_now_rect.is_none());
assert!(pane.delete_button.rect.is_none());
assert!(pane.send_now.rect.is_none());
assert!(pane.edit_button.rect.is_none());
// Hover the second row → its buttons appear (still unfocused).
let inner = pane.last_inner.expect("inner area recorded during render");
@ -1579,16 +1759,19 @@ mod tests {
pane.render(area, &mut buf, false, &layout_cfg, None, true);
let ids = pane.entry_ids();
assert_eq!(pane.delete_button_entry_id, Some(ids[1]));
assert_eq!(pane.send_now_entry_id, Some(ids[1]));
assert!(pane.delete_button_rect.is_some());
assert!(pane.send_now_rect.is_some());
assert_eq!(pane.delete_button.entry_id, Some(ids[1]));
assert_eq!(pane.send_now.entry_id, Some(ids[1]));
assert_eq!(pane.edit_button.entry_id, Some(ids[1]));
assert!(pane.delete_button.rect.is_some());
assert!(pane.send_now.rect.is_some());
assert!(pane.edit_button.rect.is_some());
// Moving off the rows clears the hover and hides the buttons again.
assert!(pane.clear_row_hover());
pane.render(area, &mut buf, false, &layout_cfg, None, true);
assert!(pane.delete_button_rect.is_none());
assert!(pane.send_now_rect.is_none());
assert!(pane.delete_button.rect.is_none());
assert!(pane.send_now.rect.is_none());
assert!(pane.edit_button.rect.is_none());
}
/// Hovering a row paints the dim hover bg across that row (matching the
@ -1649,14 +1832,14 @@ mod tests {
// Focused + turn running → [Interject] renders for the selected row.
pane.render(area, &mut buf, true, &layout_cfg, None, true);
let rect = pane.send_now_rect.expect("interject button renders");
let rect = pane.send_now.rect.expect("interject button renders");
let non_hover_fg = buf[(rect.x, rect.y)].fg;
assert_eq!(non_hover_fg, theme.gray, "plain button uses gray fg");
// Hover the [Interject] button → fg becomes text_primary.
assert!(pane.update_send_now_hover(rect.x, rect.y));
pane.render(area, &mut buf, true, &layout_cfg, None, true);
let rect = pane.send_now_rect.expect("interject button still renders");
let rect = pane.send_now.rect.expect("interject button still renders");
for x in rect.x..rect.x + rect.width {
assert_eq!(buf[(x, rect.y)].fg, theme.text_primary, "col {x}");
}
@ -1695,15 +1878,20 @@ mod tests {
pane.render(area, &mut buf, true, &layout_cfg, None, true);
assert!(
pane.delete_button_rect.is_none(),
pane.delete_button.rect.is_none(),
"off-screen hovered row must not bind the cancel button to row 0"
);
assert!(
pane.send_now_rect.is_none(),
pane.send_now.rect.is_none(),
"off-screen hovered row must not bind the interject button to row 0"
);
assert_eq!(pane.delete_button_entry_id, None);
assert_eq!(pane.send_now_entry_id, None);
assert!(
pane.edit_button.rect.is_none(),
"off-screen hovered row must not bind the edit button to row 0"
);
assert_eq!(pane.delete_button.entry_id, None);
assert_eq!(pane.send_now.entry_id, None);
assert_eq!(pane.edit_button.entry_id, None);
}
/// The `[cancel]` button's right edge reaches the full content-area width
@ -1723,7 +1911,7 @@ mod tests {
let layout_cfg = crate::appearance::LayoutConfig::default();
pane.render(area, &mut buf, true, &layout_cfg, None, true);
let cancel = pane.delete_button_rect.expect("cancel button renders");
let cancel = pane.delete_button.rect.expect("cancel button renders");
assert_eq!(
cancel.x + cancel.width,
area.x + area.width,
@ -1756,7 +1944,7 @@ mod tests {
.list_state
.scrollbar_area()
.expect("scrollbar shown for 5 rows in a height-3 pane");
let cancel = pane.delete_button_rect.expect("cancel button renders");
let cancel = pane.delete_button.rect.expect("cancel button renders");
assert!(
sb.x >= cancel.x + cancel.width,
"scrollbar (x={}) must sit clear of [cancel] (right edge {})",

View file

@ -117,54 +117,90 @@ impl SessionPickerLanes {
}
}
/// Loading gate for a session picker surface's spinner: nothing to show yet —
/// no loaded entry passes the source filter — while the native fetch or
/// foreign scan is still in flight. The filter check (not `entries.is_none()`)
/// matters because the fast foreign scan can land rows the default Grok view
/// hides before the native list arrives; the empty state must wait until both
/// lanes settle. Shared by rendering, redraw forcing, and tick demand so the
/// three cannot drift (a spinner that renders without demanding ticks parks
/// on its first frame).
pub(crate) fn loading_spinner_active(
entries: Option<&[SessionPickerEntry]>,
source_filter: SourceFilter,
loading: bool,
lanes: &SessionPickerLanes,
) -> bool {
let nothing_visible = entries.is_none_or(|entries| {
!entries
.iter()
.any(|entry| source_filter.matches(&entry.source))
});
nothing_visible && (loading || lanes.foreign_loading)
}
// ---------------------------------------------------------------------------
// Source filter
// ---------------------------------------------------------------------------
/// Filter session entries by native, remote, or external source.
///
/// Default is [`Self::Grok`]: native Grok sessions only (local / remote /
/// conversation), so `/resume` does not mix Claude/Codex/Cursor foreign
/// sessions into the list. `f` cycles Grok → External → All → Local →
/// Remote — External first so one press from the default reveals foreign
/// sessions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SourceFilter {
/// Native Grok sessions only — excludes Claude/Codex/Cursor foreign rows.
#[default]
All,
Grok,
Local,
Remote,
External,
/// Every source, including foreign agent sessions.
All,
}
impl SourceFilter {
pub fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::Grok => "Grok",
Self::Local => "Local",
Self::Remote => "Remote",
Self::External => "External",
Self::All => "All",
}
}
pub fn next(self) -> Self {
match self {
Self::Grok => Self::External,
Self::External => Self::All,
Self::All => Self::Local,
Self::Local => Self::Remote,
Self::Remote => Self::External,
Self::External => Self::All,
Self::Remote => Self::Grok,
}
}
/// Returns `true` when a non-default filter is selected.
pub fn is_active(self) -> bool {
self != Self::All
self != Self::Grok
}
/// Returns `true` if a session with the given `source` string passes the filter.
///
/// grok.com conversations carry `source == "conversation"` and live remotely,
/// so they pass the `Remote` filter (and `All`) but not `Local`.
/// so they pass the `Remote` filter (and `Grok` / `All`) but not `Local`.
/// Foreign sources (`claude` / `codex` / `cursor`) only pass `External` and
/// `All`.
pub fn matches(self, source: &str) -> bool {
match self {
Self::All => true,
Self::Grok => !crate::app::is_foreign_picker_source(source),
Self::Local => source == "local" || source == "both",
Self::Remote => source == "remote" || source == "both" || source == "conversation",
Self::External => crate::app::is_foreign_picker_source(source),
Self::All => true,
}
}
}
@ -818,6 +854,28 @@ pub(crate) fn build_content_header_label(
}
}
/// Hint shown on the default `Grok` view when the foreign-session scan loaded
/// Claude/Codex/Cursor entries it hides. Grok-only: `next(Grok) == External`
/// makes the copy literally true, and reaching Local/Remote already cycles
/// through External/All, so the discovery hint is only needed on the default
/// state.
pub(crate) fn hidden_external_hint(
entries: Option<&[SessionPickerEntry]>,
source_filter: SourceFilter,
) -> Option<String> {
if source_filter != SourceFilter::Grok {
return None;
}
let hidden = entries?
.iter()
.filter(|entry| crate::app::is_foreign_picker_source(&entry.source))
.count();
(hidden > 0).then(|| {
let plural = if hidden == 1 { "" } else { "s" };
format!("{hidden} external session{plural} hidden \u{b7} f to show")
})
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
@ -1283,6 +1341,15 @@ mod tests {
#[test]
fn source_filter_matches() {
// Default Grok filter: native only (not Claude/Codex/Cursor).
assert!(SourceFilter::Grok.matches("local"));
assert!(SourceFilter::Grok.matches("remote"));
assert!(SourceFilter::Grok.matches("both"));
assert!(SourceFilter::Grok.matches("conversation"));
assert!(!SourceFilter::Grok.matches("claude"));
assert!(!SourceFilter::Grok.matches("codex"));
assert!(!SourceFilter::Grok.matches("cursor"));
assert!(SourceFilter::All.matches("local"));
assert!(SourceFilter::All.matches("remote"));
assert!(SourceFilter::All.matches("both"));
@ -1300,7 +1367,7 @@ mod tests {
assert!(!SourceFilter::Remote.matches("local"));
assert!(!SourceFilter::Remote.matches("cursor"));
// grok.com conversations are remote: visible under All + Remote, not Local.
// grok.com conversations are remote: visible under Grok + All + Remote, not Local.
assert!(SourceFilter::All.matches("conversation"));
assert!(SourceFilter::Remote.matches("conversation"));
assert!(!SourceFilter::Local.matches("conversation"));
@ -1316,11 +1383,15 @@ mod tests {
#[test]
fn source_filter_cycles() {
// External first: one press from the default reveals foreign sessions.
assert_eq!(SourceFilter::Grok.next(), SourceFilter::External);
assert_eq!(SourceFilter::External.next(), SourceFilter::All);
assert_eq!(SourceFilter::All.next(), SourceFilter::Local);
assert_eq!(SourceFilter::Local.next(), SourceFilter::Remote);
assert_eq!(SourceFilter::Remote.next(), SourceFilter::External);
assert_eq!(SourceFilter::External.next(), SourceFilter::All);
assert_eq!(SourceFilter::Remote.next(), SourceFilter::Grok);
assert_eq!(SourceFilter::Grok.label(), "Grok");
assert_eq!(SourceFilter::External.label(), "External");
assert_eq!(SourceFilter::default(), SourceFilter::Grok);
}
#[test]
@ -1339,6 +1410,9 @@ mod tests {
entry_with_source("s5", "cursor"),
];
let grok = filter_session_entries(Some(&entries), "", SourceFilter::Grok);
assert_eq!(grok, vec![0, 1, 2]); // local + remote + both, no foreign
let all = filter_session_entries(Some(&entries), "", SourceFilter::All);
assert_eq!(all, vec![0, 1, 2, 3, 4, 5]);
@ -1354,24 +1428,69 @@ mod tests {
#[test]
fn source_filter_empty_and_unknown_source() {
// Empty source string (e.g. from old data or test fixtures) should
// only pass the All filter, never Local or Remote.
// Empty / unknown source (e.g. from old data or test fixtures) is not
// foreign, so it passes Grok + All but never Local, Remote, or External.
assert!(SourceFilter::Grok.matches(""));
assert!(SourceFilter::All.matches(""));
assert!(!SourceFilter::Local.matches(""));
assert!(!SourceFilter::Remote.matches(""));
assert!(!SourceFilter::External.matches(""));
// Unknown source values are also rejected by Local/Remote.
assert!(SourceFilter::Grok.matches("unknown"));
assert!(SourceFilter::All.matches("unknown"));
assert!(!SourceFilter::Local.matches("unknown"));
assert!(!SourceFilter::Remote.matches("unknown"));
assert!(!SourceFilter::External.matches("unknown"));
}
#[test]
fn source_filter_is_active() {
assert!(!SourceFilter::All.is_active());
assert!(!SourceFilter::Grok.is_active());
assert!(SourceFilter::Local.is_active());
assert!(SourceFilter::Remote.is_active());
assert!(SourceFilter::External.is_active());
assert!(SourceFilter::All.is_active());
}
#[test]
fn hidden_external_hint_visibility() {
fn entry_with_source(id: &str, source: &str) -> SessionPickerEntry {
let mut e = make_entry(id, "r");
e.source = source.into();
e
}
let entries = vec![
entry_with_source("s0", "local"),
entry_with_source("s1", "claude"),
entry_with_source("s2", "codex"),
];
// Only the default Grok view surfaces the hint (with the count).
assert_eq!(
hidden_external_hint(Some(&entries), SourceFilter::Grok).as_deref(),
Some("2 external sessions hidden \u{b7} f to show")
);
assert!(hidden_external_hint(Some(&entries), SourceFilter::Local).is_none());
assert!(hidden_external_hint(Some(&entries), SourceFilter::Remote).is_none());
// Singular count.
let one = vec![
entry_with_source("s0", "local"),
entry_with_source("s1", "cursor"),
];
assert_eq!(
hidden_external_hint(Some(&one), SourceFilter::Grok).as_deref(),
Some("1 external session hidden \u{b7} f to show")
);
// External / All show foreign rows — no hint.
assert!(hidden_external_hint(Some(&entries), SourceFilter::External).is_none());
assert!(hidden_external_hint(Some(&entries), SourceFilter::All).is_none());
// No foreign entries loaded (native-only or no scan) — no hint.
let native = vec![entry_with_source("s0", "local")];
assert!(hidden_external_hint(Some(&native), SourceFilter::Grok).is_none());
assert!(hidden_external_hint(None, SourceFilter::Grok).is_none());
}
#[test]

View file

@ -788,7 +788,12 @@ pub(super) fn setting_row_visible(
minimal: bool,
voice_mode: bool,
) -> bool {
if !voice_mode && matches!(meta.key, "voice_capture_mode" | "voice_stt_language") {
if !voice_mode
&& matches!(
meta.key,
"voice_keybind_enabled" | "voice_capture_mode" | "voice_stt_language"
)
{
return false;
}
if meta.key == "voice_capture_mode" && !kitty_releases {
@ -858,6 +863,7 @@ pub(super) fn action_for_bool(key: SettingKey, new: bool) -> Option<Action> {
"contextual_hints.ssh_wrap" => Some(Action::SetContextualHintSshWrap(new)),
"multiline_mode" => Some(Action::SetMultilineMode(new)),
"vim_mode" => Some(Action::SetVimMode(new)),
"voice_keybind_enabled" => Some(Action::SetVoiceKeybindEnabled(new)),
"remember_tool_approvals" => Some(Action::SetRememberToolApprovals(new)),
"toolset.ask_user_question.timeout_enabled" => {
Some(Action::SetAskUserQuestionTimeoutEnabled(new))

View file

@ -191,17 +191,22 @@ fn setting_row_visible_gates_voice_capture_on_key_releases() {
#[test]
fn setting_row_visible_hides_voice_rows_when_voice_mode_off() {
let reg = SettingsRegistry::defaults();
let keybind = meta_for(&reg, "voice_keybind_enabled");
let capture = meta_for(&reg, "voice_capture_mode");
let language = meta_for(&reg, "voice_stt_language");
let vim = meta_for(&reg, "vim_mode");
// Gate off: both voice rows gone even with kitty releases + full TUI.
// Gate off: all voice rows gone even with kitty releases + full TUI.
assert!(!setting_row_visible(keybind, true, false, false));
assert!(!setting_row_visible(capture, true, false, false));
assert!(!setting_row_visible(language, true, false, false));
// Non-voice rows unaffected.
assert!(setting_row_visible(vim, true, false, false));
// Gate on: both visible (kitty releases for capture).
// Gate on: all visible (kitty releases for capture).
assert!(setting_row_visible(keybind, true, false, true));
assert!(setting_row_visible(capture, true, false, true));
assert!(setting_row_visible(language, true, false, true));
// The keybind row (unlike capture) doesn't need key-release reporting.
assert!(setting_row_visible(keybind, false, false, true));
}
#[test]
@ -657,7 +662,8 @@ fn rows_contain_categories_and_settings_through_pr_14() {
// SHELL-owned prompt_suggestions (Editor; tab autocomplete
// ghost text, live cache).
"prompt_suggestions",
// voice_capture_mode + voice_stt_language hidden when gate is off.
// voice_keybind_enabled + voice_capture_mode + voice_stt_language
// hidden when the voice gate is off.
// SHELL-owned permission_mode (Agent category).
"permission_mode",
// SHELL-owned remember_tool_approvals (Agent category,

View file

@ -178,12 +178,14 @@ pub fn build_entries(
continue;
}
// The voice chord (`Ctrl+Space`) is hidden when the voice gate is
// off (remote kill switch / `GROK_VOICE_MODE=0`). Unlike the old
// `Ctrl+Shift+M`, `Ctrl+Space` decodes the same with or without the
// Kitty keyboard protocol (it just toggles instead of hold-to-talk
// without it), so it's shown on every terminal once the gate is on.
// off (remote kill switch / `GROK_VOICE_MODE=0`) or the user turned
// the Voice shortcut setting off — don't advertise keys that do
// nothing. `Ctrl+Space` decodes the same with or without the Kitty
// keyboard protocol (it just toggles instead of hold-to-talk), so
// it's shown on every terminal once the gates are on.
// EnableVoiceMode is slash-only and already dropped above.
if def.id == crate::actions::ActionId::VoiceToggle && !crate::app::voice_mode_enabled()
if def.id == crate::actions::ActionId::VoiceToggle
&& (!crate::app::voice_mode_enabled() || !crate::app::voice_keybind_enabled())
{
continue;
}
@ -494,6 +496,7 @@ fn picker_config(non_sel: &[bool]) -> PickerConfig<'_> {
filter_label: None,
filter_key_hint: None,
filter_active: false,
header_note: None,
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
@ -1350,6 +1353,7 @@ pub fn render_modal(
&[],
Some(theme.bg_base),
false,
0,
inner_x + inner_width - 1,
);
state.hit_areas = Some(PickerHitAreas {

View file

@ -45,16 +45,38 @@ pub fn desired_item_rows(items: &[SuggestionRow], items_width: u16) -> u16 {
flat_line_count(items, items_width as usize, MAX_DROPDOWN_ROWS as usize) as u16
}
/// The rendered `" [tag]"` suffix for a row, or `None` when untagged.
fn tag_suffix(row: &SuggestionRow) -> Option<String> {
row.tag.as_ref().map(|t| format!(" [{t}]"))
}
/// Rendered width of a row's `" [tag]"` suffix (0 when untagged). Measured
/// without allocating: space + `[` + tag + `]` = tag width + 3. The tag shares
/// the label column so descriptions stay aligned across tagged/untagged rows.
fn tag_suffix_width(row: &SuggestionRow) -> usize {
row.tag.as_ref().map(|t| t.width() + 3).unwrap_or(0)
}
/// Compute the aligned label column width from all visible items.
///
/// The label column gets up to 60% of the available width (capped at `LABEL_CAP`).
/// This prioritises showing the full command name over the description.
/// This prioritises showing the full command name over the description. The tag
/// suffix is folded in so a `/cmd [tag]` row and a plain `/cmd` row share the
/// same description column. Untagged rows keep origin/main behavior (overlong
/// commands are ignored); tagged rows always contribute a `LABEL_CAP`-clamped
/// width so a long tag can never zero out the column.
fn compute_label_column_w(items: &[SuggestionRow], content_w: usize) -> usize {
let budget = (content_w * 3 / 5).min(LABEL_CAP);
let max_display_w = items
.iter()
.map(|r| r.display.width())
.filter(|&w| w <= LABEL_CAP)
.filter_map(|r| {
let base = r.display.width();
if r.tag.is_none() {
(base <= LABEL_CAP).then_some(base)
} else {
Some((base + tag_suffix_width(r)).min(LABEL_CAP))
}
})
.max()
.unwrap_or(0);
max_display_w.min(budget)
@ -291,6 +313,7 @@ fn build_item_lines(
let match_style = Style::default().fg(match_fg).bg(row_bg).add_modifier(bold);
let desc_style = Style::default().fg(desc_fg).bg(row_bg);
let bg_style = Style::default().bg(row_bg);
let tag_style = Style::default().fg(theme.accent_system).bg(row_bg);
// 1. Build prefix + label spans with fuzzy match highlighting.
let prefix = if is_selected {
@ -303,14 +326,22 @@ fn build_item_lines(
if is_selected { normal_style } else { bg_style },
);
let label = truncate_str(&item.display, label_col_w);
// Optional " [tag]" suffix, right-aligned at the end of the label column
// (just left of the description). Truncated/reserved so the name never
// overruns it at narrow widths. Leading space in the suffix separates
// label and tag when padding is 0 (longest command+tag row).
let tag_text = tag_suffix(item).map(|s| truncate_str(&s, label_col_w));
let tag_w = tag_text.as_deref().map(|s| s.width()).unwrap_or(0);
let label = truncate_str(&item.display, label_col_w.saturating_sub(tag_w));
let label_w = label.width();
let padding = label_col_w.saturating_sub(label_w);
let padding = label_col_w.saturating_sub(label_w + tag_w);
// Build per-character spans for the label with fuzzy highlight.
let label_spans = build_highlighted_spans(&label, &item.indices, normal_style, match_style);
// Description column indent (prefix + label + gap).
// Description column indent (prefix + label + gap). `label_col_w` already
// includes the tag suffix, so descriptions align across tagged/untagged rows.
let desc_indent = PREFIX_W + label_col_w + LABEL_DESC_GAP;
let desc_w = total_w.saturating_sub(desc_indent).max(1);
@ -321,13 +352,18 @@ fn build_item_lines(
simple_word_wrap(&item.description, desc_w)
};
// 2. First line: prefix + label(padded) + gap + first desc line.
// 2. First line: prefix + label + padding + [tag] + gap + first desc line.
// Padding comes before the tag so the tag is right-aligned at the end of
// the label column (`]` sits just left of the description gap).
{
let mut spans = vec![prefix_span];
spans.extend(label_spans);
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), bg_style));
}
if let Some(tag_text) = tag_text {
spans.push(Span::styled(tag_text, tag_style));
}
if let Some(first_desc) = desc_lines.first() {
spans.push(Span::styled(" ".to_string(), bg_style));
spans.push(Span::styled(first_desc.clone(), desc_style));
@ -466,6 +502,7 @@ mod tests {
description: String::new(),
insert_text: format!("/cmd{i}"),
indices: vec![],
tag: None,
})
.collect();
assert_eq!(desired_item_rows(&matches, 80), MAX_DROPDOWN_ROWS);
@ -486,6 +523,7 @@ mod tests {
description: format!("description for command {i}"),
insert_text: format!("/cmd{i}"),
indices: vec![],
tag: None,
})
.collect();
let snap = SlashSnapshot {
@ -520,6 +558,7 @@ mod tests {
description: description.into(),
insert_text: display.into(),
indices: vec![],
tag: None,
}
}
@ -648,4 +687,125 @@ mod tests {
assert_eq!(rendered.row_items.len(), rows as usize);
assert_eq!(rendered.row_items[0], 0, "scroll starts at the top");
}
/// A tagged row renders "[tag]" (system-accent) between the command name and
/// the description; untagged rows and arg rows render no bracket.
#[test]
fn tagged_command_row_renders_bracketed_tag() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::default();
let mut tagged = row("/tagged", "does work");
tagged.tag = Some("new".to_string());
let untagged = row("/plain", "no tag here");
let arg = row("argrow", "an argument"); // arg rows always have tag = None
let width: u16 = 60;
let snap = SlashSnapshot {
open: true,
// Select row 1 so the tagged + arg rows stay unselected.
matches: vec![tagged, untagged, arg],
selected: 1,
..Default::default()
};
let mut buf = Buffer::empty(Rect::new(0, 0, width, 3));
let area = Rect::new(0, 0, width, 3);
render_dropdown(&mut buf, area, &snap, None, &theme);
let row_text = |y: u16| -> String {
(0..width)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect()
};
// True buffer column of `needle`'s first cell. Do not use `str::find` on
// `row_text`: the selected-row prefix is multi-byte (``), so byte
// offsets drift from display columns and falsely report misalignment.
let desc_col = |y: u16, needle: &str| -> u16 {
let needle_chars: Vec<char> = needle.chars().collect();
(0..width)
.find(|&start| {
needle_chars.iter().enumerate().all(|(i, ch)| {
let x = start + i as u16;
x < width
&& buf
.cell((x, y))
.is_some_and(|c| c.symbol() == ch.to_string())
})
})
.unwrap_or_else(|| panic!("row {y} missing {needle:?}: {}", row_text(y)))
};
// Row 0 (tagged): "[new]" present, and the open-bracket cell uses accent.
assert!(
row_text(0).contains("[new]"),
"tagged row shows [new]: {}",
row_text(0)
);
let bracket_x = (0..width)
.find(|&x| buf.cell((x, 0)).map(|c| c.symbol()) == Some("["))
.expect("open bracket in tagged row");
assert_eq!(
buf.cell((bracket_x, 0)).unwrap().fg,
theme.accent_system,
"tag renders in the system accent"
);
// Row 1 (untagged) and row 2 (arg): no bracket at all.
assert!(
!row_text(1).contains('['),
"untagged row has no bracket: {}",
row_text(1)
);
assert!(
!row_text(2).contains('['),
"arg row has no bracket: {}",
row_text(2)
);
// Shared-column invariant: the description starts at the same buffer
// column on the tagged row and the untagged row (the tag folds into
// the label column, so it never shifts the description).
let desc0_x = desc_col(0, "does work");
let desc1_x = desc_col(1, "no tag here");
assert_eq!(
desc0_x,
desc1_x,
"tagged and untagged descriptions must share the same column (row0={}, row1={})",
row_text(0),
row_text(1)
);
// Tag is right-aligned: closing `]` sits at the label-column right edge,
// immediately before the first-line gap space and then the description.
// First-line gap is one space (see build_item_lines), so `]` column ==
// desc_col - 1 - 1. (Do not use str::find — multi-byte selected prefix.)
let close_bracket_x = (0..width)
.rev()
.find(|&x| buf.cell((x, 0)).map(|c| c.symbol()) == Some("]"))
.expect("closing ] on tagged row");
assert_eq!(
close_bracket_x,
desc0_x - 1 - 1,
"tag right-aligned: ] should sit just left of the desc gap (row0={})",
row_text(0)
);
// A long tag at narrow widths must truncate without panicking (zero-width
// / non-char-boundary math), including the width < 4 early-return path.
let mut long_tagged = row("/x", "d");
long_tagged.tag = Some("superlongtagname".to_string());
let narrow = SlashSnapshot {
open: true,
matches: vec![long_tagged],
selected: 0,
..Default::default()
};
for w in 0..=12u16 {
let mut nb = Buffer::empty(Rect::new(0, 0, w.max(1), 1));
let na = Rect::new(0, 0, w, 1);
let _ = render_dropdown(&mut nb, na, &narrow, None, &theme);
}
}
}

View file

@ -0,0 +1,693 @@
//! Onboarding tutorial overlay (`/tutorial`).
//!
//! A top-level modal (works over both the welcome screen and an agent
//! session) with two screens:
//!
//! - **List** — the tutorial topics from [`crate::tutorial_docs`] with ✓
//! marks for explored topics. Enter opens a topic; Esc closes.
//! - **Topic** — a scrollable markdown page (same chrome as the release-notes
//! viewer); `→`/`←` flow through the topics in order, Esc returns to the
//! list.
//!
//! Opened on demand via `/tutorial` (also listed in the command palette).
//! Never auto-shows.
use std::collections::HashSet;
use crossterm::event::{Event, KeyEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Widget};
use crate::theme::Theme;
use crate::tutorial_docs::TUTORIAL_TOPICS;
use crate::views::modal_window::{
self as mw, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut,
};
use crate::views::picker::{
self, PickerConfig, PickerEntry, PickerHitAreas, PickerOutcome, PickerRow, PickerState,
handle_picker_input,
};
/// Which tutorial screen is showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TutorialScreen {
/// The topic list.
List,
/// A single topic page (index into [`TUTORIAL_TOPICS`]).
Topic { index: usize },
/// The full how-to guide a topic's "Go deeper" points at (`d` from the
/// topic page); Esc returns to that topic.
Guide { topic: usize },
}
/// State for the tutorial overlay.
pub struct TutorialState {
pub screen: TutorialScreen,
/// Topic indices the user has opened this launch (✓ marks).
pub viewed: HashSet<usize>,
/// List-screen navigation state.
pub picker: PickerState,
/// Shared modal chrome state (close button, shortcut hits).
pub window: ModalWindowState,
/// Topic-screen scroll offset.
pub scroll: u16,
/// Cached pre-rendered markdown lines for the topic screen, keyed by
/// the width they were rendered at (invalidated on resize).
pub cached_lines: Option<(u16, Vec<Line<'static>>)>,
}
impl TutorialState {
pub fn new() -> Self {
Self {
screen: TutorialScreen::List,
viewed: HashSet::new(),
picker: PickerState::default(),
window: ModalWindowState::new(),
scroll: 0,
cached_lines: None,
}
}
/// Switch to the topic page at `index`, marking it viewed.
fn open_topic(&mut self, index: usize) {
if index >= TUTORIAL_TOPICS.len() {
return;
}
self.viewed.insert(index);
self.screen = TutorialScreen::Topic { index };
self.scroll = 0;
self.cached_lines = None;
self.window = ModalWindowState::new();
}
/// Return from a topic page to the list.
fn back_to_list(&mut self) {
self.screen = TutorialScreen::List;
self.scroll = 0;
self.cached_lines = None;
self.window = ModalWindowState::new();
}
/// Open the "Go deeper" guide for the topic at `index`, if it has one.
fn open_guide(&mut self, index: usize) {
let has_guide = TUTORIAL_TOPICS
.get(index)
.and_then(|t| t.go_deeper)
.and_then(crate::docs::find_doc)
.is_some();
if has_guide {
self.screen = TutorialScreen::Guide { topic: index };
self.scroll = 0;
self.cached_lines = None;
self.window = ModalWindowState::new();
}
}
}
impl Default for TutorialState {
fn default() -> Self {
Self::new()
}
}
/// Outcome of routing an input event to the tutorial overlay.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TutorialOutcome {
/// The overlay consumed the event (it owns all input while open).
Consumed,
/// The user closed the tutorial — the host should drop the state.
Closed,
}
/// List-screen picker config. Search is disabled: six fixed topics don't
/// need filtering, and letter keys would otherwise start a query.
fn list_picker_config() -> PickerConfig<'static> {
PickerConfig {
title: None,
show_search_hint: false,
expandable: false,
esc_clears_query: false,
shortcuts: None,
pending_hint: None,
shortcuts_area: None,
non_selectable: &[],
non_selectable_clickable: &[],
tabs: None,
active_tab: 0,
filter_label: None,
filter_key_hint: None,
filter_active: false,
action_keys: &[],
disable_search: true,
compact_bottom_bar: false,
search_only_on_slash: false,
vim_normal_first: false,
header_note: None,
}
}
/// Route an input event to the tutorial overlay. The overlay consumes all
/// key and mouse input while open (top-level modal semantics).
pub fn handle_tutorial_input(ev: &Event, st: &mut TutorialState) -> TutorialOutcome {
match st.screen {
TutorialScreen::Topic { .. } => handle_topic_input(ev, st),
TutorialScreen::Guide { .. } => handle_guide_input(ev, st),
TutorialScreen::List => handle_list_input(ev, st),
}
}
/// Guide screen: scroll like a topic page; Esc (or the close button)
/// returns to the topic it came from.
fn handle_guide_input(ev: &Event, st: &mut TutorialState) -> TutorialOutcome {
let TutorialScreen::Guide { topic } = st.screen else {
return TutorialOutcome::Consumed;
};
let chrome_cfg = ModalWindowConfig {
title: "",
tabs: None,
shortcuts: &[],
sizing: ModalSizing::default(),
fold_info: None,
};
match ev {
Event::Key(key) => {
if key.kind == KeyEventKind::Release {
return TutorialOutcome::Consumed;
}
match mw::handle_modal_key(&mut st.window, key, &chrome_cfg) {
mw::ModalWindowOutcome::CloseRequested => {
st.open_topic(topic);
return TutorialOutcome::Consumed;
}
mw::ModalWindowOutcome::Handled => return TutorialOutcome::Consumed,
_ => {}
}
crate::views::modal::apply_doc_scroll(key.code, &mut st.scroll);
TutorialOutcome::Consumed
}
Event::Mouse(mouse) => {
match mw::handle_modal_mouse(&mut st.window, mouse.kind, mouse.column, mouse.row) {
mw::ModalWindowOutcome::CloseRequested => {
st.open_topic(topic);
return TutorialOutcome::Consumed;
}
mw::ModalWindowOutcome::Handled => return TutorialOutcome::Consumed,
_ => {}
}
crate::views::modal::apply_doc_mouse_scroll(mouse.kind, &mut st.scroll);
TutorialOutcome::Consumed
}
_ => TutorialOutcome::Consumed,
}
}
fn handle_topic_input(ev: &Event, st: &mut TutorialState) -> TutorialOutcome {
let chrome_cfg = ModalWindowConfig {
title: "",
tabs: None,
shortcuts: &[],
sizing: ModalSizing::default(),
fold_info: None,
};
match ev {
Event::Key(key) => {
if key.kind == KeyEventKind::Release {
return TutorialOutcome::Consumed;
}
match mw::handle_modal_key(&mut st.window, key, &chrome_cfg) {
mw::ModalWindowOutcome::CloseRequested => {
st.back_to_list();
return TutorialOutcome::Consumed;
}
mw::ModalWindowOutcome::Handled => return TutorialOutcome::Consumed,
_ => {}
}
// Linear flow: `→` reads on to the next topic (the list, once
// the tour is done); `←` steps back; `d` opens the "Go deeper"
// guide.
if let TutorialScreen::Topic { index } = st.screen {
match key.code {
crossterm::event::KeyCode::Right => {
if index + 1 < TUTORIAL_TOPICS.len() {
st.open_topic(index + 1);
} else {
st.back_to_list();
}
return TutorialOutcome::Consumed;
}
crossterm::event::KeyCode::Left => {
if let Some(prev) = index.checked_sub(1) {
st.open_topic(prev);
}
return TutorialOutcome::Consumed;
}
crossterm::event::KeyCode::Char('d') => {
st.open_guide(index);
return TutorialOutcome::Consumed;
}
_ => {}
}
}
crate::views::modal::apply_doc_scroll(key.code, &mut st.scroll);
TutorialOutcome::Consumed
}
Event::Mouse(mouse) => {
match mw::handle_modal_mouse(&mut st.window, mouse.kind, mouse.column, mouse.row) {
mw::ModalWindowOutcome::CloseRequested => {
st.back_to_list();
return TutorialOutcome::Consumed;
}
mw::ModalWindowOutcome::Handled => return TutorialOutcome::Consumed,
_ => {}
}
crate::views::modal::apply_doc_mouse_scroll(mouse.kind, &mut st.scroll);
TutorialOutcome::Consumed
}
_ => TutorialOutcome::Consumed,
}
}
fn handle_list_input(ev: &Event, st: &mut TutorialState) -> TutorialOutcome {
// Chrome first: close button clicks and Esc. `handle_picker_input` also
// maps Esc to `Closed`, but the close button lives on the ModalWindow.
if let Event::Mouse(mouse) = ev
&& matches!(
mw::handle_modal_mouse(&mut st.window, mouse.kind, mouse.column, mouse.row),
mw::ModalWindowOutcome::CloseRequested
)
{
return TutorialOutcome::Closed;
}
if let Event::Key(key) = ev
&& key.kind == KeyEventKind::Release
{
return TutorialOutcome::Consumed;
}
// Search is disabled on the fixed topic list, but the picker's paste
// path fills the query regardless of `disable_search` — swallow paste
// here so it can't start an invisible filter.
if matches!(ev, Event::Paste(_)) {
return TutorialOutcome::Consumed;
}
let config = list_picker_config();
match handle_picker_input(ev, &mut st.picker, TUTORIAL_TOPICS.len(), &config) {
PickerOutcome::Selected(i) => {
st.open_topic(i);
TutorialOutcome::Consumed
}
PickerOutcome::Closed => TutorialOutcome::Closed,
_ => TutorialOutcome::Consumed,
}
}
/// Intro copy shown above the topic list. No time promises — just what it
/// is and how to leave.
const INTRO_LINES: [&str; 2] = [
"Quick tips to get the most out of Grok Build.",
"Pick a topic. Esc when you're done.",
];
/// Topic page body: the embedded markdown minus its leading `# ` heading —
/// the modal window chrome already shows the title, so rendering the H1
/// would double it.
fn topic_body(content: &str) -> &str {
match content.split_once('\n') {
Some((first, rest)) if first.starts_with("# ") => rest.trim_start_matches('\n'),
_ => content,
}
}
/// Render the tutorial overlay (list or topic screen) over `area`.
pub fn render_tutorial(buf: &mut Buffer, area: Rect, st: &mut TutorialState, compact: bool) {
let theme = Theme::current();
match st.screen {
TutorialScreen::Topic { index } => {
// `Topic` is only constructed via `open_topic`, which bounds-checks.
let Some(topic) = TUTORIAL_TOPICS.get(index) else {
return;
};
let next_hint = match TUTORIAL_TOPICS.get(index + 1) {
Some(next) => format!("\u{2192} next: {}", next.title),
None => "\u{2192} done".to_owned(),
};
let mut shortcuts = vec![
Shortcut {
label: "\u{2191}/\u{2193} scroll",
clickable: false,
id: 0,
},
Shortcut {
label: &next_hint,
clickable: false,
id: 0,
},
];
if topic.go_deeper.is_some() {
shortcuts.push(Shortcut {
label: "d go deeper",
clickable: false,
id: 0,
});
}
shortcuts.push(Shortcut {
label: "Esc list",
clickable: false,
id: 0,
});
crate::views::modal::render_doc_viewer_overlay_with_shortcuts(
buf,
area,
&mut st.window,
topic.title,
topic_body(topic.content),
&mut st.scroll,
&mut st.cached_lines,
compact,
&theme,
&shortcuts,
);
}
TutorialScreen::Guide { topic } => {
let Some(doc) = TUTORIAL_TOPICS
.get(topic)
.and_then(|t| t.go_deeper)
.and_then(crate::docs::find_doc)
else {
return;
};
crate::views::modal::render_doc_viewer_overlay(
buf,
area,
&mut st.window,
doc.title,
doc.content,
&mut st.scroll,
&mut st.cached_lines,
compact,
&theme,
);
}
TutorialScreen::List => render_list(buf, area, st, compact, &theme),
}
}
fn render_list(buf: &mut Buffer, area: Rect, st: &mut TutorialState, compact: bool, theme: &Theme) {
let progress = format!("{}/{} explored", st.viewed.len(), TUTORIAL_TOPICS.len());
let shortcuts = [
Shortcut {
label: &progress,
clickable: false,
id: 0,
},
Shortcut {
label: "\u{2191}/\u{2193} navigate",
clickable: false,
id: 0,
},
Shortcut {
label: "Enter open",
clickable: false,
id: 0,
},
Shortcut {
label: "Esc done",
clickable: false,
id: 0,
},
];
let modal_config = ModalWindowConfig {
title: "Welcome to Grok Build",
tabs: None,
shortcuts: &shortcuts,
sizing: ModalSizing {
width_pct: 0.60,
max_width: 100,
min_width: 44,
v_margin: 4,
h_pad: 2,
v_pad: 1,
footer_lines: 2,
}
.with_compact(compact),
fold_info: None,
};
let Some(mca) = mw::render_modal_window(buf, area, &mut st.window, &modal_config, theme) else {
return;
};
// Intro copy, then a blank row, then the topic rows.
let intro_style = Style::default().fg(theme.gray_bright);
let mut y = mca.content.y;
for line in INTRO_LINES {
if y >= mca.content.y + mca.content.height {
break;
}
Paragraph::new(Line::styled(line, intro_style)).render(
Rect {
x: mca.content.x,
y,
width: mca.content.width,
height: 1,
},
buf,
);
y += 1;
}
y = y.saturating_add(1); // gap
let entries_area = Rect {
x: mca.content.x,
y,
width: mca.content.width,
height: (mca.content.y + mca.content.height).saturating_sub(y),
};
if entries_area.height == 0 {
return;
}
// Narrow modals can't fit title + blurb on one row; stack the blurb below.
const NARROW_THRESHOLD: u16 = 64;
let narrow = entries_area.width < NARROW_THRESHOLD;
let blurb_slices: Vec<[&str; 1]> = TUTORIAL_TOPICS.iter().map(|t| [t.blurb]).collect();
let picker_entries: Vec<PickerEntry<'_>> = TUTORIAL_TOPICS
.iter()
.enumerate()
.map(|(i, t)| {
let viewed = st.viewed.contains(&i);
PickerEntry::Row(PickerRow {
label: t.title,
right_label: if narrow { "" } else { t.blurb },
selected: i == st.picker.selected,
expanded: narrow,
fields: &[],
description_lines: if narrow { &blurb_slices[i][..] } else { &[] },
summary_lines: &[],
dimmed: false,
indent: 0,
badge: if viewed { "\u{2713}" } else { "" },
badge_color: Some(theme.accent_success),
collapsible: false,
underline_last_desc: false,
})
})
.collect();
let non_sel = vec![false; picker_entries.len()];
let content_hit = picker::render_picker_content_with_scrollbar_x(
buf,
entries_area,
theme,
&mut st.picker,
&picker_entries,
&non_sel,
&[],
Some(theme.bg_base),
false,
0,
mca.inner_x + mca.inner_width.saturating_sub(1),
);
st.picker.hit_areas = Some(PickerHitAreas {
close_button: Rect::default(),
search_bar: Rect::default(),
item_rects: content_hit.item_rects,
entry_indices: content_hit.entry_indices,
tab_rects: vec![],
filter_rect: None,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
#[test]
fn enter_opens_selected_topic_and_marks_viewed() {
let mut st = TutorialState::new();
assert_eq!(st.screen, TutorialScreen::List);
assert!(st.viewed.is_empty());
let outcome = handle_tutorial_input(&key(KeyCode::Down), &mut st);
assert_eq!(outcome, TutorialOutcome::Consumed);
assert_eq!(st.picker.selected, 1);
let outcome = handle_tutorial_input(&key(KeyCode::Enter), &mut st);
assert_eq!(outcome, TutorialOutcome::Consumed);
assert_eq!(st.screen, TutorialScreen::Topic { index: 1 });
assert!(st.viewed.contains(&1));
}
#[test]
fn esc_pops_topic_to_list_then_closes() {
let mut st = TutorialState::new();
handle_tutorial_input(&key(KeyCode::Enter), &mut st);
assert!(matches!(st.screen, TutorialScreen::Topic { .. }));
let outcome = handle_tutorial_input(&key(KeyCode::Esc), &mut st);
assert_eq!(outcome, TutorialOutcome::Consumed);
assert_eq!(st.screen, TutorialScreen::List);
assert!(st.viewed.contains(&0), "opened topic stays ✓-marked");
let outcome = handle_tutorial_input(&key(KeyCode::Esc), &mut st);
assert_eq!(outcome, TutorialOutcome::Closed);
}
#[test]
fn d_opens_the_go_deeper_guide_and_esc_returns_to_the_topic() {
let mut st = TutorialState::new();
st.open_topic(0);
assert!(
TUTORIAL_TOPICS[0].go_deeper.is_some(),
"topic 0 has a guide"
);
handle_tutorial_input(&key(KeyCode::Char('d')), &mut st);
assert_eq!(st.screen, TutorialScreen::Guide { topic: 0 });
// Esc returns to the topic the guide came from, not the list.
handle_tutorial_input(&key(KeyCode::Esc), &mut st);
assert_eq!(st.screen, TutorialScreen::Topic { index: 0 });
}
#[test]
fn d_is_a_noop_on_a_topic_without_a_guide() {
let last = TUTORIAL_TOPICS.len() - 1;
assert!(
TUTORIAL_TOPICS[last].go_deeper.is_none(),
"the closing topic intentionally has no single guide"
);
let mut st = TutorialState::new();
st.open_topic(last);
handle_tutorial_input(&key(KeyCode::Char('d')), &mut st);
assert_eq!(st.screen, TutorialScreen::Topic { index: last });
}
#[test]
fn topic_body_strips_the_duplicated_h1() {
// The window title already names the topic; the H1 must not render
// a second time inside the page.
assert_eq!(topic_body("# Title\n\nBody text.\n"), "Body text.\n");
// Every real topic starts with an H1, so every body drops it.
for t in TUTORIAL_TOPICS {
assert!(!topic_body(t.content).starts_with("# "), "{}", t.title);
}
// Content without a leading H1 passes through untouched.
assert_eq!(topic_body("plain text"), "plain text");
}
#[test]
fn right_flows_through_topics_and_back_to_list() {
let mut st = TutorialState::new();
handle_tutorial_input(&key(KeyCode::Enter), &mut st);
assert_eq!(st.screen, TutorialScreen::Topic { index: 0 });
// → walks the whole tour, marking each topic viewed…
for expected in 1..TUTORIAL_TOPICS.len() {
handle_tutorial_input(&key(KeyCode::Right), &mut st);
assert_eq!(st.screen, TutorialScreen::Topic { index: expected });
assert!(st.viewed.contains(&expected));
}
// …and lands back on the list after the last page.
let outcome = handle_tutorial_input(&key(KeyCode::Right), &mut st);
assert_eq!(outcome, TutorialOutcome::Consumed);
assert_eq!(st.screen, TutorialScreen::List);
assert_eq!(st.viewed.len(), TUTORIAL_TOPICS.len(), "full tour ✓-marked");
}
#[test]
fn left_steps_back_and_stops_at_first_topic() {
let mut st = TutorialState::new();
st.open_topic(1);
handle_tutorial_input(&key(KeyCode::Left), &mut st);
assert_eq!(st.screen, TutorialScreen::Topic { index: 0 });
// At the first topic, ← is a no-op (Esc returns to the list).
handle_tutorial_input(&key(KeyCode::Left), &mut st);
assert_eq!(st.screen, TutorialScreen::Topic { index: 0 });
}
#[test]
fn topic_page_scrolls_and_ignores_typing() {
let mut st = TutorialState::new();
handle_tutorial_input(&key(KeyCode::Enter), &mut st);
handle_tutorial_input(&key(KeyCode::Down), &mut st);
assert!(st.scroll > 0, "Down scrolls the topic page");
handle_tutorial_input(&key(KeyCode::Up), &mut st);
assert_eq!(st.scroll, 0);
// Printable chars are consumed without effect (no search on topics).
let outcome = handle_tutorial_input(&key(KeyCode::Char('x')), &mut st);
assert_eq!(outcome, TutorialOutcome::Consumed);
assert!(matches!(st.screen, TutorialScreen::Topic { .. }));
}
#[test]
fn list_typing_does_not_start_a_query() {
// Search is disabled: letters must not filter the fixed topic list.
let mut st = TutorialState::new();
handle_tutorial_input(&key(KeyCode::Char('w')), &mut st);
assert!(st.picker.query().is_empty());
assert_eq!(st.screen, TutorialScreen::List);
}
#[test]
fn list_paste_does_not_start_a_query() {
// The picker's paste path ignores `disable_search`; the list screen
// must swallow paste so it can't start an invisible filter.
let mut st = TutorialState::new();
let outcome = handle_tutorial_input(&Event::Paste("worktrees".to_owned()), &mut st);
assert_eq!(outcome, TutorialOutcome::Consumed);
assert!(st.picker.query().is_empty());
assert_eq!(st.screen, TutorialScreen::List);
}
#[test]
fn render_list_populates_hit_areas() {
let mut st = TutorialState::new();
let area = Rect::new(0, 0, 100, 40);
let mut buf = Buffer::empty(area);
render_tutorial(&mut buf, area, &mut st, false);
let hit = st.picker.hit_areas.as_ref().expect("hit areas populated");
assert_eq!(
hit.item_rects.len(),
TUTORIAL_TOPICS.len(),
"one click rect per topic"
);
}
#[test]
fn render_topic_screen_smoke() {
let mut st = TutorialState::new();
st.open_topic(0);
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
render_tutorial(&mut buf, area, &mut st, false);
}
}

View file

@ -626,10 +626,10 @@ pub struct WelcomeRenderParams<'a> {
pub gate: Option<&'a xai_grok_shell::auth::GateInfo>,
pub subscription_tier: Option<&'a str>,
pub session_picker_grouped: bool,
/// Source filter (local/remote/all) for the session picker.
/// Source filter for the session picker.
pub session_picker_source_filter: crate::views::session_picker::SourceFilter,
/// Process-wide `--chat`: the picker lists backend conversations only, so
/// the Local/Remote source filter and local deep search are hidden.
/// the source filter and local deep search are hidden.
pub chat_mode: bool,
/// Live working directory (tracks `Effect::SetWorkingDir`), used to pin
/// the current repo's session group to the top of the picker.
@ -1794,7 +1794,16 @@ fn render_welcome_done(
if p.session_picker_loading {
1
} else {
(picker_count as u16).min(15) + 3 // +3 for title + search + gap
// Reserve a row for the pinned hidden-external hint when shown.
let hint_row = u16::from(
!p.chat_mode
&& crate::views::session_picker::hidden_external_hint(
p.session_picker,
p.session_picker_source_filter,
)
.is_some(),
);
(picker_count as u16).min(15) + 3 + hint_row // +3 for title + search + gap
}
} else {
0
@ -2061,11 +2070,10 @@ fn render_welcome_done(
width: tip_centered.width.saturating_sub(inset * 2),
height: tip_centered.height,
};
let (accept_r, customize_r, legal_r) =
render_privacy_banner(tip_inset, buf, theme, p.mouse_pos);
privacy_banner_accept_rect = Some(accept_r);
privacy_banner_customize_rect = Some(customize_r);
privacy_banner_legal_rect = Some(legal_r);
let rects = crate::views::privacy_banner::render(tip_inset, buf, theme, p.mouse_pos);
privacy_banner_accept_rect = Some(rects.accept);
privacy_banner_customize_rect = Some(rects.customize);
privacy_banner_legal_rect = Some(rects.legal);
} else if let Some(ver) = p.pending_update_version
&& layout.tip.height > 0
{
@ -2210,147 +2218,6 @@ fn render_welcome_done(
}
}
/// Legal line copy — used for both render spans and mouse hit width.
const PRIVACY_BANNER_LEGAL: &str = "Learn more and read Terms and Privacy Policy.";
/// Welcome privacy banner: copy left, `[Customize in settings]` / `[Accept]` right.
/// Returns (accept_rect, customize_rect, legal_rect) for mouse hit-testing.
fn render_privacy_banner(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
mouse_pos: Option<(u16, u16)>,
) -> (Rect, Rect, Rect) {
let customize_label = "[Customize in settings]";
let accept_label = "[Accept]";
let right_w = (customize_label.len() + 1 + accept_label.len()) as u16;
// Buttons render whole or not at all: a clipped/overflowing [Accept]
// must never leave a click target in the blank margin (a stray click
// there would silently opt the user in).
let buttons_fit = area.width > right_w;
let left_w = if buttons_fit {
area.width - right_w - 1
} else {
area.width
};
let left = Rect {
x: area.x,
y: area.y,
width: left_w,
height: area.height.min(2),
};
let right = Rect {
x: area.x + left_w + 1,
y: area.y,
width: right_w,
height: 1,
};
let hovered = |r: Rect| {
mouse_pos.is_some_and(|(mx, my)| r.contains(ratatui::layout::Position::new(mx, my)))
};
let legal_w = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
PRIVACY_BANNER_LEGAL.len()
} else {
"Learn more".len().min(left.width as usize)
};
// The legal line only exists when the slot really has a second row —
// otherwise its rect would make the blank row below clickable.
let legal_rect = if area.height >= 2 {
Rect {
x: left.x,
y: left.y.saturating_add(1),
width: legal_w as u16,
height: 1,
}
} else {
Rect::default()
};
// Figma node 8698:3806: title fg/primary, description fg/secondary,
// legal line fg/tertiary with underlined links in the same color.
// The whole legal line is one click target, so its links brighten together.
let link_fg = if hovered(legal_rect) {
theme.gray_bright
} else {
theme.gray
};
let link = Style::default()
.fg(link_fg)
.add_modifier(Modifier::UNDERLINED);
let gray = Style::default().fg(theme.gray);
let title = Span::styled("Help improve Grok", Style::default().fg(theme.text_primary));
let desc = "Allow your sessions to improve SpaceXAI's models.";
// Drop trailing spans whole rather than clipping mid-word when narrow.
let line1 = if left.width as usize >= "Help improve Grok ".len() + desc.len() {
Line::from(vec![
title,
Span::raw(" "),
Span::styled(desc, Style::default().fg(theme.gray_bright)),
])
} else {
Line::from(title)
};
// Span pieces must reassemble to PRIVACY_BANNER_LEGAL.
let line2 = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
Line::from(vec![
Span::styled("Learn more", link),
Span::styled(" and read ", gray),
Span::styled("Terms", link),
Span::styled(" and ", gray),
Span::styled("Privacy Policy", link),
Span::styled(".", gray),
])
} else {
Line::from(Span::styled("Learn more", link))
};
Paragraph::new(vec![line1, line2]).render(left, buf);
if !buttons_fit {
return (Rect::default(), Rect::default(), legal_rect);
}
let customize_rect = Rect {
x: right.x,
y: right.y,
width: customize_label.len() as u16,
height: 1,
};
let accept_rect = Rect {
x: right.x + customize_label.len() as u16 + 1,
y: right.y,
width: accept_label.len() as u16,
height: 1,
};
// Hover treatment mirrors the plugin CTA buttons.
let customize_style = if hovered(customize_rect) {
Style::default().fg(theme.text_primary).bg(theme.bg_hover)
} else {
Style::default().fg(theme.gray_bright)
};
let accept_style = if hovered(accept_rect) {
Style::default().fg(theme.link_fg).bg(theme.bg_hover)
} else {
Style::default().fg(theme.text_primary)
};
buf.set_stringn(
customize_rect.x,
customize_rect.y,
customize_label,
customize_rect.width as usize,
customize_style,
);
buf.set_stringn(
accept_rect.x,
accept_rect.y,
accept_label,
accept_rect.width as usize,
accept_style,
);
(accept_rect, customize_rect, legal_rect)
}
/// Context for session picker rendering.
pub(crate) struct SessionPickerRenderCtx<'a> {
pub(crate) state: &'a mut crate::views::picker::PickerState,
@ -2370,7 +2237,7 @@ pub(crate) struct SessionPickerRenderCtx<'a> {
pub(crate) tick: u64,
/// When true, entries are grouped by `repo_name` with non-selectable headers.
pub(crate) grouped: bool,
/// Source filter (local/remote/all) for filtering session entries.
/// Source filter for filtering session entries.
pub(crate) source_filter: crate::views::session_picker::SourceFilter,
/// Process-wide `--chat`: hides the source-filter chip and the
/// deep-search/filter footer hints (see `WelcomeRenderParams::chat_mode`).
@ -2538,6 +2405,12 @@ pub(crate) fn render_session_picker(
}));
}
let hidden_hint = if ctx.chat_mode {
None
} else {
crate::views::session_picker::hidden_external_hint(ctx.sessions, ctx.source_filter)
};
// Build shortcuts for fullscreen mode. Chat mode drops the worktree /
// deep-search / filter hints (local-Build-row actions).
let worktree_shortcut: &'static str = "ctrl+w";
@ -2587,6 +2460,7 @@ pub(crate) fn render_session_picker(
filter_label: (!ctx.chat_mode).then(|| ctx.source_filter.label()),
filter_key_hint: (!ctx.chat_mode).then_some("f"),
filter_active: !ctx.chat_mode && ctx.source_filter.is_active(),
header_note: hidden_hint.as_deref(),
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
@ -2602,6 +2476,7 @@ pub(crate) fn render_session_picker(
&picker_entries,
&config,
ctx.loading,
ctx.tick,
)
}
@ -2900,7 +2775,7 @@ mod tests {
gate: None,
subscription_tier: None,
session_picker_grouped: false,
session_picker_source_filter: crate::views::session_picker::SourceFilter::All,
session_picker_source_filter: crate::views::session_picker::SourceFilter::default(),
chat_mode: false,
cwd: std::path::Path::new("/repo"),
credit_balance: None,
@ -3075,7 +2950,7 @@ mod tests {
entries_query,
tick: 0,
grouped: false,
source_filter: crate::views::session_picker::SourceFilter::All,
source_filter: crate::views::session_picker::SourceFilter::default(),
chat_mode: true,
},
);
@ -3111,6 +2986,83 @@ mod tests {
);
}
/// The hidden-external hint stays pinned on the welcome picker's default
/// Grok view when scanned foreign rows exist — even when the native list
/// overflows the viewport — and never renders under `--chat` (foreign
/// scanning is disabled there, so the hint is dead weight).
#[test]
fn hidden_external_hint_renders_outside_chat_mode() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = crate::theme::Theme::default();
let area = Rect::new(0, 0, 80, 20);
// More native rows than the viewport fits: a trailing list row would
// scroll out of view, a pinned row must not.
let mut entries: Vec<SessionPickerEntry> = (0..30)
.map(|i| make_entry(&format!("s{i}"), &format!("native session {i}"), "repo"))
.collect();
let mut foreign = make_entry("f1", "Claude work", "repo");
foreign.source = "claude".into();
entries.push(foreign);
let render = |chat_mode: bool| -> String {
let mut buf = Buffer::empty(area);
let mut state = PickerState::default();
render_session_picker(
area,
&mut buf,
&theme,
&mut SessionPickerRenderCtx {
state: &mut state,
sessions: Some(&entries),
cwd: std::path::Path::new("/repo"),
loading: false,
pending_hint: None,
shortcuts_area: None,
content_results: None,
content_loading: false,
entries_query: None,
tick: 0,
grouped: false,
source_filter: crate::views::session_picker::SourceFilter::default(),
chat_mode,
},
);
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let build_mode = render(false);
assert!(
build_mode.contains("1 external session hidden \u{b7} f to show"),
"default Grok filter must pin the hidden-external hint:\n{build_mode}"
);
assert!(
build_mode.find("external session hidden") < build_mode.find("native session 0"),
"the hint must be pinned above the first list row:\n{build_mode}"
);
assert!(
!build_mode.contains("Claude work"),
"the foreign row itself stays hidden under the default filter:\n{build_mode}"
);
let chat = render(true);
assert!(
!chat.contains("external session"),
"chat mode must not render the hidden-external hint:\n{chat}"
);
}
#[test]
fn grouped_entries_insert_headers() {
let entries = vec![
@ -3253,6 +3205,7 @@ mod tests {
filter_label: None,
filter_key_hint: None,
filter_active: false,
header_note: None,
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,