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:
parent
3af4d5d398
commit
a5727c5960
482 changed files with 37627 additions and 13402 deletions
|
|
@ -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,
|
||||
®istry,
|
||||
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(),
|
||||
®istry,
|
||||
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,
|
||||
®istry,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(), ®istry, 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],
|
||||
®istry,
|
||||
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],
|
||||
®istry,
|
||||
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],
|
||||
®istry,
|
||||
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],
|
||||
®istry,
|
||||
true,
|
||||
),
|
||||
label,
|
||||
),
|
||||
Some(true),
|
||||
"{label} dimmed off prompt/dashboard"
|
||||
);
|
||||
assert_eq!(
|
||||
pseudo_dimmed(
|
||||
&build_entries(
|
||||
&[When::PromptFocused, When::AgentScreen, When::Always],
|
||||
®istry,
|
||||
true,
|
||||
),
|
||||
label,
|
||||
),
|
||||
Some(false),
|
||||
"{label} lit when prompt focused"
|
||||
);
|
||||
assert_eq!(
|
||||
pseudo_dimmed(
|
||||
&build_entries(&[When::DashboardFocused, When::Always], ®istry, 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");
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue