Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
736
crates/codegen/xai-grok-pager/src/views/dashboard/layout.rs
Normal file
736
crates/codegen/xai-grok-pager/src/views/dashboard/layout.rs
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
//! Pure layout computation for the dashboard view.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
/// Minimum width at which the dashboard can render meaningful rows.
|
||||
/// Below this, the renderer falls back to a stripped, single-column
|
||||
/// view; row labels are middle-truncated.
|
||||
pub const MIN_DASHBOARD_WIDTH: u16 = 40;
|
||||
|
||||
/// Minimum total height at which the peek panel is allowed to render.
|
||||
/// Below this we drop the peek section even when toggled on so the
|
||||
/// row list still has room to breathe.
|
||||
pub const MIN_PEEK_HEIGHT: u16 = 12;
|
||||
|
||||
/// Outer horizontal padding for the dispatch box (cols on each side).
|
||||
///
|
||||
/// Matches `LayoutConfig::outer_hpad_left/right = 2` from the agent
|
||||
/// view's default appearance config.
|
||||
pub const DISPATCH_OUTER_HPAD: u16 = 2;
|
||||
|
||||
/// Outer horizontal padding for the top page header (cols on each side).
|
||||
/// Slightly less than the list to give the title and status chips a bit
|
||||
/// more horizontal real estate.
|
||||
pub const HEADER_OUTER_HPAD: u16 = 1;
|
||||
|
||||
/// Outer horizontal padding for the row list (cols on each side).
|
||||
///
|
||||
/// Gives the list (rows + group headers + scrollbar) breathing room so
|
||||
/// selection markers, group header rules (`────`), and row text don't
|
||||
/// sit flush against the terminal edges.
|
||||
pub const LIST_OUTER_HPAD: u16 = 2;
|
||||
|
||||
/// Output of [`compute_layout`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DashboardLayout {
|
||||
/// Top margin row (blank space above the header). Height: 0 or 1.
|
||||
/// Matches the welcome view's `v_margin` so the
|
||||
/// dashboard's title row doesn't sit flush against the alt-screen
|
||||
/// top edge.
|
||||
pub top_margin: Rect,
|
||||
/// Header row (title + summary). Height: 0 or 1.
|
||||
pub header: Rect,
|
||||
/// Vertical breathing room between the header and the row list.
|
||||
/// Height: 0 or 1. Drops to 0 on short terminals (`area.height
|
||||
/// <= 10`, mirroring the dispatch/shortcuts gap threshold).
|
||||
/// No sub-renderer ever touches this rect — the area-wide
|
||||
/// `bg_base` fill paints it. Conceptually it's the same "blank
|
||||
/// breathing-room row" as the dispatch_gap / shortcuts_gap (it's
|
||||
/// kept as a named rect rather than an anonymous y-cursor bump
|
||||
/// only so tests can pin its position and threshold).
|
||||
pub header_gap: Rect,
|
||||
/// Scrollable list area (rows + group headers).
|
||||
pub list: Rect,
|
||||
/// Peek panel area, or `Rect::default()` when hidden.
|
||||
pub peek: Rect,
|
||||
/// Bottom dispatch input area.
|
||||
pub dispatch: Rect,
|
||||
/// Footer / shortcut hint row.
|
||||
pub footer: Rect,
|
||||
/// Bottom margin row (blank space below the shortcuts bar).
|
||||
/// Height: 0 or 1. Matches the agent view's
|
||||
/// `bottom_vpad` from `eff_outer_vpad` in `LayoutConfig::default`
|
||||
/// so the dashboard's shortcuts bar doesn't sit flush against the
|
||||
/// alt-screen's bottom edge. Drops to 0 on short terminals
|
||||
/// (`area.height <= 16`, same threshold as
|
||||
/// `views::agent::AgentViewLayout::compute`).
|
||||
pub bottom_margin: Rect,
|
||||
}
|
||||
|
||||
/// Compute the dashboard layout for a given content area.
|
||||
///
|
||||
/// `peek_visible` requests the peek panel; the layout shows it only
|
||||
/// when the area has enough vertical room (edge case 9). When the area
|
||||
/// is narrower than [`MIN_DASHBOARD_WIDTH`], the layout still returns a
|
||||
/// valid arrangement (the renderer truncates labels).
|
||||
pub fn compute_layout(area: Rect, peek_visible: bool) -> DashboardLayout {
|
||||
// Single text row is the default — callers that support a growing
|
||||
// multiline dispatch box (Shift+Enter newlines) use
|
||||
// [`compute_layout_with_dispatch`] to request more.
|
||||
compute_layout_with_dispatch(area, peek_visible, 1)
|
||||
}
|
||||
|
||||
/// Like [`compute_layout`] but lets the caller request a taller
|
||||
/// dispatch box. `dispatch_text_rows` is the number of *text* rows the
|
||||
/// dispatch input wants (≥1); the box adds 2 more for its top/bottom
|
||||
/// border chrome. Used to grow the box as the user inserts newlines
|
||||
/// (Shift+Enter) so multiline dispatch prompts are fully visible.
|
||||
///
|
||||
/// The caller is responsible for clamping `dispatch_text_rows` so the
|
||||
/// row list keeps usable space; this function only enforces a ≥1 floor.
|
||||
pub fn compute_layout_with_dispatch(
|
||||
area: Rect,
|
||||
peek_visible: bool,
|
||||
dispatch_text_rows: u16,
|
||||
) -> DashboardLayout {
|
||||
// When `area.height == 0`, every subrect collapses
|
||||
// to zero. A footer_h = 1 default would produce a non-zero
|
||||
// footer rect even on a 0-height area.
|
||||
if area.height == 0 {
|
||||
let z = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: 0,
|
||||
};
|
||||
return DashboardLayout {
|
||||
top_margin: z,
|
||||
header: z,
|
||||
header_gap: z,
|
||||
list: z,
|
||||
peek: z,
|
||||
dispatch: z,
|
||||
footer: z,
|
||||
bottom_margin: z,
|
||||
};
|
||||
}
|
||||
// Match the welcome / agent view's top margin so
|
||||
// the dashboard's header doesn't sit flush against the alt-screen's
|
||||
// top edge. The welcome view uses `v_margin = 1` (see
|
||||
// `views::welcome::render_welcome`). Dropped to 0 on very short
|
||||
// terminals so we don't starve the row list.
|
||||
let top_margin_h: u16 = if area.height > 6 { 1 } else { 0 };
|
||||
let header_h: u16 = if area.height > 4 { 1 } else { 0 };
|
||||
// 1-row gap between the header and the row list so the title /
|
||||
// status chips don't sit flush against the first row (or the
|
||||
// first group header). Collapses on short terminals so the row
|
||||
// list isn't starved (same threshold as the dispatch/shortcuts
|
||||
// gaps).
|
||||
let header_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
|
||||
let footer_h: u16 = if area.height >= 2 { 1 } else { 0 };
|
||||
// Vertical gaps around the dispatch box, matching
|
||||
// the agent view's `prompt_gap` and `shortcuts_gap` (both = 1) so
|
||||
// the dispatch chrome doesn't sit flush against the list above or
|
||||
// the footer below. Gaps drop to 0 on short terminals so the row
|
||||
// list still gets visible space. Computed BEFORE `dispatch_h` so the
|
||||
// content-sized peek box can leave the row list at least one row.
|
||||
let dispatch_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
|
||||
let shortcuts_gap_h: u16 = if area.height > 10 { 1 } else { 0 };
|
||||
// Bottom margin below the shortcuts bar, matching
|
||||
// the agent view's `bottom_vpad` (`outer_vpad = 1` from
|
||||
// `LayoutConfig::default` dropped to 0 when `area.height <= 16`).
|
||||
let bottom_margin_h: u16 = if area.height > 16 { 1 } else { 0 };
|
||||
|
||||
// The peek panel sizes to its CONTENT instead of a fixed
|
||||
// height. Its inner rows are: status (1) + wrapped response (N) +
|
||||
// one blank breathing row (1) + `❯ reply` (1); the caller passes
|
||||
// that inner content count via `dispatch_text_rows` (floored at
|
||||
// status + blank + reply = 3 when there's no response yet). Adding
|
||||
// the 2 borders gives the box height, clamped so the row list keeps
|
||||
// at least one visible row.
|
||||
//
|
||||
// Otherwise (no peek) the dispatch reserves 2 borders + N text rows
|
||||
// so the rounded box reads as a real input field and grows for
|
||||
// multiline (Alt+Enter) prompts. Very short terminals (height ≤ 8)
|
||||
// fall back to a single line so the row list isn't starved.
|
||||
let dispatch_h: u16 = if peek_visible {
|
||||
if area.height <= 8 {
|
||||
1
|
||||
} else {
|
||||
let fixed_overhead = top_margin_h
|
||||
+ header_h
|
||||
+ header_gap_h
|
||||
+ footer_h
|
||||
+ dispatch_gap_h
|
||||
+ shortcuts_gap_h
|
||||
+ bottom_margin_h;
|
||||
let content = dispatch_text_rows.max(3);
|
||||
let desired = content + 2;
|
||||
// Keep ≥1 row for the list; never collapse below a 3-row box.
|
||||
let max_box = area.height.saturating_sub(fixed_overhead + 1).max(3);
|
||||
desired.min(max_box)
|
||||
}
|
||||
} else if area.height > 8 {
|
||||
2 + dispatch_text_rows.max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
// Standalone peek rect retired. Peek now renders
|
||||
// INSIDE the dispatch rect (which grows when `peek_visible`,
|
||||
// computed above). Kept as a zero-height field for ABI compat
|
||||
// with the existing call sites that still destructure
|
||||
// `layout.peek`; the field can be removed in a follow-up
|
||||
// cleanup.
|
||||
let peek_h: u16 = 0;
|
||||
let remaining = area.height.saturating_sub(
|
||||
top_margin_h
|
||||
+ header_h
|
||||
+ header_gap_h
|
||||
+ footer_h
|
||||
+ dispatch_h
|
||||
+ peek_h
|
||||
+ dispatch_gap_h
|
||||
+ shortcuts_gap_h
|
||||
+ bottom_margin_h,
|
||||
);
|
||||
|
||||
let mut y = area.y;
|
||||
let top_margin = Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
width: area.width,
|
||||
height: top_margin_h,
|
||||
};
|
||||
y += top_margin_h;
|
||||
// Inset the top page header using its own (slightly smaller) padding
|
||||
// so the title and status chips have breathing room without losing
|
||||
// as much width as the list content.
|
||||
let header_inner_pad = HEADER_OUTER_HPAD.saturating_mul(2);
|
||||
let header_width = area.width.saturating_sub(header_inner_pad);
|
||||
let header_x = if header_width > 0 {
|
||||
area.x.saturating_add(HEADER_OUTER_HPAD)
|
||||
} else {
|
||||
area.x
|
||||
};
|
||||
let header = Rect {
|
||||
x: header_x,
|
||||
y,
|
||||
width: if header_width > 0 {
|
||||
header_width
|
||||
} else {
|
||||
area.width
|
||||
},
|
||||
height: header_h,
|
||||
};
|
||||
y += header_h;
|
||||
|
||||
// 1-row gap between header and list (collapsed on short
|
||||
// terminals). Painted by `render_dashboard`'s full-area fill —
|
||||
// no sub-renderer touches it.
|
||||
let header_gap = Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
width: area.width,
|
||||
height: header_gap_h,
|
||||
};
|
||||
y += header_gap_h;
|
||||
|
||||
// Polish — inset the list by LIST_OUTER_HPAD on each side so the
|
||||
// row content and group header rules have side breathing room.
|
||||
// The outer columns stay painted bg_base by the area-wide fill in
|
||||
// render_dashboard. Mirrors the dispatch inset pattern but with a
|
||||
// smaller pad (1 vs 2) because row text is long and dense.
|
||||
let list_inner_pad = LIST_OUTER_HPAD.saturating_mul(2);
|
||||
let list_width = area.width.saturating_sub(list_inner_pad);
|
||||
let list_x = if list_width > 0 {
|
||||
area.x.saturating_add(LIST_OUTER_HPAD)
|
||||
} else {
|
||||
area.x
|
||||
};
|
||||
let list = Rect {
|
||||
x: list_x,
|
||||
y,
|
||||
width: if list_width > 0 {
|
||||
list_width
|
||||
} else {
|
||||
area.width
|
||||
},
|
||||
height: remaining,
|
||||
};
|
||||
y += remaining;
|
||||
|
||||
let peek = if peek_h > 0 {
|
||||
let r = Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
width: area.width,
|
||||
height: peek_h,
|
||||
};
|
||||
y += peek_h;
|
||||
r
|
||||
} else {
|
||||
Rect::default()
|
||||
};
|
||||
|
||||
// 1-row gap between list/peek and the dispatch
|
||||
// box (mirrors `prompt_gap` in `views::agent::AgentViewLayout`).
|
||||
y += dispatch_gap_h;
|
||||
|
||||
// Single-line dispatch input keeps its 2-col
|
||||
// outer padding so the `❯` prefix lines up with the row content
|
||||
// (rows are indented past the marker column too).
|
||||
let dispatch_inner_pad = DISPATCH_OUTER_HPAD.saturating_mul(2);
|
||||
let dispatch_width = area.width.saturating_sub(dispatch_inner_pad);
|
||||
let dispatch_x = if dispatch_width > 0 {
|
||||
area.x.saturating_add(DISPATCH_OUTER_HPAD)
|
||||
} else {
|
||||
area.x
|
||||
};
|
||||
let dispatch = Rect {
|
||||
x: dispatch_x,
|
||||
y,
|
||||
width: if dispatch_width > 0 {
|
||||
dispatch_width
|
||||
} else {
|
||||
area.width
|
||||
},
|
||||
height: dispatch_h,
|
||||
};
|
||||
y += dispatch_h;
|
||||
|
||||
// 1-row gap between the dispatch box and the
|
||||
// shortcuts footer (mirrors `shortcuts_gap`).
|
||||
y += shortcuts_gap_h;
|
||||
|
||||
let footer = Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
width: area.width,
|
||||
height: footer_h,
|
||||
};
|
||||
y += footer_h;
|
||||
|
||||
// Bottom margin row below the shortcuts bar
|
||||
// (matches the agent view's `bottom_vpad`). Painted with
|
||||
// `bg_base` by `render_dashboard`'s full-area fill — no
|
||||
// sub-renderer ever touches this rect.
|
||||
let bottom_margin = Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
width: area.width,
|
||||
height: bottom_margin_h,
|
||||
};
|
||||
|
||||
DashboardLayout {
|
||||
top_margin,
|
||||
header,
|
||||
header_gap,
|
||||
list,
|
||||
peek,
|
||||
dispatch,
|
||||
footer,
|
||||
bottom_margin,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layout_assigns_disjoint_areas() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let layout = compute_layout(area, true);
|
||||
// top_margin + header + list + peek + dispatch + footer
|
||||
// + bottom_margin == total. Three blank rows sit between the
|
||||
// content rects: the header_gap (between header and list),
|
||||
// the dispatch_gap (between list/peek and dispatch), and the
|
||||
// shortcuts_gap (between dispatch and footer). The
|
||||
// bottom_margin IS a rect with bg_base, so it stays inside
|
||||
// `total`. Same mental model as the dispatch/shortcuts
|
||||
// gaps — those are intentional blank breathing-room rows
|
||||
// that the area-wide bg fill paints without any dedicated
|
||||
// sub-renderer.
|
||||
let total = layout.top_margin.height
|
||||
+ layout.header.height
|
||||
+ layout.list.height
|
||||
+ layout.peek.height
|
||||
+ layout.dispatch.height
|
||||
+ layout.footer.height
|
||||
+ layout.bottom_margin.height;
|
||||
// 3 rows are absorbed by the gaps (header_gap +
|
||||
// dispatch_gap + shortcuts_gap).
|
||||
assert_eq!(total + 3, area.height);
|
||||
}
|
||||
|
||||
/// Multiline dispatch: the box grows by exactly one row per extra
|
||||
/// text row (2 border rows + N text rows), and the row list gives up
|
||||
/// the space so the totals still tile the area.
|
||||
#[test]
|
||||
fn dispatch_box_grows_for_multiline_input() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let single = compute_layout(area, false);
|
||||
// Single-line default is 3 rows (top border + 1 text + bottom).
|
||||
assert_eq!(single.dispatch.height, 3);
|
||||
|
||||
let three = compute_layout_with_dispatch(area, false, 3);
|
||||
assert_eq!(
|
||||
three.dispatch.height, 5,
|
||||
"3 text rows → 2 border + 3 text = 5 rows",
|
||||
);
|
||||
// The list absorbs the extra two rows the dispatch box took.
|
||||
assert_eq!(
|
||||
three.list.height + 2,
|
||||
single.list.height,
|
||||
"the row list must shrink by exactly the dispatch growth",
|
||||
);
|
||||
// Dispatch still sits above the footer with the same gap.
|
||||
assert_eq!(three.footer.y, three.dispatch.y + three.dispatch.height + 1);
|
||||
}
|
||||
|
||||
/// A `dispatch_text_rows` of 0 is floored to a single text row so the
|
||||
/// box never collapses below its single-line chrome.
|
||||
#[test]
|
||||
fn dispatch_box_floors_at_single_text_row() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let zero = compute_layout_with_dispatch(area, false, 0);
|
||||
assert_eq!(zero.dispatch.height, 3, "0 text rows floors to 1 (3 total)");
|
||||
}
|
||||
|
||||
/// The dashboard reserves 1 row of bottom margin
|
||||
/// below the shortcuts bar on tall enough terminals so the
|
||||
/// shortcuts don't sit flush against the alt-screen's bottom edge.
|
||||
/// Mirrors the agent view's `bottom_vpad` (`outer_vpad = 1`,
|
||||
/// dropped to 0 at `area.height <= 16`).
|
||||
#[test]
|
||||
fn layout_reserves_bottom_margin_on_tall_terminals() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(
|
||||
layout.bottom_margin.height, 1,
|
||||
"tall terminal must reserve a bottom margin row",
|
||||
);
|
||||
// Bottom margin must sit directly below the footer.
|
||||
assert_eq!(
|
||||
layout.bottom_margin.y,
|
||||
layout.footer.y + layout.footer.height,
|
||||
"bottom_margin must sit directly below the footer",
|
||||
);
|
||||
// Bottom margin must end exactly at area.height — no slack
|
||||
// before the alt-screen's bottom edge.
|
||||
assert_eq!(
|
||||
layout.bottom_margin.y + layout.bottom_margin.height,
|
||||
area.y + area.height,
|
||||
"bottom_margin must extend to the bottom of `area`",
|
||||
);
|
||||
}
|
||||
|
||||
/// Bottom margin collapses to 0 on short
|
||||
/// terminals (`area.height <= 16`, matching the agent view's
|
||||
/// threshold) so the row list isn't starved.
|
||||
#[test]
|
||||
fn layout_drops_bottom_margin_on_short_terminals() {
|
||||
let area = Rect::new(0, 0, 80, 16);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(layout.bottom_margin.height, 0);
|
||||
}
|
||||
|
||||
/// Dispatch box gets `DISPATCH_OUTER_HPAD` cols of
|
||||
/// outer padding on each side so its rounded border doesn't reach
|
||||
/// the terminal edge. Matches the agent view's `outer_hpad_left/right`.
|
||||
#[test]
|
||||
fn layout_applies_outer_hpad_to_dispatch_box() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(
|
||||
layout.dispatch.x,
|
||||
area.x + DISPATCH_OUTER_HPAD,
|
||||
"dispatch must be inset by DISPATCH_OUTER_HPAD on the left",
|
||||
);
|
||||
assert_eq!(
|
||||
layout.dispatch.width,
|
||||
area.width - DISPATCH_OUTER_HPAD * 2,
|
||||
"dispatch width must lose DISPATCH_OUTER_HPAD on each side",
|
||||
);
|
||||
}
|
||||
|
||||
/// Header is inset by HEADER_OUTER_HPAD; list by LIST_OUTER_HPAD.
|
||||
/// Footer remains full-width.
|
||||
#[test]
|
||||
fn layout_insets_header_and_list() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(
|
||||
layout.header.width,
|
||||
area.width - HEADER_OUTER_HPAD * 2,
|
||||
"header must be inset by HEADER_OUTER_HPAD on each side",
|
||||
);
|
||||
assert_eq!(layout.footer.width, area.width);
|
||||
// List is intentionally inset by LIST_OUTER_HPAD on each side.
|
||||
assert_eq!(layout.list.width, area.width - LIST_OUTER_HPAD * 2);
|
||||
}
|
||||
|
||||
/// Polish — the list rect is inset by LIST_OUTER_HPAD cols on each
|
||||
/// side so row content (markers, rules, text) has breathing room
|
||||
/// and doesn't touch the terminal edges. The outer columns remain
|
||||
/// bg_base (painted by the top-level area fill).
|
||||
#[test]
|
||||
fn layout_applies_outer_hpad_to_list() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(
|
||||
layout.list.x,
|
||||
area.x + LIST_OUTER_HPAD,
|
||||
"list must be inset by LIST_OUTER_HPAD on the left",
|
||||
);
|
||||
assert_eq!(
|
||||
layout.list.width,
|
||||
area.width - LIST_OUTER_HPAD * 2,
|
||||
"list width must lose LIST_OUTER_HPAD on each side",
|
||||
);
|
||||
}
|
||||
|
||||
/// The header rect is inset by HEADER_OUTER_HPAD (slightly less
|
||||
/// than the list) for side breathing room on the title and status chips.
|
||||
#[test]
|
||||
fn layout_applies_outer_hpad_to_header() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(
|
||||
layout.header.x,
|
||||
area.x + HEADER_OUTER_HPAD,
|
||||
"header must be inset by HEADER_OUTER_HPAD on the left",
|
||||
);
|
||||
assert_eq!(
|
||||
layout.header.width,
|
||||
area.width - HEADER_OUTER_HPAD * 2,
|
||||
"header width must lose HEADER_OUTER_HPAD on each side",
|
||||
);
|
||||
}
|
||||
|
||||
/// A 1-row gap separates the list/peek from the
|
||||
/// dispatch box, and another 1-row gap separates the dispatch box
|
||||
/// from the footer. Mirrors `prompt_gap` + `shortcuts_gap` in the
|
||||
/// agent view's layout.
|
||||
#[test]
|
||||
fn layout_reserves_dispatch_and_shortcuts_gaps() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
// 1-row gap before dispatch: dispatch.y - (list.y + list.height) == 1.
|
||||
let list_end = layout.list.y + layout.list.height;
|
||||
assert_eq!(
|
||||
layout.dispatch.y - list_end,
|
||||
1,
|
||||
"expected 1-row gap between list and dispatch, got {} (list_end={list_end}, dispatch.y={})",
|
||||
layout.dispatch.y - list_end,
|
||||
layout.dispatch.y,
|
||||
);
|
||||
// 1-row gap before footer: footer.y - (dispatch.y + dispatch.height) == 1.
|
||||
let dispatch_end = layout.dispatch.y + layout.dispatch.height;
|
||||
assert_eq!(
|
||||
layout.footer.y - dispatch_end,
|
||||
1,
|
||||
"expected 1-row gap between dispatch and footer, got {} (dispatch_end={dispatch_end}, footer.y={})",
|
||||
layout.footer.y - dispatch_end,
|
||||
layout.footer.y,
|
||||
);
|
||||
}
|
||||
|
||||
/// Gaps collapse to 0 on short terminals so the
|
||||
/// row list isn't starved. Threshold mirrors the top-margin
|
||||
/// threshold pattern (`> 10`).
|
||||
#[test]
|
||||
fn layout_drops_gaps_on_short_terminals() {
|
||||
let area = Rect::new(0, 0, 80, 10);
|
||||
let layout = compute_layout(area, false);
|
||||
let list_end = layout.list.y + layout.list.height;
|
||||
let dispatch_end = layout.dispatch.y + layout.dispatch.height;
|
||||
assert_eq!(
|
||||
layout.dispatch.y - list_end,
|
||||
0,
|
||||
"short terminal must collapse dispatch gap",
|
||||
);
|
||||
assert_eq!(
|
||||
layout.footer.y - dispatch_end,
|
||||
0,
|
||||
"short terminal must collapse shortcuts gap",
|
||||
);
|
||||
}
|
||||
|
||||
/// The dashboard reserves a 1-row gap between
|
||||
/// the header and the row list on tall enough terminals so the
|
||||
/// status chips / `Dashboard` label don't sit flush against the
|
||||
/// first group header or row. The gap sits immediately below the
|
||||
/// header and immediately above the list.
|
||||
#[test]
|
||||
fn layout_reserves_header_gap_on_tall_terminals() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(
|
||||
layout.header_gap.height, 1,
|
||||
"tall terminal must reserve a header gap row",
|
||||
);
|
||||
// Header gap sits directly below the header.
|
||||
assert_eq!(
|
||||
layout.header_gap.y,
|
||||
layout.header.y + layout.header.height,
|
||||
"header_gap must sit directly below the header",
|
||||
);
|
||||
// List starts directly below the header gap.
|
||||
assert_eq!(
|
||||
layout.list.y,
|
||||
layout.header_gap.y + layout.header_gap.height,
|
||||
"list must start directly below the header_gap",
|
||||
);
|
||||
}
|
||||
|
||||
/// The header gap collapses to 0 on short
|
||||
/// terminals (`area.height <= 10`, same threshold as the
|
||||
/// dispatch / shortcuts gaps) so the row list still gets visible
|
||||
/// space.
|
||||
#[test]
|
||||
fn layout_drops_header_gap_on_short_terminals() {
|
||||
let area = Rect::new(0, 0, 80, 10);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(
|
||||
layout.header_gap.height, 0,
|
||||
"short terminal must collapse header_gap",
|
||||
);
|
||||
// And the list must start immediately below the header (no
|
||||
// implicit gap left behind).
|
||||
assert_eq!(
|
||||
layout.list.y,
|
||||
layout.header.y + layout.header.height,
|
||||
"list must start directly below the header when the gap collapses",
|
||||
);
|
||||
}
|
||||
|
||||
/// The dashboard reserves one row of top margin
|
||||
/// on terminals tall enough to spare it, mirroring the welcome
|
||||
/// view's `v_margin`. Below the height threshold the margin
|
||||
/// collapses to 0 so the row list isn't starved.
|
||||
#[test]
|
||||
fn layout_reserves_top_margin_on_tall_terminals() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(
|
||||
layout.top_margin.height, 1,
|
||||
"tall terminal must reserve a top margin row",
|
||||
);
|
||||
assert_eq!(
|
||||
layout.header.y,
|
||||
area.y + 1,
|
||||
"header must sit below the top margin",
|
||||
);
|
||||
}
|
||||
|
||||
/// Short terminals collapse the top margin to 0
|
||||
/// so the row list still gets visible space. Threshold matches
|
||||
/// the dispatch chrome's threshold (`area.height > 6`).
|
||||
#[test]
|
||||
fn layout_drops_top_margin_on_short_terminals() {
|
||||
let area = Rect::new(0, 0, 80, 6);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(layout.top_margin.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_hides_peek_when_too_short() {
|
||||
let area = Rect::new(0, 0, 80, 8);
|
||||
let layout = compute_layout(area, true);
|
||||
assert_eq!(layout.peek.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_at_minimum_width_returns_valid_rect() {
|
||||
let area = Rect::new(0, 0, MIN_DASHBOARD_WIDTH, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
// List is inset by LIST_OUTER_HPAD on each side even at the
|
||||
// minimum dashboard width (40 cols → 38 usable for content).
|
||||
assert_eq!(layout.list.width, MIN_DASHBOARD_WIDTH - LIST_OUTER_HPAD * 2);
|
||||
}
|
||||
|
||||
// Boundary tests: the existing three tests cover the happy paths;
|
||||
// the following four close the boundary gaps explicitly.
|
||||
|
||||
/// Zero-height area produces zero-height sub-rects
|
||||
/// (and doesn't panic on saturating subtraction).
|
||||
#[test]
|
||||
fn layout_height_zero_produces_zero_subrects() {
|
||||
let area = Rect::new(0, 0, 80, 0);
|
||||
let layout = compute_layout(area, true);
|
||||
assert_eq!(layout.header.height, 0);
|
||||
assert_eq!(layout.list.height, 0);
|
||||
assert_eq!(layout.peek.height, 0);
|
||||
assert_eq!(layout.dispatch.height, 0);
|
||||
assert_eq!(layout.footer.height, 0);
|
||||
}
|
||||
|
||||
/// One row below the peek minimum hides the peek.
|
||||
#[test]
|
||||
fn layout_just_below_min_peek_height_hides_peek() {
|
||||
let area = Rect::new(0, 0, 80, MIN_PEEK_HEIGHT - 1);
|
||||
let layout = compute_layout(area, true);
|
||||
assert_eq!(layout.peek.height, 0);
|
||||
}
|
||||
|
||||
/// The standalone peek rect was retired (peek now
|
||||
/// renders INSIDE the dispatch box). The peek rect is always
|
||||
/// zero-height; what changes when `peek_visible == true` is
|
||||
/// the dispatch rect, which grows from 3 to 5 rows to host
|
||||
/// the peek's status + reply input.
|
||||
#[test]
|
||||
fn layout_grows_dispatch_when_peek_visible() {
|
||||
let area = Rect::new(0, 0, 80, 30);
|
||||
let no_peek = compute_layout(area, false);
|
||||
let with_peek = compute_layout(area, true);
|
||||
assert_eq!(no_peek.peek.height, 0);
|
||||
assert_eq!(with_peek.peek.height, 0);
|
||||
assert!(
|
||||
with_peek.dispatch.height > no_peek.dispatch.height,
|
||||
"peek-visible must grow the dispatch rect, no_peek={} with_peek={}",
|
||||
no_peek.dispatch.height,
|
||||
with_peek.dispatch.height,
|
||||
);
|
||||
}
|
||||
|
||||
/// The peek box sizes to its content: 2 borders + the
|
||||
/// inner content rows (status + response + blank + reply) the caller
|
||||
/// passes via `dispatch_text_rows`. A bigger response → taller box.
|
||||
#[test]
|
||||
fn peek_box_sizes_to_content_rows() {
|
||||
let area = Rect::new(0, 0, 80, 40);
|
||||
// content = status(1) + blank(1) + reply(1) = 3 → box 5 (no response).
|
||||
let empty = compute_layout_with_dispatch(area, true, 3);
|
||||
// content = status + 3 response + blank + reply = 6 → box 8.
|
||||
let full = compute_layout_with_dispatch(area, true, 6);
|
||||
assert_eq!(empty.dispatch.height, 5);
|
||||
assert_eq!(full.dispatch.height, 8);
|
||||
assert!(full.dispatch.height > empty.dispatch.height);
|
||||
// The list reclaims the rows the smaller box doesn't use.
|
||||
assert!(empty.list.height > full.list.height);
|
||||
}
|
||||
|
||||
/// Zero-width area returns valid zero-width rects.
|
||||
#[test]
|
||||
fn layout_width_zero_returns_zero_width_subrects() {
|
||||
let area = Rect::new(0, 0, 0, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(layout.list.width, 0);
|
||||
assert_eq!(layout.dispatch.width, 0);
|
||||
}
|
||||
|
||||
/// A 39-wide area (below `MIN_DASHBOARD_WIDTH=40`)
|
||||
/// returns valid sub-rects — the renderer will fall back to
|
||||
/// narrow mode. List still receives its outer hpad inset.
|
||||
#[test]
|
||||
fn layout_width_below_min_returns_valid_subrects() {
|
||||
let area = Rect::new(0, 0, MIN_DASHBOARD_WIDTH - 1, 30);
|
||||
let layout = compute_layout(area, false);
|
||||
assert_eq!(
|
||||
layout.list.width,
|
||||
MIN_DASHBOARD_WIDTH - 1 - LIST_OUTER_HPAD * 2
|
||||
);
|
||||
assert!(layout.list.height > 0);
|
||||
}
|
||||
}
|
||||
152
crates/codegen/xai-grok-pager/src/views/dashboard/mod.rs
Normal file
152
crates/codegen/xai-grok-pager/src/views/dashboard/mod.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
//! Agent Dashboard — top-level overview of every session in flight.
|
||||
//!
|
||||
//! Centralised, agent-native list of every top-level agent and its subagents,
|
||||
//! grouped by state, with peek, attach, and dispatch affordances.
|
||||
//!
|
||||
//! Owned by `AppView::dashboard` (`Option<DashboardState>`); active only when
|
||||
//! `app.active_view == ActiveView::AgentDashboard`. State survives the user
|
||||
//! closing and reopening the dashboard within a single pager process (the
|
||||
//! `Option` is reset only on shutdown).
|
||||
//!
|
||||
//! ## Module layout
|
||||
//!
|
||||
//! - [`state`] — public `DashboardState`, `DashboardRowId`, `RowState`,
|
||||
//! `Grouping`, `Filter`, `FilterValue`, `PersistedDashboard`.
|
||||
//! - [`row`] — `DashboardRow`, `build_rows()`, classifiers, sort.
|
||||
//! - [`layout`] — pure rect computation.
|
||||
//! - [`render`] — `Widget`-style rendering routine.
|
||||
//! - [`peek`] — peek panel state + rendering.
|
||||
//!
|
||||
//! ## Lifetime
|
||||
//!
|
||||
//! Rows are rebuilt every render frame off `app.agents` — no caching. The
|
||||
//! per-row sort key (state + last_change_at) is recomputed each frame; for
|
||||
//! a single pager process with single-digit numbers of agents this is
|
||||
//! free.
|
||||
|
||||
pub mod layout;
|
||||
pub mod peek;
|
||||
pub mod render;
|
||||
pub mod row;
|
||||
pub mod state;
|
||||
|
||||
pub use render::render_dashboard;
|
||||
pub use render::{
|
||||
DashboardOverlayChrome, HeaderUpgradeCta, popup_rect, render_dashboard_session_header,
|
||||
render_dashboard_session_overlay, render_popup_overlay,
|
||||
};
|
||||
pub use row::{
|
||||
DashboardRow, RowBadge, build_rows, build_rows_with_roster, classify_subagent,
|
||||
classify_top_level, sort_rows,
|
||||
};
|
||||
pub use state::{
|
||||
DashboardDispatchMode, DashboardRowId, DashboardState, Filter, FilterValue, Focusable,
|
||||
Grouping, LocationCandidate, LocationPickerState, PendingDispatchModel, PersistedDashboard,
|
||||
PersistedRowId, RowState, SectionKey, SessionIdResolver, ShortcutsModalState, load_persisted,
|
||||
parse_filter, parse_row_state_token,
|
||||
};
|
||||
|
||||
/// Top-level agents visible in the dashboard's row list, in the
|
||||
/// exact order [`render_dashboard`] paints them. Used by the
|
||||
/// session overlay's cycle (the `[‹]` / `[›]` chips and
|
||||
/// `dispatch_dashboard_overlay_cycle`) so "previous" / "next"
|
||||
/// follow what the user actually sees instead of the agent map's
|
||||
/// insertion order. Subagent rows and `… N more` placeholders
|
||||
/// are skipped — only attachable top-level rows show up.
|
||||
pub fn overlay_cycle_order(
|
||||
state: &DashboardState,
|
||||
agents: &indexmap::IndexMap<crate::app::agent::AgentId, crate::app::agent_view::AgentView>,
|
||||
) -> Vec<crate::app::agent::AgentId> {
|
||||
let home = render::cached_home();
|
||||
let rows = build_rows(
|
||||
agents,
|
||||
&state.pinned,
|
||||
&state.reorder,
|
||||
None,
|
||||
state.grouping,
|
||||
&state.filter,
|
||||
home,
|
||||
);
|
||||
rows.iter()
|
||||
.filter_map(|r| match &r.id {
|
||||
DashboardRowId::TopLevel(id) if !r.is_more_placeholder => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the dashboard feature is enabled.
|
||||
///
|
||||
/// Order: env override (`GROK_AGENT_DASHBOARD=0` → off) wins, else the
|
||||
/// persisted `[dashboard].enabled` flag (default `true`).
|
||||
///
|
||||
/// The slash command and CLI subcommand check this before opening; on
|
||||
/// `false` they print a friendly toast and stay where they are.
|
||||
///
|
||||
/// `var_os` avoids the per-call allocation of `var`.
|
||||
pub fn dashboard_enabled() -> bool {
|
||||
if std::env::var_os("GROK_AGENT_DASHBOARD")
|
||||
.as_deref()
|
||||
.is_some_and(|v| v == std::ffi::OsStr::new("0"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
state::load_persisted_enabled().unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Command to name in the "use /X to switch between sessions" session
|
||||
/// banners (the `/new` session-created banner and the fork marker).
|
||||
///
|
||||
/// Minimal mode has no dashboard — `/dashboard` is refused there — but the
|
||||
/// `/resume` session picker still works, so point at it instead (regardless
|
||||
/// of the dashboard flag, which gates a surface minimal doesn't have).
|
||||
/// Outside minimal, `/dashboard` when the feature is enabled; `None` when it
|
||||
/// is off — the tip would point at a refused command, so callers fall back
|
||||
/// to a plain session-id banner.
|
||||
pub(crate) fn session_switch_hint_command(minimal: bool) -> Option<&'static str> {
|
||||
if minimal {
|
||||
Some("/resume")
|
||||
} else if dashboard_enabled() {
|
||||
Some("/dashboard")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal mode always points at `/resume`: the dashboard is refused
|
||||
/// there no matter what the feature flag says, so the hint must not
|
||||
/// depend on it. Runs under the same serial key as the other
|
||||
/// `GROK_AGENT_DASHBOARD` env-mutating tests.
|
||||
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn switch_hint_minimal_is_resume_even_with_dashboard_disabled() {
|
||||
// SAFETY: the test temporarily mutates a process-wide env var.
|
||||
// `serial_test`'s lock ensures no other test marked with the same
|
||||
// `GROK_AGENT_DASHBOARD` key reads it concurrently.
|
||||
unsafe { std::env::set_var("GROK_AGENT_DASHBOARD", "0") };
|
||||
assert_eq!(session_switch_hint_command(true), Some("/resume"));
|
||||
unsafe { std::env::remove_var("GROK_AGENT_DASHBOARD") };
|
||||
}
|
||||
|
||||
/// Outside minimal the hint mirrors the dashboard flag: `None` when the
|
||||
/// env override disables it (the tip would name a refused command),
|
||||
/// otherwise whatever `dashboard_enabled()` says — asserted as
|
||||
/// consistency, not a fixed value, so the test doesn't depend on the
|
||||
/// machine's persisted `[dashboard].enabled`.
|
||||
#[serial_test::serial(GROK_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn switch_hint_non_minimal_follows_dashboard_flag() {
|
||||
// SAFETY: see above — serialized on the GROK_AGENT_DASHBOARD key.
|
||||
unsafe { std::env::set_var("GROK_AGENT_DASHBOARD", "0") };
|
||||
assert_eq!(session_switch_hint_command(false), None);
|
||||
unsafe { std::env::remove_var("GROK_AGENT_DASHBOARD") };
|
||||
assert_eq!(
|
||||
session_switch_hint_command(false),
|
||||
dashboard_enabled().then_some("/dashboard")
|
||||
);
|
||||
}
|
||||
}
|
||||
2631
crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs
Normal file
2631
crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs
Normal file
File diff suppressed because it is too large
Load diff
8520
crates/codegen/xai-grok-pager/src/views/dashboard/render.rs
Normal file
8520
crates/codegen/xai-grok-pager/src/views/dashboard/render.rs
Normal file
File diff suppressed because it is too large
Load diff
2029
crates/codegen/xai-grok-pager/src/views/dashboard/row.rs
Normal file
2029
crates/codegen/xai-grok-pager/src/views/dashboard/row.rs
Normal file
File diff suppressed because it is too large
Load diff
10263
crates/codegen/xai-grok-pager/src/views/dashboard/state.rs
Normal file
10263
crates/codegen/xai-grok-pager/src/views/dashboard/state.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue