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,417 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{self, Write},
|
||||
};
|
||||
|
||||
use ratatui::layout::{Rect, Size};
|
||||
|
||||
use crate::common::TerminalLike;
|
||||
|
||||
/// Mock terminal for testing
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockTerminal {
|
||||
pub size: Size,
|
||||
pub viewport_area: Rect,
|
||||
pub clear_count: usize,
|
||||
pub viewport_updates: Vec<Rect>,
|
||||
pub writer: MockWriter,
|
||||
}
|
||||
|
||||
/// Mock writer that captures all output
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockWriter {
|
||||
pub buffer: Vec<u8>,
|
||||
pub flush_count: usize,
|
||||
pub commands: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl Write for MockWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.extend_from_slice(buf);
|
||||
// Parse and store readable command representation
|
||||
if let Ok(s) = std::str::from_utf8(buf) {
|
||||
self.commands.push_back(s.to_string());
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.flush_count += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl MockTerminal {
|
||||
pub fn new(width: u16, height: u16, viewport_height: u16) -> Self {
|
||||
let viewport_y = height - viewport_height;
|
||||
Self {
|
||||
size: Size { width, height },
|
||||
viewport_area: Rect::new(0, viewport_y, width, viewport_height),
|
||||
clear_count: 0,
|
||||
viewport_updates: Vec::new(),
|
||||
writer: MockWriter {
|
||||
buffer: Vec::new(),
|
||||
flush_count: 0,
|
||||
commands: VecDeque::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalLike for MockTerminal {
|
||||
type Writer = MockWriter;
|
||||
|
||||
fn size(&self) -> io::Result<Size> {
|
||||
Ok(self.size)
|
||||
}
|
||||
|
||||
fn viewport_area(&self) -> Rect {
|
||||
self.viewport_area
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
self.clear_count += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_viewport_area(&mut self, area: Rect) {
|
||||
self.viewport_updates.push(area);
|
||||
self.viewport_area = area;
|
||||
}
|
||||
|
||||
fn writer_mut(&mut self) -> &mut Self::Writer {
|
||||
&mut self.writer
|
||||
}
|
||||
|
||||
fn reset_back_buffer(&mut self) {
|
||||
// Mock implementation - just track that it was called
|
||||
self.clear_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests for the diffed OSC 8 hyperlink layer (`set_frame_links` /
|
||||
/// `flush_with_links`).
|
||||
mod links {
|
||||
use std::io::{self, Write};
|
||||
|
||||
use ratatui::backend::{Backend, WindowSize};
|
||||
use ratatui::buffer::Cell;
|
||||
use ratatui::layout::{Position, Rect, Size};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::{TerminalOptions, Viewport};
|
||||
|
||||
use crate::{LinkSpan, Terminal};
|
||||
|
||||
/// Backend that records the raw byte stream and renders each drawn cell as
|
||||
/// its bare symbol, so tests can assert on OSC 8 sequences interleaved with
|
||||
/// cell content without depending on crossterm's exact SGR output.
|
||||
#[derive(Default)]
|
||||
struct RecordingBackend {
|
||||
buf: Vec<u8>,
|
||||
/// Total lines passed to `append_lines` (used by the
|
||||
/// `set_viewport_height` grow-path test).
|
||||
appended_lines: u16,
|
||||
}
|
||||
|
||||
impl Write for RecordingBackend {
|
||||
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
|
||||
self.buf.extend_from_slice(b);
|
||||
Ok(b.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for RecordingBackend {
|
||||
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||
{
|
||||
for (_x, _y, cell) in content {
|
||||
self.buf.extend_from_slice(cell.symbol().as_bytes());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn hide_cursor(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn show_cursor(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn get_cursor_position(&mut self) -> io::Result<Position> {
|
||||
Ok(Position::ORIGIN)
|
||||
}
|
||||
fn set_cursor_position<P: Into<Position>>(&mut self, _position: P) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn clear_region(&mut self, _clear_type: ratatui::backend::ClearType) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn append_lines(&mut self, n: u16) -> io::Result<()> {
|
||||
self.appended_lines += n;
|
||||
Ok(())
|
||||
}
|
||||
fn size(&self) -> io::Result<Size> {
|
||||
Ok(Size::new(80, 24))
|
||||
}
|
||||
fn window_size(&mut self) -> io::Result<WindowSize> {
|
||||
Ok(WindowSize {
|
||||
columns_rows: Size::new(80, 24),
|
||||
pixels: Size::new(0, 0),
|
||||
})
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn term(w: u16, h: u16) -> Terminal<RecordingBackend> {
|
||||
Terminal::with_options(
|
||||
RecordingBackend::default(),
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Fixed(Rect::new(0, 0, w, h)),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn span(col_start: u16, col_end: u16, url: &str, id: Option<u32>) -> LinkSpan {
|
||||
LinkSpan {
|
||||
row: 0,
|
||||
col_start,
|
||||
col_end,
|
||||
url: url.into(),
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `text` at (0,0), set `spans`, flush, and return the bytes emitted
|
||||
/// during this single frame.
|
||||
fn frame(t: &mut Terminal<RecordingBackend>, text: &str, spans: &[LinkSpan]) -> String {
|
||||
t.backend_mut().buf.clear();
|
||||
{
|
||||
let mut f = t.get_frame();
|
||||
f.buffer_mut().set_string(0, 0, text, Style::default());
|
||||
}
|
||||
t.set_frame_links(spans);
|
||||
t.flush_with_links().unwrap();
|
||||
t.swap_buffers();
|
||||
String::from_utf8(t.backend().buf.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_osc8_around_linked_cells() {
|
||||
let mut t = term(20, 3);
|
||||
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
|
||||
assert!(
|
||||
out.contains("\x1b]8;;https://x.ai\x07"),
|
||||
"missing open: {out:?}"
|
||||
);
|
||||
assert!(out.contains("AB"));
|
||||
assert!(out.contains("\x1b]8;;\x07"), "missing close: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_link_emits_no_osc8() {
|
||||
let mut t = term(20, 3);
|
||||
let out = frame(&mut t, "AB", &[]);
|
||||
assert!(!out.contains("\x1b]8;"), "unexpected OSC8: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grow_viewport_scrolls_committed_lines_into_history() {
|
||||
// A small inline viewport near the bottom of the screen, grown to full
|
||||
// height, must scroll the rows it will cover up into native scrollback
|
||||
// (append_lines) instead of overwriting them. Regression guard for the
|
||||
// previously-commented-out scroll_up in set_viewport_height's grow path
|
||||
// (the overlay host depends on this in minimal mode).
|
||||
let mut t = Terminal::with_options(
|
||||
RecordingBackend::default(),
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(3),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Pin the 3-row viewport near the bottom of the 24-row screen.
|
||||
t.set_viewport_area(Rect::new(0, 21, 80, 3));
|
||||
let before = t.backend().appended_lines;
|
||||
// Grow to full height: overflow = (21 + 24) - 24 = 21 rows must scroll up.
|
||||
t.set_viewport_height(24).unwrap();
|
||||
let scrolled = t.backend().appended_lines - before;
|
||||
assert!(
|
||||
scrolled >= 21,
|
||||
"expected >= 21 lines scrolled into history, got {scrolled}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: `set_viewport_height` must judge grow-vs-shrink against the
|
||||
/// live `viewport_area.height`, not the stored `Viewport::Inline(height)`.
|
||||
///
|
||||
/// Minimal mode resizes the viewport out-of-band via `set_viewport_area`
|
||||
/// (its content-anchored commit path shrinks the region before
|
||||
/// `insert_before`), which leaves the stored `Inline` height STALE. If the
|
||||
/// next `set_viewport_height` compared against that stale (larger) height, a
|
||||
/// genuine grow would be misread as a shrink: the grow-time `scroll_up`
|
||||
/// would be skipped and the viewport's top would not move up, so the taller
|
||||
/// viewport would run off the bottom of the screen (dropdown items rendered
|
||||
/// off-screen — the "empty dropdown over a full screen" bug).
|
||||
#[test]
|
||||
fn grow_after_out_of_band_area_shrink_still_scrolls() {
|
||||
let mut t = Terminal::with_options(
|
||||
RecordingBackend::default(),
|
||||
TerminalOptions {
|
||||
// Stored Inline height starts tall (mimics a streaming turn that
|
||||
// grew the viewport to near full screen).
|
||||
viewport: Viewport::Inline(21),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Out-of-band shrink to a 3-row viewport pinned at the bottom of the
|
||||
// 24-row screen — as the commit path does. This does NOT update the
|
||||
// stored Inline height (still 21), creating the drift.
|
||||
t.set_viewport_area(Rect::new(0, 21, 80, 3));
|
||||
|
||||
let before = t.backend().appended_lines;
|
||||
// Grow to 10 rows. Against the real height (3) this is a GROW that
|
||||
// overflows the bottom by (21 + 10) - 24 = 7 rows, which must scroll up.
|
||||
// Against the stale stored height (21) it would look like a shrink and
|
||||
// scroll nothing.
|
||||
t.set_viewport_height(10).unwrap();
|
||||
|
||||
let scrolled = t.backend().appended_lines - before;
|
||||
assert!(
|
||||
scrolled >= 7,
|
||||
"grow after an out-of-band area shrink must scroll the covered rows \
|
||||
into history (expected >= 7, got {scrolled})"
|
||||
);
|
||||
// The viewport top moved up so the whole 10-row region fits on screen.
|
||||
let area = t.viewport_area();
|
||||
assert_eq!(area.height, 10, "height should be the requested 10");
|
||||
assert!(
|
||||
area.y + area.height <= 24,
|
||||
"viewport must fit on screen, got y={} h={}",
|
||||
area.y,
|
||||
area.height
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_removed_next_frame_rewrites_cells_without_osc8() {
|
||||
let mut t = term(20, 3);
|
||||
let _ = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
|
||||
// Same glyphs, but the link is gone: the cells must be rewritten (so the
|
||||
// terminal's hyperlink clears) and carry no OSC 8. This is the `/new`
|
||||
// regression — clearing is driven purely by the diff.
|
||||
let out = frame(&mut t, "AB", &[]);
|
||||
assert!(out.contains("AB"), "cells should be redrawn: {out:?}");
|
||||
assert!(!out.contains("\x1b]8;"), "stale OSC8 leaked: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_link_and_content_emits_nothing() {
|
||||
let mut t = term(20, 3);
|
||||
let _ = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
|
||||
// Identical glyphs AND identical link → empty diff → no output at all.
|
||||
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
|
||||
assert!(out.is_empty(), "expected empty diff, got: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeted_link_rewrites_cells() {
|
||||
let mut t = term(20, 3);
|
||||
let _ = frame(&mut t, "AB", &[span(0, 2, "https://a", None)]);
|
||||
let out = frame(&mut t, "AB", &[span(0, 2, "https://b", None)]);
|
||||
assert!(
|
||||
out.contains("\x1b]8;;https://b\x07"),
|
||||
"new url not emitted: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_id_param_included() {
|
||||
let mut t = term(20, 3);
|
||||
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", Some(7))]);
|
||||
assert!(
|
||||
out.contains("\x1b]8;id=7;https://x.ai\x07"),
|
||||
"id param missing: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_control_chars_sanitized() {
|
||||
let mut t = term(20, 3);
|
||||
let out = frame(&mut t, "AB", &[span(0, 2, "https://x\x07\x1b/y", None)]);
|
||||
assert!(
|
||||
out.contains("\x1b]8;;https://x/y\x07"),
|
||||
"url not sanitized: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_links_split_into_separate_runs() {
|
||||
let mut t = term(20, 3);
|
||||
// "AxB": A→a, gap x (no link), B→b.
|
||||
let out = frame(
|
||||
&mut t,
|
||||
"AxB",
|
||||
&[span(0, 1, "https://a", None), span(2, 3, "https://b", None)],
|
||||
);
|
||||
// Each link wraps exactly its own cell; the gap is not wrapped.
|
||||
assert!(
|
||||
out.contains("\x1b]8;;https://a\x07A\x1b]8;;\x07"),
|
||||
"a-run: {out:?}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("\x1b]8;;https://b\x07B\x1b]8;;\x07"),
|
||||
"b-run: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_char_under_link_wraps_lead_cell_only() {
|
||||
let mut t = term(20, 3);
|
||||
// A width-2 char occupies two cells; only the lead cell is drawn, and
|
||||
// the OSC 8 wraps it.
|
||||
let out = frame(&mut t, "世", &[span(0, 2, "https://x.ai", None)]);
|
||||
assert!(
|
||||
out.contains("\x1b]8;;https://x.ai\x07世\x1b]8;;\x07"),
|
||||
"wide-char run: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_origin_viewport_maps_links() {
|
||||
// The screen→cell mapping subtracts the viewport offset; verify a link
|
||||
// at an absolute (row, col) inside a non-origin viewport wraps the right
|
||||
// cells (regression guard for `(y - area.y)` / `(x - area.x)`).
|
||||
let area = Rect::new(2, 5, 20, 4);
|
||||
let mut t = Terminal::with_options(
|
||||
RecordingBackend::default(),
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
{
|
||||
let mut f = t.get_frame();
|
||||
f.buffer_mut().set_string(2, 5, "AB", Style::default());
|
||||
}
|
||||
t.set_frame_links(&[LinkSpan {
|
||||
row: 5,
|
||||
col_start: 2,
|
||||
col_end: 4,
|
||||
url: "https://x.ai".into(),
|
||||
id: None,
|
||||
}]);
|
||||
t.flush_with_links().unwrap();
|
||||
let out = String::from_utf8(t.backend().buf.clone()).unwrap();
|
||||
assert!(
|
||||
out.contains("\x1b]8;;https://x.ai\x07AB\x1b]8;;\x07"),
|
||||
"non-origin mapping: {out:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user