Files
Kigi-CLI/crates/codegen/kigi-pager-minimal/src/full_view.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

448 lines
18 KiB
Rust

//! Minimal-mode "full view": the complete conversation rendered with **every**
//! block fully expanded — reasoning shown in full (not the collapsed
//! `Thought for Xs` marker), tool output uncapped — as an ANSI string to open in
//! `$PAGER` (`less -R`).
//!
//! Minimal commits blocks into the terminal's *native* scrollback as static
//! text (collapsed reasoning, truncated tool output), which cannot be
//! re-rendered in place when the user toggles verbose. So "expand everything"
//! is served by re-rendering the whole transcript off-screen at full fidelity
//! and handing it to a pager (transcript mode). Reuses [`EntryRenderer`] (all
//! per-block layout, syntax highlighting, diff colors) with the display mode
//! forced to `Expanded`, then serializes the resulting cell buffer to ANSI so
//! colors survive in the pager.
use std::time::{Duration, Instant};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use kigi_tui::app::app_view::AppView;
use kigi_tui::minimal_api;
use kigi_tui::render::Renderable;
use kigi_tui::scrollback::entry::ScrollbackEntry;
use kigi_tui::scrollback::types::DisplayMode;
use kigi_tui::scrollback::wrappers::EntryRenderer;
use kigi_tui::theme::Theme;
/// Fixed render width for the transcript. A stable, readable column count
/// independent of the current terminal size (the pager wraps to the real
/// terminal, and committed-vs-full-view wrapping need not match).
const FULL_VIEW_WIDTH: u16 = 100;
/// Per-frame budget for the incremental transcript build. Small enough that a
/// slice never blocks input/streaming noticeably (frames tick at ~16ms while a
/// build is active — see `AppView::tick_interval_ceiling`), large enough to
/// drain a big session in a couple of seconds.
const PUMP_BUDGET: Duration = Duration::from_millis(8);
/// Advance the in-progress `/transcript` build by one time-budgeted slice.
/// Called once per frame from [`crate::draw`]; a no-op when no build is armed
/// (`minimal_api::request_minimal_transcript`).
///
/// Why sliced: the full-fidelity transcript is a layout + syntax-highlight +
/// ANSI-serialization pass over every block. Doing it in one shot froze the
/// event loop for seconds on long sessions, and it cannot move off-thread —
/// the block model is `!Send` (syntect's resumable highlighter lives inside
/// streaming-markdown blocks). Budgeted slices amortized across frames are how
/// other scrollback TUIs keep transcript-scale work off the critical path
/// (budgeted commit ticks / cell caches / throttled frame loops). On completion
/// the file is written and `pending_pager_path` armed; the event loop then
/// suspends into `$PAGER`.
pub fn pump_transcript(app: &mut AppView) {
let Some(mut build) = minimal_api::take_minimal_transcript(app) else {
return;
};
// Resolve against the build's OWNING agent, never the active view:
// `EntryId`s are per-`ScrollbackState` counters, so a session switch
// mid-build must not re-target the snapshot at another agent's scrollback
// (id collisions would stitch the transcript from the wrong session). The
// owner keeps existing across view switches, so the build also survives
// the user tabbing away — only a truly-removed agent drops it.
let id = build.agent;
let appearance = super::commit::committed_appearance(&app.appearance);
{
let Some(agent) = app.agents.get(&id) else {
tracing::warn!("minimal: transcript build's agent removed; dropping the build");
return;
};
let theme = Theme::current();
let sb = &agent.scrollback;
// Show every thinking entry THAT EXISTS in the session: this view is
// the advertised full-fidelity "expand everything" surface, and
// thinking entries render zero rows while the `[ui]`
// show_thinking_blocks toggle is off — they were silently omitted from
// the transcript (bugbot). The toggle is thread-local; restore it
// before returning to live rendering on this same thread.
//
// Scope caveat: with the setting off, the tracker drops reasoning at
// INGESTION (`handle_thought_chunk` returns before pushing — a
// deliberate, test-encoded memory tradeoff that predates minimal), so
// sessions run entirely with the toggle off have no thinking entries
// for any view to show. This override covers the sessions that do:
// toggle on at ingestion (the default), or toggled off mid-session.
let prev_thinking = kigi_tui::appearance::cache::load_show_thinking_blocks();
kigi_tui::appearance::cache::set_show_thinking_blocks(true);
let start = Instant::now();
while build.next < build.ids.len() {
let eid = build.ids[build.next];
build.next += 1;
// Re-resolve by id: entries removed mid-build (rewind / clear)
// are skipped rather than skewing positions.
if let Some(entry) = sb.index_of_id(eid).and_then(|idx| sb.entry(idx)) {
render_entry_to_ansi(
entry,
&theme,
&appearance,
&agent.session.cwd,
&mut build.out,
);
}
if start.elapsed() >= PUMP_BUDGET {
break;
}
}
kigi_tui::appearance::cache::set_show_thinking_blocks(prev_thinking);
}
if build.next < build.ids.len() {
// More to do — resume next frame (ticks keep flowing via
// `needs_animation`; progress shows in the status row).
minimal_api::set_minimal_transcript(app, Some(build));
return;
}
// Done: hand the file to the event loop's suspend-into-$PAGER path.
finish_transcript(app, id, build.out);
}
/// Write the finished transcript and arm `pending_pager_path` (ANSI → the
/// event loop adds `-R` for `less`). Errors surface as a system block on the
/// build's owning agent (which may differ from the active view — the user can
/// tab away while the build runs).
fn finish_transcript(app: &mut AppView, id: kigi_tui::app::agent::AgentId, out: String) {
if out.is_empty() {
if let Some(agent) = app.agents.get_mut(&id) {
agent
.scrollback
.push_block(kigi_tui::scrollback::block::RenderBlock::system(
"No conversation transcript to view yet",
));
}
return;
}
let path = std::env::temp_dir().join(format!("kigi-transcript-{}.ansi", uuid::Uuid::new_v4()));
match std::fs::write(&path, out) {
Ok(()) => {
app.pending_pager_path = Some(path);
app.pending_pager_ansi = true;
}
Err(e) => {
if let Some(agent) = app.agents.get_mut(&id) {
agent
.scrollback
.push_block(kigi_tui::scrollback::block::RenderBlock::system(format!(
"Failed to write transcript: {e}"
)));
}
}
}
}
/// Render one entry (fully expanded, at [`FULL_VIEW_WIDTH`]) and append its
/// ANSI serialization to `out`. Cloning keeps the live entry's display mode —
/// which drives the on-screen committed look — untouched.
fn render_entry_to_ansi(
entry: &ScrollbackEntry,
theme: &Theme,
appearance: &kigi_tui::appearance::AppearanceConfig,
cwd: &std::path::Path,
out: &mut String,
) {
let mut expanded = entry.clone();
expanded.set_display_mode(DisplayMode::Expanded);
let renderer = EntryRenderer::new(&expanded, theme)
.with_appearance(appearance.clone())
.with_cwd(Some(cwd))
.with_flat_background(true);
let height = renderer.desired_height(FULL_VIEW_WIDTH);
if height == 0 {
return;
}
let area = Rect::new(0, 0, FULL_VIEW_WIDTH, height);
let mut buf = Buffer::empty(area);
renderer.render(area, &mut buf);
buffer_to_ansi(&buf, out);
// Blank line between blocks so the transcript breathes in the pager.
out.push('\n');
}
/// Serialize a rendered cell [`Buffer`] to ANSI text (one `\n`-terminated line
/// per row), emitting an SGR sequence whenever the style changes and resetting
/// at each row end. Trailing blank cells are trimmed so lines stay short.
fn buffer_to_ansi(buf: &Buffer, out: &mut String) {
let area = buf.area;
for y in area.y..area.y.saturating_add(area.height) {
// Last column carrying a visible glyph (trim trailing spaces).
let mut last: Option<u16> = None;
for x in (area.x..area.x.saturating_add(area.width)).rev() {
if let Some(cell) = buf.cell((x, y)) {
let s = cell.symbol();
if !s.is_empty() && s != " " {
last = Some(x);
break;
}
}
}
if let Some(last_x) = last {
// Track the current style as the raw (fg, bg, modifier) tuple and
// only build the escape string on a run boundary — comparing three
// Copy fields per cell is far cheaper than building + comparing an
// SGR string per cell (the previous hot spot on long transcripts).
let mut cur: Option<(Color, Color, Modifier)> = None;
let mut sgr = String::with_capacity(32);
for x in area.x..=last_x {
let Some(cell) = buf.cell((x, y)) else {
continue;
};
let sym = cell.symbol();
if sym.is_empty() {
// Continuation cell of a wide glyph — already emitted.
continue;
}
let style = (cell.fg, cell.bg, cell.modifier);
if cur != Some(style) {
cell_sgr(style.0, style.1, style.2, &mut sgr);
out.push_str(&sgr);
cur = Some(style);
}
out.push_str(sym);
}
out.push_str("\x1b[0m");
}
out.push('\n');
}
}
/// Build a full SGR sequence (leading reset, then modifiers + fg + bg) for a
/// cell's style. Emitted only when the style changes, so the reset can't leak
/// attributes across cells.
///
/// Writes into `sgr` (cleared first) instead of allocating: this runs once per
/// style *run*, which in syntax-highlighted code is nearly once per token —
/// the `Vec<String>` + `join` version dominated the serializer's profile on
/// long transcripts.
fn cell_sgr(fg: Color, bg: Color, modifier: Modifier, sgr: &mut String) {
use std::fmt::Write as _;
sgr.clear();
sgr.push_str("\x1b[0");
if modifier.contains(Modifier::BOLD) {
sgr.push_str(";1");
}
if modifier.contains(Modifier::DIM) {
sgr.push_str(";2");
}
if modifier.contains(Modifier::ITALIC) {
sgr.push_str(";3");
}
if modifier.contains(Modifier::UNDERLINED) {
sgr.push_str(";4");
}
if modifier.contains(Modifier::REVERSED) {
sgr.push_str(";7");
}
if modifier.contains(Modifier::CROSSED_OUT) {
sgr.push_str(";9");
}
sgr.push(';');
let _ = write!(sgr, "{}", color_code(fg, false));
sgr.push(';');
let _ = write!(sgr, "{}", color_code(bg, true));
sgr.push('m');
}
/// Map a ratatui [`Color`] to its SGR parameter (foreground, or background when
/// `bg`). Named colors use the 16-color codes; `Indexed`/`Rgb` use the 256 /
/// truecolor forms. `Reset` is the terminal default (39 fg / 49 bg).
fn color_code(color: Color, bg: bool) -> String {
// Named-color base code (30-series fg); +10 shifts to the 40-series bg.
let named = |n: u16| -> String { (if bg { n + 10 } else { n }).to_string() };
match color {
Color::Reset => named(39),
Color::Black => named(30),
Color::Red => named(31),
Color::Green => named(32),
Color::Yellow => named(33),
Color::Blue => named(34),
Color::Magenta => named(35),
Color::Cyan => named(36),
Color::Gray => named(37),
Color::DarkGray => named(90),
Color::LightRed => named(91),
Color::LightGreen => named(92),
Color::LightYellow => named(93),
Color::LightBlue => named(94),
Color::LightMagenta => named(95),
Color::LightCyan => named(96),
Color::White => named(97),
Color::Indexed(i) => format!("{};5;{}", if bg { 48 } else { 38 }, i),
Color::Rgb(r, g, b) => format!("{};2;{};{};{}", if bg { 48 } else { 38 }, r, g, b),
}
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_tui::scrollback::block::RenderBlock;
fn test_cwd() -> &'static std::path::Path {
std::path::Path::new("/test/session")
}
/// Bugbot "Transcript omits thinking blocks": with the `[ui]`
/// show_thinking_blocks toggle off, a thinking entry renders zero rows and
/// vanished from the "full-fidelity" transcript. The pump enables the
/// (thread-local) toggle for the build; this locks the mechanism: off →
/// omitted, on (what `pump_transcript` sets) → included.
#[test]
fn transcript_includes_thinking_when_pump_enables_toggle() {
let theme = Theme::current();
let appearance = super::super::commit::committed_appearance(
&kigi_tui::appearance::AppearanceConfig::default(),
);
let entry = ScrollbackEntry::new(RenderBlock::thinking(
"deep reasoning about haikus and syllables",
));
kigi_tui::appearance::cache::set_show_thinking_blocks(false);
let mut out = String::new();
render_entry_to_ansi(&entry, &theme, &appearance, test_cwd(), &mut out);
assert!(
out.is_empty(),
"thinking hidden while the toggle is off: {out:?}"
);
// What `pump_transcript` sets for the duration of a slice.
kigi_tui::appearance::cache::set_show_thinking_blocks(true);
let mut out = String::new();
render_entry_to_ansi(&entry, &theme, &appearance, test_cwd(), &mut out);
kigi_tui::appearance::cache::set_show_thinking_blocks(false);
assert!(
out.contains("reasoning"),
"thinking content included in the transcript: {out:?}"
);
}
/// A thinking entry built the way live streaming builds it (streaming
/// block + per-chunk pushes + finish) must render its BODY in the
/// transcript, not just the collapsed "Thought for Xs" header.
#[test]
fn transcript_expands_streamed_thinking_body() {
use kigi_tui::scrollback::state::ScrollbackState;
let theme = Theme::current();
let appearance = super::super::commit::committed_appearance(
&kigi_tui::appearance::AppearanceConfig::default(),
);
kigi_tui::appearance::cache::set_show_thinking_blocks(true);
let mut sb = ScrollbackState::new();
let id = sb.push_block(RenderBlock::thinking_streaming());
assert!(sb.push_chunk_to_thinking(id, "REASONINGBODY pondering "));
sb.push_chunk_to_thinking(id, "quietly about wraps");
sb.finish_running_with_time(id, Some(1200));
let entry = sb.get_by_id(id).expect("thinking entry");
let mut out = String::new();
render_entry_to_ansi(entry, &theme, &appearance, test_cwd(), &mut out);
kigi_tui::appearance::cache::set_show_thinking_blocks(false);
assert!(
out.contains("REASONINGBODY"),
"transcript must include the streamed thinking body: {out:?}"
);
}
#[test]
fn transcript_uses_owning_session_cwd_for_tool_paths() {
let theme = Theme::current();
let appearance = super::super::commit::committed_appearance(
&kigi_tui::appearance::AppearanceConfig::default(),
);
let entry =
ScrollbackEntry::new(RenderBlock::edit("/alternate/worktree/src/main.rs", None));
let mut out = String::new();
render_entry_to_ansi(
&entry,
&theme,
&appearance,
std::path::Path::new("/alternate/worktree"),
&mut out,
);
assert!(out.contains("src/main.rs"), "transcript: {out:?}");
assert!(
!out.contains("/alternate/worktree"),
"session prefix should be elided: {out:?}"
);
}
#[test]
fn color_code_maps_reset_named_indexed_rgb() {
assert_eq!(color_code(Color::Reset, false), "39");
assert_eq!(color_code(Color::Reset, true), "49");
assert_eq!(color_code(Color::Red, false), "31");
assert_eq!(color_code(Color::Red, true), "41");
assert_eq!(color_code(Color::DarkGray, false), "90");
assert_eq!(color_code(Color::DarkGray, true), "100");
assert_eq!(color_code(Color::Indexed(200), false), "38;5;200");
assert_eq!(color_code(Color::Rgb(1, 2, 3), true), "48;2;1;2;3");
}
#[test]
fn cell_sgr_includes_modifiers_and_colors() {
let mut sgr = String::new();
cell_sgr(
Color::Rgb(10, 20, 30),
Color::Reset,
Modifier::BOLD | Modifier::ITALIC,
&mut sgr,
);
// Leading reset, bold, italic, truecolor fg, default bg.
assert_eq!(sgr, "\x1b[0;1;3;38;2;10;20;30;49m");
// Reused buffer is cleared, not appended.
cell_sgr(Color::Red, Color::Reset, Modifier::empty(), &mut sgr);
assert_eq!(sgr, "\x1b[0;31;49m");
}
#[test]
fn buffer_to_ansi_trims_trailing_and_terminates_rows() {
// A 6-wide, 2-row buffer: "hi" on row 0, blank row 1.
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 2));
buf.cell_mut((0, 0)).unwrap().set_symbol("h");
buf.cell_mut((1, 0)).unwrap().set_symbol("i");
let mut out = String::new();
buffer_to_ansi(&buf, &mut out);
let lines: Vec<&str> = out.split('\n').collect();
// Row 0 has content ending in a reset; row 1 is blank; trailing newline.
assert!(lines[0].contains('h') && lines[0].contains('i'));
assert!(
lines[0].ends_with("\x1b[0m"),
"row must reset: {:?}",
lines[0]
);
assert_eq!(lines[1], "", "blank row emits nothing but the newline");
// No trailing spaces before the reset.
assert!(
!lines[0].contains(" "),
"trailing spaces not trimmed: {:?}",
lines[0]
);
}
}