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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user