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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+603
View File
@@ -0,0 +1,603 @@
//! Tool usage statistics aggregation for the pager.
//!
//! This module provides:
//!
//! - [`ToolCategory`] — categories for tool calls (Execute, Read, Edit, Search, ListDir, Other)
//! - [`BlockStatus`] — status of a tool block (Success, Failed, Running)
//! - [`CategoryStats`] — per-category statistics (counts, failures, positions)
//! - [`ToolUsageStats`] — aggregated stats with scope (Session / SelectedTurn)
//!
//! ## Phase 1 MVP
//!
//! Stats are computed over visible scrollback blocks only (ToolCallBlock variants).
//! Thinking blocks and non-tool RenderBlock variants are excluded.
//! Time tracking is deferred to Phase 2.
use std::collections::{BTreeMap, HashMap};
use std::ops::Range;
use std::time::Instant;
use ratatui::style::Color;
use crate::scrollback::blocks::tool::ToolCallBlock;
use crate::scrollback::state::ScrollbackState;
use crate::theme::Theme;
// ---------------------------------------------------------------------------
// ToolCategory — categories derived from ToolCallBlock variants only
// ---------------------------------------------------------------------------
/// Block category for stats aggregation.
/// Maps scrollback block variants to semantic groups.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ToolCategory {
/// Shell command execution (run_terminal_cmd, bash).
Execute,
/// File read operations (read_file).
Read,
/// File edits (search_replace, write, apply_patch).
Edit,
/// Search/grep operations (grep, glob).
Search,
/// Skill invocations (user-defined skills via the Skill tool).
Skill,
/// Directory listing (list_dir, ls).
ListDir,
/// Web fetch (URL content retrieval).
WebFetch,
/// Web search (web search with citations).
WebSearch,
/// Other/unknown tool types.
Other,
/// Agent thinking/reasoning.
Thinking,
/// Agent response message.
Message,
}
impl ToolCategory {
/// Display name for UI.
pub fn label(&self) -> &'static str {
match self {
Self::Execute => "Execute",
Self::Read => "Read",
Self::Edit => "Edit",
Self::Search => "Search",
Self::Skill => "Skill",
Self::ListDir => "ListDir",
Self::WebFetch => "Fetch",
Self::WebSearch => "WebSearch",
Self::Other => "Other",
Self::Thinking => "Thinking",
Self::Message => "Message",
}
}
/// Compact symbol for sequence bar (single char, no emoji).
pub fn symbol(&self) -> char {
match self {
Self::Execute => '█',
Self::Read => '▓',
Self::Edit => '▒',
Self::Search => '░',
Self::Skill => crate::glyphs::diamond_filled_char(),
Self::ListDir => '▀',
Self::WebFetch => '▄',
Self::WebSearch => '○',
Self::Other => crate::glyphs::diamond_filled_char(),
Self::Thinking => crate::glyphs::diamond_hollow_char(),
Self::Message => '▪',
}
}
/// Category color from theme.
pub fn color(&self, theme: &Theme) -> Color {
match self {
Self::Execute => theme.command,
Self::Read => theme.accent_system,
Self::Edit => theme.accent_success,
Self::Search => theme.running,
Self::Skill => theme.accent_skill,
Self::ListDir => theme.accent_model,
Self::WebFetch => theme.accent_tool,
Self::WebSearch => theme.accent_tool,
Self::Other => theme.path,
Self::Thinking => theme.accent_running,
Self::Message => theme.text_primary,
}
}
/// Map from ToolCallBlock to category.
/// Only ToolCallBlock variants are supported; other blocks are excluded.
pub fn from_tool_block(tc: &ToolCallBlock) -> Self {
match tc {
ToolCallBlock::Execute(_) => Self::Execute,
ToolCallBlock::Read(_) => Self::Read,
ToolCallBlock::Edit(_) => Self::Edit,
ToolCallBlock::Search(_) => Self::Search,
ToolCallBlock::Skill(_) => Self::Skill,
ToolCallBlock::ListDir(_) => Self::ListDir,
ToolCallBlock::WebFetch(_) => Self::WebFetch,
ToolCallBlock::WebSearch(_) => Self::WebSearch,
ToolCallBlock::IntegrationSearch(_) | ToolCallBlock::UseTool(_) => Self::Other,
ToolCallBlock::MemorySearch(_)
| ToolCallBlock::Other(_)
| ToolCallBlock::Lifecycle(_) => Self::Other,
}
}
}
// ---------------------------------------------------------------------------
// BlockStatus — status dimension (not category)
// ---------------------------------------------------------------------------
/// Status of a tool block at aggregation time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum BlockStatus {
/// Tool completed successfully.
#[default]
Success,
/// Tool failed with an error.
Failed,
/// Tool is currently running/streaming.
Running,
}
/// A single entry in the activity lineage (ordered timeline).
#[derive(Debug, Clone)]
pub struct LineageEntry {
/// What kind of block this is.
pub category: ToolCategory,
/// Duration in ms (None if unknown/pre-completed).
pub duration_ms: Option<i64>,
/// Whether this block is still running.
pub running: bool,
}
// ---------------------------------------------------------------------------
// CategoryStats — per-category aggregation
// ---------------------------------------------------------------------------
/// Statistics for a single tool category.
#[derive(Debug, Clone, Default)]
pub struct CategoryStats {
/// Number of operations in this category (all statuses).
pub count: usize,
/// Count by status.
pub by_status: HashMap<BlockStatus, usize>,
/// Sequence positions where this category appeared.
/// Used for building the sequence strip.
pub sequence_positions: Vec<usize>,
/// Total elapsed time in ms for this category (Phase 2).
pub total_time_ms: i64,
}
impl CategoryStats {
/// Number of failed operations.
pub fn failed_count(&self) -> usize {
*self.by_status.get(&BlockStatus::Failed).unwrap_or(&0)
}
/// Number of running operations.
pub fn running_count(&self) -> usize {
*self.by_status.get(&BlockStatus::Running).unwrap_or(&0)
}
/// Percentage of total operations.
pub fn percent_of(&self, total: usize) -> f64 {
if total == 0 {
return 0.0;
}
(self.count as f64 / total as f64) * 100.0
}
/// Format total time as human-readable string (Phase 2).
pub fn format_time(&self) -> String {
if self.total_time_ms == 0 {
return "".to_string();
}
let secs = self.total_time_ms / 1000;
let ms = self.total_time_ms % 1000;
if secs == 0 {
format!("{}ms", ms)
} else if secs < 60 {
format!("{}.{:03}s", secs, ms)
} else {
let mins = secs / 60;
let secs = secs % 60;
format!("{}m {}s", mins, secs)
}
}
}
// ---------------------------------------------------------------------------
// StatsScope — aggregation scope
// ---------------------------------------------------------------------------
/// Aggregation scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StatsScope {
/// All visible scrollback entries for the current agent session.
#[default]
Session,
/// The turn returned by `ScrollbackState::current_turn()`,
/// with fallback to the latest turn if no current turn exists.
SelectedTurn,
}
// ---------------------------------------------------------------------------
// ToolUsageStats — full aggregation
// ---------------------------------------------------------------------------
/// Aggregated tool usage statistics.
#[derive(Debug, Clone)]
pub struct ToolUsageStats {
/// Per-category stats (includes Thinking and Message).
pub categories: BTreeMap<ToolCategory, CategoryStats>,
/// Total operations across all categories.
pub total_operations: usize,
/// When stats were last computed.
pub computed_at: Instant,
/// Scope of aggregation.
pub scope: StatsScope,
/// Ordered timeline of all blocks with category and duration.
pub lineage: Vec<LineageEntry>,
}
impl Default for ToolUsageStats {
fn default() -> Self {
Self {
categories: BTreeMap::new(),
total_operations: 0,
computed_at: Instant::now(),
scope: StatsScope::default(),
lineage: Vec::new(),
}
}
}
impl ToolUsageStats {
/// Aggregate stats from entire visible scrollback (session scope).
pub fn from_scrollback(scrollback: &ScrollbackState) -> Self {
let n = scrollback.len();
Self::from_range(scrollback, 0..n, StatsScope::Session)
}
/// Aggregate stats from a specific turn.
///
/// If turn_index is out of range, returns empty stats.
pub fn from_turn(scrollback: &ScrollbackState, turn_index: usize) -> Self {
match scrollback.turn(turn_index) {
Some(turn) => Self::from_range(scrollback, turn.range(), StatsScope::SelectedTurn),
None => ToolUsageStats {
scope: StatsScope::SelectedTurn,
computed_at: Instant::now(),
..Default::default()
},
}
}
/// Aggregate stats from selected turn if any, otherwise latest turn.
///
/// Returns empty stats if no turns exist.
pub fn from_selected_or_latest(scrollback: &ScrollbackState) -> Self {
// Prefer selected turn; fall back to latest turn.
let turn_idx = scrollback.current_turn().or_else(|| {
let count = scrollback.turns().len();
if count > 0 { Some(count - 1) } else { None }
});
match turn_idx {
Some(idx) => Self::from_turn(scrollback, idx),
None => ToolUsageStats {
scope: StatsScope::SelectedTurn,
computed_at: Instant::now(),
..Default::default()
},
}
}
/// Core aggregation logic over a range of entry indices.
///
/// Uses real ScrollbackState/Entry APIs:
/// - scrollback.len() / scrollback.entry(i)
/// - scrollback.turns() / scrollback.turn(i) / scrollback.current_turn()
fn from_range(scrollback: &ScrollbackState, range: Range<usize>, scope: StatsScope) -> Self {
let mut stats = ToolUsageStats {
scope,
computed_at: Instant::now(),
..Default::default()
};
for idx in range.clone() {
let Some(entry) = scrollback.entry(idx) else {
continue;
};
match &entry.block {
crate::scrollback::block::RenderBlock::ToolCall(tc) => {
let category = ToolCategory::from_tool_block(tc);
let status = if entry.is_running {
BlockStatus::Running
} else if !Self::tool_block_is_success(tc) {
BlockStatus::Failed
} else {
BlockStatus::Success
};
let elapsed = Self::tool_block_elapsed_ms(tc);
let cat_stats = stats.categories.entry(category).or_default();
cat_stats.count += 1;
*cat_stats.by_status.entry(status).or_default() += 1;
cat_stats.sequence_positions.push(idx);
if let Some(ms) = elapsed {
cat_stats.total_time_ms += ms;
}
stats.lineage.push(LineageEntry {
category,
duration_ms: elapsed,
running: entry.is_running,
});
}
crate::scrollback::block::RenderBlock::Thinking(thinking) => {
let elapsed = thinking.elapsed_time_ms();
let cat_stats = stats.categories.entry(ToolCategory::Thinking).or_default();
cat_stats.count += 1;
let status = if entry.is_running {
BlockStatus::Running
} else {
BlockStatus::Success
};
*cat_stats.by_status.entry(status).or_default() += 1;
cat_stats.sequence_positions.push(idx);
if let Some(ms) = elapsed {
cat_stats.total_time_ms += ms;
}
stats.lineage.push(LineageEntry {
category: ToolCategory::Thinking,
duration_ms: elapsed,
running: entry.is_running,
});
}
crate::scrollback::block::RenderBlock::AgentMessage(_) => {
let cat_stats = stats.categories.entry(ToolCategory::Message).or_default();
cat_stats.count += 1;
let status = if entry.is_running {
BlockStatus::Running
} else {
BlockStatus::Success
};
*cat_stats.by_status.entry(status).or_default() += 1;
cat_stats.sequence_positions.push(idx);
stats.lineage.push(LineageEntry {
category: ToolCategory::Message,
duration_ms: None,
running: entry.is_running,
});
}
_ => {}
}
}
stats.total_operations = stats.categories.values().map(|c| c.count).sum();
stats
}
/// Check if a tool block completed successfully.
///
/// Each ToolCallBlock variant has an is_success() method or error field.
fn tool_block_is_success(tc: &ToolCallBlock) -> bool {
match tc {
ToolCallBlock::Execute(b) => b.is_success(),
ToolCallBlock::Read(b) => b.is_success(),
ToolCallBlock::Edit(b) => b.is_success(),
ToolCallBlock::Search(b) => b.is_success(),
ToolCallBlock::ListDir(b) => b.is_success(),
ToolCallBlock::WebFetch(b) => b.is_success(),
ToolCallBlock::WebSearch(b) => b.is_success(),
ToolCallBlock::IntegrationSearch(b) => b.is_success(),
ToolCallBlock::UseTool(b) => b.is_success(),
ToolCallBlock::MemorySearch(b) => b.is_success(),
ToolCallBlock::Skill(b) => b.is_success(),
ToolCallBlock::Other(b) => b.is_success(),
ToolCallBlock::Lifecycle(_) => true,
}
}
/// Get elapsed time from a tool block (Phase 2).
fn tool_block_elapsed_ms(tc: &ToolCallBlock) -> Option<i64> {
match tc {
ToolCallBlock::Execute(b) => b.elapsed_ms(),
ToolCallBlock::Read(b) => b.elapsed_ms(),
ToolCallBlock::Edit(b) => b.elapsed_ms(),
ToolCallBlock::Search(b) => b.elapsed_ms(),
ToolCallBlock::ListDir(b) => b.elapsed_ms(),
ToolCallBlock::WebFetch(b) => b.elapsed_ms(),
ToolCallBlock::WebSearch(b) => b.elapsed_ms(),
ToolCallBlock::IntegrationSearch(b) => b.elapsed_ms(),
ToolCallBlock::UseTool(b) => b.elapsed_ms(),
ToolCallBlock::MemorySearch(b) => b.elapsed_ms(),
ToolCallBlock::Skill(b) => b.elapsed_ms(),
ToolCallBlock::Other(b) => b.elapsed_ms(),
ToolCallBlock::Lifecycle(_) => None,
}
}
/// Get categories sorted by count (descending).
pub fn sorted_categories(&self) -> Vec<(ToolCategory, &CategoryStats)> {
let mut items: Vec<_> = self.categories.iter().map(|(k, v)| (*k, v)).collect();
items.sort_by_key(|item| std::cmp::Reverse(item.1.count));
items
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::tool::{
EditToolCallBlock, ExecuteToolCallBlock, ReadToolCallBlock, ToolCallBlock,
};
use crate::scrollback::state::ScrollbackState;
#[test]
fn test_tool_category_labels() {
assert_eq!(ToolCategory::Execute.label(), "Execute");
assert_eq!(ToolCategory::Read.label(), "Read");
assert_eq!(ToolCategory::Edit.label(), "Edit");
assert_eq!(ToolCategory::Search.label(), "Search");
assert_eq!(ToolCategory::ListDir.label(), "ListDir");
assert_eq!(ToolCategory::Other.label(), "Other");
}
#[test]
fn test_category_stats_percent() {
let stats = CategoryStats {
count: 10,
..Default::default()
};
assert_eq!(stats.percent_of(100), 10.0);
assert_eq!(stats.percent_of(0), 0.0);
}
#[test]
fn test_block_status_default() {
let status = BlockStatus::default();
assert_eq!(status, BlockStatus::Success);
}
#[test]
fn test_from_scrollback_mixed_categories() {
let mut scrollback = ScrollbackState::new();
// Push mixed tool blocks
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(
ExecuteToolCallBlock::new("cargo build"),
)));
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Read(
ReadToolCallBlock::new("src/main.rs"),
)));
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Edit(
EditToolCallBlock::new("src/lib.rs", Vec::new()),
)));
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(
ExecuteToolCallBlock::new("cargo test"),
)));
let stats = ToolUsageStats::from_scrollback(&scrollback);
assert_eq!(stats.total_operations, 4);
assert_eq!(stats.scope, StatsScope::Session);
// Verify category counts
let execute_count = stats
.categories
.get(&ToolCategory::Execute)
.map(|c| c.count)
.unwrap_or(0);
let read_count = stats
.categories
.get(&ToolCategory::Read)
.map(|c| c.count)
.unwrap_or(0);
let edit_count = stats
.categories
.get(&ToolCategory::Edit)
.map(|c| c.count)
.unwrap_or(0);
assert_eq!(execute_count, 2);
assert_eq!(read_count, 1);
assert_eq!(edit_count, 1);
}
#[test]
fn test_from_scrollback_failed_blocks() {
let mut scrollback = ScrollbackState::new();
// Push execute blocks - one with error, one without
let mut success_block = ExecuteToolCallBlock::new("echo hello");
success_block.finish();
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(success_block)));
let mut failed_block =
ExecuteToolCallBlock::new("bad_command").with_error("command not found");
failed_block.finish();
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(failed_block)));
let stats = ToolUsageStats::from_scrollback(&scrollback);
assert_eq!(stats.total_operations, 2);
let exec_stats = stats.categories.get(&ToolCategory::Execute).unwrap();
assert_eq!(exec_stats.count, 2);
assert_eq!(exec_stats.failed_count(), 1);
}
#[test]
fn test_from_selected_or_latest_empty_scrollback() {
let scrollback = ScrollbackState::new();
let stats = ToolUsageStats::from_selected_or_latest(&scrollback);
assert_eq!(stats.total_operations, 0);
assert_eq!(stats.scope, StatsScope::SelectedTurn);
}
#[test]
fn test_from_selected_or_latest_no_turns_fallback() {
let mut scrollback = ScrollbackState::new();
// Push tool blocks without starting a turn
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(
ExecuteToolCallBlock::new("echo test"),
)));
let stats = ToolUsageStats::from_selected_or_latest(&scrollback);
// No turns exist, so it falls back to latest turn (which is empty)
// or returns empty stats
assert_eq!(stats.scope, StatsScope::SelectedTurn);
}
#[test]
fn test_from_selected_or_latest_with_turns() {
let mut scrollback = ScrollbackState::new();
// Push user prompt (starts a turn) and some tool blocks
scrollback.push_block(RenderBlock::user_prompt("test prompt"));
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(
ExecuteToolCallBlock::new("echo hello"),
)));
scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Read(
ReadToolCallBlock::new("file.txt"),
)));
// Set as current turn
scrollback.set_selected(Some(0));
let stats = ToolUsageStats::from_selected_or_latest(&scrollback);
assert_eq!(stats.scope, StatsScope::SelectedTurn);
// Should have aggregated the tool blocks in the turn
// Verify it computed without panicking
let _ = stats.total_operations;
}
#[test]
fn test_tool_block_is_success() {
let success = ExecuteToolCallBlock::new("echo test");
assert!(ToolUsageStats::tool_block_is_success(
&ToolCallBlock::Execute(success)
));
let mut failed = ExecuteToolCallBlock::new("bad");
failed.set_error(Some("error".into()));
assert!(!ToolUsageStats::tool_block_is_success(
&ToolCallBlock::Execute(failed)
));
}
}