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,332 @@
|
||||
//! Popup dialog for creating a new worktree with an optional label.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app::app_view::NewWorktreeDialogState;
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Minimum dialog width (fits title + empty input + hints comfortably).
|
||||
const MIN_DIALOG_WIDTH: u16 = 50;
|
||||
const DIALOG_HEIGHT: u16 = 5;
|
||||
/// Left/right padding inside the border (`inner_x = dialog.x + 2`).
|
||||
const INNER_PAD: u16 = 4;
|
||||
const LABEL_PREFIX: &str = "Name (optional): ";
|
||||
|
||||
/// Render the new-worktree popup dialog centered on screen.
|
||||
///
|
||||
/// The dialog grows with the typed label (up to the available terminal
|
||||
/// width) so long names stay fully visible. When the terminal itself is
|
||||
/// too narrow for the full name, the input scrolls to keep the cursor
|
||||
/// (end of the label) in view, with a leading `…` when scrolled.
|
||||
pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorktreeDialogState) {
|
||||
let theme = Theme::current();
|
||||
|
||||
let dialog_width = dialog_width_for(area.width, &state.label_input);
|
||||
|
||||
if area.height < DIALOG_HEIGHT || area.width < 20 {
|
||||
// Too small to render — draw a minimal "resize" hint so the user
|
||||
// knows the dialog is still active and can press Esc to dismiss.
|
||||
if area.height >= 1 && area.width >= 16 {
|
||||
let hint = Line::from(Span::styled(
|
||||
"[Esc] to close",
|
||||
Style::default().fg(theme.gray_dim),
|
||||
));
|
||||
hint.render(Rect::new(area.x, area.y, area.width.min(16), 1), buf);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let [_, dialog_h, _] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(dialog_width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(area);
|
||||
|
||||
let [_, dialog, _] = Layout::vertical([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(DIALOG_HEIGHT),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(dialog_h);
|
||||
|
||||
// Draw background
|
||||
let bg_style = Style::default().bg(theme.bg_dark);
|
||||
for y in dialog.y..dialog.y + dialog.height {
|
||||
for x in dialog.x..dialog.x + dialog.width {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
cell.set_char(' ');
|
||||
cell.set_style(bg_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw border
|
||||
let border_style = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
|
||||
// Top border
|
||||
if let Some(cell) = buf.cell_mut((dialog.x, dialog.y)) {
|
||||
cell.set_char('\u{256D}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
for x in dialog.x + 1..dialog.x + dialog.width - 1 {
|
||||
if let Some(cell) = buf.cell_mut((x, dialog.y)) {
|
||||
cell.set_char('\u{2500}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((dialog.x + dialog.width - 1, dialog.y)) {
|
||||
cell.set_char('\u{256E}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
// Bottom border
|
||||
let bottom = dialog.y + dialog.height - 1;
|
||||
if let Some(cell) = buf.cell_mut((dialog.x, bottom)) {
|
||||
cell.set_char('\u{2570}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
for x in dialog.x + 1..dialog.x + dialog.width - 1 {
|
||||
if let Some(cell) = buf.cell_mut((x, bottom)) {
|
||||
cell.set_char('\u{2500}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((dialog.x + dialog.width - 1, bottom)) {
|
||||
cell.set_char('\u{256F}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
// Side borders
|
||||
for y in dialog.y + 1..dialog.y + dialog.height - 1 {
|
||||
if let Some(cell) = buf.cell_mut((dialog.x, y)) {
|
||||
cell.set_char('\u{2502}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((dialog.x + dialog.width - 1, y)) {
|
||||
cell.set_char('\u{2502}');
|
||||
cell.set_style(border_style);
|
||||
}
|
||||
}
|
||||
|
||||
let inner_x = dialog.x + 2;
|
||||
let inner_width = dialog.width.saturating_sub(INNER_PAD);
|
||||
|
||||
// Row 1: Title
|
||||
let title = Line::from(Span::styled(
|
||||
"New Worktree",
|
||||
Style::default()
|
||||
.fg(theme.text_primary)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
title.render(Rect::new(inner_x, dialog.y + 1, inner_width, 1), buf);
|
||||
|
||||
// Row 2: Label input — grow with content; scroll when still too wide.
|
||||
let prefix_w = LABEL_PREFIX.width() as u16;
|
||||
let cursor_w = 1u16;
|
||||
let input_budget = inner_width
|
||||
.saturating_sub(prefix_w)
|
||||
.saturating_sub(cursor_w) as usize;
|
||||
let visible_input = visible_input_suffix(&state.label_input, input_budget);
|
||||
|
||||
let prefix_span = Span::styled(LABEL_PREFIX, Style::default().fg(theme.gray_bright));
|
||||
let input_span = Span::styled(visible_input, Style::default().fg(theme.text_primary));
|
||||
let cursor_span = Span::styled("\u{2588}", Style::default().fg(theme.accent_user));
|
||||
let input_line = Line::from(vec![prefix_span, input_span, cursor_span]);
|
||||
input_line.render(Rect::new(inner_x, dialog.y + 2, inner_width, 1), buf);
|
||||
|
||||
// Row 3: Hints
|
||||
let hints = Line::from(vec![
|
||||
Span::styled(
|
||||
"enter",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" = create ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"esc",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" = cancel", Style::default().fg(theme.gray)),
|
||||
]);
|
||||
hints.render(Rect::new(inner_x, dialog.y + 3, inner_width, 1), buf);
|
||||
}
|
||||
|
||||
/// Dialog width that fits the typed label, clamped to the available area.
|
||||
fn dialog_width_for(area_width: u16, label: &str) -> u16 {
|
||||
let max_width = area_width.saturating_sub(4);
|
||||
// prefix + label + block cursor + inner pad
|
||||
let needed = (LABEL_PREFIX.width() + label.width() + 1 + INNER_PAD as usize) as u16;
|
||||
needed.max(MIN_DIALOG_WIDTH).min(max_width)
|
||||
}
|
||||
|
||||
/// Return the visible portion of `label` for an end-anchored input field.
|
||||
///
|
||||
/// When `label` fits in `budget` columns, returns it unchanged. Otherwise
|
||||
/// returns a leading `…` plus the suffix that fits, so the cursor at the
|
||||
/// end of the label stays visible while typing a long name.
|
||||
///
|
||||
/// Walks Unicode grapheme clusters (not scalar values) so combining marks
|
||||
/// and ZWJ sequences are never split across the scroll boundary.
|
||||
fn visible_input_suffix(label: &str, budget: usize) -> String {
|
||||
if budget == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if label.width() <= budget {
|
||||
return label.to_string();
|
||||
}
|
||||
if budget == 1 {
|
||||
return "…".to_string();
|
||||
}
|
||||
|
||||
let suffix_budget = budget - 1; // reserve one column for leading …
|
||||
let mut width = 0usize;
|
||||
let mut start = label.len();
|
||||
let graphemes: Vec<(usize, &str)> = label.grapheme_indices(true).collect();
|
||||
for &(i, g) in graphemes.iter().rev() {
|
||||
let cw = UnicodeWidthStr::width(g);
|
||||
if width + cw > suffix_budget {
|
||||
break;
|
||||
}
|
||||
width += cw;
|
||||
start = i;
|
||||
}
|
||||
format!("…{}", &label[start..])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
fn render_to_text(area: Rect, label: &str) -> String {
|
||||
let mut buf = Buffer::empty(area);
|
||||
let state = NewWorktreeDialogState {
|
||||
label_input: label.to_string(),
|
||||
};
|
||||
render_new_worktree_dialog(area, &mut buf, &state);
|
||||
let mut lines = Vec::new();
|
||||
for y in 0..area.height {
|
||||
let mut row = String::new();
|
||||
for x in 0..area.width {
|
||||
row.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
lines.push(row);
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dialog_uses_minimum_width() {
|
||||
assert_eq!(dialog_width_for(120, ""), MIN_DIALOG_WIDTH);
|
||||
assert_eq!(dialog_width_for(40, ""), 36); // area.width - 4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_grows_with_long_label() {
|
||||
let label = "a-very-long-worktree-name-that-exceeds-fifty";
|
||||
let width = dialog_width_for(120, label);
|
||||
assert!(
|
||||
width > MIN_DIALOG_WIDTH,
|
||||
"expected dialog wider than min for long label, got {width}"
|
||||
);
|
||||
// Full label + chrome must fit inside the grown dialog.
|
||||
let inner = width.saturating_sub(INNER_PAD) as usize;
|
||||
let needed = LABEL_PREFIX.width() + label.width() + 1;
|
||||
assert!(
|
||||
needed <= inner,
|
||||
"grown dialog inner={inner} should fit needed={needed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_clamps_to_terminal_width() {
|
||||
let label = "x".repeat(100);
|
||||
let width = dialog_width_for(60, &label);
|
||||
assert_eq!(width, 56); // 60 - 4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_suffix_keeps_end_when_scrolled() {
|
||||
let label = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let visible = visible_input_suffix(label, 10);
|
||||
assert!(
|
||||
visible.starts_with('…'),
|
||||
"expected leading ellipsis: {visible}"
|
||||
);
|
||||
assert!(
|
||||
visible.ends_with("0123456789") || visible.ends_with("123456789"),
|
||||
"expected end of label visible: {visible}"
|
||||
);
|
||||
assert_eq!(visible.width(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_suffix_unchanged_when_fits() {
|
||||
assert_eq!(visible_input_suffix("short", 20), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_suffix_does_not_split_grapheme_clusters() {
|
||||
// "e" + combining acute (U+0301) is one grapheme; pad so we must scroll.
|
||||
let cluster = "e\u{0301}";
|
||||
let label = format!("{}{}", "x".repeat(20), cluster);
|
||||
let visible = visible_input_suffix(&label, 8);
|
||||
assert!(
|
||||
visible.starts_with('…'),
|
||||
"expected leading ellipsis: {visible}"
|
||||
);
|
||||
// Either the full cluster is present, or it was dropped as a unit —
|
||||
// never a lone combining mark after the ellipsis.
|
||||
let after_ellipsis = &visible[visible.char_indices().nth(1).map(|(i, _)| i).unwrap_or(0)..];
|
||||
assert!(
|
||||
!after_ellipsis.starts_with('\u{0301}'),
|
||||
"must not start scrolled suffix on a combining mark: {visible:?}"
|
||||
);
|
||||
if after_ellipsis.contains('e') {
|
||||
assert!(
|
||||
after_ellipsis.contains(cluster),
|
||||
"base 'e' must keep its combining mark: {visible:?}"
|
||||
);
|
||||
}
|
||||
assert!(visible.width() <= 8, "width overflow: {visible:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_name_fully_visible_on_wide_terminal() {
|
||||
let area = Rect::new(0, 0, 100, 20);
|
||||
let label = "biscuit-worktree-popup-long-name-fix";
|
||||
let text = render_to_text(area, label);
|
||||
assert!(
|
||||
text.contains(label),
|
||||
"full long name must be visible on a wide terminal:\n{text}"
|
||||
);
|
||||
assert!(text.contains("New Worktree"), "title missing:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_name_end_visible_on_narrow_terminal() {
|
||||
// Terminal narrower than the full label — end (cursor side) must show.
|
||||
let area = Rect::new(0, 0, 40, 12);
|
||||
let label = "super-long-worktree-name-that-will-not-fit";
|
||||
let text = render_to_text(area, label);
|
||||
let tail = &label[label.len().saturating_sub(8)..];
|
||||
assert!(
|
||||
text.contains(tail),
|
||||
"end of long name must remain visible when scrolled:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains('…') || text.contains(tail),
|
||||
"expected scrolled indicator or tail:\n{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user