Synced from monorepo

Changes:
- Non-blocking coding-data sharing upsell banner
- Consolidate remediation in Doctor
- Auto mode defers fail-closed gate asks to the classifier
- Coalesce marketplace list fetches
- Allow removing a marketplace source by name
- Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal)
- Label failed workspace RPCs with error_kind
- Drop redundant explicit tonic/prost deps from xai-grok-shell
- Report real exit codes for completed background shells
- Narrow the date-rollover reminder to date-bearing templates
- Wire toolOverrides through the session and agent
- Security: Bash(git:*) allowlist matches whole command chain by prefix
- Split prompt-trigger telemetry and record classifier provenance
- Raise connectors-manager timeout to 60s
- Auto classifier honors recorded approvals for repeat actions
- Apply doctor fixes in the TUI
- Auto-mode classifier timeouts prompt instead of silently denying
- Scope subagent completion drains to the owning session
- Add the toolOverrides wire types
- Set client_identifier=grok-agent-sdk
- Accept both spellings of the workspace-teleport kill switch
- Persist one-shot occurrence journal
- Stop turns that poll the exact same tool call 16x in a row
- Copy compaction checkpoint files when forking sessions
- Auto-focus permission prompt from scrollback
- Esc cancels the running turn in non-vim and minimal modes
- List Ctrl+Z undo and redo in keyboard shortcuts
- Out-of-process macOS mic capture
- Show active auth mode on session-info
- Install the npm binary under $GROK_HOME
- Remove hover/click dead zones between dashboard items
- Route startup warnings to doctor
- Document [feedback.user] author identity config
- Extend bang command timeout
- Close combine-queued edit-hold race
- Integrate relocation recovery
- Expose privacy notice rollout flag
- Break harness discovery ref cycle so connections can idle-evict
- Shift/Alt+Enter inserts newline when editing a queued prompt
- Gate project Claude permissions on folder trust
- Echo response.create.event_id on response.created
- Toast when session creation fails from disk full
- Add shared test process lifecycle
- Enable dynamic workflows by default
- Add relocation transaction state machine
- Add shared test sandbox
- Surface auth failures on model-switch compact
- Persist durable scheduler expiry
- Confirm before removing extensions-modal items
- Re-run compact and prompt after login when compact hit expired auth
- Recap sends hosted tools under backend search
This commit is contained in:
grokkybara[bot] 2026-07-22 19:18:53 +01:00
commit a5727c5960
482 changed files with 37627 additions and 13402 deletions

View file

