Files
Kigi-CLI/crates/codegen/kigi-tui/benches/render.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

364 lines
12 KiB
Rust

//! Criterion benchmarks for the kigi-tui rendering pipeline.
//!
//! Measures the per-frame cost of rendering a rich markdown document
//! into a ratatui `Buffer`. This isolates the render hot path (entry
//! rendering, scratch buffer copies, layout computation) from one-time
//! setup (markdown parsing, syntax highlighting, word wrapping).
use std::time::Duration;
use criterion::{Criterion, criterion_group, criterion_main};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use kigi_tui::appearance::AppearanceConfig;
use kigi_tui::render::Renderable;
use kigi_tui::scrollback::entry::ScrollbackEntry;
use kigi_tui::scrollback::render::render_scrolled_entries_with_scratch;
use kigi_tui::scrollback::wrappers::EntryRenderer;
use kigi_tui::scrollback::{
EntryId, EntryLayoutInfo, HorizontalLayout, RenderBlock, ScrollbackState,
};
use kigi_tui::theme::Theme;
static BENCH_MD: &str = include_str!("bench.md");
/// Viewport dimensions for the benchmark.
const VIEWPORT_WIDTH: u16 = 120;
const VIEWPORT_HEIGHT: u16 = 50;
/// How many lines to advance per step in full_scroll.
const SCROLL_STEP: u16 = 10;
/// Entry count for the reveal benchmarks. Approximates the ~3,200-entry
/// scrollback of a long real session (~5 MB of searchable text).
const REVEAL_ENTRIES: usize = 3000;
/// Build the entries used by every benchmark iteration.
///
/// This is called once in the setup closure so that markdown parsing,
/// syntax highlighting, and word-wrap caching are **not** measured.
fn build_entries() -> Vec<ScrollbackEntry> {
vec![
// A user prompt to exercise multi-entry layout
ScrollbackEntry::new(RenderBlock::user_prompt(
"Explain the rendering pipeline architecture in detail",
)),
// The main payload — a large markdown agent response
ScrollbackEntry::new(RenderBlock::agent_message(BENCH_MD)),
]
}
/// Pre-compute entry layout info (same as prepare_layout does in production).
fn compute_layouts(
entries: &[ScrollbackEntry],
appearance: &AppearanceConfig,
) -> Vec<EntryLayoutInfo> {
let theme = Theme::current();
let viewport = Rect::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
let layout = HorizontalLayout::new(viewport, &appearance.scrollback.layout);
let entry_width = layout.entry_content_area().width;
entries
.iter()
.map(|e| {
let height = EntryRenderer::new(e, &theme)
.with_appearance(appearance.clone())
.desired_height(entry_width);
EntryLayoutInfo {
height,
gap_after: 1,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
}
})
.collect()
}
// ─── Benchmarks ────────────────────────────────────────────────────
/// Render a single frame at scroll offset 0 (top of document).
///
/// The per-frame baseline with no top clipping — only bottom-clipped.
fn bench_single_frame(c: &mut Criterion) {
let entries = build_entries();
let entry_refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let viewport = Rect::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let layouts = compute_layouts(&entries, &appearance);
// Prime the wrap cache
{
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&entry_refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
}
c.bench_function("render/single_frame", |b| {
let mut buf = Buffer::empty(viewport);
b.iter(|| {
buf.reset();
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&entry_refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
});
});
}
/// Scroll through the entire document, stepping by SCROLL_STEP lines.
///
/// Simulates a user paging through the document, exercising the full
/// render pipeline at many offsets including partial entry rendering.
fn bench_full_scroll(c: &mut Criterion) {
let entries = build_entries();
let entry_refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let viewport = Rect::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let layouts = compute_layouts(&entries, &appearance);
// usize: scroll offset is usize in the render path.
let total: usize = layouts
.iter()
.map(|l| l.height as usize + l.gap_after as usize)
.sum(); // heights + gaps
let max_scroll = total.saturating_sub(VIEWPORT_HEIGHT as usize);
// Prime the wrap cache
{
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&entry_refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
}
c.bench_function("render/full_scroll", |b| {
let mut buf = Buffer::empty(viewport);
b.iter(|| {
let mut offset = 0usize;
while offset <= max_scroll {
buf.reset();
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&entry_refs,
offset,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
offset += SCROLL_STEP as usize;
}
});
});
}
/// Scroll through a large scrollback the way production does: per step,
/// locate the paint window via `ScrollbackState::paint_window` (partition
/// point over the cached virtual-y prefix sum) and render only that slice
/// with `content_y0`/`entry_index_base` — mirroring
/// `ScrollbackPane::render_content`. `full_scroll` above measures the
/// renderer's full-list walk; this measures the shipped windowed path, so
/// regressions in the window computation show up here.
fn bench_windowed_scroll(c: &mut Criterion) {
let (state, _think_id) = build_reveal_state();
let viewport = Rect::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
let theme = Theme::current();
let n = state.len();
let virtual_y = state.get_cached_virtual_y().expect("layout cache");
let layouts = state.get_cached_entry_layouts().expect("layout cache");
let total: usize =
virtual_y[n - 1] + layouts[n - 1].height as usize + layouts[n - 1].gap_after as usize;
let max_scroll = total.saturating_sub(VIEWPORT_HEIGHT as usize);
let mut g = c.benchmark_group("render");
// Each iteration pages through the whole corpus; cap samples to keep the
// run short (same treatment as reveal/navigate_rebuild).
g.sample_size(10).warm_up_time(Duration::from_millis(500));
g.bench_function("windowed_scroll", |b| {
let mut buf = Buffer::empty(viewport);
b.iter(|| {
let mut offset = 0usize;
while offset <= max_scroll {
buf.reset();
let (paint_range, content_y0) =
state.paint_window(0..n, offset, VIEWPORT_HEIGHT as usize);
let window = state.entries_in_range(paint_range.clone());
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&window,
offset,
None,
&theme,
state.appearance(),
&layouts[paint_range.clone()],
0,
None,
None,
None,
content_y0,
paint_range.start,
&[],
None,
None,
);
offset += SCROLL_STEP as usize;
}
});
});
g.finish();
}
// ─── Reveal (scrollback-search n/N navigation) ─────────────────────
/// One paragraph of lorem-style body per entry (~1.7 KB), so the
/// `REVEAL_ENTRIES`-entry corpus is on the order of the motivating session's
/// searchable text (a few MB; the exact size is logged).
fn reveal_body(i: usize) -> String {
let lorem = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do \
eiusmod tempor incididunt ut labore et dolore magna aliqua ut \
enim ad minim veniam quis nostrud exercitation ullamco laboris ";
format!(
"Entry {i}: the quick brown fox jumps over the lazy dog. {} done.",
lorem.repeat(9)
)
}
/// Build a large scrollback approximating a long search session, returning it
/// with the `EntryId` of a thinking block placed in the middle (the rebuild
/// bench dirties that entry's height to force reveal's rebuild branch).
fn build_reveal_state() -> (ScrollbackState, EntryId) {
let mut state = ScrollbackState::new();
let mut total_bytes = 0usize;
let middle = REVEAL_ENTRIES / 2;
let mut think_id = None;
for i in 0..REVEAL_ENTRIES {
if i == middle {
let body = "reasoning about the matched entry and the nearby context";
total_bytes += body.len();
think_id = Some(state.push_block(RenderBlock::thinking(body)));
} else {
let body = reveal_body(i);
total_bytes += body.len();
state.push_block(RenderBlock::user_prompt(body));
}
}
// ~1-2 KB prompts are foldable, so they default to Collapsed; expand them so
// a reveal over an already-visible match doesn't change display state — the
// steady n/N case the skip path optimizes. (Setup only; not measured.)
state.expand_all();
// Settle the layout cache and clear dirty heights so the measured reveals
// start from a clean cache.
state.prepare_layout(VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
eprintln!(
"reveal corpus: {REVEAL_ENTRIES} entries, ~{:.1} MB text",
total_bytes as f64 / (1024.0 * 1024.0)
);
(state, think_id.expect("thinking block pushed"))
}
/// New per-`n` cost: revealing an already-visible match reuses the settled
/// cache and only refreshes the cheap total-height sum — no O(history) rebuild.
fn bench_reveal_skip(c: &mut Criterion) {
let (mut state, _think_id) = build_reveal_state();
c.bench_function("reveal/navigate_skip", |b| {
let mut i = 0usize;
b.iter(|| {
state.reveal_entry_line(i % REVEAL_ENTRIES, 0);
i += 1;
});
});
}
/// Old per-`n` cost: the pre-fix reveal ran a full layout rebuild unconditionally.
/// The O(1) `push_chunk_to_thinking` dirties one entry's height, negligible next
/// to the O(N) `rebuild_layout` it forces reveal to take on every call.
fn bench_reveal_rebuild(c: &mut Criterion) {
let (mut state, think_id) = build_reveal_state();
let mut g = c.benchmark_group("reveal");
// Each iteration rebuilds the whole layout (O(N)); cap samples so the run
// doesn't take minutes.
g.sample_size(20).warm_up_time(Duration::from_millis(500));
g.bench_function("navigate_rebuild", |b| {
let mut i = 0usize;
b.iter(|| {
state.push_chunk_to_thinking(think_id, "x");
state.reveal_entry_line(i % REVEAL_ENTRIES, 0);
i += 1;
});
});
g.finish();
}
criterion_group!(
benches,
bench_single_frame,
bench_full_scroll,
bench_windowed_scroll,
bench_reveal_skip,
bench_reveal_rebuild
);
criterion_main!(benches);