Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/completion_dropdown.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

415 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}