@ -200,7 +200,9 @@ impl AgentViewLayout {
bottom_vpad,
));
let inner_area = outer_block.inner(area);
let mut constraints = vec![Constraint::Length(1)];
let mut constraints = vec![
Constraint::Length(1), // StatusBar
];
if startup_warning_height > 0 {
constraints.push(Constraint::Length(startup_warning_height));
}
@ -925,6 +927,7 @@ pub fn build_hints(
vim_mode: bool,
is_subagent_view: bool,
is_turn_running: bool,
esc_would_cancel_turn: bool,
has_queued_follow_up: bool,
selected_is_user_prompt: bool,
selected_is_agent_message: bool,
@ -1189,7 +1192,11 @@ pub fn build_hints(
}
};
if is_turn_running && let Some(def) = registry.find(ActionId::CancelTurn) {
hints.push(def.hint());
let mut hint = def.hint();
if esc_would_cancel_turn {
hint.keys = vec![crate::key!(Esc)];
}
hints.push(hint);
}
let has_composer_payload = !prompt.text().trim().is_empty() || is_editing_queued;
if matches!(active_pane, ActivePane::Prompt)
@ -1259,6 +1266,7 @@ mod tests {
false,
false,
false,
false,
selected_is_user_prompt,
selected_is_agent_message,
false,
@ -1295,6 +1303,7 @@ mod tests {
false,
false,
false,
false,
None,
);
let hint = hints
@ -1329,6 +1338,7 @@ mod tests {
false,
false,
false,
false,
None,
);
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
@ -1493,6 +1503,7 @@ mod tests {
false,
false,
false,
false,
Some(&search),
)
}
@ -1596,6 +1607,7 @@ mod tests {
false,
false,
false,
false,
None,
);
assert!(
@ -1639,6 +1651,7 @@ mod tests {
false,
false,
false,
false,
shift_enter_unavailable,
None,
)
@ -1694,6 +1707,7 @@ mod tests {
true,
false,
true,
false,
true,
false,
false,
@ -1709,6 +1723,159 @@ mod tests {
);
}
}
/// Running-turn cancel hint key tracks `esc_would_cancel_turn` — the
/// input-routing predicate computed by the caller: Esc when a bare press
/// would reach the policy's mid-turn cancel, the registry Ctrl+C binding
/// otherwise. (The predicate itself — gate, panes, and higher-priority
/// Esc consumers — is pinned by `esc_would_cancel_turn_tests` in
/// `agent_view::input`.)
#[test]
fn running_turn_cancel_hint_key_tracks_esc_predicate() {
let prompt = PromptWidget::default();
let registry = ActionRegistry::defaults();
for (esc_would_cancel_turn, expected) in
[(true, crate::key!(Esc)), (false, crate::key!('c', CONTROL))]
{
let hints = build_hints(
ActivePane::Prompt,
&prompt,
&registry,
false,
None,
None,
"expand thinking",
false,
false,
None,
false,
false,
false,
false,
true,
false,
true,
esc_would_cancel_turn,
false,
false,
false,
false,
false,
None,
);
let cancel = hints
.iter()
.find(|h| h.label == "cancel")
.expect("running turn must surface the cancel hint");
assert_eq!(
cancel.keys,
vec![expected],
"cancel hint key for esc_would_cancel_turn={esc_would_cancel_turn}"
);
}
}
/// Running turn + open scrollback search: the search's own `Esc cancel`
/// hint stays the ONLY Esc hint — the CancelTurn hint keeps Ctrl+C (the
/// caller's predicate is false while the search would steal Esc), so the
/// bar never shows two different `Esc cancel` meanings at once.
#[test]
fn running_turn_with_scrollback_search_keeps_ctrl_c_cancel_hint() {
let registry = ActionRegistry::defaults();
let search = ScrollbackSearchState::open();
let hints = build_hints(
ActivePane::Scrollback,
&PromptWidget::default(),
&registry,
false,
None,
None,
"expand thinking",
false,
false,
None,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
Some(&search),
);
let esc_cancels: Vec<&HintItem> = hints
.iter()
.filter(|h| h.label == "cancel" && h.keys == vec![crate::key!(Esc)])
.collect();
assert_eq!(
esc_cancels.len(),
1,
"exactly one Esc:cancel hint (the search's own dismiss)"
);
assert!(
hints
.iter()
.any(|h| h.label == "cancel" && h.keys == vec![crate::key!('c', CONTROL)]),
"CancelTurn hint must stay on Ctrl+C while the search owns Esc"
);
}
/// Running turn + editing a queued prompt: the edit's own `Esc cancel`
/// (discard) hint is the ONLY Esc-keyed row — the CancelTurn hint keeps
/// Ctrl+C (the caller's predicate is false while the edit owns Esc), so
/// the bar never shows two contradictory `Esc cancel` rows.
#[test]
fn running_turn_editing_queued_keeps_ctrl_c_cancel_hint() {
let registry = ActionRegistry::defaults();
let mut prompt = PromptWidget::default();
prompt.textarea.insert_str("edited row");
let hints = build_hints(
ActivePane::Prompt,
&prompt,
&registry,
true,
None,
None,
"expand thinking",
false,
false,
None,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
None,
);
let esc_rows: Vec<&HintItem> = hints
.iter()
.filter(|h| h.keys.contains(&crate::key!(Esc)))
.collect();
assert_eq!(
esc_rows.len(),
1,
"exactly one Esc-keyed hint (the edit's discard), got {:?}",
hints.iter().map(|h| h.label.as_ref()).collect::<Vec<_>>()
);
assert_eq!(esc_rows[0].label, "cancel");
assert!(
hints
.iter()
.any(|h| h.label == "cancel" && h.keys == vec![crate::key!('c', CONTROL)]),
"CancelTurn hint must stay on Ctrl+C while the edit owns Esc"
);
}
#[test]
fn prompt_legacy_vte_adds_alt_enter_newline_hint() {
let hints = prompt_hints_with_text(false, true);

View file

@ -1299,7 +1299,7 @@ fn render_agents_tab(
}
let selected_row = rows
.iter()
.position(|r| matches!(r, FlatRow::Agent(i) if * i == state.selected))
.position(|r| matches!(r, FlatRow::Agent(i) if *i == state.selected))
.unwrap_or(0);
let mut selected_end = selected_row + 1;
while selected_end < rows.len()
@ -1582,7 +1582,7 @@ fn render_personas_tab(
}
let selected_row = rows
.iter()
.position(|r| matches!(r, PersonaFlatRow::Name(i) if * i == state.persona_selected))
.position(|r| matches!(r, PersonaFlatRow::Name(i) if *i == state.persona_selected))
.unwrap_or(0);
let mut selected_end = selected_row + 1;
while selected_end < rows.len()

View file

@ -531,9 +531,8 @@ fn render_rename_editor(
fn rename_cursor_pos(state: &DashboardState, rows: &[DashboardRow]) -> Option<(u16, u16)> {
let rn = state.rename.as_ref()?;
let (_, rect) = state.row_rects.iter().find(|(id, _)| *id == rn.row)?;
let (marker_width, indent_width, icon_width) = rows
.iter()
.find(|r| r.id == rn.row)
let row = rows.iter().find(|r| r.id == rn.row);
let (marker_width, indent_width, icon_width) = row
.map(|r| {
(
UnicodeWidthStr::width(crate::glyphs::selection_bar()) as u16,
@ -549,7 +548,10 @@ fn rename_cursor_pos(state: &DashboardState, rows: &[DashboardRow]) -> Option<(u
let cursor_x = content_x
.saturating_add(cursor_offset)
.min(rect.x.saturating_add(rect.width.saturating_sub(1)));
Some((cursor_x, rect.y))
// Mirror `render_row`'s vertical centering so the caret lands on
// the title line (narrow-mode single-line rects yield offset 0).
let title_y = rect.y + row.map_or(0, |r| row_content_offset(rect.height, r));
Some((cursor_x, title_y))
}
/// Render the compact dashboard "banner" used when an agent is
@ -1553,7 +1555,7 @@ fn render_rows(
}
// Rows are 3 visual cells tall (title + secondary
// + breathing gap) and headers are 2 cells tall (label + gap).
// + padding) and headers are 2 cells tall (label + gap).
// Viewport scrolling works on cumulative cell offsets so partial
// rows can't peek out at the top / bottom of the list. The
// clamp helper still operates in "1 unit = 1 cell" — we just
@ -1645,6 +1647,10 @@ fn render_rows(
let body_width = area.width;
let max_y = area.y + area.height;
// Content background per visible line (`None` = spacer), consumed
// by the half-block halo pass after the items are painted.
let mut line_bg: Vec<Option<Color>> = vec![None; viewport_h];
let mut cell_y: usize = 0;
for (line, &h) in lines.iter().zip(heights.iter()) {
let next_cell_y = cell_y + h as usize;
@ -1672,6 +1678,14 @@ fn render_rows(
width: body_width,
height: render_h,
};
// `line_bg` records each visible line's CONTENT background
// (`None` = spacer line) for the half-block halo pass below.
let mark = |line_bg: &mut Vec<Option<Color>>, dy: u16, bg: Color| {
let idx = (y - area.y + dy) as usize;
if let Some(slot) = line_bg.get_mut(idx) {
*slot = Some(bg);
}
};
match line {
DashboardLine::PinnedHeader { count } => {
let key = SectionKey::Pinned;
@ -1681,12 +1695,16 @@ fn render_rows(
render_group_header(
buf, line_rect, theme, "Pinned", *count, collapsed, selected, hovered,
);
mark(&mut line_bg, 0, theme.bg_base);
// Full-height hit rect (label + trailing gap) — no
// hover/click dead zone between items.
state
.section_rects
.push((key, Rect::new(area.x, y, body_width, 1)));
.push((key, Rect::new(area.x, y, body_width, render_h)));
}
DashboardLine::Divider => {
render_divider(buf, line_rect, theme);
mark(&mut line_bg, 0, theme.bg_base);
}
DashboardLine::Header { state: rs, count } => {
// Headers only paint into the first cell; the
@ -1705,22 +1723,31 @@ fn render_rows(
selected,
hovered,
);
mark(&mut line_bg, 0, theme.bg_base);
// Full-height hit rect (label + trailing gap) — no
// hover/click dead zone between items.
state
.section_rects
.push((key, Rect::new(area.x, y, body_width, 1)));
.push((key, Rect::new(area.x, y, body_width, render_h)));
}
DashboardLine::Row(row) => {
render_row(buf, line_rect, theme, row, state);
let bg = row_bg(theme, state, row);
let content_top = row_content_offset(render_h, row);
let content_h = row_content_height(row).min(render_h);
for dy in content_top..(content_top + content_h).min(render_h) {
mark(&mut line_bg, dy, bg);
}
if !row.is_more_placeholder {
// Hit rect covers the two content cells so a
// click on the secondary line still selects the
// row. The trailing gap (if any) stays outside.
let hit_h = render_h.min(2);
// Full-height hit rect (content + spacer lines) —
// no hover/click dead zone between items; the
// highlight covers the content plus half-cell
// halos on the neighbouring spacer lines.
let hit = Rect {
x: area.x,
y,
width: body_width,
height: hit_h,
height: render_h,
};
state.row_rects.push((row.id.clone(), hit));
}
@ -1735,17 +1762,62 @@ fn render_rows(
state.selected_idle_overflow,
state.hovered_idle_overflow,
);
state.idle_overflow_rect = Some(Rect::new(area.x, y, body_width, 1));
mark(&mut line_bg, 0, theme.bg_base);
// Full-height hit rect (label + trailing gap) — no
// hover/click dead zone below the overflow row.
state.idle_overflow_rect = Some(Rect::new(area.x, y, body_width, render_h));
}
}
cell_y = next_cell_y;
}
render_spacer_halos(buf, area, body_width, &line_bg, theme.bg_base);
if needs_scrollbar {
render_scrollbar(buf, area, offset, viewport_h, total_cells, theme);
}
}
/// Paint the spacer lines between items as half-cell "halos" so a
/// highlighted row reads as vertically centered: the spacer below a
/// highlighted block shows the highlight in its TOP half, and the
/// spacer above shows it in its BOTTOM half. Implemented with the
/// upper-half-block glyph (`▀`, CP437 `0xDF` — safe on legacy
/// consoles): fg paints the top half with the colour of the content
/// line above, bg paints the bottom half with the colour of the
/// content line below. Spacers between two `bg_base` neighbours are
/// left untouched.
fn render_spacer_halos(
buf: &mut Buffer,
area: Rect,
body_width: u16,
line_bg: &[Option<Color>],
base: Color,
) {
for (i, slot) in line_bg.iter().enumerate() {
if slot.is_some() {
continue;
}
let above = if i > 0 {
line_bg[i - 1].unwrap_or(base)
} else {
base
};
let below = line_bg.get(i + 1).copied().flatten().unwrap_or(base);
if above == base && below == base {
continue;
}
let y = area.y + i as u16;
if above == below {
let fill = " ".repeat(body_width as usize);
buf.set_string(area.x, y, &fill, Style::default().bg(above));
} else {
let fill = "\u{2580}".repeat(body_width as usize);
buf.set_string(area.x, y, &fill, Style::default().fg(above).bg(below));
}
}
}
/// Wide-mode group header reads:
///
/// ```text
@ -2026,10 +2098,38 @@ fn snap_offset_to_line_boundary(offset: usize, heights: &[u16]) -> usize {
snapped
}
/// Render a row as a 2-line block (`rect.height` is
/// expected to be `>= 2`; the caller — `render_rows` — sizes the
/// rect to either 2 or 3 lines depending on whether the trailing
/// breathing-room gap is in budget).
/// Number of content lines a row renders: title + optional secondary.
fn row_content_height(row: &DashboardRow) -> u16 {
if row.secondary_line.as_deref().is_some_and(|s| !s.is_empty()) {
2
} else {
1
}
}
/// Vertical offset of a row's content block within its rect. The
/// 1- or 2-line content is centered at cell granularity: a title-only
/// row in a 3-cell rect gets one padding line above and below, while
/// a title+secondary row stays top-aligned ((3 - 2) / 2 = 0).
fn row_content_offset(height: u16, row: &DashboardRow) -> u16 {
height.saturating_sub(row_content_height(row)) / 2
}
/// The row's background: keyboard selection wins over mouse hover.
fn row_bg(theme: &Theme, state: &DashboardState, row: &DashboardRow) -> Color {
if state.selected.as_ref().is_some_and(|s| *s == row.id) {
theme.bg_highlight
} else if state.hovered_row.as_ref().is_some_and(|h| *h == row.id) {
theme.bg_hover
} else {
theme.bg_base
}
}
/// Render a row as a 2-line block plus a trailing padding line
/// (`rect.height` is expected to be `>= 2`; the caller —
/// `render_rows` — sizes the rect to either 2 or 3 lines depending
/// on whether the padding is in budget).
///
/// Visual:
///
@ -2043,9 +2143,15 @@ fn snap_offset_to_line_boundary(offset: usize, heights: &[u16]) -> usize {
/// tool call, the last assistant message, or a `Pending: …` preview
/// of the front-most permission request.
///
/// Selection / hover backgrounds cover both content rows (the
/// trailing gap row, if any, stays on `bg_base` so consecutive
/// selected rows still look distinct).
/// The content block is vertically centered within the rect (see
/// [`row_content_offset`]): a title-only row in a 3-cell rect renders
/// as padding + title + padding.
///
/// Selection / hover backgrounds fill the CONTENT lines; the spacer
/// lines around them are painted afterwards by `render_rows`'s
/// half-block pass (see `render_spacer_halos`), which extends the
/// highlight half a cell above and below so it reads as centered on
/// the text.
fn render_row(
buf: &mut Buffer,
rect: Rect,
@ -2057,21 +2163,16 @@ fn render_row(
return;
}
let selected = state.selected.as_ref().is_some_and(|s| *s == row.id);
let hovered = state.hovered_row.as_ref().is_some_and(|h| *h == row.id);
let renaming = state.rename.as_ref().is_some_and(|r| r.row == row.id);
let bg = if selected {
theme.bg_highlight
} else if hovered {
theme.bg_hover
} else {
theme.bg_base
};
let bg = row_bg(theme, state, row);
// Paint both content rows with the same background so selection
// reads as a single block.
let content_h = rect.height.min(2);
// Paint the content lines with the row background. Spacer lines
// keep `bg_base` here; the halo pass splits them between the
// neighbouring items.
let content_top = row_content_offset(rect.height, row);
let content_h = row_content_height(row).min(rect.height);
let fill = " ".repeat(rect.width as usize);
for dy in 0..content_h {
for dy in content_top..(content_top + content_h).min(rect.height) {
buf.set_string(rect.x, rect.y + dy, &fill, Style::default().bg(bg));
}
@ -2095,8 +2196,11 @@ fn render_row(
let icon_w = UnicodeWidthStr::width(icon) as u16;
// Title-row paint cursor (no leading 1-col gap before the marker
// — the marker IS the leftmost cell, mirroring the wide-mode
// header which starts flush-left at col 0).
let title_y = rect.y;
// header which starts flush-left at col 0). The content block is
// vertically centered within the rect at cell granularity:
// title-only rows sit padded above and below, while 2-line rows
// stay top-aligned (2 lines cannot center in a 3-cell row).
let title_y = rect.y + row_content_offset(rect.height, row);
let content_start_x = rect.x + marker_w + 1 + indent_w + icon_w + 1;
// Rename overlay: keep the row's chrome (marker + state icon) in
@ -2113,14 +2217,14 @@ fn render_row(
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
);
// Keep the left bar continuous on secondary lines even while
// the rename overlay is active on the title line.
if selected && content_h >= 2 {
// Keep the left bar continuous on every content line even
// while the rename overlay is active on the title line.
if selected {
let bar_style = Style::default()
.bg(bg)
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD);
for dy in 1..content_h {
for dy in content_top..(content_top + content_h).min(rect.height) {
buf.set_string(
rect.x,
rect.y + dy,
@ -2163,16 +2267,15 @@ fn render_row(
);
// For the active selection, extend the thin left bar down every
// content line of the row (title + secondary) so it forms one
// continuous vertical rule along the full height of the selected
// item. Hover and normal states keep their marker only on the
// title line.
if selected && content_h >= 2 {
// content line of the row so it forms one continuous vertical rule
// along the highlighted text. Hover and normal states keep their
// marker only on the title line.
if selected {
let bar_style = Style::default()
.bg(bg)
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD);
for dy in 1..content_h {
for dy in content_top..(content_top + content_h).min(rect.height) {
buf.set_string(
rect.x,
rect.y + dy,
@ -2305,7 +2408,7 @@ fn render_row(
&& let Some(secondary) = row.secondary_line.as_deref()
&& !secondary.is_empty()
{
let sec_y = rect.y + 1;
let sec_y = title_y + 1;
let avail = rect
.width
.saturating_sub(content_start_x - rect.x)
@ -5071,6 +5174,112 @@ mod tests {
assert!(!state.row_rects.is_empty());
}
/// Wide-mode hit rects include each item's trailing gap line and
/// tile the list contiguously, so hover/click never falls into a
/// dead zone between items.
#[test]
fn render_rows_hit_rects_leave_no_dead_zones() {
let rows = vec![
header_test_row(1, RowState::Working, "alpha"),
header_test_row(2, RowState::Working, "beta"),
header_test_row(3, RowState::Idle, "gamma"),
];
let area = Rect::new(0, 0, 60, 30);
let mut buf = Buffer::empty(area);
let mut state = DashboardState::new();
state.grouping = Grouping::State;
let theme = Theme::current();
render_rows(&mut buf, area, &theme, &rows, &mut state);
assert_eq!(state.row_rects.len(), 3);
for (id, rect) in &state.row_rects {
assert_eq!(rect.height, ROW_HEIGHT, "row {id:?} must be full-height");
}
assert_eq!(state.section_rects.len(), 2);
for (key, rect) in &state.section_rects {
assert_eq!(
rect.height, GROUP_HEADER_HEIGHT,
"section {key:?} must be full-height",
);
}
// Each hit rect starts exactly where the previous one ended.
let mut rects: Vec<Rect> = state
.row_rects
.iter()
.map(|(_, r)| *r)
.chain(state.section_rects.iter().map(|(_, r)| *r))
.collect();
rects.sort_by_key(|r| r.y);
for pair in rects.windows(2) {
assert_eq!(
pair[0].y + pair[0].height,
pair[1].y,
"hit rects must tile without gaps: {pair:?}",
);
}
// Hovering a row highlights its content line fully and paints
// half-cell halos on the spacer lines above and below, so the
// highlight reads as centered on the text. These rows are
// title-only, so the content line is the middle of the 3-cell
// rect. Use an unquantized theme: `Theme::current()` in the
// test environment collapses `bg_hover` onto `bg_base`, which
// (correctly) suppresses the halos.
let theme = Theme::groknight();
assert_ne!(theme.bg_hover, theme.bg_base);
let (id, rect) = state.row_rects[0].clone();
state.hovered_row = Some(id);
render_rows(&mut buf, area, &theme, &rows, &mut state);
let title_y = rect.y + 1;
assert_eq!(
buf[(rect.x, title_y)].style().bg,
Some(theme.bg_hover),
"hovered row must highlight its content line",
);
let above = &buf[(rect.x, title_y - 1)];
assert_eq!(above.symbol(), "\u{2580}", "spacer above must be a halo");
assert_eq!(
above.style().bg,
Some(theme.bg_hover),
"halo above must show the hover colour in its bottom half",
);
let below = &buf[(rect.x, title_y + 1)];
assert_eq!(below.symbol(), "\u{2580}", "spacer below must be a halo");
assert_eq!(
below.style().fg,
Some(theme.bg_hover),
"halo below must show the hover colour in its top half",
);
}
/// A row's content is vertically centered within its 3-cell rect:
/// a title-only row renders padding + title + padding, while a
/// title + secondary row stays top-aligned (2 lines cannot center
/// in 3 cells).
#[test]
fn render_row_centers_title_only_content() {
let theme = Theme::current();
let state = DashboardState::new();
// Title-only → centered on the middle line.
let row = header_test_row(1, RowState::Idle, "solo");
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
render_row(&mut buf, Rect::new(0, 0, 40, 3), &theme, &row, &state);
assert_eq!(buf[(4, 1)].symbol(), "s", "title must sit on line 1");
assert_eq!(buf[(4, 0)].symbol(), " ", "line 0 must be padding");
assert_eq!(buf[(4, 2)].symbol(), " ", "line 2 must be padding");
// Title + secondary → top-aligned.
let mut row = header_test_row(2, RowState::Working, "pair");
row.secondary_line = Some("Responding".to_string());
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
render_row(&mut buf, Rect::new(0, 0, 40, 3), &theme, &row, &state);
assert_eq!(buf[(4, 0)].symbol(), "p", "title must sit on line 0");
assert_eq!(buf[(4, 1)].symbol(), "R", "secondary must sit on line 1");
assert_eq!(buf[(4, 2)].symbol(), " ", "line 2 must be padding");
}
/// Empty area is a quick exit.
#[test]
fn render_empty_state_zero_area_is_no_op() {
@ -5549,7 +5758,9 @@ mod tests {
(0..w).map(|x| buf[(x, y)].symbol().to_string()).collect()
};
// Wide path: title row sits 2 below the group header (header + gap).
// Wide path: this title-only row centers its title within its
// 3-cell rect, so the title sits 3 below the group header
// (header + gap + row top padding).
// `title_byte` is a byte offset (for `str::find` comparisons);
// `title_col` is the display column (the icon glyph is
// multi-byte UTF-8, so the two differ) for cursor math.
@ -5557,7 +5768,7 @@ mod tests {
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5));
let mut state = DashboardState::new();
render_rows(&mut buf, Rect::new(0, 0, 80, 5), &theme, &rows, &mut state);
let line = row_text(&buf, 2, 80);
let line = row_text(&buf, 3, 80);
let byte = line.find("row label").expect("title must render");
(byte, line[..byte].chars().count() as u16)
};
@ -5566,14 +5777,14 @@ mod tests {
let mut state = DashboardState::new();
state.rename = Some(RenameDraft::new(id.clone(), "new name"));
render_rows(&mut buf, Rect::new(0, 0, 80, 5), &theme, &rows, &mut state);
let line = row_text(&buf, 2, 80);
let line = row_text(&buf, 3, 80);
assert_eq!(
line.find("rename: new name"),
Some(title_byte),
"wide: `rename:` must start at the title column, got: {line:?}",
);
assert_eq!(
buf[(2, 2)].symbol(),
buf[(2, 3)].symbol(),
crate::glyphs::diamond_hollow(),
"wide: the state icon must stay in place while renaming",
);
@ -5583,7 +5794,7 @@ mod tests {
let draft_w = "new name".len() as u16;
assert_eq!(
rename_cursor_pos(&state, &rows),
Some((title_col + prefix_w + draft_w, 2)),
Some((title_col + prefix_w + draft_w, 3)),
"cursor must sit one cell past the draft text",
);
// With an empty draft the cursor sits immediately after
@ -5591,7 +5802,7 @@ mod tests {
state.rename = Some(RenameDraft::new(id.clone(), ""));
assert_eq!(
rename_cursor_pos(&state, &rows),
Some((title_col + prefix_w, 2)),
Some((title_col + prefix_w, 3)),
"empty draft: cursor must sit right after `rename: `",
);
}
@ -5654,7 +5865,7 @@ mod tests {
let theme = Theme::current();
let registry = crate::actions::ActionRegistry::defaults();
for (width, narrow, row_y) in [(80, false, 2), (30, true, 1)] {
for (width, narrow, row_y) in [(80, false, 3), (30, true, 1)] {
let area = Rect::new(0, 0, width, if narrow { 3 } else { 5 });
let mut buffer = Buffer::empty(area);
let mut state = DashboardState::new();
@ -6041,8 +6252,7 @@ mod tests {
assert!(
lines.iter().any(|l| matches!(
l,
DashboardLine::Header { state, count }
if *state == RowState::Working && *count == 2
DashboardLine::Header { state, count } if *state == RowState::Working && *count == 2
)),
"collapsed Working header must still render with its true count",
);
@ -6144,8 +6354,7 @@ if *state == RowState::Working && *count == 2
assert!(
lines.iter().any(|l| matches!(
l,
DashboardLine::Header { state, count }
if *state == RowState::Idle && *count == total as usize
DashboardLine::Header { state, count } if *state == RowState::Idle && *count == total as usize
)),
"Idle header keeps the true total count",
);
@ -6847,8 +7056,9 @@ if *state == RowState::Idle && *count == total as usize
/// Group header (section title) leads with a disclosure glyph at
/// col 0, then the label at col 2, within the list area. Row content
/// below is indented (marker col 0, gap col 1, icon col 2). The
/// header is 2 visual cells tall (label + gap) so the row's title
/// sits 2 rows below the header in this fixture.
/// header is 2 visual cells tall (label + gap) and the title-only
/// row centers its title, so the title sits 3 rows below the
/// header in this fixture.
#[test]
fn render_group_header_leads_with_disclosure_glyph() {
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 8));
@ -6871,12 +7081,13 @@ if *state == RowState::Idle && *count == total as usize
"section title `Idle …` must start after the disclosure glyph, got: {header_label_x:?}",
);
// Header gap → row 1 is blank. Row's title row starts at y=2
// (after the 2-cell header). Rows still render their marker/icon
// in the left chrome columns.
let row_col0 = buf[(0, 2)].symbol().to_string();
let row_col1 = buf[(1, 2)].symbol().to_string();
let row_col2 = buf[(2, 2)].symbol().to_string();
// Header gap → row 1 is blank. The title-only row centers its
// title within its 3-cell rect (y=2..5), so the title sits at
// y=3. Rows still render their marker/icon in the left chrome
// columns.
let row_col0 = buf[(0, 3)].symbol().to_string();
let row_col1 = buf[(1, 3)].symbol().to_string();
let row_col2 = buf[(2, 3)].symbol().to_string();
assert_eq!(
row_col0, " ",
"row's col 0 must be the marker space when nothing selected, got: {row_col0:?}",
@ -6966,7 +7177,7 @@ if *state == RowState::Idle && *count == total as usize
#[test]
fn render_rows_subagents_do_not_trigger_their_own_headers() {
use crate::app::agent::AgentId;
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
let mut state = DashboardState::new();
let parent = DashboardRow {
id: DashboardRowId::TopLevel(AgentId(1)),

View file

@ -1387,7 +1387,7 @@ mod tests {
let older = newer - Duration::from_secs(60);
let mut rows = vec![
DashboardRow {
last_change_at: newer,
last_change_at: newer, // recency would put id1 first
..make_row_with_id(id1.clone(), 0, RowState::Working)
},
DashboardRow {
@ -1737,6 +1737,7 @@ mod tests {
bg_tool_call_to_task: std::collections::HashMap::new(),
scheduled_tasks: std::collections::HashMap::new(),
in_flight_prompt: None,
compact_held_prompt: None,
current_prompt_id: None,
created_via_new: false,
};

View file

@ -1020,20 +1020,27 @@ pub enum McpSetupOutcome {
Submit,
}
/// Modal message overlay (errors, confirmations).
#[derive(Debug, Clone)]
/// Concrete action to run after the user presses `y` on a confirmation overlay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfirmationAction {
/// Replay a hooks action (e.g. remove a hook source directory).
Hooks(xai_hooks_plugins_types::HooksAction),
/// Replay a plugins action (e.g. uninstall; may still be `confirmed: false`
/// so multi-plugin repos can return a second server-owned prompt).
Plugins(xai_hooks_plugins_types::PluginsAction),
/// Replay a marketplace action (uninstall plugin or remove source).
Marketplace(xai_hooks_plugins_types::MarketplaceAction),
/// Delete a removable (local) MCP server by name.
DeleteMcpServer { server_name: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModalMessage {
/// An error message from a failed action. Any key dismisses.
Error(String),
/// A confirmation prompt. Stores the action to replay with confirmed=true.
Confirmation {
message: String,
action: xai_hooks_plugins_types::PluginsAction,
},
/// A confirmation prompt for a marketplace action (install/uninstall/update).
MarketplaceConfirmation {
message: String,
action: xai_hooks_plugins_types::MarketplaceAction,
action: ConfirmationAction,
pending_entry_index: Option<usize>,
},
}
@ -1723,6 +1730,13 @@ pub struct ExtensionsModalState {
pub plugins_scroll: usize,
/// Marketplace tab state.
pub marketplace_data: TabDataState<xai_hooks_plugins_types::MarketplaceListResponse>,
/// A marketplace list fetch is in flight. Overlapping list calls
/// serialize on the shell's per-source cache lock and each re-scans
/// every git source, so duplicates multiply the slowest source's latency.
pub marketplace_fetch_inflight: bool,
/// A refetch arrived while one was in flight; it runs when the current
/// fetch lands so post-action results stay fresh.
pub marketplace_refetch_queued: bool,
pub marketplace_selected: usize,
pub marketplace_scroll: usize,
/// Skills tab state.
@ -1811,6 +1825,8 @@ impl ExtensionsModalState {
hooks_scroll: 0,
plugins_scroll: 0,
marketplace_data: TabDataState::Loading,
marketplace_fetch_inflight: false,
marketplace_refetch_queued: false,
marketplace_selected: 0,
marketplace_scroll: 0,
skills_data: TabDataState::Loading,
@ -3258,9 +3274,7 @@ pub fn render_extensions_modal(
// The overlay above is shortened to leave the footer line visible.
let modal_msg_kind = state.modal_message.as_ref().map(|m| match m {
ModalMessage::Error(_) => ModalMsgKind::Error,
ModalMessage::Confirmation { .. } | ModalMessage::MarketplaceConfirmation { .. } => {
ModalMsgKind::Confirm
}
ModalMessage::Confirmation { .. } => ModalMsgKind::Confirm,
});
let mut shortcuts: Vec<Shortcut<'_>> = Vec::new();
if modal_msg_kind.is_some() {
@ -3517,7 +3531,8 @@ pub fn render_extensions_modal(
badge: entry_badge_text.get(i).map(|s| s.as_str()).unwrap_or(""),
badge_color: entry_badge_color.get(i).copied().flatten(),
collapsible: is_collapsible,
underline_last_desc: group_key.is_some_and(|k| *k == managed_section_key),
underline_last_desc: state.modal_message.is_none()
&& group_key.is_some_and(|k| *k == managed_section_key),
})
}
})
@ -3615,22 +3630,21 @@ pub fn render_extensions_modal(
msg_content_width,
msg_content_height,
);
// Buffer::set_string merges styles; Style::reset clears UNDERLINED/BOLD
// left by the list underneath (e.g. Managed connectors URL).
let clear_style = Style::reset().bg(theme.bg_base);
let text_style = Style::reset().fg(theme.accent_tool).bg(theme.bg_base);
for y in msg_area.y..msg_area.y + msg_area.height {
buf.set_string(
msg_area.x,
y,
" ".repeat(msg_area.width as usize),
Style::default().bg(theme.bg_base),
clear_style,
);
}
let msg_y = msg_area.y + msg_area.height / 2;
let msg_x = msg_area.x + msg_area.width.saturating_sub(display.width() as u16) / 2;
buf.set_string(
msg_x,
msg_y,
&display,
Style::default().fg(theme.accent_tool).bg(theme.bg_base),
);
buf.set_string(msg_x, msg_y, &display, text_style);
}
}
@ -3638,10 +3652,7 @@ pub fn render_extensions_modal(
if let Some(ref msg) = state.modal_message {
let (text, fg) = match msg {
ModalMessage::Error(e) => (e.as_str(), theme.accent_error),
ModalMessage::Confirmation { message, .. }
| ModalMessage::MarketplaceConfirmation { message, .. } => {
(message.as_str(), theme.accent_tool)
}
ModalMessage::Confirmation { message, .. } => (message.as_str(), theme.accent_tool),
};
if let Some(popup_rect) = state.window.popup_area {
let msg_content_y = popup_rect.y + 2;
@ -3659,12 +3670,14 @@ pub fn render_extensions_modal(
msg_content_width,
msg_content_height,
);
let clear_style = Style::reset().bg(theme.bg_base);
let text_style = Style::reset().fg(fg).bg(theme.bg_base);
for y in msg_area.y..msg_area.y + msg_area.height {
buf.set_string(
msg_area.x,
y,
" ".repeat(msg_area.width as usize),
Style::default().bg(theme.bg_base),
clear_style,
);
}
let pad = 2u16;
@ -3673,12 +3686,7 @@ pub fn render_extensions_modal(
let msg_height = wrapped_lines.len().min(msg_area.height as usize);
let msg_y = msg_area.y + (msg_area.height.saturating_sub(msg_height as u16)) / 2;
for (i, wline) in wrapped_lines.iter().enumerate().take(msg_height) {
buf.set_string(
msg_area.x + pad,
msg_y + i as u16,
wline,
Style::default().fg(fg).bg(theme.bg_base),
);
buf.set_string(msg_area.x + pad, msg_y + i as u16, wline, text_style);
}
// Dismissal hints (for both errors and confirmations)
// are rendered into the footer below, not inline.
@ -7028,4 +7036,84 @@ mod tests {
"expanded view shows the install hint placeholder exactly once"
);
}
#[test]
fn confirmation_overlay_suppresses_managed_url_underline() {
use crate::views::mcps_modal::McpWireSource;
// Tall list so the Managed connectors URL sits above the centered
// confirmation text (not only cells the message string overwrites).
let mut managed = Vec::new();
for i in 0..20 {
managed.push(make_mcp_server_for_rows(
&format!("grok_com_srv_{i}"),
McpWireSource::Managed,
vec![],
));
}
managed.push(make_mcp_server_for_rows(
"local-grafana",
McpWireSource::Local,
vec![],
));
let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers);
state.mcps_data = TabDataState::Loaded(managed);
state.session_team_id = Some("team-1".into());
let area = Rect::new(0, 0, 100, 40);
let mut open_buf = Buffer::empty(area);
render_extensions_modal(&mut open_buf, area, &mut state, None, false, 0);
let underlined = |buf: &Buffer| -> usize {
let mut n = 0usize;
for y in 0..area.height {
for x in 0..area.width {
if buf
.cell((x, y))
.is_some_and(|c| c.modifier.contains(Modifier::UNDERLINED))
{
n += 1;
}
}
}
n
};
assert!(
underlined(&open_buf) > 0,
"precondition: managed connectors URL paints UNDERLINED cells"
);
assert!(
state.picker_state.link_band.is_some(),
"precondition: link hit band recorded for connectors URL"
);
state.modal_message = Some(ModalMessage::Confirmation {
message: "Remove MCP server \"local-grafana\"?".into(),
action: ConfirmationAction::DeleteMcpServer {
server_name: "local-grafana".into(),
},
pending_entry_index: Some(0),
});
state.picker_state.link_band = None;
let mut confirm_buf = Buffer::empty(area);
render_extensions_modal(&mut confirm_buf, area, &mut state, None, false, 0);
assert_eq!(
buffer_count(&confirm_buf, "Remove MCP server \"local-grafana\"?"),
1,
"confirmation message must be painted"
);
assert_eq!(
underlined(&confirm_buf),
0,
"confirmation must not paint UNDERLINED under the full overlay"
);
assert!(
state.picker_state.link_band.is_none(),
"confirmation must not record a connectors link hit band"
);
}
}

View file

@ -373,6 +373,7 @@ pub(crate) fn default_palette_entries(
screen_mode: crate::app::ScreenMode,
) -> Vec<PaletteEntry> {
let mut entries = vec![
// ── Session ──
PaletteEntry {
label: "Session".into(),
shortcut: String::new(),
@ -423,6 +424,7 @@ pub(crate) fn default_palette_entries(
shortcut: "/feedback".into(),
command: PaletteCommand::SlashCommand("/feedback ".into()),
},
// ── Context ──
PaletteEntry {
label: "Context".into(),
shortcut: String::new(),
@ -448,6 +450,7 @@ pub(crate) fn default_palette_entries(
shortcut: "/memory".into(),
command: PaletteCommand::Memory,
},
// ── Model & Input ──
PaletteEntry {
label: "Model & Input".into(),
shortcut: String::new(),
@ -473,6 +476,7 @@ pub(crate) fn default_palette_entries(
shortcut: "Ctrl+G".into(),
command: PaletteCommand::EditPromptExternal,
},
// ── Tools ──
PaletteEntry {
label: "Tools".into(),
shortcut: String::new(),
@ -518,6 +522,7 @@ pub(crate) fn default_palette_entries(
shortcut: "/config-agents".into(),
command: PaletteCommand::OpenAgentsModal,
},
// ── Other ──
PaletteEntry {
label: "Other".into(),
shortcut: String::new(),
@ -555,10 +560,7 @@ pub(crate) fn default_palette_entries(
];
entries.retain(|entry| {
if !sharing_enabled
&& matches!(
& entry.command, PaletteCommand::SlashCommand(s) if s.trim() ==
"/share"
)
&& matches!(&entry.command, PaletteCommand::SlashCommand(s) if s.trim() == "/share")
{
return false;
}
@ -1261,11 +1263,9 @@ mod doc_viewer_scroll_tests {
mod palette_sharing_tests {
use super::*;
fn has_share(entries: &[PaletteEntry]) -> bool {
entries.iter().any(|e| {
matches!(
& e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/share"
)
})
entries
.iter()
.any(|e| matches!(&e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/share"))
}
#[test]
fn default_palette_includes_share_when_enabled() {
@ -1278,12 +1278,9 @@ mod palette_sharing_tests {
#[test]
fn default_palette_includes_dashboard() {
let entries = default_palette_entries(true, crate::app::ScreenMode::Fullscreen);
let has_dashboard = entries.iter().any(|e| {
matches!(
& e.command, PaletteCommand::SlashCommand(s) if s.trim() ==
"/dashboard"
)
});
let has_dashboard = entries.iter().any(
|e| matches!(&e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/dashboard"),
);
assert!(
has_dashboard,
"/dashboard entry must be present in the palette so users can switch between agents"
@ -1354,8 +1351,10 @@ mod palette_sharing_tests {
.find(|e| e.label == label)
.unwrap_or_else(|| panic!("Tools entry {label:?} missing from palette"));
assert!(
matches!(& entry.command, PaletteCommand::OpenExtensionsTab(t) if * t ==
expected,),
matches!(
&entry.command,
PaletteCommand::OpenExtensionsTab(t) if *t == expected,
),
"Tools entry {label:?} dispatches to the wrong tab",
);
}

View file

@ -1641,8 +1641,9 @@ impl PromptWidget {
// ── Normal key handling ─────────────────────────────────────────
// Newline: Shift-Enter or Alt-Enter
if key!(Enter, SHIFT).matches(key) || key!(Enter, ALT).matches(key) {
// Newline: Shift/Alt+Enter, or Apple Terminal bare Enter with a
// newline modifier held (CoreGraphics rescue inside is_mod_enter).
if crate::input::is_mod_enter(key) {
self.textarea.insert_str("\n");
self.update_file_search_context();
return PromptEvent::Edited;

View file

@ -127,6 +127,10 @@ pub enum LocalQuestionKind {
model_id: agent_client_protocol::ModelId,
effort: Option<xai_grok_shell::sampling::types::ReasoningEffort>,
},
DoctorFix {
target: crate::app::actions::DoctorFixTarget,
plan: Box<crate::diagnostics::FixPlan>,
},
}
// ── State ──────────────────────────────────────────────────────────────

View file

@ -254,6 +254,21 @@ impl SettingsModalState {
}
}
/// Focus a setting by registry key (Browse mode). Returns whether the
/// key was found; no-op if missing.
pub fn focus_key(&mut self, key: &str) -> bool {
if let Some(idx) = self
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
{
self.selected = idx;
self.clamp_selected_to_visible();
return true;
}
false
}
/// Filtered row indices in render order.
pub fn filtered_indices(&self) -> &[usize] {
&self.filtered_cache

View file

@ -41,8 +41,7 @@ fn contextual_hints_group_sub_sheet_flow() {
assert!(
!s.rows.iter().any(|r| matches!(
r,
RowEntry::Setting { key, .. }
if key.starts_with("contextual_hints.")
RowEntry::Setting { key, .. } if key.starts_with("contextual_hints.")
)),
"child rows must be hidden from the top-level list",
);

View file

@ -111,6 +111,16 @@ Use Ctrl+V for screenshots, browser \"Copy Image\", and file-manager image \
copies.\n\
You can also drag an image file into the prompt.";
// Undo/redo are textarea chords, not ActionRegistry entries. Super/Cmd also
// works where the terminal delivers it; list Ctrl only (hosts often swallow Super).
const UNDO_LONG_HELP: &str = "\
Undoes the last change in the prompt editor.\n\
Covers typing, deletes, line/word kills, and clearing a draft.";
const REDO_LONG_HELP: &str = "\
Redoes the last undone change in the prompt editor.\n\
Ctrl+Shift+Z is primary; Ctrl+R is an alternate.";
/// Build the entries vector for the modal, grouped by category.
///
/// All registered actions are included, grouped by category. Actions
@ -264,22 +274,38 @@ pub fn build_entries(
long_help: None,
});
}
// Paste is handled by `is_paste_key`, not the registry. Ctrl+V always;
// Windows also Alt+V as a fallback. Super/Cmd omitted — many terminals
// swallow it. Lit on the agent prompt and the dashboard (both paste).
// Clipboard + textarea chords not in ActionRegistry. Super/Cmd omitted
// (often swallowed). Lit on agent prompt and dashboard reply hosts.
if cat == Category::Input {
let mut item = HintItem::new(crate::key!('v', CONTROL), "paste");
item.description = Some("Paste images (and text) from the clipboard".into());
#[cfg(target_os = "windows")]
item.keys.push(crate::key!('v', ALT));
let dimmed = !active_contexts.contains(&When::PromptFocused)
&& !active_contexts.contains(&When::DashboardFocused);
entries.push(ShortcutsHelpEntry::Hint {
item,
dimmed,
action_id: None,
long_help: Some(PASTE_LONG_HELP),
});
let push_pseudo = |entries: &mut Vec<ShortcutsHelpEntry>,
item: HintItem,
long_help: Option<&'static str>| {
entries.push(ShortcutsHelpEntry::Hint {
item,
dimmed,
action_id: None,
long_help,
});
};
let mut paste = HintItem::new(crate::key!('v', CONTROL), "paste");
paste.description = Some("Paste images (and text) from the clipboard".into());
#[cfg(target_os = "windows")]
paste.keys.push(crate::key!('v', ALT));
push_pseudo(&mut entries, paste, Some(PASTE_LONG_HELP));
let mut undo = HintItem::new(crate::key!('z', CONTROL), "undo");
undo.description = Some("Undo the last prompt edit".into());
push_pseudo(&mut entries, undo, Some(UNDO_LONG_HELP));
// Textarea: Ctrl+Shift+Z (+ Ctrl+R alt). Ctrl+R is prompt-only;
// scrollback may bind it to mouse reporting when that toggle is on.
let mut redo = HintItem::new(crate::key!('z', CONTROL | SHIFT), "redo");
redo.description = Some("Redo the last undone prompt edit".into());
redo.keys.push(crate::key!('r', CONTROL));
push_pseudo(&mut entries, redo, Some(REDO_LONG_HELP));
}
let count = entries.len() - header_idx - 1;
if count == 0 {
@ -1682,8 +1708,7 @@ mod tests {
let has_row = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "mouse reporting"
ShortcutsHelpEntry::Hint { item, .. } if item.label == "mouse reporting"
)
});
assert!(
@ -1801,8 +1826,7 @@ if item.label == "mouse reporting"
item,
action_id: Some(id),
..
}
if item.keys.contains(&crate::key!('g', CONTROL))
} if item.keys.contains(&crate::key!('g', CONTROL))
&& registry
.find(*id)
.is_some_and(|def| def.context == When::AgentScreen) =>
@ -1844,22 +1868,19 @@ if item.keys.contains(&crate::key!('g', CONTROL))
let has_todos = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "todos"
ShortcutsHelpEntry::Hint { item, .. } if item.label == "todos"
)
});
let has_sessions = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "sessions"
ShortcutsHelpEntry::Hint { item, .. } if item.label == "sessions"
)
});
let has_queue = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "queue"
ShortcutsHelpEntry::Hint { item, .. } if item.label == "queue"
)
});
assert!(has_todos, "should include toggle todos");
@ -1910,8 +1931,7 @@ if item.label == "queue"
item,
action_id: None,
..
}
if item.label == "paste"
} if item.label == "paste"
)
})
.expect("cheatsheet should list paste");
@ -1939,50 +1959,87 @@ if item.label == "paste"
assert!(!item.keys.iter().any(|k| *k == key!('v', ALT)));
}
fn paste_is_dimmed(entries: &[ShortcutsHelpEntry]) -> Option<bool> {
/// Display-only Input rows for textarea undo/redo (mirrors paste).
#[test]
fn build_entries_lists_undo_and_redo() {
let registry = ActionRegistry::defaults();
let entries = build_entries(&all_contexts(), &registry, true);
let (undo_keys, undo_help) = pseudo_hint(&entries, "undo").expect("undo row");
assert!(undo_keys.contains(&key!('z', CONTROL)));
assert_eq!(undo_help, Some(UNDO_LONG_HELP));
let (redo_keys, redo_help) = pseudo_hint(&entries, "redo").expect("redo row");
assert!(redo_keys.contains(&key!('z', CONTROL | SHIFT)));
assert!(redo_keys.contains(&key!('r', CONTROL)));
assert_eq!(redo_help, Some(REDO_LONG_HELP));
}
fn pseudo_hint<'a>(
entries: &'a [ShortcutsHelpEntry],
label: &str,
) -> Option<(&'a [KeyShortcut], Option<&'static str>)> {
entries.iter().find_map(|e| match e {
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help,
..
} if item.label == label => Some((item.keys.as_slice(), *long_help)),
_ => None,
})
}
fn pseudo_dimmed(entries: &[ShortcutsHelpEntry], label: &str) -> Option<bool> {
entries.iter().find_map(|e| match e {
ShortcutsHelpEntry::Hint {
item,
dimmed,
action_id: None,
..
} if item.label == "paste" => Some(*dimmed),
} if item.label == label => Some(*dimmed),
_ => None,
})
}
#[test]
fn build_entries_dims_paste_outside_prompt_and_dashboard() {
fn build_entries_dims_editor_pseudo_rows_outside_prompt_and_dashboard() {
let registry = ActionRegistry::defaults();
assert_eq!(
paste_is_dimmed(&build_entries(
&[When::ScrollbackFocused, When::AgentScreen, When::Always],
&registry,
true,
)),
Some(true),
"paste dimmed when neither prompt nor dashboard is active"
);
assert_eq!(
paste_is_dimmed(&build_entries(
&[When::PromptFocused, When::AgentScreen, When::Always],
&registry,
true,
)),
Some(false),
"paste lit when prompt is focused"
);
// Dashboard host opens the cheatsheet with only DashboardFocused + Always
// and handles paste itself — must not dim a working shortcut.
assert_eq!(
paste_is_dimmed(&build_entries(
&[When::DashboardFocused, When::Always],
&registry,
true,
)),
Some(false),
"paste lit on the dashboard host"
);
// paste / undo / redo share the same host lit/dim policy.
for label in ["paste", "undo", "redo"] {
assert_eq!(
pseudo_dimmed(
&build_entries(
&[When::ScrollbackFocused, When::AgentScreen, When::Always],
&registry,
true,
),
label,
),
Some(true),
"{label} dimmed off prompt/dashboard"
);
assert_eq!(
pseudo_dimmed(
&build_entries(
&[When::PromptFocused, When::AgentScreen, When::Always],
&registry,
true,
),
label,
),
Some(false),
"{label} lit when prompt focused"
);
assert_eq!(
pseudo_dimmed(
&build_entries(&[When::DashboardFocused, When::Always], &registry, true),
label,
),
Some(false),
"{label} lit on dashboard host"
);
}
}
#[test]
@ -1994,8 +2051,7 @@ if item.label == "paste"
let nav_dimmed = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "nav"
ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "nav"
)
});
assert!(
@ -2006,8 +2062,7 @@ if item.label == "nav"
let quit_bright = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
if item.label == "quit"
ShortcutsHelpEntry::Hint { item, dimmed: false, .. } if item.label == "quit"
)
});
assert!(quit_bright, "quit should not be dimmed (When::Always)");
@ -2015,8 +2070,7 @@ if item.label == "quit"
let cancel_bright = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
if item.label == "cancel"
ShortcutsHelpEntry::Hint { item, dimmed: false, .. } if item.label == "cancel"
)
});
assert!(
@ -2034,8 +2088,7 @@ if item.label == "cancel"
let send_dimmed = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "send"
ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "send"
)
});
assert!(
@ -2046,8 +2099,7 @@ if item.label == "send"
let nav_dimmed = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "nav"
ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "nav"
)
});
assert!(
@ -2705,7 +2757,6 @@ if item.label == "nav"
);
}
/// Paste ships long_help — Enter opens the man-page detail view.
#[test]
fn enter_on_paste_pseudo_row_opens_detail() {
let registry = ActionRegistry::defaults();
@ -2720,8 +2771,7 @@ if item.label == "nav"
action_id: None,
long_help: Some(_),
..
}
if item.label == "paste"
} if item.label == "paste"
)
})
.expect("paste pseudo-row with long_help");
@ -3102,8 +3152,7 @@ if item.label == "paste"
let present = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == label
ShortcutsHelpEntry::Hint { item, .. } if item.label == label
)
});
assert!(
@ -3215,9 +3264,11 @@ if item.label == label
"registry-backed hints must carry their ActionId for expand/detail"
);
// Registry rows carry ActionId; search + paste are display-only.
// Registry rows carry ActionId; known display-only rows stay action-less.
let search_key = key!('/');
let paste_key = key!('v', CONTROL);
let undo_key = key!('z', CONTROL);
let redo_key = key!('z', CONTROL | SHIFT);
for entry in &entries {
let ShortcutsHelpEntry::Hint {
item, action_id, ..
@ -3225,8 +3276,13 @@ if item.label == label
else {
continue;
};
let is_pseudo = (item.label == "search" && item.keys.contains(&search_key))
|| (item.label == "paste" && item.keys.contains(&paste_key));
let is_pseudo = match item.label.as_ref() {
"search" => item.keys.contains(&search_key),
"paste" => item.keys.contains(&paste_key),
"undo" => item.keys.contains(&undo_key),
"redo" => item.keys.contains(&redo_key),
_ => false,
};
if is_pseudo {
assert!(
action_id.is_none(),
@ -3399,8 +3455,7 @@ if item.label == label
action_id: None,
long_help: Some(_),
..
}
if item.label == "paste"
} if item.label == "paste"
)
})
.expect("paste pseudo-row with long_help");

View file

@ -503,6 +503,17 @@ mod tests {
render_dropdown(&mut buf, area, &snap, Some(1), &theme);
}
#[test]
fn fuzzy_indices_render_with_theme_accent() {
let theme = Theme::default();
let normal = Style::default().fg(theme.text_primary);
let matched = Style::default().fg(theme.fuzzy_accent);
let spans = build_highlighted_spans("ssh-wrap", &[0, 1, 2], normal, matched);
assert_eq!(spans[0].content.as_ref(), "ssh");
assert_eq!(spans[0].style.fg, Some(theme.fuzzy_accent));
assert_eq!(spans[1].style.fg, Some(theme.text_primary));
}
fn row(display: &str, description: &str) -> SuggestionRow {
SuggestionRow {
display: display.into(),

View file

@ -117,6 +117,9 @@ pub struct WelcomeRenderResult {
pub announcement_rect: Option<Rect>,
/// Hit-test rect for the promo upgrade CTA `[label]` button (click → open).
pub upgrade_cta_rect: Option<Rect>,
pub privacy_banner_accept_rect: Option<Rect>,
pub privacy_banner_customize_rect: Option<Rect>,
pub privacy_banner_legal_rect: Option<Rect>,
}
use hero_box::HERO_BOX_MIN_WIDTH;
@ -648,6 +651,8 @@ pub struct WelcomeRenderParams<'a> {
/// drives both the reserved row height and the `[label]` button. `None` = no
/// CTA on the welcome screen.
pub upgrade_cta: Option<&'a str>,
/// Non-blocking welcome privacy banner above the prompt.
pub privacy_banner: bool,
}
/// Render the welcome screen.
@ -724,6 +729,9 @@ pub fn render_welcome(
announcement_truncated: false,
announcement_rect: None,
upgrade_cta_rect: None,
privacy_banner_accept_rect: None,
privacy_banner_customize_rect: None,
privacy_banner_legal_rect: None,
}
}
AuthState::Authenticating { auth_url, mode, .. } => {
@ -756,6 +764,9 @@ pub fn render_welcome(
announcement_truncated: false,
announcement_rect: None,
upgrade_cta_rect: None,
privacy_banner_accept_rect: None,
privacy_banner_customize_rect: None,
privacy_banner_legal_rect: None,
}
}
AuthState::Done if params.is_zdr_blocked => {
@ -789,6 +800,9 @@ pub fn render_welcome(
announcement_truncated: false,
announcement_rect: None,
upgrade_cta_rect: None,
privacy_banner_accept_rect: None,
privacy_banner_customize_rect: None,
privacy_banner_legal_rect: None,
}
}
// Folder-trust question: shown after auth, before any session is
@ -1705,9 +1719,16 @@ fn render_welcome_done(
});
let has_update_tip = p.pending_update_version.is_some();
let has_resume_tip = !has_update_tip && p.foreign_resume_hint.is_some();
// Tip slot precedence: pending update > privacy banner (2 rows) > resume
// hint > random tip. The update outranks the upsell so a ready update is
// never invisible; the banner takes the slot back once it's applied.
let tip_height = if !show_picker {
if has_update_tip || has_resume_tip {
1u16 // update/resume tips are short, always 1 row
if has_update_tip {
1u16
} else if p.privacy_banner {
2u16
} else if has_resume_tip {
1u16
} else if let Some(tip_text) = p.tip {
let inset = prompt::prompt_inset(welcome_compact);
let tip_width = content_area.width.saturating_sub(inset * 2);
@ -1911,6 +1932,9 @@ fn render_welcome_done(
// shortcuts are rendered inside the picker content area.
let mut refresh_hit_rect: Option<Rect> = None;
let mut gate_url_hit_rect: Option<Rect> = None;
let mut privacy_banner_accept_rect: Option<Rect> = None;
let mut privacy_banner_customize_rect: Option<Rect> = None;
let mut privacy_banner_legal_rect: Option<Rect> = None;
let (cursor_pos, post_flush_escapes) = if show_picker {
(None, None)
} else if !p.has_access {
@ -2020,13 +2044,32 @@ fn render_welcome_done(
);
(None, None)
} else {
// When a background update is available, show the update
// notification in the tip area instead of the random tip.
// Render the update notification with accent styling when present.
if let Some(ver) = p.pending_update_version
// Privacy banner owns the tip slot when visible (above the prompt),
// except a pending-update notification, which outranks it.
if p.privacy_banner && p.pending_update_version.is_none() && layout.tip.height > 0 {
let [_, tip_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(layout.tip);
let inset = prompt::prompt_inset(p.compact);
let tip_inset = Rect {
x: tip_centered.x + inset,
y: tip_centered.y,
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);
} else if let Some(ver) = p.pending_update_version
&& layout.tip.height > 0
{
// Background update notification in the tip area.
let [_, tip_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
@ -2061,7 +2104,8 @@ fn render_welcome_done(
// Recent foreign session: offer a one-click resume in the tip area
// (only when no update is pending — the update shares ctrl+u and wins).
if p.pending_update_version.is_none()
if !p.privacy_banner
&& p.pending_update_version.is_none()
&& let Some(hint) = p.foreign_resume_hint
&& layout.tip.height > 0
{
@ -2122,8 +2166,11 @@ fn render_welcome_done(
p.prompt_focus,
prompt,
&usage_info,
if p.pending_update_version.is_some() || p.foreign_resume_hint.is_some() {
// Update/resume tip already rendered above with custom styling.
if p.privacy_banner
|| p.pending_update_version.is_some()
|| p.foreign_resume_hint.is_some()
{
// Banner/update/resume tip already rendered above with custom styling.
None
} else {
p.tip
@ -2157,9 +2204,153 @@ fn render_welcome_done(
announcement_truncated,
announcement_rect,
upgrade_cta_rect,
privacy_banner_accept_rect,
privacy_banner_customize_rect,
privacy_banner_legal_rect,
}
}
/// 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,
@ -2467,9 +2658,8 @@ fn render_auth_input_box(
/// kitty-keyboard banner is prepended ahead of `summarize_warnings()`
/// output — see `diagnostics::assemble_startup_warnings`), but only one is
/// rendered — the severity-aware pick from `startup::banner_warning`, so a
/// runtime-pushed Warning displaces an earlier Info entry; all of them point
/// at `/terminal-setup`, which remains an alias and lists every issue. One
/// message line, one optional action line, plus a buffer row for spacing.
/// runtime-pushed Warning displaces an earlier Info entry. One message line,
/// one optional action line, plus a buffer row for spacing.
/// Severity controls color (yellow for `Warning`, dim for `Info`).
fn render_startup_warnings(
area: Rect,
@ -2720,6 +2910,7 @@ mod tests {
changelog_has_full_notes: false,
welcome_announcement_expanded: false,
upgrade_cta: None,
privacy_banner: false,
}
}