M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, 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 (`KIGI_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("KIGI_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
|
||||
/// `KIGI_AGENT_DASHBOARD` env-mutating tests.
|
||||
#[serial_test::serial(KIGI_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
|
||||
// `KIGI_AGENT_DASHBOARD` key reads it concurrently.
|
||||
unsafe { std::env::set_var("KIGI_AGENT_DASHBOARD", "0") };
|
||||
assert_eq!(session_switch_hint_command(true), Some("/resume"));
|
||||
unsafe { std::env::remove_var("KIGI_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(KIGI_AGENT_DASHBOARD)]
|
||||
#[test]
|
||||
fn switch_hint_non_minimal_follows_dashboard_flag() {
|
||||
// SAFETY: see above — serialized on the KIGI_AGENT_DASHBOARD key.
|
||||
unsafe { std::env::set_var("KIGI_AGENT_DASHBOARD", "0") };
|
||||
assert_eq!(session_switch_hint_command(false), None);
|
||||
unsafe { std::env::remove_var("KIGI_AGENT_DASHBOARD") };
|
||||
assert_eq!(
|
||||
session_switch_hint_command(false),
|
||||
dashboard_enabled().then_some("/dashboard")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user