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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,688 @@
//! Minimal-mode below-prompt **list panels**: `/resume` (session picker) and
//! `/mcps` (MCP server status), rendered as simple lists *below the input bar*
//! instead of centered modal windows (design nit: "the mcps / resume lists
//! should not be in a modal").
//!
//! ## Why this is a render-only change
//!
//! Input routing is unchanged — the existing `handle_modal_key`
//! (`ActiveModal::SessionPicker`) and `handle_extensions_modal_key`
//! (`extensions_modal`) own navigation and close-on-Esc. Two different coupling
//! contracts are honored here:
//!
//! * **Session picker** rebuilds its entry map from data on every keypress
//! (render-independent), so we just reuse the *same* builders
//! ([`build_grouped_picker_entries`]) — the rendered order then matches the
//! handler's `selected`.
//! * **Extensions modal** reads render-stored state (`entry_data_indices`,
//! `entry_group_keys`, `entry_non_selectable*`). The MCP renderer repopulates
//! those exactly as the full modal does (via the shared
//! [`build_mcp_servers_picker_rows`]), so keyboard nav + section fold stay in
//! sync without touching the input handler.
//!
//! Both reuse [`picker::render_picker_content`] for the rows, so row look +
//! selection highlight match the full TUI; only the modal-window chrome (border,
//! tabs, footer bar) is dropped.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use kigi_tui::app::agent_view::AgentView;
use kigi_tui::minimal_api;
use kigi_tui::theme::Theme;
use kigi_tui::views::extensions_modal::{ExtensionsTab, TabDataState};
use kigi_tui::views::modal::ActiveModal;
use kigi_tui::views::picker::{self, PickerEntry, PickerField, PickerHitAreas, PickerRow};
/// Rows of chrome around the scrolling list: title + subtitle/search + divider
/// + footer.
const CHROME_ROWS: u16 = 4;
/// Which below-prompt list panel is active for the focused agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ListPanel {
/// `/resume` session picker (`ActiveModal::SessionPicker`).
Resume,
/// `/mcps` MCP server status (extensions modal on the McpServers tab).
Mcps,
}
/// Detect an active below-prompt list panel, or `None`.
///
/// Only the session picker and the MCP-servers tab are hosted as simple lists;
/// every other modal keeps its existing (centered) rendering. Callers must check
/// this *before* `overlay::app_modal_active`, since `SessionPicker` is also an
/// `active_modal`.
pub(super) fn active(agent: &AgentView) -> Option<ListPanel> {
if matches!(agent.active_modal, Some(ActiveModal::SessionPicker { .. })) {
return Some(ListPanel::Resume);
}
if minimal_api::extensions_modal(agent)
.is_some_and(|s| s.active_tab == ExtensionsTab::McpServers)
{
return Some(ListPanel::Mcps);
}
None
}
/// Target viewport height for the active list panel: chrome + the exact body
/// height, clamped to `[CHROME_ROWS + 1, ceiling]`. Sizing to the exact content
/// height keeps the footer directly under the last row (no blank band); when the
/// body exceeds `ceiling` the list scrolls internally.
pub(super) fn panel_height(agent: &AgentView, kind: ListPanel, width: u16, ceiling: u16) -> u16 {
let body = match kind {
ListPanel::Resume => resume_body_rows(agent, width),
ListPanel::Mcps => mcps_body_rows(agent),
};
CHROME_ROWS
.saturating_add(body)
.clamp(CHROME_ROWS + 1, ceiling.max(CHROME_ROWS + 1))
}
/// Render the active list panel into `area` (the whole live region). Returns the
/// text cursor for the panel's search bar when search is focused, else `None`.
pub(super) fn render(
buf: &mut Buffer,
area: Rect,
agent: &mut AgentView,
kind: ListPanel,
theme: &Theme,
) -> Option<(u16, u16)> {
if area.height < 2 || area.width < 8 {
return None;
}
match kind {
ListPanel::Resume => render_resume(buf, area, agent, theme),
ListPanel::Mcps => render_mcps(buf, area, agent, theme),
}
}
// ─────────────────────────────── chrome ─────────────────────────────────────
/// Split `area` into (title_row, second_row, divider_row, list_area, footer_row).
/// `second_row` hosts the subtitle (mcps) or the search bar (resume).
fn chrome_layout(area: Rect) -> (Rect, Rect, Rect, Rect, Rect) {
let row = |dy: u16| Rect {
x: area.x,
y: area.y + dy,
width: area.width,
height: 1,
};
let title = row(0);
let second = row(1);
let divider = row(2);
let footer = Rect {
x: area.x,
y: area.y + area.height - 1,
..row(0)
};
let list = Rect {
x: area.x,
y: area.y + 3,
width: area.width,
height: area.height.saturating_sub(CHROME_ROWS),
};
(title, second, divider, list, footer)
}
fn render_title(buf: &mut Buffer, row: Rect, theme: &Theme, title: &str) {
buf.set_style(row, Style::default().bg(Color::Reset));
let style = Style::default()
.fg(theme.accent_user)
.bg(Color::Reset)
.add_modifier(Modifier::BOLD);
buf.set_span(row.x + 1, row.y, &Span::styled(title, style), row.width);
}
fn render_dim_line(buf: &mut Buffer, row: Rect, theme: &Theme, text: &str) {
buf.set_style(row, Style::default().bg(Color::Reset));
let style = theme.dim().bg(Color::Reset);
buf.set_span(row.x + 1, row.y, &Span::styled(text, style), row.width);
}
/// `/resume` session picker: Enter picks a session.
const RESUME_FOOTER: &str = "\u{2191}/\u{2193} navigate \u{00b7} enter confirm \u{00b7} esc cancel";
/// `/mcps` list: Enter expands tools; reconnect is space (off then on); `r` re-lists status.
const MCPS_FOOTER: &str = "\u{2191}/\u{2193} navigate \u{00b7} space enable/disable \u{00b7} r refresh \u{00b7} enter expand \u{00b7} esc cancel";
fn render_footer(buf: &mut Buffer, row: Rect, theme: &Theme, text: &str) {
render_dim_line(buf, row, theme, text);
}
fn render_divider(buf: &mut Buffer, row: Rect, theme: &Theme) {
picker::render_divider(buf, row.x, row.y, row.width, theme, None);
}
// ─────────────────────────────── resume ─────────────────────────────────────
/// Exact body height (display rows) for the session-picker list.
fn resume_body_rows(agent: &AgentView, width: u16) -> u16 {
let Some(ActiveModal::SessionPicker {
entries,
state,
source_filter,
..
}) = &agent.active_modal
else {
return 0;
};
let entries_data = entries.as_deref().unwrap_or(&[]);
let content_width = width.saturating_sub(2);
let filtered =
minimal_api::filter_session_entries(entries.as_deref(), &state.query, *source_filter);
let built =
minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width);
let fields_vecs: Vec<Vec<PickerField>> = built
.iter()
.map(|b| {
b.field_data
.iter()
.map(|(l, v)| PickerField { label: l, value: v })
.collect()
})
.collect();
let current_repo = minimal_api::repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
let (picker_entries, _) = minimal_api::build_grouped_picker_entries(
entries_data,
&filtered,
&built,
&fields_vecs,
state,
Some(current_repo.as_str()),
);
measure_entries(&picker_entries)
}
fn render_resume(
buf: &mut Buffer,
area: Rect,
agent: &mut AgentView,
theme: &Theme,
) -> Option<(u16, u16)> {
let cwd = agent.session.cwd.to_string_lossy().to_string();
let Some(ActiveModal::SessionPicker {
entries,
state,
source_filter,
..
}) = &mut agent.active_modal
else {
return None;
};
let (title_row, search_row, divider_row, list_area, footer_row) = chrome_layout(area);
let entries_data = entries.as_deref().unwrap_or(&[]);
let content_width = area.width.saturating_sub(2);
let filtered =
minimal_api::filter_session_entries(entries.as_deref(), &state.query, *source_filter);
let built =
minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width);
let fields_vecs: Vec<Vec<PickerField>> = built
.iter()
.map(|b| {
b.field_data
.iter()
.map(|(l, v)| PickerField { label: l, value: v })
.collect()
})
.collect();
let current_repo = minimal_api::repo_name_from_cwd(&cwd);
let (picker_entries, non_sel) = minimal_api::build_grouped_picker_entries(
entries_data,
&filtered,
&built,
&fields_vecs,
state,
Some(current_repo.as_str()),
);
render_title(buf, title_row, theme, "Resume session");
// Focus-aware search bar (cursor only when search is focused).
picker::render_search_bar(
buf,
search_row.x + 1,
search_row.y,
search_row.width.saturating_sub(1),
theme,
&state.query,
state.search_active,
true,
state.query_cursor,
None,
);
render_divider(buf, divider_row, theme);
let nsc = vec![false; picker_entries.len()];
let hit = picker::render_picker_content(
buf,
list_area,
theme,
state,
&picker_entries,
&non_sel,
&nsc,
None,
false,
);
state.hit_areas = Some(PickerHitAreas {
close_button: Rect::default(),
search_bar: search_row,
item_rects: hit.item_rects,
entry_indices: hit.entry_indices,
tab_rects: vec![],
filter_rect: None,
});
render_footer(buf, footer_row, theme, RESUME_FOOTER);
None
}
// ──────────────────────────────── mcps ──────────────────────────────────────
/// Exact body height (display rows) for the MCP list: one line per row.
fn mcps_body_rows(agent: &AgentView) -> u16 {
let Some(s) = minimal_api::extensions_modal(agent) else {
return 0;
};
let servers = match &s.mcps_data {
TabDataState::Loaded(v) => v.as_slice(),
_ => return 1, // a single "loading…" / error row
};
let rows = minimal_api::build_mcp_picker_rows(
servers,
&s.picker_state.query,
s.mcps_filter,
&s.mcps_collapsed_sections,
&s.mcps_tools_expanded,
);
rows.0.len() as u16
}
fn render_mcps(
buf: &mut Buffer,
area: Rect,
agent: &mut AgentView,
theme: &Theme,
) -> Option<(u16, u16)> {
let (title_row, subtitle_row, divider_row, list_area, footer_row) = chrome_layout(area);
render_title(buf, title_row, theme, "Manage MCP servers");
// Phase 1 (immutable): build the row mapping + owned per-row render data.
let labels: Vec<String>;
let group_keys: Vec<Option<String>>;
let data_indices: Vec<Option<usize>>;
let badges: Vec<String>;
let badge_colors: Vec<Option<Color>>;
let right_labels: Vec<String>;
let indents: Vec<u8>;
let collapsibles: Vec<bool>;
let expandeds: Vec<bool>;
let subtitle: String;
let loading;
{
let s = minimal_api::extensions_modal(agent)?;
let searching = !s.picker_state.query.is_empty();
loading = matches!(s.mcps_data, TabDataState::Loading);
match &s.mcps_data {
TabDataState::Loaded(servers) => {
let (row_labels, row_group_keys, row_data_indices) =
minimal_api::build_mcp_picker_rows(
servers,
&s.picker_state.query,
s.mcps_filter,
&s.mcps_collapsed_sections,
&s.mcps_tools_expanded,
);
let n = row_labels.len();
let mut b = vec![String::new(); n];
let mut bc: Vec<Option<Color>> = vec![None; n];
let mut rl = vec![String::new(); n];
let mut ind = vec![0u8; n];
let mut col = vec![false; n];
let mut exp = vec![false; n];
for i in 0..n {
let gk = row_group_keys[i].as_deref();
if gk.is_some_and(|k| k.starts_with("mcp-section:")) {
col[i] = true;
exp[i] = !minimal_api::mcp_section_children_hidden(
&s.mcps_collapsed_sections,
gk.unwrap(),
searching,
);
} else if gk.is_some_and(|k| k.starts_with("mcp-tools:")) {
ind[i] = 1;
col[i] = true;
if let Some(si) = row_data_indices[i] {
exp[i] = s.mcps_tools_expanded.contains(&si);
if let Some(srv) = servers.get(si) {
if !srv.enabled {
b[i] = "disabled".to_string();
bc[i] = Some(theme.accent_error);
} else {
b[i] = minimal_api::mcp_status_label(&srv.status).to_string();
bc[i] = Some(minimal_api::mcp_status_theme_color(
&srv.status,
theme,
));
}
rl[i] = if srv.tool_count == 1 {
"1 tool".to_string()
} else {
format!("{} tools", srv.tool_count)
};
}
}
} else {
ind[i] = 2; // tool child
}
}
subtitle = format!(
"{} server{}",
servers.len(),
if servers.len() == 1 { "" } else { "s" }
);
labels = row_labels;
group_keys = row_group_keys;
data_indices = row_data_indices;
badges = b;
badge_colors = bc;
right_labels = rl;
indents = ind;
collapsibles = col;
expandeds = exp;
}
TabDataState::Loading => {
subtitle = "loading\u{2026}".to_string();
labels = vec![];
group_keys = vec![];
data_indices = vec![];
badges = vec![];
badge_colors = vec![];
right_labels = vec![];
indents = vec![];
collapsibles = vec![];
expandeds = vec![];
}
TabDataState::Error(msg) => {
subtitle = format!("error: {msg}");
labels = vec![];
group_keys = vec![];
data_indices = vec![];
badges = vec![];
badge_colors = vec![];
right_labels = vec![];
indents = vec![];
collapsibles = vec![];
expandeds = vec![];
}
}
}
let n = labels.len();
render_dim_line(buf, subtitle_row, theme, &subtitle);
render_divider(buf, divider_row, theme);
// Phase 2 (mutable): mirror the row mapping onto state for the input handler.
{
let s = minimal_api::extensions_modal_mut(agent)?;
s.entry_data_indices = data_indices;
s.entry_group_keys = group_keys;
s.entry_labels_cache = labels.clone();
s.entry_non_selectable = vec![false; n];
s.entry_non_selectable_clickable = vec![false; n];
if n == 0 {
s.picker_state.selected = 0;
} else if s.picker_state.selected >= n {
s.picker_state.selected = n - 1;
}
}
// Phase 3 (mutable picker_state): build PickerEntry from owned data + render.
let s = minimal_api::extensions_modal_mut(agent)?;
let selected = s.picker_state.selected;
let search_active = s.picker_state.search_active;
let empty_fields: [PickerField; 0] = [];
let no_lines: [&str; 0] = [];
let entries: Vec<PickerEntry> = (0..n)
.map(|i| {
PickerEntry::Row(PickerRow {
label: labels[i].as_str(),
right_label: right_labels[i].as_str(),
selected: !search_active && i == selected,
expanded: expandeds[i],
fields: &empty_fields,
description_lines: &no_lines,
summary_lines: &no_lines,
dimmed: false,
indent: indents[i],
badge: badges[i].as_str(),
badge_color: badge_colors[i],
collapsible: collapsibles[i],
underline_last_desc: false,
})
})
.collect();
let non_sel = vec![false; n];
let hit = picker::render_picker_content(
buf,
list_area,
theme,
&mut s.picker_state,
&entries,
&non_sel,
&non_sel,
None,
loading,
);
s.picker_state.hit_areas = Some(PickerHitAreas {
close_button: Rect::default(),
search_bar: Rect::default(),
item_rects: hit.item_rects,
entry_indices: hit.entry_indices,
tab_rects: vec![],
filter_rect: None,
});
render_footer(buf, footer_row, theme, MCPS_FOOTER);
None
}
// ─────────────────────────────── helpers ────────────────────────────────────
/// Sum the display height of grouped picker entries: a header is one row; a row
/// is its label line plus its collapsed summary lines (what the picker draws
/// when the row is not expanded).
fn measure_entries(entries: &[PickerEntry<'_>]) -> u16 {
entries
.iter()
.map(|e| match e {
PickerEntry::Header { .. } => 1u16,
PickerEntry::Row(r) => {
if r.expanded {
1u16.saturating_add(r.description_lines.len() as u16)
.saturating_add(r.fields.len() as u16)
} else {
1u16.saturating_add(r.summary_lines.len() as u16)
}
}
})
.fold(0u16, |acc, h| acc.saturating_add(h))
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_tui::views::extensions_modal::ExtensionsModalState;
use kigi_tui::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo, McpWireSource};
use ratatui::layout::Rect;
fn agent() -> AgentView {
minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp/repo"))
}
fn mcp_server(name: &str, status: McpServerDisplayStatus, tools: usize) -> McpServerInfo {
McpServerInfo {
name: name.to_string(),
display_name: None,
status,
tool_count: tools,
auth_required: false,
tools: Vec::new(),
enabled: true,
source: "local".to_string(),
wire_source: McpWireSource::Local,
plugin_name: None,
is_managed_gateway: false,
}
}
fn with_mcps(servers: Vec<McpServerInfo>) -> AgentView {
let mut a = agent();
minimal_api::set_extensions_modal(
&mut a,
Some(ExtensionsModalState {
active_tab: ExtensionsTab::McpServers,
mcps_data: TabDataState::Loaded(servers),
..Default::default()
}),
);
a
}
fn session_entry(id: &str) -> kigi_tui::app::app_view::SessionPickerEntry {
kigi_tui::app::app_view::SessionPickerEntry {
id: id.into(),
summary: id.into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: "/tmp/repo".into(),
hostname: None,
source: String::new(),
model_id: None,
num_messages: 0,
last_active_at: None,
branch: None,
repo_name: "repo".into(),
worktree_label: None,
card_detail: None,
}
}
fn with_resume(entries: Vec<kigi_tui::app::app_view::SessionPickerEntry>) -> AgentView {
let mut a = agent();
a.active_modal = Some(ActiveModal::SessionPicker {
state: picker::PickerState::default(),
entries: Some(entries),
loading: false,
lanes: Default::default(),
previous_palette: None,
window: kigi_tui::views::modal_window::ModalWindowState::new(),
content_results: None,
content_loading: false,
deep_search_seq: 0,
source_filter: kigi_tui::views::session_picker::SourceFilter::default(),
pending_delete: None,
entries_query: None,
});
a
}
fn buffer_text(buf: &Buffer) -> String {
let area = buf.area;
let mut out = String::new();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
out.push_str(buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "));
}
out.push('\n');
}
out
}
#[test]
fn active_detects_resume_mcps_and_none() {
assert_eq!(active(&agent()), None);
assert_eq!(
active(&with_mcps(vec![mcp_server(
"alpha",
McpServerDisplayStatus::Ready,
3
)])),
Some(ListPanel::Mcps)
);
assert_eq!(
active(&with_resume(vec![session_entry("hello")])),
Some(ListPanel::Resume)
);
}
#[test]
fn mcps_panel_renders_list_and_mirrors_handler_state() {
let mut a = with_mcps(vec![
mcp_server("alpha", McpServerDisplayStatus::Ready, 3),
mcp_server("bravo", McpServerDisplayStatus::Unavailable, 0),
]);
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
render(&mut buf, area, &mut a, ListPanel::Mcps, &theme);
let text = buffer_text(&buf);
assert!(text.contains("Manage MCP servers"), "title:\n{text}");
assert!(text.contains("2 servers"), "subtitle:\n{text}");
assert!(text.contains("alpha"), "server row:\n{text}");
assert!(text.contains("bravo"), "server row:\n{text}");
assert!(text.contains("space enable/disable"), "footer:\n{text}");
assert!(text.contains("r refresh"), "footer:\n{text}");
assert!(text.contains("enter expand"), "footer:\n{text}");
assert!(
!text.contains("enter confirm"),
"MCP footer must not reuse resume confirm copy:\n{text}"
);
// The input handler reads these render-stored fields; the panel must
// mirror them (section header + 2 servers = 3 rows) so keyboard nav and
// fold stay correct without touching the handler.
let s = minimal_api::extensions_modal(&a).unwrap();
assert_eq!(s.entry_data_indices.len(), 3, "section + 2 servers");
assert_eq!(
s.entry_data_indices.iter().filter(|d| d.is_some()).count(),
2,
"two selectable server rows map to catalog indices"
);
assert_eq!(s.entry_non_selectable.len(), 3);
}
#[test]
fn resume_panel_renders_title_rows_and_footer() {
let mut a = with_resume(vec![session_entry("first task"), session_entry("second")]);
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
render(&mut buf, area, &mut a, ListPanel::Resume, &theme);
let text = buffer_text(&buf);
assert!(text.contains("Resume session"), "title:\n{text}");
assert!(text.contains("first task"), "session row:\n{text}");
assert!(text.contains("enter confirm"), "resume footer:\n{text}");
assert!(
!text.contains("r refresh"),
"resume footer must stay session-picker copy:\n{text}"
);
}
#[test]
fn mcps_panel_height_is_chrome_plus_rows() {
// One section header + 2 server rows = 3 body rows; + 4 chrome = 7.
let a = with_mcps(vec![
mcp_server("alpha", McpServerDisplayStatus::Ready, 1),
mcp_server("bravo", McpServerDisplayStatus::Ready, 2),
]);
assert_eq!(panel_height(&a, ListPanel::Mcps, 80, 40), 7);
// Clamps to the screen ceiling when content is taller.
assert_eq!(panel_height(&a, ListPanel::Mcps, 80, 5), 5);
}
}