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,414 @@
|
||||
//! Dropdown renderer for shell command completion suggestions.
|
||||
//!
|
||||
//! Mirrors `slash_dropdown.rs` layout: aligned label column, description,
|
||||
//! selection highlight, mouse hover, and scrollbar when items exceed
|
||||
//! `MAX_VISIBLE_ROWS`.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::render::SafeBuf;
|
||||
use crate::render::line_utils::truncate_str;
|
||||
use crate::render::scrollbar::render_scrollbar_styled;
|
||||
use crate::theme::Theme;
|
||||
use crate::views::suggestion_controller::{CompletionDropdownState, CompletionItemParsed};
|
||||
|
||||
/// Maximum visible rows in the completion dropdown.
|
||||
pub const MAX_VISIBLE_ROWS: u16 = 6;
|
||||
|
||||
/// Hard cap on label column width.
|
||||
const LABEL_CAP: usize = 40;
|
||||
|
||||
/// Gap between label and description columns.
|
||||
const LABEL_DESC_GAP: usize = 2;
|
||||
|
||||
/// Prefix width (`"❯ "` or `" "`).
|
||||
const PREFIX_W: usize = 2;
|
||||
|
||||
/// Height needed for the dropdown (separator + items), or 0 when hidden.
|
||||
pub fn dropdown_height(state: &CompletionDropdownState) -> u16 {
|
||||
if !state.open || state.items.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let item_rows = (state.items.len() as u16).min(MAX_VISIBLE_ROWS);
|
||||
1 + item_rows // separator + items
|
||||
}
|
||||
|
||||
/// Compute the scroll offset so the selected row stays centred.
|
||||
pub fn scroll_offset(state: &CompletionDropdownState) -> usize {
|
||||
let total = state.items.len();
|
||||
let visible = MAX_VISIBLE_ROWS as usize;
|
||||
let selected = state.selected.min(total.saturating_sub(1));
|
||||
if total <= visible || selected < visible / 2 {
|
||||
0
|
||||
} else if selected + visible / 2 >= total {
|
||||
total.saturating_sub(visible)
|
||||
} else {
|
||||
selected.saturating_sub(visible / 2)
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_label_column_w(items: &[CompletionItemParsed], content_w: usize) -> usize {
|
||||
let budget = (content_w * 3 / 5).min(LABEL_CAP);
|
||||
let max_w = items
|
||||
.iter()
|
||||
.map(|r| r.display.width())
|
||||
.filter(|&w| w <= LABEL_CAP)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
max_w.min(budget)
|
||||
}
|
||||
|
||||
/// Render completion dropdown items into `area` (no borders — caller draws chrome).
|
||||
pub fn render_dropdown(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
state: &CompletionDropdownState,
|
||||
theme: &Theme,
|
||||
) {
|
||||
if area.height == 0 || area.width < 4 || !state.open {
|
||||
return;
|
||||
}
|
||||
|
||||
let items = &state.items;
|
||||
let selected = state.selected.min(items.len().saturating_sub(1));
|
||||
let hovered = state.hovered;
|
||||
|
||||
let content_w = area.width as usize;
|
||||
let visible_rows = area.height as usize;
|
||||
let needs_scrollbar = items.len() > visible_rows;
|
||||
let row_w = if needs_scrollbar {
|
||||
content_w.saturating_sub(2)
|
||||
} else {
|
||||
content_w
|
||||
};
|
||||
|
||||
let label_col_w = compute_label_column_w(items, row_w.saturating_sub(PREFIX_W));
|
||||
|
||||
let scroll = scroll_offset(state);
|
||||
|
||||
for vis_row in 0..visible_rows {
|
||||
let item_idx = scroll + vis_row;
|
||||
if item_idx >= items.len() {
|
||||
break;
|
||||
}
|
||||
let item = &items[item_idx];
|
||||
let y = area.y + vis_row as u16;
|
||||
let is_selected = item_idx == selected;
|
||||
let is_hovered = hovered == Some(item_idx) && !is_selected;
|
||||
|
||||
let row_bg = match crate::views::modal_window::embedded_row_style(theme, is_selected) {
|
||||
Some(e) => e.bg,
|
||||
None if is_selected => theme.bg_visual,
|
||||
None if is_hovered => theme.bg_hover,
|
||||
None => theme.bg_light,
|
||||
};
|
||||
|
||||
let line = build_item_line(item, is_selected, label_col_w, row_w, row_bg, theme);
|
||||
|
||||
// Skip rows that fall outside the buffer (resize race).
|
||||
if y < buf.area.y || y >= buf.area.bottom() || area.x >= buf.area.right() {
|
||||
continue;
|
||||
}
|
||||
let clamped_w = row_w.min(buf.area.right().saturating_sub(area.x) as usize) as u16;
|
||||
let row_rect = Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
width: clamped_w,
|
||||
height: 1,
|
||||
};
|
||||
buf.set_style(row_rect, Style::default().bg(row_bg));
|
||||
buf.set_line_safe(area.x, y, &line, row_w as u16);
|
||||
}
|
||||
|
||||
if needs_scrollbar {
|
||||
let sb_x = area.x + area.width.saturating_sub(1);
|
||||
let sb_y = area.y.max(buf.area.y);
|
||||
let sb_bottom = (area.y.saturating_add(area.height)).min(buf.area.bottom());
|
||||
if sb_x < buf.area.right() && sb_bottom > sb_y {
|
||||
let sb_area = Rect {
|
||||
x: sb_x,
|
||||
y: sb_y,
|
||||
width: 1,
|
||||
height: sb_bottom - sb_y,
|
||||
};
|
||||
let track = Style::default().bg(theme.bg_dark);
|
||||
let thumb = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
|
||||
render_scrollbar_styled(
|
||||
buf,
|
||||
Some(sb_area),
|
||||
items.len() as u16,
|
||||
sb_area.height,
|
||||
scroll as u16,
|
||||
track,
|
||||
thumb,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_item_line(
|
||||
item: &CompletionItemParsed,
|
||||
is_selected: bool,
|
||||
label_col_w: usize,
|
||||
total_w: usize,
|
||||
row_bg: ratatui::style::Color,
|
||||
theme: &Theme,
|
||||
) -> Line<'static> {
|
||||
let bold = if is_selected {
|
||||
Modifier::BOLD
|
||||
} else {
|
||||
Modifier::empty()
|
||||
};
|
||||
let embed = crate::views::modal_window::embedded_row_style(theme, is_selected);
|
||||
let primary_fg = embed.map_or(theme.text_primary, |e| e.fg(theme.text_primary));
|
||||
let desc_fg = embed.map_or(theme.gray, |e| e.fg(theme.gray));
|
||||
let normal = Style::default()
|
||||
.fg(primary_fg)
|
||||
.bg(row_bg)
|
||||
.add_modifier(bold);
|
||||
let desc_style = Style::default().fg(desc_fg).bg(row_bg);
|
||||
let bg_style = Style::default().bg(row_bg);
|
||||
|
||||
let prefix = if is_selected {
|
||||
crate::glyphs::prompt_arrow()
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let prefix_span = Span::styled(
|
||||
prefix.to_string(),
|
||||
if is_selected { normal } else { bg_style },
|
||||
);
|
||||
|
||||
let label = truncate_str(&item.display, label_col_w);
|
||||
let label_w = label.width();
|
||||
let padding = label_col_w.saturating_sub(label_w);
|
||||
|
||||
let label_span = Span::styled(label, normal);
|
||||
|
||||
let desc_indent = PREFIX_W + label_col_w + LABEL_DESC_GAP;
|
||||
let desc_w = total_w.saturating_sub(desc_indent).max(1);
|
||||
let desc = truncate_str(&item.description, desc_w);
|
||||
|
||||
let mut spans = vec![prefix_span, label_span];
|
||||
if padding > 0 {
|
||||
spans.push(Span::styled(" ".repeat(padding), bg_style));
|
||||
}
|
||||
if !desc.is_empty() {
|
||||
spans.push(Span::styled(" ".to_string(), bg_style));
|
||||
spans.push(Span::styled(desc, desc_style));
|
||||
}
|
||||
|
||||
Line::from(spans).style(bg_style)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::views::suggestion_controller::SuggestionSource;
|
||||
|
||||
fn make_item(display: &str, desc: &str, insert: &str) -> CompletionItemParsed {
|
||||
CompletionItemParsed {
|
||||
display: display.into(),
|
||||
description: desc.into(),
|
||||
insert_text: insert.into(),
|
||||
source: SuggestionSource::History,
|
||||
priority: 0,
|
||||
replace_range: None,
|
||||
token_text: None,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_zero_when_closed() {
|
||||
let state = CompletionDropdownState::default();
|
||||
assert_eq!(dropdown_height(&state), 0);
|
||||
}
|
||||
|
||||
/// Items area past buffer bottom must not panic on resize races.
|
||||
#[test]
|
||||
fn render_dropdown_past_buffer_bottom_does_not_panic() {
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
let theme = Theme::current();
|
||||
let items: Vec<CompletionItemParsed> = (0..12)
|
||||
.map(|i| make_item(&format!("item{i}"), "desc", &format!("item{i}")))
|
||||
.collect();
|
||||
let state = CompletionDropdownState {
|
||||
open: true,
|
||||
items,
|
||||
selected: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||||
let area = Rect::new(2, 8, 76, 8);
|
||||
render_dropdown(&mut buf, area, &state, &theme);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_zero_when_empty() {
|
||||
let state = CompletionDropdownState {
|
||||
open: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(dropdown_height(&state), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_with_items() {
|
||||
let state = CompletionDropdownState {
|
||||
open: true,
|
||||
items: vec![
|
||||
make_item("ls", "list", "ls"),
|
||||
make_item("cd", "change dir", "cd"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(dropdown_height(&state), 3); // 1 separator + 2 items
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_capped_at_max() {
|
||||
let items: Vec<_> = (0..20)
|
||||
.map(|i| make_item(&format!("cmd{i}"), "", &format!("cmd{i}")))
|
||||
.collect();
|
||||
let state = CompletionDropdownState {
|
||||
open: true,
|
||||
items,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(dropdown_height(&state), 1 + MAX_VISIBLE_ROWS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_selection_wraps() {
|
||||
let mut state = CompletionDropdownState {
|
||||
open: true,
|
||||
items: vec![
|
||||
make_item("a", "", "a"),
|
||||
make_item("b", "", "b"),
|
||||
make_item("c", "", "c"),
|
||||
],
|
||||
selected: 0,
|
||||
..Default::default()
|
||||
};
|
||||
state.move_selection(-1);
|
||||
assert_eq!(state.selected, 2);
|
||||
state.move_selection(1);
|
||||
assert_eq!(state.selected, 0);
|
||||
state.move_selection(1);
|
||||
assert_eq!(state.selected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_selection_clamps_at_edges() {
|
||||
let mut state = CompletionDropdownState {
|
||||
open: true,
|
||||
items: vec![
|
||||
make_item("a", "", "a"),
|
||||
make_item("b", "", "b"),
|
||||
make_item("c", "", "c"),
|
||||
],
|
||||
selected: 0,
|
||||
..Default::default()
|
||||
};
|
||||
// Scrolling up at the first item stays put (no wrap).
|
||||
state.scroll_selection(-1);
|
||||
assert_eq!(state.selected, 0);
|
||||
state.scroll_selection(1);
|
||||
assert_eq!(state.selected, 1);
|
||||
// Scrolling down at the last item stays put (no wrap).
|
||||
state.scroll_selection(1);
|
||||
assert_eq!(state.selected, 2);
|
||||
state.scroll_selection(1);
|
||||
assert_eq!(state.selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_returns_item_and_closes() {
|
||||
let mut state = CompletionDropdownState {
|
||||
open: true,
|
||||
items: vec![make_item("ls -la", "list all", "ls -la /tmp")],
|
||||
selected: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let item = state.accept().expect("selected item accepted");
|
||||
assert_eq!(item.insert_text, "ls -la /tmp");
|
||||
assert!(!state.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_without_items_returns_none() {
|
||||
let mut state = CompletionDropdownState::default();
|
||||
assert!(state.accept().is_none());
|
||||
}
|
||||
|
||||
/// `accept` is independent of the `open` render flag: the
|
||||
/// single-candidate insta-accept consumes an item that was never shown.
|
||||
#[test]
|
||||
fn accept_works_on_closed_dropdown_with_items() {
|
||||
let mut state = CompletionDropdownState {
|
||||
open: false,
|
||||
items: vec![make_item("ls", "", "ls -la")],
|
||||
..Default::default()
|
||||
};
|
||||
let item = state.accept().expect("item accepted while closed");
|
||||
assert_eq!(item.insert_text, "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_offset_no_scroll_needed() {
|
||||
let state = CompletionDropdownState {
|
||||
open: true,
|
||||
items: vec![make_item("a", "", "a")],
|
||||
selected: 0,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(scroll_offset(&state), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_offset_centres_selected() {
|
||||
let items: Vec<_> = (0..20)
|
||||
.map(|i| make_item(&format!("c{i}"), "", &format!("c{i}")))
|
||||
.collect();
|
||||
let state = CompletionDropdownState {
|
||||
open: true,
|
||||
items,
|
||||
selected: 10,
|
||||
..Default::default()
|
||||
};
|
||||
let offset = scroll_offset(&state);
|
||||
let visible = MAX_VISIBLE_ROWS as usize;
|
||||
assert!(offset <= 10);
|
||||
assert!(offset + visible > 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_resets_state() {
|
||||
let mut state = CompletionDropdownState {
|
||||
open: true,
|
||||
items: vec![make_item("a", "", "a")],
|
||||
selected: 0,
|
||||
hovered: Some(0),
|
||||
generation: 5,
|
||||
request_text: "a".into(),
|
||||
request_cursor: 1,
|
||||
};
|
||||
state.close();
|
||||
assert!(!state.open);
|
||||
assert_eq!(state.selected, 0);
|
||||
assert!(state.hovered.is_none());
|
||||
assert_eq!(state.generation, 5); // generation preserved
|
||||
assert!(state.items.is_empty());
|
||||
// Anchor left in place (inert without items); the next landing
|
||||
// overwrites it atomically with the new items.
|
||||
assert_eq!(state.request_text, "a");
|
||||
assert_eq!(state.request_cursor, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user