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,254 @@
|
||||
//! `/jump` picker: an overlay listing every turn in the conversation.
|
||||
//!
|
||||
//! Pure client-side navigation over the scrollback timeline
|
||||
//! ([`crate::scrollback::state::TimelineEntry`]): moving the cursor
|
||||
//! live-scrolls the transcript to the hovered turn, Enter jumps there,
|
||||
//! Esc restores the viewport the picker opened from. Unlike `/rewind`
|
||||
//! nothing is fetched and nothing is mutated.
|
||||
//!
|
||||
//! Chrome, row geometry, and hit-testing come from
|
||||
//! [`crate::views::overlay_list::ListOverlay`] (shared with the rewind
|
||||
//! picker); this module owns only the row content and input mapping.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
use crate::render::line_utils::truncate_str;
|
||||
use crate::scrollback::entry::EntryId;
|
||||
use crate::scrollback::state::{ScrollAnchor, TimelineEntry};
|
||||
use crate::theme::Theme;
|
||||
use crate::views::overlay_list::ListOverlay;
|
||||
|
||||
/// Viewport snapshot captured when the picker opens, restored on Esc / failed
|
||||
/// jump. The viewport is a width-stable [`ScrollAnchor`] bookmark, not a raw
|
||||
/// scroll offset (which clamps and drifts under a resize).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct JumpRestore {
|
||||
pub(crate) bookmark: Option<ScrollAnchor>,
|
||||
pub selected: Option<usize>,
|
||||
pub follow_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JumpState {
|
||||
/// One row per turn, oldest first (row index == `turn_idx`).
|
||||
pub entries: Vec<TimelineEntry>,
|
||||
/// Cursor row.
|
||||
pub selected: usize,
|
||||
/// Viewport to restore on dismiss.
|
||||
pub restore: JumpRestore,
|
||||
}
|
||||
|
||||
impl JumpState {
|
||||
fn list(&self) -> ListOverlay {
|
||||
ListOverlay {
|
||||
len: self.entries.len(),
|
||||
selected: self.selected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum JumpInput {
|
||||
/// Jump to the turn (by its prompt's stable id) and close.
|
||||
Select(EntryId),
|
||||
Dismissed,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
Consumed,
|
||||
}
|
||||
|
||||
pub fn handle_jump_key(state: &JumpState, key: &KeyEvent) -> JumpInput {
|
||||
if key.kind == crossterm::event::KeyEventKind::Release {
|
||||
return JumpInput::Consumed;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('j') | KeyCode::Down => JumpInput::MoveDown,
|
||||
KeyCode::Char('k') | KeyCode::Up => JumpInput::MoveUp,
|
||||
KeyCode::Enter => jump_activate(state),
|
||||
KeyCode::Esc => JumpInput::Dismissed,
|
||||
_ => JumpInput::Consumed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor by `delta`, clamped to the entry list.
|
||||
pub fn move_cursor(state: &mut JumpState, delta: i32) {
|
||||
if state.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max = state.entries.len() as i32 - 1;
|
||||
state.selected = (state.selected as i32 + delta).clamp(0, max) as usize;
|
||||
}
|
||||
|
||||
/// Move the cursor to `idx` (mouse hover/click). Returns `true` on change.
|
||||
pub fn set_jump_cursor(state: &mut JumpState, idx: usize) -> bool {
|
||||
if state.entries.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let new = idx.min(state.entries.len() - 1);
|
||||
if state.selected != new {
|
||||
state.selected = new;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The activation input for the current cursor row (Enter-equivalent).
|
||||
pub fn jump_activate(state: &JumpState) -> JumpInput {
|
||||
state
|
||||
.entries
|
||||
.get(state.selected)
|
||||
.map(|e| JumpInput::Select(e.prompt_entry_id))
|
||||
.unwrap_or(JumpInput::Consumed)
|
||||
}
|
||||
|
||||
/// Hit-test a screen position against the picker's clickable rows.
|
||||
pub fn jump_row_at(state: &JumpState, area: Rect, col: u16, row: u16) -> Option<usize> {
|
||||
state.list().row_at(area, col, row)
|
||||
}
|
||||
|
||||
pub fn jump_overlay_height(state: &JumpState, screen_h: u16) -> u16 {
|
||||
state.list().height(screen_h)
|
||||
}
|
||||
|
||||
pub fn render_jump_overlay(buf: &mut Buffer, area: Rect, state: &JumpState, focused: bool) {
|
||||
let theme = Theme::current();
|
||||
// Ordinal gutter sized to the widest turn number.
|
||||
let ord_width = state.entries.len().to_string().len();
|
||||
|
||||
state
|
||||
.list()
|
||||
.render(buf, area, "Jump to which turn?", focused, |i, ctx| {
|
||||
let entry = &state.entries[i];
|
||||
let ordinal = format!("{:>ord_width$} ", entry.turn_idx + 1);
|
||||
let ord_style = Style::default().fg(theme.gray).bg(ctx.row_bg);
|
||||
let preview: String = if entry.preview.is_empty() {
|
||||
"(no preview)".to_string()
|
||||
} else {
|
||||
truncate_str(
|
||||
&entry.preview,
|
||||
ctx.content_width.saturating_sub(ord_width as u16 + 3) as usize,
|
||||
)
|
||||
};
|
||||
let text_style = Style::default()
|
||||
.fg(theme.text_primary)
|
||||
.bg(ctx.row_bg)
|
||||
.add_modifier(if ctx.is_cursor {
|
||||
Modifier::BOLD
|
||||
} else {
|
||||
Modifier::empty()
|
||||
});
|
||||
Line::from(vec![
|
||||
Span::styled(ordinal, ord_style),
|
||||
Span::styled(preview, text_style),
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyEventKind, KeyModifiers};
|
||||
|
||||
fn entry(turn_idx: usize) -> TimelineEntry {
|
||||
TimelineEntry {
|
||||
turn_idx,
|
||||
prompt_entry_id: EntryId::new(turn_idx as u64 * 2),
|
||||
preview: format!("turn {turn_idx}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn state(n: usize) -> JumpState {
|
||||
JumpState {
|
||||
entries: (0..n).map(entry).collect(),
|
||||
selected: 0,
|
||||
restore: JumpRestore {
|
||||
bookmark: None,
|
||||
selected: None,
|
||||
follow_mode: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
kind: KeyEventKind::Press,
|
||||
state: crossterm::event::KeyEventState::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn area() -> Rect {
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 40,
|
||||
height: 10,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_map_to_inputs() {
|
||||
let s = state(3);
|
||||
assert!(matches!(
|
||||
handle_jump_key(&s, &key(KeyCode::Char('j'))),
|
||||
JumpInput::MoveDown
|
||||
));
|
||||
assert!(matches!(
|
||||
handle_jump_key(&s, &key(KeyCode::Up)),
|
||||
JumpInput::MoveUp
|
||||
));
|
||||
assert!(matches!(
|
||||
handle_jump_key(&s, &key(KeyCode::Enter)),
|
||||
JumpInput::Select(id) if id == EntryId::new(0)
|
||||
));
|
||||
assert!(matches!(
|
||||
handle_jump_key(&s, &key(KeyCode::Esc)),
|
||||
JumpInput::Dismissed
|
||||
));
|
||||
assert!(matches!(
|
||||
handle_jump_key(&s, &key(KeyCode::Char('x'))),
|
||||
JumpInput::Consumed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_moves_and_clamps() {
|
||||
let mut s = state(3);
|
||||
move_cursor(&mut s, 1);
|
||||
assert_eq!(s.selected, 1);
|
||||
move_cursor(&mut s, 10);
|
||||
assert_eq!(s.selected, 2);
|
||||
move_cursor(&mut s, -10);
|
||||
assert_eq!(s.selected, 0);
|
||||
|
||||
assert!(set_jump_cursor(&mut s, 2));
|
||||
assert!(!set_jump_cursor(&mut s, 2));
|
||||
assert!(!set_jump_cursor(&mut s, 99), "clamps to last (no change)");
|
||||
assert_eq!(s.selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activate_selects_turn_under_cursor() {
|
||||
let mut s = state(3);
|
||||
s.selected = 2;
|
||||
assert!(matches!(jump_activate(&s), JumpInput::Select(id) if id == EntryId::new(4)));
|
||||
|
||||
let empty = state(0);
|
||||
assert!(matches!(jump_activate(&empty), JumpInput::Consumed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_hit_test_maps_to_entry_index() {
|
||||
let s = state(3);
|
||||
// Title at y+1; rows start at y+2 (ListOverlay geometry).
|
||||
assert_eq!(jump_row_at(&s, area(), 5, 1), None);
|
||||
assert_eq!(jump_row_at(&s, area(), 5, 2), Some(0));
|
||||
assert_eq!(jump_row_at(&s, area(), 5, 4), Some(2));
|
||||
assert_eq!(jump_row_at(&s, area(), 5, 5), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user