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,540 @@
|
||||
//! Native terminal rendering for command output.
|
||||
//!
|
||||
//! Bash/terminal tool output arrives as a raw PTY byte stream that can contain
|
||||
//! ANSI SGR (colors/styles), cursor movement, line erases, and carriage returns
|
||||
//! (progress bars rewriting a line). ratatui paints text verbatim and does not
|
||||
//! interpret these, so without this module the scrollback shows literal escape
|
||||
//! codes like `[1m[36m`.
|
||||
//!
|
||||
//! [`render_terminal_lines`] feeds the stream through a minimal, line-oriented
|
||||
//! VTE emulator (built on the `vte` parser) and produces styled
|
||||
//! [`Line`]s plus de-escaped plain text — what a terminal would actually
|
||||
//! display. Unlike a screen/grid emulator it keeps an unbounded, fully-styled
|
||||
//! transcript that maps onto the pager's line model.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use vte::{Params, Parser, Perform};
|
||||
|
||||
use crate::theme::color_support::quantize;
|
||||
|
||||
/// Bound transcript growth against pathological cursor jumps. Tool output is
|
||||
/// already truncated upstream; these only guard against escape-code abuse.
|
||||
const MAX_ROWS: usize = 50_000;
|
||||
const MAX_COLS: usize = 8_192;
|
||||
|
||||
/// A single rendered transcript line: styled spans plus de-escaped plain text.
|
||||
pub struct RenderedLine {
|
||||
pub line: Line<'static>,
|
||||
pub plain: String,
|
||||
}
|
||||
|
||||
/// Parse a raw terminal stream (ANSI SGR + cursor/erase + carriage return) into
|
||||
/// styled lines. `base` is the default style for text without an SGR override.
|
||||
///
|
||||
/// Deterministic and idempotent: a fresh emulator per call, safe to invoke from
|
||||
/// both the render path and the height-cache path.
|
||||
pub fn render_terminal_lines(raw: &str, base: Style) -> Vec<RenderedLine> {
|
||||
if raw.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut sink = TermSink::new(base);
|
||||
let mut parser = Parser::new();
|
||||
parser.advance(&mut sink, raw.as_bytes());
|
||||
sink.finish()
|
||||
}
|
||||
|
||||
/// De-escaped, cursor-resolved plain text of a terminal stream, for
|
||||
/// clipboard/search. Lines are joined with `\n`.
|
||||
pub fn render_terminal_plain(raw: &str) -> String {
|
||||
render_terminal_lines(raw, Style::default())
|
||||
.into_iter()
|
||||
.map(|rl| rl.plain)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Cell {
|
||||
ch: char,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
struct TermSink {
|
||||
base: Style,
|
||||
cur: Style,
|
||||
rows: Vec<Vec<Cell>>,
|
||||
row: usize,
|
||||
col: usize,
|
||||
}
|
||||
|
||||
impl TermSink {
|
||||
fn new(base: Style) -> Self {
|
||||
Self {
|
||||
base,
|
||||
cur: base,
|
||||
rows: vec![Vec::new()],
|
||||
row: 0,
|
||||
col: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_row(&mut self) {
|
||||
if self.row >= MAX_ROWS {
|
||||
self.row = MAX_ROWS - 1;
|
||||
}
|
||||
while self.rows.len() <= self.row {
|
||||
self.rows.push(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
fn put(&mut self, ch: char) {
|
||||
if self.col >= MAX_COLS {
|
||||
return;
|
||||
}
|
||||
self.ensure_row();
|
||||
let blank = Cell {
|
||||
ch: ' ',
|
||||
style: self.base,
|
||||
};
|
||||
let line = &mut self.rows[self.row];
|
||||
if self.col >= line.len() {
|
||||
line.resize(self.col + 1, blank);
|
||||
}
|
||||
line[self.col] = Cell {
|
||||
ch,
|
||||
style: self.cur,
|
||||
};
|
||||
self.col += 1;
|
||||
}
|
||||
|
||||
fn newline(&mut self) {
|
||||
self.row += 1;
|
||||
self.col = 0;
|
||||
self.ensure_row();
|
||||
}
|
||||
|
||||
fn erase_line(&mut self, mode: u16) {
|
||||
self.ensure_row();
|
||||
let blank = Cell {
|
||||
ch: ' ',
|
||||
style: self.base,
|
||||
};
|
||||
let line = &mut self.rows[self.row];
|
||||
match mode {
|
||||
0 => line.truncate(self.col.min(line.len())),
|
||||
1 => {
|
||||
let end = (self.col + 1).min(line.len());
|
||||
line[..end].fill(blank);
|
||||
}
|
||||
2 => line.clear(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn erase_display(&mut self, mode: u16) {
|
||||
match mode {
|
||||
0 => {
|
||||
self.ensure_row();
|
||||
let len = self.rows[self.row].len();
|
||||
self.rows[self.row].truncate(self.col.min(len));
|
||||
self.rows.truncate(self.row + 1);
|
||||
}
|
||||
2 | 3 => {
|
||||
self.rows.clear();
|
||||
self.rows.push(Vec::new());
|
||||
self.row = 0;
|
||||
self.col = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_sgr(&mut self, params: &Params) {
|
||||
if params.is_empty() {
|
||||
self.cur = self.base;
|
||||
return;
|
||||
}
|
||||
let groups: Vec<&[u16]> = params.iter().collect();
|
||||
let mut i = 0;
|
||||
while i < groups.len() {
|
||||
let code = groups[i].first().copied().unwrap_or(0);
|
||||
match code {
|
||||
0 => self.cur = self.base,
|
||||
1 => self.cur = self.cur.add_modifier(Modifier::BOLD),
|
||||
2 => self.cur = self.cur.add_modifier(Modifier::DIM),
|
||||
3 => self.cur = self.cur.add_modifier(Modifier::ITALIC),
|
||||
4 => self.cur = self.cur.add_modifier(Modifier::UNDERLINED),
|
||||
7 => self.cur = self.cur.add_modifier(Modifier::REVERSED),
|
||||
22 => self.cur = self.cur.remove_modifier(Modifier::BOLD | Modifier::DIM),
|
||||
23 => self.cur = self.cur.remove_modifier(Modifier::ITALIC),
|
||||
24 => self.cur = self.cur.remove_modifier(Modifier::UNDERLINED),
|
||||
27 => self.cur = self.cur.remove_modifier(Modifier::REVERSED),
|
||||
30..=37 => self.cur.fg = Some(quantize(ansi16(code - 30))),
|
||||
39 => self.cur.fg = self.base.fg,
|
||||
40..=47 => self.cur.bg = Some(quantize(ansi16(code - 40))),
|
||||
49 => self.cur.bg = self.base.bg,
|
||||
90..=97 => self.cur.fg = Some(quantize(ansi16_bright(code - 90))),
|
||||
100..=107 => self.cur.bg = Some(quantize(ansi16_bright(code - 100))),
|
||||
38 => {
|
||||
if let Some(c) = ext_color(&groups, &mut i) {
|
||||
self.cur.fg = Some(quantize(c));
|
||||
}
|
||||
}
|
||||
48 => {
|
||||
if let Some(c) = ext_color(&groups, &mut i) {
|
||||
self.cur.bg = Some(quantize(c));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Vec<RenderedLine> {
|
||||
// `str::lines()` ignores a single trailing newline; mirror that so a
|
||||
// command ending in `\n` does not gain a spurious blank line.
|
||||
if self.rows.last().is_some_and(|r| r.is_empty()) {
|
||||
self.rows.pop();
|
||||
}
|
||||
let base = self.base;
|
||||
self.rows
|
||||
.into_iter()
|
||||
.map(|cells| row_to_line(cells, base))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Perform for TermSink {
|
||||
fn print(&mut self, c: char) {
|
||||
self.put(c);
|
||||
}
|
||||
|
||||
fn execute(&mut self, byte: u8) {
|
||||
match byte {
|
||||
b'\n' | 0x0b | 0x0c => self.newline(),
|
||||
b'\r' => self.col = 0,
|
||||
b'\t' => self.col = (self.col / 8 + 1) * 8,
|
||||
0x08 => self.col = self.col.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn csi_dispatch(
|
||||
&mut self,
|
||||
params: &Params,
|
||||
_intermediates: &[u8],
|
||||
_ignore: bool,
|
||||
action: char,
|
||||
) {
|
||||
match action {
|
||||
'm' => self.apply_sgr(params),
|
||||
'K' => self.erase_line(first_param(params, 0)),
|
||||
'J' => self.erase_display(first_param(params, 0)),
|
||||
'A' => self.row = self.row.saturating_sub(first_param(params, 1) as usize),
|
||||
'B' => {
|
||||
let n = first_param(params, 1) as usize;
|
||||
self.row = (self.row + n).min(self.rows.len().saturating_sub(1));
|
||||
}
|
||||
'C' => self.col = (self.col + first_param(params, 1) as usize).min(MAX_COLS),
|
||||
'D' => self.col = self.col.saturating_sub(first_param(params, 1) as usize),
|
||||
'G' => {
|
||||
self.col = (first_param(params, 1) as usize)
|
||||
.saturating_sub(1)
|
||||
.min(MAX_COLS)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First parameter value, substituting `default` for a missing or `0` value
|
||||
/// (CSI cursor ops treat `0` as `1`; erase ops pass `0` as the default).
|
||||
fn first_param(params: &Params, default: u16) -> u16 {
|
||||
match params.iter().next().and_then(|p| p.first().copied()) {
|
||||
Some(0) | None => default,
|
||||
Some(v) => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a 0-7 ANSI color index to a named ratatui color.
|
||||
fn ansi16(n: u16) -> Color {
|
||||
match n {
|
||||
0 => Color::Black,
|
||||
1 => Color::Red,
|
||||
2 => Color::Green,
|
||||
3 => Color::Yellow,
|
||||
4 => Color::Blue,
|
||||
5 => Color::Magenta,
|
||||
6 => Color::Cyan,
|
||||
_ => Color::Gray,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a 0-7 bright ANSI color index to a named ratatui color.
|
||||
fn ansi16_bright(n: u16) -> Color {
|
||||
match n {
|
||||
0 => Color::DarkGray,
|
||||
1 => Color::LightRed,
|
||||
2 => Color::LightGreen,
|
||||
3 => Color::LightYellow,
|
||||
4 => Color::LightBlue,
|
||||
5 => Color::LightMagenta,
|
||||
6 => Color::LightCyan,
|
||||
_ => Color::White,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an extended color (`38`/`48`) in either `;` (advancing `i` over the
|
||||
/// consumed groups) or `:` subparameter form. Returns an un-quantized color.
|
||||
fn ext_color(groups: &[&[u16]], i: &mut usize) -> Option<Color> {
|
||||
let g = groups[*i];
|
||||
if g.len() >= 2 {
|
||||
return parse_ext(&g[1..]);
|
||||
}
|
||||
match groups.get(*i + 1).and_then(|p| p.first().copied())? {
|
||||
5 => {
|
||||
let idx = groups.get(*i + 2).and_then(|p| p.first().copied())?;
|
||||
*i += 2;
|
||||
Some(Color::Indexed(idx as u8))
|
||||
}
|
||||
2 => {
|
||||
let r = groups.get(*i + 2).and_then(|p| p.first().copied())?;
|
||||
let g = groups.get(*i + 3).and_then(|p| p.first().copied())?;
|
||||
let b = groups.get(*i + 4).and_then(|p| p.first().copied())?;
|
||||
*i += 4;
|
||||
Some(Color::Rgb(r as u8, g as u8, b as u8))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the subparameter form of an extended color, e.g. `[5, n]` (256) or
|
||||
/// `[2, r, g, b]` (with an optional leading colorspace id). Un-quantized.
|
||||
fn parse_ext(sub: &[u16]) -> Option<Color> {
|
||||
match sub.first().copied()? {
|
||||
5 => sub.get(1).map(|n| Color::Indexed(*n as u8)),
|
||||
2 => {
|
||||
let vals = &sub[1..];
|
||||
let (r, g, b) = match vals.len() {
|
||||
3 => (vals[0], vals[1], vals[2]),
|
||||
n if n >= 4 => (vals[n - 3], vals[n - 2], vals[n - 1]),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Color::Rgb(r as u8, g as u8, b as u8))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_line(cells: Vec<Cell>, base: Style) -> RenderedLine {
|
||||
let mut end = cells.len();
|
||||
while end > 0 && cells[end - 1].ch == ' ' && cells[end - 1].style == base {
|
||||
end -= 1;
|
||||
}
|
||||
let cells = &cells[..end];
|
||||
if cells.is_empty() {
|
||||
return RenderedLine {
|
||||
line: Line::default(),
|
||||
plain: String::new(),
|
||||
};
|
||||
}
|
||||
let plain: String = cells.iter().map(|c| c.ch).collect();
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut buf = String::new();
|
||||
let mut style = cells[0].style;
|
||||
for c in cells {
|
||||
if c.style != style {
|
||||
spans.push(Span::styled(std::mem::take(&mut buf), style));
|
||||
style = c.style;
|
||||
}
|
||||
buf.push(c.ch);
|
||||
}
|
||||
spans.push(Span::styled(buf, style));
|
||||
RenderedLine {
|
||||
line: Line::from(spans),
|
||||
plain,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn plain(raw: &str) -> String {
|
||||
render_terminal_plain(raw)
|
||||
}
|
||||
|
||||
fn lines(raw: &str) -> Vec<String> {
|
||||
render_terminal_lines(raw, Style::default())
|
||||
.into_iter()
|
||||
.map(|rl| rl.plain)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_sgr_to_plain_text() {
|
||||
assert_eq!(plain("\x1b[1m\x1b[36mbazel\x1b[0m"), "bazel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carriage_return_overwrites_in_place() {
|
||||
assert_eq!(plain("aaaa\rbb"), "bbaa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_bar_collapses_to_final_state() {
|
||||
assert_eq!(plain("10%\r50%\r100%\n"), "100%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_splits_lines() {
|
||||
assert_eq!(lines("a\nb"), vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_newline_adds_no_blank_line() {
|
||||
assert_eq!(lines("a\n"), vec!["a"]);
|
||||
assert_eq!(lines("a\n\n"), vec!["a", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_up_then_carriage_return_and_erase() {
|
||||
// Write two lines, move up, overwrite the start, erase to end of line.
|
||||
assert_eq!(lines("line1\nline2\x1b[A\rXX\x1b[K"), vec!["XX", "line2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_advances_to_next_stop() {
|
||||
assert_eq!(plain("a\tb"), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_escape_does_not_panic() {
|
||||
assert_eq!(plain("\x1b[38;5mhi"), "hi");
|
||||
assert!(plain("\x1b[99999999999m\x1b[mok").contains("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_splits_into_styled_spans() {
|
||||
let rendered = render_terminal_lines("plain \x1b[31mred\x1b[0m", Style::default());
|
||||
assert_eq!(rendered.len(), 1);
|
||||
let spans = &rendered[0].line.spans;
|
||||
assert_eq!(spans.len(), 2);
|
||||
assert_eq!(spans[0].content.as_ref(), "plain ");
|
||||
assert_eq!(spans[1].content.as_ref(), "red");
|
||||
assert!(spans[1].style.fg.is_some());
|
||||
assert_eq!(rendered[0].plain, "plain red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_line_count() {
|
||||
let raw = "a\nb\x1b[32mc\x1b[0m\rd\ne";
|
||||
let first = render_terminal_lines(raw, Style::default()).len();
|
||||
let second = render_terminal_lines(raw, Style::default()).len();
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_yields_no_lines() {
|
||||
assert!(render_terminal_lines("", Style::default()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi16_mapping() {
|
||||
assert_eq!(ansi16(1), Color::Red);
|
||||
assert_eq!(ansi16(6), Color::Cyan);
|
||||
assert_eq!(ansi16_bright(2), Color::LightGreen);
|
||||
assert_eq!(ansi16_bright(7), Color::White);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_color_subparam_forms() {
|
||||
assert_eq!(parse_ext(&[5, 42]), Some(Color::Indexed(42)));
|
||||
assert_eq!(parse_ext(&[2, 10, 20, 30]), Some(Color::Rgb(10, 20, 30)));
|
||||
assert_eq!(parse_ext(&[2, 0, 10, 20, 30]), Some(Color::Rgb(10, 20, 30)));
|
||||
assert_eq!(parse_ext(&[2, 1]), None);
|
||||
}
|
||||
|
||||
// Cross-platform robustness. Bash/terminal output is captured via pipes
|
||||
// (non-TTY) on macOS, Linux, and Windows alike, so the input is plain text
|
||||
// plus line endings plus optionally forced SGR — never a ConPTY screen
|
||||
// stream. Windows uses CRLF, and unsupported control sequences (DEC private
|
||||
// modes, OSC, cursor save/restore, absolute positioning) must be ignored
|
||||
// without corrupting surrounding text.
|
||||
|
||||
#[test]
|
||||
fn windows_crlf_line_endings() {
|
||||
assert_eq!(lines("a\r\nb\r\nc\r\n"), vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_dec_private_modes_and_osc() {
|
||||
let raw = "\x1b[?25l\x1b]0;window title\x07hello\x1b[?1049h world\x1b[?25h";
|
||||
assert_eq!(plain(raw), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_cursor_save_restore_and_absolute_positioning() {
|
||||
assert_eq!(plain("\x1b7\x1b[10;5Hkept\x1b8"), "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_sgr_over_crlf_renders_styled() {
|
||||
let rendered =
|
||||
render_terminal_lines("\x1b[01;31mmatch\x1b[0m\r\nplain\r\n", Style::default());
|
||||
assert_eq!(rendered.len(), 2);
|
||||
assert_eq!(rendered[0].plain, "match");
|
||||
assert_eq!(rendered[1].plain, "plain");
|
||||
assert!(rendered[0].line.spans.iter().any(|s| s.style.fg.is_some()));
|
||||
}
|
||||
|
||||
// Real Windows shell output samples. Each pins a distinct parser behavior
|
||||
// exercised by a sequence these shells actually emit on the wire.
|
||||
|
||||
// Git Bash / GNU `grep --color=always`: the match is wrapped in a bold-red
|
||||
// SGR with an interleaved EL (`\x1b[K`) and closed by an empty-param reset
|
||||
// (`\x1b[m`). The EL must not truncate already-printed text, and `\x1b[m`
|
||||
// must restore the base style for the trailing run.
|
||||
#[test]
|
||||
fn git_bash_gnu_grep_color() {
|
||||
let rendered =
|
||||
render_terminal_lines("\x1b[01;31m\x1b[Kfoo\x1b[m\x1b[Kbar\n", Style::default());
|
||||
assert_eq!(rendered.len(), 1);
|
||||
assert_eq!(rendered[0].plain, "foobar");
|
||||
let spans = &rendered[0].line.spans;
|
||||
assert_eq!(spans.len(), 2);
|
||||
assert_eq!(spans[0].content.as_ref(), "foo");
|
||||
assert!(spans[0].style.fg.is_some());
|
||||
assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
|
||||
assert_eq!(spans[1].content.as_ref(), "bar");
|
||||
assert_eq!(spans[1].style, Style::default());
|
||||
}
|
||||
|
||||
// PowerShell 7 (`$PSStyle`): 24-bit color via the semicolon form
|
||||
// `\x1b[38;2;R;G;Bm`, which drives the multi-group extended-color branch of
|
||||
// `ext_color` (consume-following-groups + advance). If that advance were
|
||||
// wrong the trailing `0` param would reset and drop the color.
|
||||
#[test]
|
||||
fn powershell_truecolor_psstyle() {
|
||||
let rendered = render_terminal_lines(
|
||||
"\x1b[38;2;255;128;0mWARNING\x1b[0m: low disk\n",
|
||||
Style::default(),
|
||||
);
|
||||
assert_eq!(rendered.len(), 1);
|
||||
assert_eq!(rendered[0].plain, "WARNING: low disk");
|
||||
let spans = &rendered[0].line.spans;
|
||||
assert_eq!(spans[0].content.as_ref(), "WARNING");
|
||||
assert!(spans[0].style.fg.is_some());
|
||||
assert_eq!(spans[1].style, Style::default());
|
||||
}
|
||||
|
||||
// Progress output (cargo/npm/pip style under cmd/PowerShell): a status line
|
||||
// is wiped with EL mode 2 (`\x1b[2K`) regardless of cursor column, then
|
||||
// rewritten, so the transcript collapses to the final line.
|
||||
#[test]
|
||||
fn progress_erase_entire_line_collapses() {
|
||||
assert_eq!(lines("loading 99%\x1b[2K\rdone\n"), vec!["done"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user