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,447 @@
|
||||
//! 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!("grok-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]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user