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).
311 lines
11 KiB
Rust
311 lines
11 KiB
Rust
use std::{
|
|
io,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use ansi_width::ansi_width;
|
|
use anyhow::Result;
|
|
use crossterm::{
|
|
event::{self, Event, KeyCode, KeyModifiers},
|
|
style::Color as CColor,
|
|
};
|
|
use lipsum::lipsum;
|
|
use ratatui::{
|
|
TerminalOptions, Viewport,
|
|
prelude::CrosstermBackend,
|
|
style::{Color, Style},
|
|
widgets::Block,
|
|
};
|
|
|
|
use kigi_ratatui_inline::{
|
|
Terminal, emit_to_scrollback, resize_purge_rerender, resize_viewport_height,
|
|
with_synchronized_output,
|
|
};
|
|
|
|
/// Build a line with markers for visualization
|
|
fn build_marked_line(line_num: usize, content: &str) -> String {
|
|
format!("[[{:02}]] {} [[{:02}]]", line_num, content, line_num)
|
|
}
|
|
|
|
/// Generate test content based on pattern
|
|
fn generate_test_line(line_num: usize, terminal_width: usize) -> String {
|
|
let i = line_num - 1;
|
|
let mut line = match i % 3 {
|
|
0 => lipsum(4), // Short line
|
|
1 => lipsum(40), // Long line
|
|
2 => {
|
|
// Unicode string with ANSI colors and hyperlink for testing
|
|
let unicode_with_ansi = "\x1b[31m😀\u{200D}\x1b[0m\x1b[32mé\x1b[0m中\u{0300}\x1b[34mX\x1b[0m\x1b]8;;https://example.com\x1b\\H\x1b]8;;\x1b\\";
|
|
let visual_width = ansi_width(unicode_with_ansi);
|
|
let marker_adjustment = if i % 6 >= 3 { 7 } else { 14 };
|
|
let remaining_width = terminal_width * 2 - marker_adjustment - visual_width;
|
|
format!("{unicode_with_ansi}{}", "B".repeat(remaining_width))
|
|
}
|
|
_ => unreachable!(),
|
|
};
|
|
|
|
// Add newline prefix for cases 3-5, alternating between \n and \r\n
|
|
if i % 6 >= 3 {
|
|
line = format!("{}\n{}\r\n{line}", lipsum(2), lipsum(8));
|
|
}
|
|
|
|
line
|
|
}
|
|
|
|
/// Generate tall content that's 1.5x terminal height
|
|
fn generate_tall_content(terminal_width: usize, terminal_height: usize) -> String {
|
|
let num_lines = terminal_height;
|
|
let mut lines = Vec::new();
|
|
for i in 1..=num_lines {
|
|
lines.push(format!(
|
|
"Line {i:03}/{num_lines:03}: {}",
|
|
if i % 2 == 1 {
|
|
lipsum((terminal_width / 25).max(1))
|
|
} else {
|
|
lipsum((terminal_width / 5).max(1))
|
|
}
|
|
));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
/// Build content with ANSI color codes
|
|
fn colorize_content(content: &str, color: CColor) -> String {
|
|
let color_code = match color {
|
|
CColor::Black => 30,
|
|
CColor::Red | CColor::DarkRed => 31,
|
|
CColor::Green | CColor::DarkGreen => 32,
|
|
CColor::Yellow | CColor::DarkYellow => 33,
|
|
CColor::Blue | CColor::DarkBlue => 34,
|
|
CColor::Magenta | CColor::DarkMagenta => 35,
|
|
CColor::Cyan | CColor::DarkCyan => 36,
|
|
CColor::White | CColor::Grey | CColor::DarkGrey => 37,
|
|
_ => 37, // Default to white for RGB or other colors
|
|
};
|
|
format!("\x1b[{}m{}\x1b[0m", color_code, content)
|
|
}
|
|
|
|
fn init_terminal(inline_height: u16) -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
|
crossterm::terminal::enable_raw_mode()?;
|
|
let hook = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(move |info| {
|
|
fn try_restore() -> io::Result<()> {
|
|
crossterm::terminal::disable_raw_mode()?;
|
|
Ok(())
|
|
}
|
|
|
|
if let Err(err) = try_restore() {
|
|
eprintln!("Failed to restore terminal: {err}");
|
|
}
|
|
hook(info);
|
|
}));
|
|
let backend = CrosstermBackend::new(io::stdout());
|
|
let options = TerminalOptions {
|
|
viewport: Viewport::Inline(inline_height),
|
|
};
|
|
let terminal = Terminal::with_options(backend, options)?;
|
|
Ok(terminal)
|
|
}
|
|
|
|
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
|
// Clear only the viewport, preserving log content above it
|
|
terminal.clear()?;
|
|
crossterm::terminal::disable_raw_mode()?;
|
|
Ok(())
|
|
}
|
|
|
|
enum KeyAction {
|
|
Quit,
|
|
Normal,
|
|
Tall,
|
|
IncreaseViewport,
|
|
DecreaseViewport,
|
|
}
|
|
|
|
/// Spinner animation state
|
|
struct Spinner {
|
|
frames: Vec<&'static str>,
|
|
current: usize,
|
|
interval: Duration,
|
|
next_frame_time: Instant,
|
|
}
|
|
|
|
impl Spinner {
|
|
fn new() -> Self {
|
|
let frames = vec!["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"];
|
|
Self {
|
|
frames,
|
|
current: 0,
|
|
interval: Duration::from_millis(100),
|
|
next_frame_time: Instant::now() + Duration::from_millis(100),
|
|
}
|
|
}
|
|
|
|
fn tick(&mut self) {
|
|
self.current = (self.current + 1) % self.frames.len();
|
|
self.next_frame_time = Instant::now() + self.interval;
|
|
}
|
|
|
|
fn current_frame(&self) -> &str {
|
|
self.frames[self.current]
|
|
}
|
|
|
|
fn time_until_next_frame(&self) -> Duration {
|
|
self.next_frame_time
|
|
.saturating_duration_since(Instant::now())
|
|
}
|
|
}
|
|
|
|
enum PollResult {
|
|
KeyPressed(KeyAction),
|
|
AnimationTick,
|
|
Resize,
|
|
}
|
|
|
|
fn poll_for_key_or_animation(spinner: &Spinner) -> io::Result<PollResult> {
|
|
let timeout = spinner.time_until_next_frame();
|
|
|
|
if event::poll(timeout)? {
|
|
match event::read()? {
|
|
Event::Key(key) => {
|
|
if key.code == KeyCode::Esc
|
|
|| (key.code == KeyCode::Char('c')
|
|
&& key.modifiers.contains(KeyModifiers::CONTROL))
|
|
|| key.code == KeyCode::Char('q')
|
|
|| key.code == KeyCode::Char('Q')
|
|
{
|
|
return Ok(PollResult::KeyPressed(KeyAction::Quit));
|
|
} else if key.code == KeyCode::Char('t') || key.code == KeyCode::Char('T') {
|
|
return Ok(PollResult::KeyPressed(KeyAction::Tall));
|
|
} else if key.code == KeyCode::Char('+') || key.code == KeyCode::Char('=') {
|
|
return Ok(PollResult::KeyPressed(KeyAction::IncreaseViewport));
|
|
} else if key.code == KeyCode::Char('-') || key.code == KeyCode::Char('_') {
|
|
return Ok(PollResult::KeyPressed(KeyAction::DecreaseViewport));
|
|
} else if matches!(key.code, KeyCode::Char(_) | KeyCode::Enter) {
|
|
return Ok(PollResult::KeyPressed(KeyAction::Normal));
|
|
}
|
|
// Key we don't care about, continue polling
|
|
poll_for_key_or_animation(spinner)
|
|
}
|
|
Event::Resize(_, _) => Ok(PollResult::Resize),
|
|
_ => {
|
|
// Other event we don't care about, continue polling
|
|
poll_for_key_or_animation(spinner)
|
|
}
|
|
}
|
|
} else {
|
|
// Timeout reached, time to animate
|
|
Ok(PollResult::AnimationTick)
|
|
}
|
|
}
|
|
|
|
fn render_viewport(
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
spinner: &Spinner,
|
|
viewport_height: u16,
|
|
) -> io::Result<()> {
|
|
terminal.draw(|frame| {
|
|
let area = frame.area();
|
|
let spinner_text = format!(
|
|
" {} Inline Viewport ({} lines) {} ",
|
|
spinner.current_frame(),
|
|
viewport_height,
|
|
spinner.current_frame()
|
|
);
|
|
let block = Block::bordered()
|
|
.title(spinner_text)
|
|
.border_style(Style::default().fg(Color::Cyan));
|
|
frame.render_widget(block, area);
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
const INITIAL_HEIGHT: u16 = 3;
|
|
const MIN_HEIGHT: u16 = 2;
|
|
|
|
let mut terminal = init_terminal(INITIAL_HEIGHT)?;
|
|
let mut spinner = Spinner::new();
|
|
let mut viewport_height = INITIAL_HEIGHT;
|
|
|
|
render_viewport(&mut terminal, &spinner, viewport_height)?;
|
|
|
|
let colors = [
|
|
CColor::Green,
|
|
CColor::Yellow,
|
|
CColor::Magenta,
|
|
CColor::Blue,
|
|
CColor::Red,
|
|
CColor::Cyan,
|
|
];
|
|
|
|
let mut line_num = 1;
|
|
let mut scrollback_history = String::new(); // has crlf-s instead of lf-s
|
|
|
|
loop {
|
|
match poll_for_key_or_animation(&spinner)? {
|
|
PollResult::AnimationTick => {
|
|
spinner.tick();
|
|
render_viewport(&mut terminal, &spinner, viewport_height)?;
|
|
}
|
|
PollResult::Resize => {
|
|
with_synchronized_output(&mut terminal, |terminal| {
|
|
resize_purge_rerender(terminal, &scrollback_history)?;
|
|
render_viewport(terminal, &spinner, viewport_height)?;
|
|
Ok(())
|
|
})?;
|
|
}
|
|
PollResult::KeyPressed(action) => match action {
|
|
KeyAction::Quit => break,
|
|
KeyAction::IncreaseViewport | KeyAction::DecreaseViewport => {
|
|
let new_viewport_height = if let KeyAction::IncreaseViewport = action {
|
|
viewport_height + 1
|
|
} else {
|
|
viewport_height.saturating_sub(1)
|
|
}
|
|
.clamp(MIN_HEIGHT, terminal.size()?.height.saturating_sub(1));
|
|
if new_viewport_height != viewport_height {
|
|
viewport_height = new_viewport_height;
|
|
with_synchronized_output(&mut terminal, |terminal| {
|
|
resize_viewport_height(terminal, viewport_height)?;
|
|
render_viewport(terminal, &spinner, viewport_height)?;
|
|
Ok(())
|
|
})?;
|
|
}
|
|
}
|
|
KeyAction::Tall | KeyAction::Normal => {
|
|
let area = terminal.size()?;
|
|
let (terminal_width, terminal_height) =
|
|
(area.width as usize, area.height as usize);
|
|
let content = match action {
|
|
KeyAction::Tall => generate_tall_content(terminal_width, terminal_height),
|
|
KeyAction::Normal => generate_test_line(line_num, terminal_width),
|
|
_ => unreachable!(),
|
|
};
|
|
let marked_line = build_marked_line(line_num, &content);
|
|
let colored_content =
|
|
colorize_content(&marked_line, colors[(line_num - 1) % colors.len()]);
|
|
|
|
// Save to scrollback history, replace LF with CRLF
|
|
if !scrollback_history.is_empty() {
|
|
scrollback_history.push_str("\r\n");
|
|
}
|
|
scrollback_history.push_str(&colored_content.replace('\n', "\r\n"));
|
|
|
|
// Emit to scrollback and render viewport
|
|
with_synchronized_output(&mut terminal, |terminal| {
|
|
emit_to_scrollback(terminal, &colored_content)?;
|
|
render_viewport(terminal, &spinner, viewport_height)?;
|
|
Ok(())
|
|
})?;
|
|
|
|
line_num += 1;
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
restore_terminal(&mut terminal)?;
|
|
Ok(())
|
|
}
|