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,277 @@
|
||||
//! Input flight recorder — rolling buffer of recent key events.
|
||||
//!
|
||||
//! Ctrl+Shift+D dumps to `~/.kigi/logs/input-debug-<timestamp>.json`.
|
||||
//! Can be better utilized once input bugs are fully resolved.
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
/// Default ring buffer capacity (~10 seconds of fast typing).
|
||||
const DEFAULT_CAPACITY: usize = 200;
|
||||
/// Snapshot of textarea state captured by `PromptWidget::handle_key`.
|
||||
///
|
||||
/// Stored on `PromptWidget` after each key; read by `AgentView` when
|
||||
/// building `RawInputEntry`. `None` fields mean the key was handled before
|
||||
/// reaching the textarea (e.g., file search, slash command).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LastInputDelta {
|
||||
pub cursor_before: Option<usize>,
|
||||
pub cursor_after: Option<usize>,
|
||||
pub text_len_before: Option<usize>,
|
||||
pub text_len_after: Option<usize>,
|
||||
pub had_selection_before: Option<bool>,
|
||||
pub had_selection_after: Option<bool>,
|
||||
pub textarea_changed: Option<bool>,
|
||||
}
|
||||
/// Copy-friendly snapshot of the active pane. No heap allocation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum ActivePaneSnapshot {
|
||||
Prompt,
|
||||
Scrollback,
|
||||
Todo,
|
||||
Queue,
|
||||
Tasks,
|
||||
Catalog,
|
||||
Other,
|
||||
}
|
||||
/// Copy-friendly snapshot of the input outcome. No heap allocation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum OutcomeSnapshot {
|
||||
Changed,
|
||||
Unchanged,
|
||||
Action,
|
||||
}
|
||||
/// Raw entry stored in the ring buffer. No heap allocations —
|
||||
/// formatting happens only during [`InputRingBuffer::snapshot_entries`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RawInputEntry {
|
||||
pub wall_ts: u64,
|
||||
pub key_code: KeyCode,
|
||||
pub key_modifiers: KeyModifiers,
|
||||
pub key_kind: KeyEventKind,
|
||||
pub active_pane: ActivePaneSnapshot,
|
||||
pub outcome: OutcomeSnapshot,
|
||||
pub cursor_before: Option<usize>,
|
||||
pub cursor_after: Option<usize>,
|
||||
pub text_len_before: Option<usize>,
|
||||
pub text_len_after: Option<usize>,
|
||||
pub sel_before: Option<bool>,
|
||||
pub sel_after: Option<bool>,
|
||||
pub textarea_changed: Option<bool>,
|
||||
}
|
||||
/// Serializable output record, constructed from [`RawInputEntry`] during dump.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct InputRecord {
|
||||
/// Milliseconds relative to the first entry in the buffer (0-based).
|
||||
pub ts_ms: u64,
|
||||
/// Wall-clock unix millis for log correlation.
|
||||
pub wall_ts: u64,
|
||||
/// Sanitized key category (see [`sanitize_key_code`]).
|
||||
/// Printable chars are logged as `"Char"` without the character value
|
||||
/// to prevent reconstructing typed text from the dump.
|
||||
pub key: String,
|
||||
/// Modifier flags, e.g. `"NONE"`, `"CONTROL"`.
|
||||
pub mods: String,
|
||||
/// Key event kind: `"Press"`, `"Repeat"`, `"Release"`.
|
||||
pub kind: String,
|
||||
/// Which pane was active: `"Prompt"`, `"Scrollback"`, etc.
|
||||
pub pane: String,
|
||||
/// Input outcome: `"Changed"`, `"Unchanged"`, `"Action"`.
|
||||
pub outcome: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_before: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_after: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_len_before: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_len_after: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sel_before: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sel_after: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub textarea_changed: Option<bool>,
|
||||
}
|
||||
/// Fixed-capacity ring buffer of raw input events.
|
||||
pub struct InputRingBuffer {
|
||||
entries: VecDeque<(Instant, RawInputEntry)>,
|
||||
capacity: usize,
|
||||
}
|
||||
impl InputRingBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: VecDeque::with_capacity(DEFAULT_CAPACITY),
|
||||
capacity: DEFAULT_CAPACITY,
|
||||
}
|
||||
}
|
||||
/// Push a new record, dropping the oldest if at capacity.
|
||||
pub fn push(&mut self, entry: RawInputEntry) {
|
||||
let now = Instant::now();
|
||||
if self.entries.len() >= self.capacity {
|
||||
self.entries.pop_front();
|
||||
}
|
||||
self.entries.push_back((now, entry));
|
||||
}
|
||||
/// Snapshot all entries, formatting raw types into serializable records.
|
||||
/// Timestamps are relative to the first entry in the buffer.
|
||||
/// Printable characters are scrubbed to `"Char"`.
|
||||
pub fn snapshot_entries(&self) -> Vec<InputRecord> {
|
||||
let base = self.entries.front().map(|(t, _)| *t);
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|(t, raw)| InputRecord {
|
||||
ts_ms: base.map_or(0, |b| t.duration_since(b).as_millis() as u64),
|
||||
wall_ts: raw.wall_ts,
|
||||
key: sanitize_key_code(&raw.key_code),
|
||||
mods: format!("{:?}", raw.key_modifiers),
|
||||
kind: format!("{:?}", raw.key_kind),
|
||||
pane: format!("{:?}", raw.active_pane),
|
||||
outcome: format!("{:?}", raw.outcome),
|
||||
cursor_before: raw.cursor_before,
|
||||
cursor_after: raw.cursor_after,
|
||||
text_len_before: raw.text_len_before,
|
||||
text_len_after: raw.text_len_after,
|
||||
sel_before: raw.sel_before,
|
||||
sel_after: raw.sel_after,
|
||||
textarea_changed: raw.textarea_changed,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Number of raw entries in the buffer.
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
/// Time span covered by the buffer in milliseconds.
|
||||
pub fn time_span_ms(&self) -> u64 {
|
||||
match (self.entries.front(), self.entries.back()) {
|
||||
(Some((first, _)), Some((last, _))) => last.duration_since(*first).as_millis() as u64,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for InputRingBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
/// Top-level structure for the input debug dump file.
|
||||
#[derive(Serialize)]
|
||||
pub struct InputDump {
|
||||
pub dumped_at: String,
|
||||
pub session_id: Option<String>,
|
||||
pub pager_version: &'static str,
|
||||
pub terminal: kigi_pager_render::terminal::TerminalDiagnostics,
|
||||
pub active_pane: String,
|
||||
pub textarea_cursor: usize,
|
||||
pub textarea_text_len: usize,
|
||||
pub textarea_has_selection: bool,
|
||||
pub entry_count: usize,
|
||||
pub time_span_ms: u64,
|
||||
pub entries: Vec<InputRecord>,
|
||||
}
|
||||
/// Return a privacy-safe description of a key code.
|
||||
pub fn sanitize_key_code(code: &KeyCode) -> String {
|
||||
match code {
|
||||
KeyCode::Char('\x08') => "Char(BS)".to_string(),
|
||||
KeyCode::Char('\x7f') => "Char(DEL)".to_string(),
|
||||
KeyCode::Char(_) => "Char".to_string(),
|
||||
other => format!("{other:?}"),
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn format_key_code_raw(code: &KeyCode) -> String {
|
||||
match code {
|
||||
KeyCode::Char(c) if c.is_ascii_graphic() || *c == ' ' => format!("Char({c:?})"),
|
||||
other => format!("{other:?}"),
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
/// Helper: build a minimal `RawInputEntry` with the given key code.
|
||||
fn stub_entry(key_code: KeyCode) -> RawInputEntry {
|
||||
RawInputEntry {
|
||||
wall_ts: 0,
|
||||
key_code,
|
||||
key_modifiers: KeyModifiers::NONE,
|
||||
key_kind: KeyEventKind::Press,
|
||||
active_pane: ActivePaneSnapshot::Prompt,
|
||||
outcome: OutcomeSnapshot::Changed,
|
||||
cursor_before: None,
|
||||
cursor_after: None,
|
||||
text_len_before: None,
|
||||
text_len_after: None,
|
||||
sel_before: None,
|
||||
sel_after: None,
|
||||
textarea_changed: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn sanitize_printable_char_strips_value() {
|
||||
assert_eq!(sanitize_key_code(&KeyCode::Char('a')), "Char");
|
||||
assert_eq!(sanitize_key_code(&KeyCode::Char('Z')), "Char");
|
||||
assert_eq!(sanitize_key_code(&KeyCode::Char(' ')), "Char");
|
||||
}
|
||||
#[test]
|
||||
fn sanitize_control_chars_preserved() {
|
||||
assert_eq!(sanitize_key_code(&KeyCode::Char('\x08')), "Char(BS)");
|
||||
assert_eq!(sanitize_key_code(&KeyCode::Char('\x7f')), "Char(DEL)");
|
||||
}
|
||||
#[test]
|
||||
fn format_key_code_raw_shows_punctuation() {
|
||||
assert_eq!(format_key_code_raw(&KeyCode::Char(';')), "Char(';')");
|
||||
assert_eq!(
|
||||
format_key_code_raw(&KeyCode::Char('\'')),
|
||||
format!("Char({:?})", '\'')
|
||||
);
|
||||
assert_eq!(
|
||||
format_key_code_raw(&KeyCode::Char('\\')),
|
||||
format!("Char({:?})", '\\')
|
||||
);
|
||||
assert_eq!(format_key_code_raw(&KeyCode::Enter), "Enter");
|
||||
}
|
||||
#[test]
|
||||
fn sanitize_named_keys_use_debug() {
|
||||
assert_eq!(sanitize_key_code(&KeyCode::Backspace), "Backspace");
|
||||
assert_eq!(sanitize_key_code(&KeyCode::Enter), "Enter");
|
||||
assert_eq!(sanitize_key_code(&KeyCode::Delete), "Delete");
|
||||
assert_eq!(sanitize_key_code(&KeyCode::Tab), "Tab");
|
||||
}
|
||||
#[test]
|
||||
fn ring_buffer_push_and_snapshot() {
|
||||
let mut buf = InputRingBuffer::new();
|
||||
for _ in 0..5 {
|
||||
buf.push(stub_entry(KeyCode::Char('x')));
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
let entries = buf.snapshot_entries();
|
||||
assert_eq!(entries.len(), 5);
|
||||
assert_eq!(entries[0].ts_ms, 0);
|
||||
for w in entries.windows(2) {
|
||||
assert!(w[1].ts_ms >= w[0].ts_ms);
|
||||
}
|
||||
assert_eq!(entries[0].key, "Char");
|
||||
assert_eq!(entries[0].mods, format!("{:?}", KeyModifiers::NONE));
|
||||
assert_eq!(entries[0].pane, "Prompt");
|
||||
}
|
||||
#[test]
|
||||
fn ring_buffer_capacity() {
|
||||
let mut buf = InputRingBuffer::new();
|
||||
for _ in 0..250 {
|
||||
buf.push(stub_entry(KeyCode::Backspace));
|
||||
}
|
||||
assert_eq!(buf.entry_count(), DEFAULT_CAPACITY);
|
||||
let entries = buf.snapshot_entries();
|
||||
assert_eq!(entries.len(), DEFAULT_CAPACITY);
|
||||
assert_eq!(entries[0].key, "Backspace");
|
||||
}
|
||||
#[test]
|
||||
fn ring_buffer_empty() {
|
||||
let buf = InputRingBuffer::new();
|
||||
assert_eq!(buf.snapshot_entries().len(), 0);
|
||||
assert_eq!(buf.time_span_ms(), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user