use amberfork_model::{DiffResult, FieldDiffKind, MoveKind, Payload, Run, Step};
use std::fmt::Write;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
Plain,
Bold,
Ansi256,
Truecolor,
}
#[derive(Debug, Clone, Copy)]
pub struct RenderOpts {
pub color: ColorMode,
pub width: usize,
}
pub fn render(result: &DiffResult, reference: &Run, observed: &Run, opts: &RenderOpts) -> String {
let layout = Layout::compute(result, reference, observed, opts.width);
let mut rows = Vec::new();
let id_width = result
.runs
.a
.id
.chars()
.count()
.max(result.runs.b.id.chars().count());
rows.push(header_row(
'A',
&result.runs.a.id,
id_width,
"reference",
reference,
));
rows.push(header_row(
'B',
&result.runs.b.id,
id_width,
"observed ",
observed,
));
rows.push(Row::blank());
for (i, mv) in result.alignment.iter().enumerate() {
match result.fork {
Some(fork) if i == fork.index => {
fork_block(&mut rows, result, reference, observed, &layout, i)
}
Some(fork) if i > fork.index => {
rows.push(move_row(mv, reference, observed, &layout, Role::Downstream))
}
_ => rows.push(move_row(mv, reference, observed, &layout, Role::Spine)),
}
}
if result.fork.is_none() {
rows.push(Row::blank());
rows.push(Row {
role: Role::Footer,
prefix: String::new(),
body: format!(
" converged — identical through {} steps",
result.alignment.len()
),
});
}
if let Some(attribution) = &result.attribution {
rows.push(Row::blank());
rows.push(Row {
role: Role::Footer,
prefix: String::new(),
body: attribution_line(attribution, &layout),
});
}
let mut out = String::new();
for row in &rows {
let _ = writeln!(out, "{}", row.paint(opts.color, opts.width));
}
out
}
pub fn resolve_color_mode(
no_color_flag: bool,
stdout_is_tty: bool,
no_color_env: Option<&str>,
term: Option<&str>,
colorterm: Option<&str>,
) -> ColorMode {
let opted_out = no_color_flag
|| no_color_env.is_some_and(|v| !v.is_empty())
|| !stdout_is_tty
|| term == Some("dumb");
if opted_out {
return ColorMode::Plain;
}
match colorterm {
Some("truecolor" | "24bit") => ColorMode::Truecolor,
_ if term.is_some_and(|t| t.contains("256color")) => ColorMode::Ansi256,
_ => ColorMode::Bold,
}
}
impl ColorMode {
fn sgr(self, code: &str, text: &str) -> String {
if self == Self::Plain || code.is_empty() {
return text.to_string();
}
format!("\x1b[{code}m{text}\x1b[0m")
}
pub(crate) fn dim(self, text: &str) -> String {
self.sgr("2", text)
}
fn amber(self, text: &str) -> String {
let code = match self {
Self::Plain => "",
Self::Bold => "1",
Self::Ansi256 => "38;5;208",
Self::Truecolor => "38;2;255;122;26",
};
self.sgr(code, text)
}
fn removed(self, text: &str) -> String {
let code = match self {
Self::Plain | Self::Bold => "",
Self::Ansi256 => "31",
Self::Truecolor => "38;2;255;92;92",
};
self.sgr(code, text)
}
fn added(self, text: &str) -> String {
let code = match self {
Self::Plain | Self::Bold => "",
Self::Ansi256 => "32",
Self::Truecolor => "38;2;70;211;154",
};
self.sgr(code, text)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
Header,
Spine,
Fork,
FieldRemoved,
FieldAdded,
Downstream,
Footer,
Blank,
}
struct Row {
role: Role,
prefix: String,
body: String,
}
impl Row {
fn blank() -> Self {
Self {
role: Role::Blank,
prefix: String::new(),
body: String::new(),
}
}
fn paint(&self, color: ColorMode, width: usize) -> String {
let line = truncate(&format!("{}{}", self.prefix, self.body), width);
match self.role {
Role::Header | Role::Spine => color.dim(&line),
Role::Fork => color.amber(&line),
Role::FieldRemoved => color.removed(&line),
Role::FieldAdded => color.added(&line),
Role::Downstream => {
let split = self.prefix.chars().count().min(line.chars().count());
let (prefix, body) = split_at_char(&line, split);
format!("{}{}", color.amber(prefix), body)
}
Role::Footer | Role::Blank => line,
}
}
}
struct Layout {
idx_width: usize,
prefix_width: usize,
kind_width: usize,
name_width: usize,
content_width: usize,
}
impl Layout {
fn compute(result: &DiffResult, reference: &Run, observed: &Run, width: usize) -> Self {
let max_idx = reference
.steps
.iter()
.chain(&observed.steps)
.map(|s| s.idx)
.max()
.unwrap_or(0);
let idx_width = decimal_digits(max_idx).max(2);
let prefix_width = 2 + 5 + idx_width + 2 + 1 + 2;
let kind_width = 5; let name_width = reference
.steps
.iter()
.chain(&observed.steps)
.map(|s| s.name.chars().count())
.max()
.unwrap_or(0)
.clamp(4, 16);
let tag_width = result
.alignment
.iter()
.enumerate()
.map(|(i, mv)| tag_text(result, i, mv).chars().count())
.max()
.unwrap_or(0);
let fixed = prefix_width + kind_width + 2 + name_width + 2 + tag_width + 2;
let content_width = width.saturating_sub(fixed).max(20);
Self {
idx_width,
prefix_width,
kind_width,
name_width,
content_width,
}
}
fn prefix(&self, gutter: char, idx: Option<usize>, marker: char) -> String {
let idx = match idx {
Some(i) => format!("{i:0w$}", w = self.idx_width),
None => "·".repeat(self.idx_width),
};
format!("{gutter} step {idx} {marker} ")
}
fn columns(&self, step: Option<&Step>, content: &str, tag: &str) -> String {
let kind = step.map_or("", |s| kind_str(s.kind));
let name = step.map_or(String::new(), |s| truncate(&s.name, self.name_width));
format!(
"{kind:<kw$} {name:<nw$} {content:<cw$} {tag}",
kw = self.kind_width,
nw = self.name_width,
cw = self.content_width,
)
}
}
fn header_row(side: char, id: &str, id_width: usize, role_label: &str, run: &Run) -> Row {
let outcome = run
.outcome
.map_or(String::new(), |o| format!(" · {}", outcome_str(o)));
Row {
role: Role::Header,
prefix: String::new(),
body: format!(
" {side} {id:<id_width$} · {role_label} · {n} steps{outcome}",
n = run.steps.len()
),
}
}
fn move_row(
mv: &amberfork_model::Move,
reference: &Run,
observed: &Run,
layout: &Layout,
role: Role,
) -> Row {
let step = pick_step(mv, reference, observed);
let marker = if role == Role::Downstream {
'✗'
} else {
'·'
};
Row {
role,
prefix: layout.prefix(' ', display_idx(mv), marker),
body: layout.columns(
step,
&truncate(&step.map_or(String::new(), summarize), layout.content_width),
&tag_str(mv.kind),
),
}
}
fn fork_block(
rows: &mut Vec<Row>,
result: &DiffResult,
reference: &Run,
observed: &Run,
layout: &Layout,
index: usize,
) {
let mv = &result.alignment[index];
let fork = result.fork.expect("fork_block is only called with a fork");
let side_content = |step: Option<usize>, run: &Run, label: char| match step {
Some(idx) => format!(
"{label}: {}",
run.steps.get(idx).map_or_else(String::new, summarize)
),
None => format!("{label}: (no aligned step)"),
};
let tag = format!("[FORK · {}]", conf_text(fork.confidence));
let a_lines = wrap(
&side_content(fork.a_step, reference, 'A'),
layout.content_width,
);
let b_lines = wrap(
&side_content(fork.b_step, observed, 'B'),
layout.content_width,
);
let step = pick_step(mv, reference, observed);
rows.push(Row {
role: Role::Fork,
prefix: layout.prefix('⑂', display_idx(mv), '✗'),
body: layout.columns(step, &a_lines[0], &tag),
});
let cont_indent = layout.prefix_width + layout.kind_width + 2 + layout.name_width + 2;
for line in a_lines[1..].iter().chain(&b_lines) {
rows.push(Row {
role: Role::Fork,
prefix: " ".repeat(cont_indent),
body: line.clone(),
});
}
let field_width = |prefix_len: usize| layout.content_width.max(20).saturating_sub(prefix_len);
for fd in result.field_diffs.iter().filter(|fd| fd.step == index) {
let mut push_side = |sign: char, role: Role, value: &Option<serde_json::Value>| {
let Some(value) = value else { return };
let text = format!("{sign} {}: {}", fd.path, compact_json(value));
for line in wrap(&text, field_width(2) + layout.name_width) {
rows.push(Row {
role,
prefix: " ".repeat(layout.prefix_width),
body: line,
});
}
};
if fd.kind != FieldDiffKind::Added {
push_side('-', Role::FieldRemoved, &fd.before);
}
if fd.kind != FieldDiffKind::Removed {
push_side('+', Role::FieldAdded, &fd.after);
}
}
}
fn pick_step<'r>(
mv: &amberfork_model::Move,
reference: &'r Run,
observed: &'r Run,
) -> Option<&'r Step> {
match mv.kind {
MoveKind::Sync | MoveKind::Log => mv.b_idx.and_then(|i| observed.steps.get(i)),
MoveKind::Model => mv.a_idx.and_then(|i| reference.steps.get(i)),
}
}
fn display_idx(mv: &amberfork_model::Move) -> Option<usize> {
mv.b_idx.or(mv.a_idx)
}
fn summarize(step: &Step) -> String {
let payload = step.outputs.as_ref().or(step.inputs.as_ref());
match payload {
None => "(no content captured)".to_string(),
Some(Payload::Text(t)) => t.lines().next().unwrap_or("").to_string(),
Some(Payload::Object(map)) => compact_json(&serde_json::Value::Object(map.clone())),
Some(Payload::Other(v)) => compact_json(v),
}
}
fn tag_text(result: &DiffResult, index: usize, mv: &amberfork_model::Move) -> String {
match result.fork {
Some(fork) if fork.index == index => format!("[FORK · {}]", conf_text(fork.confidence)),
_ => tag_str(mv.kind),
}
}
fn conf_text(confidence: f64) -> String {
if confidence <= f64::EPSILON {
"marginal call".to_string()
} else {
format!("conf {confidence:.2}")
}
}
fn attribution_line(attribution: &amberfork_model::Attribution, layout: &Layout) -> String {
use amberfork_model::AttributionMode;
let mode = match attribution.mode {
AttributionMode::Static => "static",
AttributionMode::Counterfactual => "counterfactual",
};
let origin = attribution.origin_step.map_or_else(
|| "origin unlocalized".to_string(),
|s| format!("origin step {s:0w$}", w = layout.idx_width),
);
format!(
" attribution · {mode} · {origin} · propagation {} · {}",
steps_text(&attribution.propagation, layout.idx_width),
conf_text(attribution.confidence)
)
}
fn steps_text(steps: &[usize], idx_width: usize) -> String {
let pad = |s: &usize| format!("{s:0idx_width$}");
let contiguous = steps.windows(2).all(|w| w[1] == w[0] + 1);
match steps {
[] => "none".to_string(),
[only] => format!("step {}", pad(only)),
[first, .., last] if contiguous => format!("steps {}–{}", pad(first), pad(last)),
_ => format!(
"steps {}",
steps.iter().map(pad).collect::<Vec<_>>().join(", ")
),
}
}
fn tag_str(kind: MoveKind) -> String {
match kind {
MoveKind::Sync => "[sync]",
MoveKind::Log => "[log-move]",
MoveKind::Model => "[model-move]",
}
.to_string()
}
fn kind_str(kind: amberfork_model::StepKind) -> &'static str {
use amberfork_model::StepKind;
match kind {
StepKind::Llm => "llm",
StepKind::Tool => "tool",
StepKind::Agent => "agent",
StepKind::Other => "other",
}
}
fn outcome_str(outcome: amberfork_model::Outcome) -> &'static str {
use amberfork_model::Outcome;
match outcome {
Outcome::Pass => "pass",
Outcome::Fail => "fail",
Outcome::Unknown => "unknown",
}
}
fn compact_json(value: &serde_json::Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
}
fn decimal_digits(n: usize) -> usize {
if n == 0 { 1 } else { n.ilog10() as usize + 1 }
}
fn truncate(s: &str, width: usize) -> String {
if s.chars().count() <= width {
return s.to_string();
}
let mut out: String = s.chars().take(width.saturating_sub(1)).collect();
out.push('…');
out
}
fn wrap(s: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in s.split_whitespace() {
let fits_appended =
!current.is_empty() && current.chars().count() + 1 + word.chars().count() <= width;
if fits_appended {
current.push(' ');
current.push_str(word);
continue;
}
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
}
let chars: Vec<char> = word.chars().collect();
let mut chunks = chars.chunks(width).peekable();
while let Some(chunk) = chunks.next() {
let piece: String = chunk.iter().collect();
if chunks.peek().is_some() {
lines.push(piece);
} else {
current = piece;
}
}
}
if !current.is_empty() || lines.is_empty() {
lines.push(current);
}
lines
}
fn split_at_char(s: &str, chars: usize) -> (&str, &str) {
let byte = s
.char_indices()
.nth(chars)
.map_or(s.len(), |(byte, _)| byte);
s.split_at(byte)
}
#[cfg(test)]
mod tests {
use super::*;
use amberfork_model::{
Attribution, AttributionMode, DiffResult, FieldDiff, FieldDiffKind, Fork, Meta, Move,
Outcome, Payload, Run, RunPair, RunRef, SchemaVersion, Source, Step, StepKind,
};
use serde_json::{Map, json};
const WIDTH: usize = 100;
const SGR_AMBER_TRUECOLOR: &str = "\x1b[38;2;255;122;26m"; const SGR_RED_TRUECOLOR: &str = "\x1b[38;2;255;92;92m"; const SGR_GREEN_TRUECOLOR: &str = "\x1b[38;2;70;211;154m";
fn opts(color: ColorMode) -> RenderOpts {
RenderOpts {
color,
width: WIDTH,
}
}
fn step(idx: usize, name: &str, out: &str) -> Step {
Step {
idx,
kind: StepKind::Tool,
name: name.to_string(),
inputs: None,
outputs: Some(Payload::Text(out.to_string())),
attrs: Map::new(),
t_start: None,
t_end: None,
parent_idx: None,
}
}
fn run(id: &str, outcome: Outcome, steps: Vec<Step>) -> Run {
Run {
schema_version: SchemaVersion::current(),
id: id.to_string(),
task: None,
outcome: Some(outcome),
steps,
edges: None,
}
}
fn result(a: &Run, b: &Run, alignment: Vec<Move>, fork: Option<Fork>) -> DiffResult {
DiffResult {
runs: RunPair {
a: RunRef {
id: a.id.clone(),
task: None,
outcome: a.outcome,
n_steps: a.steps.len(),
},
b: RunRef {
id: b.id.clone(),
task: None,
outcome: b.outcome,
n_steps: b.steps.len(),
},
},
alignment,
fork,
field_diffs: Vec::new(),
attribution: None,
warnings: Vec::new(),
meta: Meta::current(Source::Passive),
}
}
fn converged() -> (Run, Run, DiffResult) {
let steps = || {
vec![
step(0, "plan", "search for census data"),
step(1, "web.search", "9 results, top census.gov"),
step(2, "answer", "population is 8,443,000"),
]
};
let a = run("good", Outcome::Pass, steps());
let b = run("good_again", Outcome::Pass, steps());
let alignment = vec![
Move::sync(0, 0, 0.0, 1.0),
Move::sync(1, 1, 0.0, 1.0),
Move::sync(2, 2, 0.0, 1.0),
];
let res = result(&a, &b, alignment, None);
(a, b, res)
}
fn forked(confidence: f64) -> (Run, Run, DiffResult) {
let a = run(
"good",
Outcome::Pass,
vec![
step(0, "plan", "search for census data"),
step(1, "web.search", "9 results, top census.gov"),
step(2, "web.fetch", "census.gov page: population 8,443,000"),
],
);
let b = run(
"bad",
Outcome::Fail,
vec![
step(0, "plan", "search for census data"),
step(1, "web.search", "9 results, top census.gov"),
step(
2,
"web.fetch",
"blogspot page: the city has grown to 9,100,000",
),
step(3, "reader", "blog says about 9,100,000 people"),
],
);
let alignment = vec![
Move::sync(0, 0, 0.02, 0.98),
Move::sync(1, 1, 0.05, 0.95),
Move::sync(2, 2, 0.82, 0.18),
Move::log(3, 0.6, 0.9),
];
let fork = Fork {
index: 2,
a_step: Some(2),
b_step: Some(2),
confidence,
};
let mut res = result(&a, &b, alignment, Some(fork));
res.field_diffs = vec![FieldDiff {
step: 2,
path: "outputs".to_string(),
before: Some(json!("census.gov page")),
after: Some(json!("blogspot page")),
kind: FieldDiffKind::Changed,
}];
res.attribution = Some(Attribution {
mode: AttributionMode::Static,
origin_step: Some(2),
propagation: vec![3],
counterfactual: None,
cause_label: None,
confidence,
});
(a, b, res)
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
for esc in chars.by_ref() {
if esc == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn converged_renders_the_designed_state() {
let (a, b, res) = converged();
let out = render(&res, &a, &b, &opts(ColorMode::Plain));
assert!(
out.contains("converged — identical through 3 steps"),
"DR1 designed converged state, got:\n{out}"
);
assert!(!out.contains('⑂'), "no fork glyph on a converged diff");
assert!(
!out.contains('✗'),
"no divergence markers on a converged diff"
);
assert!(
!out.contains("attribution"),
"nothing to attribute on a converged diff"
);
assert!(!out.contains('\x1b'), "Plain mode must emit no ANSI");
}
#[test]
fn forked_render_closes_with_the_attribution_line() {
let (a, b, res) = forked(0.47);
let out = render(&res, &a, &b, &opts(ColorMode::Plain));
let last = out.lines().last().expect("non-empty render");
assert_eq!(
last, " attribution · static · origin step 02 · propagation step 03 · conf 0.47",
"every forked diff ends with a designed answer line, like converged does"
);
}
#[test]
fn fork_line_carries_glyph_confidence_and_downstream_markers() {
let (a, b, res) = forked(0.47);
let out = render(&res, &a, &b, &opts(ColorMode::Plain));
let lines: Vec<&str> = out.lines().collect();
let fork_at = lines
.iter()
.position(|l| l.contains('⑂'))
.expect("fork line carries the ⑂ gutter glyph");
assert!(
lines[fork_at].contains("[FORK · conf 0.47]"),
"fork tag with confidence, got: {}",
lines[fork_at]
);
assert!(
lines[fork_at].contains('✗'),
"fork line carries the non-color divergence marker"
);
assert!(out.contains("A: "), "fork block shows the reference side");
assert!(out.contains("B: "), "fork block shows the observed side");
let downstream = lines
.iter()
.skip(fork_at + 1)
.find(|l| l.contains("[log-move]"))
.expect("downstream move is rendered");
assert!(downstream.contains('✗'), "downstream keeps the ✗ marker");
for l in &lines[..fork_at] {
assert!(!l.contains('✗') && !l.contains('⑂'), "pre-fork line: {l}");
}
}
#[test]
fn zero_confidence_renders_an_explicit_marginal_call() {
let (a, b, res) = forked(0.0);
let out = render(&res, &a, &b, &opts(ColorMode::Plain));
assert!(
out.contains("[FORK · marginal call]"),
"notebook 005: conf 0 is a designed weak-call state, got:\n{out}"
);
assert!(
out.contains("propagation step 03 · marginal call"),
"the attribution line follows the same weak-call rule, got:\n{out}"
);
assert!(
!out.contains("conf 0.0"),
"never render the marginal state as a small number"
);
}
#[test]
fn red_green_confined_to_field_diff_lines_and_amber_to_the_fork() {
let (a, b, res) = forked(0.47);
let out = render(&res, &a, &b, &opts(ColorMode::Truecolor));
let fork_at = out
.lines()
.position(|l| l.contains('⑂'))
.expect("fork line present");
for (i, line) in out.lines().enumerate() {
let plain = strip_ansi(line);
let body = plain.trim_start();
if line.contains(SGR_RED_TRUECOLOR) {
assert!(body.starts_with('-'), "red outside a `-` line: {plain}");
}
if line.contains(SGR_GREEN_TRUECOLOR) {
assert!(body.starts_with('+'), "green outside a `+` line: {plain}");
}
if line.contains(SGR_AMBER_TRUECOLOR) {
assert!(
i >= fork_at,
"amber before the fork (sameness must recede): {plain}"
);
}
}
assert!(
out.contains(SGR_RED_TRUECOLOR) && out.contains(SGR_GREEN_TRUECOLOR),
"field diff at the fork renders in red/green"
);
assert!(out.contains(SGR_AMBER_TRUECOLOR), "the fork glows amber");
}
#[test]
fn structure_is_identical_across_color_modes() {
let (a, b, res) = forked(0.47);
let colored = render(&res, &a, &b, &opts(ColorMode::Truecolor));
let plain = render(&res, &a, &b, &opts(ColorMode::Plain));
assert_eq!(
strip_ansi(&colored),
plain,
"color is styling only; structure is the contract"
);
}
#[test]
fn every_line_fits_the_requested_width() {
let (a, b, res) = forked(0.47);
let out = render(&res, &a, &b, &opts(ColorMode::Plain));
for line in out.lines() {
assert!(
line.chars().count() <= WIDTH,
"line exceeds width {WIDTH}: {line}"
);
}
}
#[test]
fn color_ladder_resolves_per_design() {
use ColorMode::{Ansi256, Bold, Plain, Truecolor};
let resolve = resolve_color_mode;
assert_eq!(
resolve(true, true, None, Some("xterm"), Some("truecolor")),
Plain
);
assert_eq!(
resolve(false, true, Some("1"), Some("xterm"), Some("truecolor")),
Plain
);
assert_eq!(
resolve(false, false, None, Some("xterm"), Some("truecolor")),
Plain
);
assert_eq!(
resolve(false, true, None, Some("dumb"), Some("truecolor")),
Plain
);
assert_eq!(
resolve(false, true, Some(""), None, Some("truecolor")),
Truecolor
);
assert_eq!(
resolve(false, true, None, Some("xterm-256color"), Some("truecolor")),
Truecolor
);
assert_eq!(
resolve(false, true, None, Some("xterm-256color"), Some("24bit")),
Truecolor
);
assert_eq!(
resolve(false, true, None, Some("xterm-256color"), None),
Ansi256
);
assert_eq!(resolve(false, true, None, Some("xterm"), None), Bold);
}
}