Synced from monorepo
Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
169
crates/codegen/xai-grok-pager/src/views/privacy_banner.rs
Normal file
169
crates/codegen/xai-grok-pager/src/views/privacy_banner.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {})",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(®, "voice_keybind_enabled");
|
||||
let capture = meta_for(®, "voice_capture_mode");
|
||||
let language = meta_for(®, "voice_stt_language");
|
||||
let vim = meta_for(®, "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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
693
crates/codegen/xai-grok-pager/src/views/tutorial.rs
Normal file
693
crates/codegen/xai-grok-pager/src/views/tutorial.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue