Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/jump.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

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);
}
}