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

@ -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)),