Skip to content

Commit 58cb5db

Browse files
Add colour to the CLI on a terminal, and nothing at all when piped
Human-mode output gains a Claude-Code-style colour hierarchy: dim labels, accented names and paths, semantic status colour (goal_met and admitted green, rejected and REFUSED red), and aligned key/value blocks. The whole design rests on one constraint: styling is decided at print time from the stream's own isatty(), so piped, redirected and captured output is byte-identical to before. That is what lets this land without touching a single expected block — tests/test_readme.py and tests/test_cookbook_models.py byte-compare CLI output against README.md and docs/cookbook/02-models.md, and both harnesses present a non-tty stdout. Padding is emitted outside the escape sequences, so a column is the same character count either way. Verified: 50/36/32/18 escapes on a pty for plan/models/models --check/demo, zero escapes on every piped stream, and ANSI-stripped tty output identical to piped output. NO_COLOR, TERM=dumb, --no-color and --json each yield zero escapes. The contracts that matter are unchanged — --json is one parseable document on stdout with zero-byte stderr, a text-mode failure leaves stdout empty with "error: ..." on stderr, viz stays raw pasteable Mermaid, and replay/diff still emit only the engine formatter's text. New grapharc/cli/style.py is stdlib only. No dependency was added: rich is not in the lock, and the README markets a four-package runtime dep list. Deliberately no glyphs, boxes or rules, even on a terminal. README and two cookbook pages print these blocks verbatim, so tty-only decoration would make what a user sees diverge from what the docs show — drift in the one direction the byte-comparison tests cannot catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 21bc764 commit 58cb5db

9 files changed

Lines changed: 610 additions & 84 deletions

File tree

grapharc/cli/agent.py

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from pathlib import Path
2121
from typing import Any
2222

23-
from grapharc.cli import optional
23+
from grapharc.cli import optional, style
2424
from grapharc.cli.output import EXIT_FAILED, EXIT_OK, emit, fail
2525

2626
# Entry points accepted from `grapharc.tools`, in preference order: a registrar
@@ -241,24 +241,62 @@ def run_agent(
241241
"refused": len(result.refused),
242242
}
243243

244+
width = style.LABEL_WIDTH
245+
note = f" {style.dim(f'({result.note})')}" if result.note else ""
246+
247+
def count(number: int) -> str:
248+
"""A count, red once it is not zero.
249+
250+
Zero refusals is not news; one is the reason to read the tool-call rows
251+
underneath it. The digits are the same either way when colour is off.
252+
"""
253+
return style.err(str(number)) if number else str(number)
254+
244255
lines = [
245-
f"task : {task}",
246-
f"model : {model_spec}",
247-
f"workspace : {workspace}",
248-
f"tools : {', '.join(visible) or '(none visible under this policy)'}",
249-
f"policy : allow={allow} ask={ask} deny={deny}",
256+
style.kv("task", task, width=width),
257+
style.kv("model", model_spec, width=width, tint=style.accent),
258+
style.kv("workspace", str(workspace), width=width, tint=style.accent),
259+
style.kv(
260+
"tools",
261+
", ".join(visible) or "(none visible under this policy)",
262+
width=width,
263+
),
264+
style.kv(
265+
"policy",
266+
f"{style.dim('allow=')}{allow} {style.dim('ask=')}{ask} {style.dim('deny=')}{deny}",
267+
width=width,
268+
),
250269
"",
251-
f"stopped : {reason}{f' ({result.note})' if result.note else ''}",
252-
f"turns : {result.iterations} tool calls: {len(result.tool_calls)} "
253-
f"denied: {len(result.denied)} refused: {len(result.refused)}",
254-
f"tokens : {meter.tokens:,}",
270+
style.kv(
271+
"stopped",
272+
f"{(style.ok if met else style.warn)(reason)}{note}",
273+
width=width,
274+
),
275+
style.kv(
276+
"turns",
277+
f"{result.iterations} {style.dim('tool calls:')} {len(result.tool_calls)} "
278+
f"{style.dim('denied:')} {count(len(result.denied))} "
279+
f"{style.dim('refused:')} {count(len(result.refused))}",
280+
width=width,
281+
),
282+
style.kv("tokens", f"{meter.tokens:,}", width=width),
255283
]
256284
for call in result.tool_calls:
257-
suffix = f" [{call.refused_by}]" if call.refused_by else ""
258-
lines.append(f" {call.status.value:<8} {call.tool}{suffix}")
285+
suffix = f" {style.dim(f'[{call.refused_by}]')}" if call.refused_by else ""
286+
# `ToolCallStatus` is ok / denied / error; anything a later version adds
287+
# lands on amber rather than being quietly called a success.
288+
verdict = {"ok": True, "denied": False, "error": False}.get(call.status.value)
289+
lines.append(
290+
f" {style.cell(call.status.value, 8, tint=style.tint_for(verdict))} "
291+
f"{style.accent(call.tool)}{suffix}"
292+
)
259293
lines.append("")
260-
lines.append(f"answer : {result.output}" if met else f"partial : {result.partial_output}")
261-
lines.append(f"trace : {trace_path}")
294+
lines.append(
295+
style.kv("answer", str(result.output), width=width)
296+
if met
297+
else style.kv("partial", str(result.partial_output), width=width, tint=style.dim)
298+
)
299+
lines.append(style.kv("trace", str(trace_path), width=width, tint=style.accent))
262300

