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).
255 lines
7.7 KiB
Rust
255 lines
7.7 KiB
Rust
//! `/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);
|
|
}
|
|
}
|