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
@@ -0,0 +1,521 @@
//! Smooth scrollbar widget with follow-mode awareness.
//!
//! This module provides scrollbar rendering using `tui-scrollbar` for smooth
//! Unicode-based scrollbars with sub-character precision.
//!
//! # Visual Design
//!
//! The scrollbar visibility indicates follow mode state:
//! - **Following (at bottom):** Very dim scrollbar (subtle indicator of content above)
//! - **Not following:** Brighter scrollbar (draws attention to "scrolled up" state)
//!
//! This helps users understand when they're viewing live content vs. scrolled back.
//!
//! # Layout
//!
//! Callers should reserve space for the scrollbar:
//! - 1 column gap (visual separation from content)
//! - 1 column track (the scrollbar itself)
//!
//! Use [`split_area_for_scrollbar`] to compute content and scrollbar areas.
//!
//! # TODO: Mouse Support
//!
//! `tui-scrollbar` already provides mouse interaction support via:
//! - [`tui_scrollbar::ScrollBarInteraction`] for drag state
//! - [`tui_scrollbar::ScrollEvent`] / [`tui_scrollbar::PointerEvent`] for input
//! - [`tui_scrollbar::ScrollBar::handle_event`] for hit testing and drag math
//!
//! To wire this up:
//! 1. Store `ScrollBarInteraction` in pane state
//! 2. Translate crossterm `MouseEvent` to `tui_scrollbar::PointerEvent`
//! 3. Call `scrollbar.handle_event()` to get `ScrollCommand::SetOffset`
//! 4. Update scroll position accordingly
use std::sync::atomic::{AtomicBool, Ordering};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui_core::buffer::Buffer as CoreBuffer;
use ratatui_core::layout::Rect as CoreRect;
use ratatui_core::widgets::Widget as _;
use tui_scrollbar::ScrollBar;
use tui_scrollbar::ScrollLengths;
use tui_scrollbar::{SUBCELL, ScrollMetrics};
/// When set, every scrollbar renders as a no-op. The pager toggles this on in
/// minimal (scrollback-native) mode, where lists/dropdowns show
/// no scrollbar bar at all — they scroll internally and the footer carries the
/// "↑/↓ navigate" hint. Off (default) everywhere else, so the full TUI is
/// unaffected.
static SCROLLBARS_HIDDEN: AtomicBool = AtomicBool::new(false);
/// Globally hide or show all scrollbars. See [`SCROLLBARS_HIDDEN`].
pub fn set_scrollbars_hidden(hidden: bool) {
SCROLLBARS_HIDDEN.store(hidden, Ordering::Relaxed);
}
/// Whether scrollbars are currently globally hidden.
pub fn scrollbars_hidden() -> bool {
SCROLLBARS_HIDDEN.load(Ordering::Relaxed)
}
/// Number of columns reserved between content and the scrollbar track.
/// This creates the "X" gap in the XSXBXX pattern (gap between selection_right and scrollbar).
const SCROLLBAR_GAP_COLS: u16 = 1;
/// Width of the scrollbar track itself (in terminal cells).
const SCROLLBAR_TRACK_COLS: u16 = 1;
/// Total columns reserved for scrollbar UI (gap + track).
pub const SCROLLBAR_TOTAL_COLS: u16 = SCROLLBAR_GAP_COLS + SCROLLBAR_TRACK_COLS;
/// Split an area into content + scrollbar regions.
///
/// Layout:
/// - `content_area`: original area minus [`SCROLLBAR_TOTAL_COLS`] on the right
/// - `scrollbar_area`: the last column of the original area (1 cell wide)
/// - The column between them is the "gap" (left intentionally blank)
///
/// Returns `(content_area, None)` when the terminal is too narrow.
///
/// **Note**: This always reserves space for scrollbar. Use [`maybe_split_for_scrollbar`]
/// to only reserve space when the scrollbar will actually be shown.
pub fn split_area_for_scrollbar(area: Rect) -> (Rect, Option<Rect>) {
if area.width <= SCROLLBAR_TOTAL_COLS {
return (area, None);
}
let content_width = area.width.saturating_sub(SCROLLBAR_TOTAL_COLS);
let content_area = Rect {
x: area.x,
y: area.y,
width: content_width,
height: area.height,
};
let scrollbar_area = Rect {
x: area.right().saturating_sub(1),
y: area.y,
width: SCROLLBAR_TRACK_COLS,
height: area.height,
};
(content_area, Some(scrollbar_area))
}
/// Split an area only if scrollbar is actually needed.
///
/// Unlike [`split_area_for_scrollbar`], this gives full width to content
/// when scrollbar won't be shown (`total_lines <= viewport_lines`).
///
/// Use this when you know the content height before splitting.
pub fn maybe_split_for_scrollbar(area: Rect, total_lines: u16) -> (Rect, Option<Rect>) {
// Only reserve space if scrollbar will actually be shown
if needs_scrollbar(total_lines, area.height) {
split_area_for_scrollbar(area)
} else {
// No scrollbar needed - give full width to content
(area, None)
}
}
/// Whether the scrollbar should be shown (content overflows viewport).
pub fn needs_scrollbar(total_lines: u16, viewport_lines: u16) -> bool {
total_lines > viewport_lines
}
/// Whether the view is at the bottom (following mode position).
#[allow(dead_code)] // Useful helper, kept for future use
pub fn is_at_bottom(total_lines: u16, viewport_lines: u16, offset: u16) -> bool {
let max_offset = total_lines.saturating_sub(viewport_lines);
offset >= max_offset
}
/// Result of mapping a scrollbar click/drag position to a scroll offset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarClickResult {
/// Jump to the very top (click on first row of track).
Top,
/// Jump to the very bottom (click on last row of track).
Bottom,
/// Set scroll offset to this value (proportional position).
Offset(usize),
}
/// Map a click on the scrollbar gutter to a scroll offset.
///
/// Uses the same `tui_scrollbar::ScrollMetrics` that the renderer uses to
/// position the thumb, so the click is the exact inverse of the rendering.
/// Emulates `JumpToClick` behavior: centers the thumb on the click position.
///
/// # Arguments
///
/// * `cell_index` — 0-based row within the scrollbar area (screen_y - sb.y)
/// * `track_cells` — height of the scrollbar area (sb.height)
/// * `total_lines` — total content height (pre-scaled)
/// * `viewport_lines` — viewport height
///
/// Returns `Top`/`Bottom` for clicks on the first/last row, otherwise
/// an offset that places the thumb centered on the click.
pub fn scrollbar_click_to_offset(
cell_index: u16,
track_cells: u16,
total_lines: u16,
viewport_lines: u16,
) -> ScrollbarClickResult {
if track_cells == 0 {
return ScrollbarClickResult::Top;
}
// First row → go to top.
if cell_index == 0 {
return ScrollbarClickResult::Top;
}
// Last row → go to bottom.
if cell_index >= track_cells.saturating_sub(1) {
return ScrollbarClickResult::Bottom;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let metrics = ScrollMetrics::new(lengths, 0, track_cells);
// Center the thumb on the clicked cell (same as tui_scrollbar JumpToClick).
let position = (cell_index as usize)
.saturating_mul(SUBCELL)
.saturating_add(SUBCELL / 2);
let half_thumb = metrics.thumb_len() / 2;
let thumb_start = position.saturating_sub(half_thumb);
let offset = metrics.offset_for_thumb_start(thumb_start);
ScrollbarClickResult::Offset(offset)
}
/// Render a scrollbar with follow-mode aware styling.
///
/// # Arguments
///
/// * `buf` - The ratatui buffer to render into
/// * `scrollbar_area` - The 1-column area for the scrollbar track
/// * `total_lines` - Total content height in lines
/// * `viewport_lines` - Visible viewport height in lines
/// * `offset` - Current scroll offset (lines from top)
/// * `is_following` - Whether follow mode is active (dims the scrollbar)
///
/// The scrollbar is always rendered when content overflows, but styled differently
/// based on follow state:
/// - Following: very dim (subtle indicator)
/// - Not following: brighter (draws attention)
pub fn render_scrollbar(
buf: &mut Buffer,
scrollbar_area: Option<Rect>,
total_lines: u16,
viewport_lines: u16,
offset: u16,
is_following: bool,
) {
if SCROLLBARS_HIDDEN.load(Ordering::Relaxed) {
return;
}
let Some(scrollbar_area) = scrollbar_area else {
return;
};
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
return;
}
if !needs_scrollbar(total_lines, viewport_lines) {
return;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
// Render into ratatui-core scratch buffer
let core_area = CoreRect {
x: scrollbar_area.x,
y: scrollbar_area.y,
width: scrollbar_area.width,
height: scrollbar_area.height,
};
let mut scratch = CoreBuffer::empty(core_area);
(&scrollbar).render(core_area, &mut scratch);
// Copy to ratatui buffer with follow-aware styling
let (track_style, thumb_style) = scrollbar_styles(is_following);
for row in 0..scrollbar_area.height {
let x = scrollbar_area.x;
let y = scrollbar_area.y + row;
let src = &scratch[(x, y)];
let dst = &mut buf[(x, y)];
if src.symbol() == " " {
dst.set_symbol(" ");
dst.set_style(track_style);
} else {
dst.set_symbol("\u{2588}");
dst.set_style(thumb_style);
}
}
}
/// Get track and thumb styles based on follow mode.
///
/// Following mode: very dim colors (scrollbar recedes into background)
/// Not following: brighter colors (scrollbar "pops out")
fn scrollbar_styles(is_following: bool) -> (Style, Style) {
let theme = crate::theme::Theme::current();
if is_following {
// Very dim - scrollbar is subtle when following
let track_style = Style::new().bg(theme.scrollbar_bg);
let thumb_style = Style::new().fg(theme.scrollbar_fg).bg(theme.scrollbar_bg);
(track_style, thumb_style)
} else {
// Brighter - scrollbar stands out when scrolled up
let track_style = Style::new().bg(theme.bg_highlight);
let thumb_style = Style::new().fg(theme.gray).bg(theme.bg_highlight);
(track_style, thumb_style)
}
}
/// Render a scrollbar with custom track and thumb styles.
///
/// Like [`render_scrollbar`] but allows custom styling for theme integration.
pub fn render_scrollbar_styled(
buf: &mut Buffer,
scrollbar_area: Option<Rect>,
total_lines: u16,
viewport_lines: u16,
offset: u16,
track_style: Style,
thumb_style: Style,
) {
if SCROLLBARS_HIDDEN.load(Ordering::Relaxed) {
return;
}
let Some(scrollbar_area) = scrollbar_area else {
return;
};
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
return;
}
if !needs_scrollbar(total_lines, viewport_lines) {
return;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
// Render into ratatui-core scratch buffer
let core_area = CoreRect {
x: scrollbar_area.x,
y: scrollbar_area.y,
width: scrollbar_area.width,
height: scrollbar_area.height,
};
let mut scratch = CoreBuffer::empty(core_area);
(&scrollbar).render(core_area, &mut scratch);
// Copy to ratatui buffer with custom styling
for row in 0..scrollbar_area.height {
let x = scrollbar_area.x;
let y = scrollbar_area.y + row;
let src = &scratch[(x, y)];
let dst = &mut buf[(x, y)];
if src.symbol() == " " {
dst.set_symbol(" ");
dst.set_style(track_style);
} else {
dst.set_symbol("\u{2588}");
dst.set_style(thumb_style);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[test]
fn test_split_area_normal() {
let area = Rect::new(0, 0, 40, 10);
let (content, scrollbar) = split_area_for_scrollbar(area);
// Content should be 40 - 2 = 38 wide (gap + track)
assert_eq!(content.width, 38);
assert_eq!(content.height, 10);
// Scrollbar should be at x=39, 1 column wide
let sb = scrollbar.expect("scrollbar area");
assert_eq!(sb.x, 39);
assert_eq!(sb.width, 1);
assert_eq!(sb.height, 10);
}
#[test]
fn test_split_area_too_narrow() {
let area = Rect::new(0, 0, 2, 10);
let (content, scrollbar) = split_area_for_scrollbar(area);
// Too narrow - return original area, no scrollbar
assert_eq!(content, area);
assert!(scrollbar.is_none());
}
#[test]
fn test_maybe_split_reserves_when_needed() {
let area = Rect::new(0, 0, 40, 10);
// Content overflows (20 > 10) - should reserve scrollbar space
let (content, scrollbar) = maybe_split_for_scrollbar(area, 20);
assert_eq!(content.width, 38); // Reduced by 2 for gap + scrollbar track
assert!(scrollbar.is_some());
}
#[test]
fn test_maybe_split_full_width_when_not_needed() {
let area = Rect::new(0, 0, 40, 10);
// Content fits (5 <= 10) - should give full width to content
let (content, scrollbar) = maybe_split_for_scrollbar(area, 5);
assert_eq!(content.width, 40); // Full width
assert!(scrollbar.is_none());
}
#[test]
fn test_needs_scrollbar() {
assert!(needs_scrollbar(100, 10)); // Content > viewport
assert!(!needs_scrollbar(10, 10)); // Content == viewport
assert!(!needs_scrollbar(5, 10)); // Content < viewport
}
#[test]
fn test_is_at_bottom() {
// total=100, viewport=10 -> max_offset=90
assert!(is_at_bottom(100, 10, 90)); // At bottom
assert!(is_at_bottom(100, 10, 95)); // Past bottom (clamped)
assert!(!is_at_bottom(100, 10, 89)); // One line above bottom
assert!(!is_at_bottom(100, 10, 0)); // At top
}
#[test]
fn test_render_scrollbar_no_area() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
// Should not panic with None area
render_scrollbar(&mut buf, None, 100, 10, 0, false);
}
#[test]
fn test_render_scrollbar_no_overflow() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
let mut buf = Buffer::empty(area);
// Content fits - should not render anything
render_scrollbar(&mut buf, scrollbar_area, 5, 10, 0, false);
// Check scrollbar column is empty (spaces with no custom background)
let sb = scrollbar_area.unwrap();
for y in 0..sb.height {
let cell = &buf[(sb.x, sb.y + y)];
assert_eq!(cell.symbol(), " ");
// The cell should NOT have our scrollbar background colors
// (i.e., it should be reset/default, not Color::Rgb)
if let Some(Color::Rgb(_, _, _)) = cell.style().bg {
panic!("Should not have RGB background when no scrollbar rendered");
}
// Otherwise - Reset, None, or other default-like value
}
}
#[test]
fn test_render_scrollbar_following_vs_not() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
// Render following
let mut buf_following = Buffer::empty(area);
render_scrollbar(&mut buf_following, scrollbar_area, 100, 10, 90, true);
// Render not following
let mut buf_not_following = Buffer::empty(area);
render_scrollbar(&mut buf_not_following, scrollbar_area, 100, 10, 50, false);
// The styles should differ - not following should be brighter
let sb = scrollbar_area.unwrap();
let following_style = buf_following[(sb.x, sb.y)].style();
let not_following_style = buf_not_following[(sb.x, sb.y)].style();
// Both should have backgrounds set (non-default)
assert!(following_style.bg.is_some());
assert!(not_following_style.bg.is_some());
// At 256-color or truecolor, the backgrounds should be distinguishable.
// At Basic (16-color) level, both dark grays map to Black — expected.
if crate::theme::color_support::get().has_256() {
assert_ne!(following_style.bg, not_following_style.bg);
}
}
#[test]
fn test_scrollbar_thumb_position() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
let sb = scrollbar_area.unwrap();
// At top
let mut buf_top = Buffer::empty(area);
render_scrollbar(&mut buf_top, scrollbar_area, 100, 10, 0, false);
// At bottom
let mut buf_bottom = Buffer::empty(area);
render_scrollbar(&mut buf_bottom, scrollbar_area, 100, 10, 90, false);
// Count thumb cells (non-space)
let count_thumb = |buf: &Buffer| -> usize {
(0..sb.height)
.filter(|&y| buf[(sb.x, sb.y + y)].symbol() != " ")
.count()
};
// Both should have a thumb
let top_thumb = count_thumb(&buf_top);
let bottom_thumb = count_thumb(&buf_bottom);
assert!(top_thumb > 0, "Should have thumb at top");
assert!(bottom_thumb > 0, "Should have thumb at bottom");
// Thumb size should be consistent
assert_eq!(top_thumb, bottom_thumb, "Thumb size should be consistent");
// Thumb position should differ (visual inspection would show top vs bottom)
// We can check that the thumb cells are in different positions
let thumb_positions = |buf: &Buffer| -> Vec<u16> {
(0..sb.height)
.filter(|&y| buf[(sb.x, sb.y + y)].symbol() != " ")
.collect()
};
let top_pos = thumb_positions(&buf_top);
let bottom_pos = thumb_positions(&buf_bottom);
assert_ne!(
top_pos, bottom_pos,
"Thumb should be at different positions"
);
}
}