263301
emit(payload, lines, as_json=as_json)
264302
return EXIT_OK if met else EXIT_FAILED

grapharc/cli/graphrun.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from pathlib import Path
3939
from typing import Any
4040

41+
from grapharc.cli import style
4142
from grapharc.cli.config import ConfigError
4243
from grapharc.cli.config import load as load_settings
4344
from grapharc.cli.generate import resolve_or_generate_policy
@@ -183,15 +184,32 @@ def run_graph(
183184
**settings.provenance(policy_source=policy_source),
184185
}
185186

187+
# `REFUSED` and `ADMITTED` are the whole answer, so on a terminal they carry
188+
# the only two colours that matter here. The words, the widths and the order
189+
# are untouched: `--check-only` is what CI runs, and CI reads text.
190+
width = style.LABEL_WIDTH
191+
header = [
192+
style.kv("graph", graph_path, width=width, tint=style.accent),
193+
style.kv("policy", policy_description, width=width),
194+
]
195+
trace_line = style.kv("trace", str(trace_path), width=width, tint=style.accent)
196+
186197
if not verdict.admitted:
187198
lines = [
188-
f"graph : {graph_path}",
189-
f"policy : {policy_description}",
199+
*header,
190200
"",
191-
f"REFUSED : {len(verdict.rejections)} objection(s)",
201+
style.kv(
202+
"REFUSED",
203+
f"{len(verdict.rejections)} objection(s)",
204+
width=width,
205+
key_tint=style.err,
206+
tint=style.err,
207+
),
208+
]
209+
lines += [
210+
f" {style.cell(r.code, 18, tint=style.err)} {r.detail}" for r in verdict.rejections
192211
]
193-
lines += [f" {r.code:<18} {r.detail}" for r in verdict.rejections]
194-
lines += ["", f"trace : {trace_path}"]
212+
lines += ["", trace_line]
195213
emit({"ok": False, **common}, lines, as_json=as_json)
196214
return EXIT_FAILED
197215

@@ -210,13 +228,12 @@ def run_graph(
210228
compiled = materializer.materialize(verdict, proposal)
211229
except MaterializationError as exc:
212230
lines = [
213-
f"graph : {graph_path}",
214-
f"policy : {policy_description}",
231+
*header,
215232
"",
216-
"ADMITTED, BUT CANNOT BE BUILT",
233+
style.err("ADMITTED, BUT CANNOT BE BUILT"),
217234
f" {exc}",
218235
"",
219-
f"trace : {trace_path}",
236+
trace_line,
220237
]
221238
emit(
222239
{"ok": False, "buildable": False, "error": str(exc), **common},
@@ -227,12 +244,13 @@ def run_graph(
227244

228245
if check_only:
229246
lines = [
230-
f"graph : {graph_path}",
231-
f"policy : {policy_description}",
232-
f"nodes : {proposal.node_count()}",
247+
*header,
248+
style.kv("nodes", str(proposal.node_count()), width=width),
233249
"",
234-
"ADMITTED and buildable. Nothing was run.",
235-
f"fingerprint: {verdict.fingerprint}",
250+
style.ok("ADMITTED") + style.dim(" and buildable. Nothing was run."),
251+
# Wider than the label column on purpose, and always has been: the
252+
# fingerprint is what a later run is compared against.
253+
style.kv("fingerprint", verdict.fingerprint, width=width, tint=style.accent),
236254
]
237255
emit(
238256
{"ok": True, "checked_only": True, "buildable": True, **common},
@@ -245,13 +263,12 @@ def run_graph(
245263

246264
payload = {"ok": True, "checked_only": False, **common, "state": state}
247265
lines = [
248-
f"graph : {graph_path}",
249-
f"policy : {policy_description}",
250-
f"nodes : {proposal.node_count()}",
266+
*header,
267+
style.kv("nodes", str(proposal.node_count()), width=width),
251268
"",
252-
"ADMITTED and executed.",
253-
f"state : {state}",
254-
f"trace : {trace_path}",
269+
style.ok("ADMITTED") + style.dim(" and executed."),
270+
style.kv("state", str(state), width=width),
271+
trace_line,
255272
]
256273
emit(payload, lines, as_json=as_json)
257274
return EXIT_OK

grapharc/cli/live.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from pathlib import Path
1717

18+
from grapharc.cli import style
1819
from grapharc.cli.output import EXIT_FAILED, EXIT_OK, emit
1920
from grapharc.gateway import different_providers, get_model
2021
from grapharc.observe.metrics import summarize
@@ -23,6 +24,10 @@
2324

2425
DEFAULT_REVIEWER = "openrouter/openai/gpt-4o-mini"
2526

27+
#: The header labels line up at nine characters, which is what `model`,
28+
#: `reviewer` and `budget` have always printed at.
29+
LABEL_WIDTH = 9
30+
2631
# Real models are open-ended, so a live run always carries a ceiling.
2732
LIVE_BUDGET = Budget(max_iterations=40, max_tokens=200_000, max_seconds=600)
2833

@@ -55,22 +60,32 @@ def say(line: str) -> None:
5560
if not as_json:
5661
print(line)
5762

58-
say(f"model : {model_spec}")
63+
say(style.kv("model", model_spec, width=LABEL_WIDTH, tint=style.accent))
5964
model = get_model(model_spec, temperature=0)
6065

6166
reviewer = None
6267
correlated = None
6368
if _needs_reviewer(example):
6469
reviewer_spec = reviewer_spec or DEFAULT_REVIEWER
65-
say(f"reviewer : {reviewer_spec}")
70+
say(style.kv("reviewer", reviewer_spec, width=LABEL_WIDTH, tint=style.accent))
6671
correlated = not different_providers(model_spec, reviewer_spec)
6772
if correlated:
73+
# Amber, not red: the run is still valid, the evidence is just weaker.
6874
say(
69-
" warning: author and reviewer share a provider — correlated "
70-
"agreement makes this weaker evidence than a cross-vendor pair"
75+
style.warn(
76+
" warning: author and reviewer share a provider — correlated "
77+
"agreement makes this weaker evidence than a cross-vendor pair"
78+
)
7179
)
7280
reviewer = get_model(reviewer_spec, temperature=0)
73-
say(f"budget : {LIVE_BUDGET.max_tokens:,} tokens / {LIVE_BUDGET.max_seconds:.0f}s")
81+
say(
82+
style.kv(
83+
"budget",
84+
f"{LIVE_BUDGET.max_tokens:,}{style.dim(' tokens / ')}"
85+
f"{LIVE_BUDGET.max_seconds:.0f}{style.dim('s')}",
86+
width=LABEL_WIDTH,
87+
)
88+
)
7489
say("")
7590

7691
header = {
@@ -90,7 +105,7 @@ def say(line: str) -> None:
90105
if result is None:
91106
emit(
92107
{"ok": False, **header, "error": f"'{example}' has no live wiring yet"},
93-
[f"'{example}' has no live wiring yet"],
108+
[style.err(f"'{example}' has no live wiring yet")],
94109
as_json=as_json,
95110
)
96111
return EXIT_FAILED
@@ -99,14 +114,18 @@ def say(line: str) -> None:
99114
lines = []
100115
for key, value in result.items():
101116
rendered = str(value)
102-
lines.append(f"{key}: {rendered[:400]}{'…' if len(rendered) > 400 else ''}")
117+
clipped = f"{rendered[:400]}{'…' if len(rendered) > 400 else ''}"
118+
lines.append(style.kv(str(key), clipped))
103119
if metrics:
104120
lines.append("")
105121
lines.append(
106-
f"spent: {metrics.tokens:,} tokens across {metrics.nodes_executed} nodes "
107-
f"in {metrics.duration_ms / 1000:.1f}s"
122+
style.kv(
123+
"spent",
124+
f"{metrics.tokens:,}{style.dim(' tokens across ')}{metrics.nodes_executed}"
125+
f"{style.dim(' nodes in ')}{metrics.duration_ms / 1000:.1f}{style.dim('s')}",
126+
)
108127
)
109-
lines.append(f"trace: {trace_path}")
128+
lines.append(style.kv("trace", str(trace_path), tint=style.accent))
110129

111130
emit(
112131
{"ok": True, **header, "result": result, "metrics": metrics},

0 commit comments

Comments
 (0)