@sweny-ai/core 0.1.170 → 0.1.171

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/agent-env.d.ts +2 -1
  2. package/dist/agent-env.js +9 -3
  3. package/dist/browser.d.ts +2 -0
  4. package/dist/browser.js +3 -0
  5. package/dist/budget.js +5 -1
  6. package/dist/cli/comment-output.d.ts +4 -2
  7. package/dist/cli/comment-output.js +16 -8
  8. package/dist/cli/main.js +42 -26
  9. package/dist/cli/new.js +3 -0
  10. package/dist/cli/output.d.ts +3 -2
  11. package/dist/cli/output.js +39 -37
  12. package/dist/cli/progress.d.ts +33 -0
  13. package/dist/cli/progress.js +69 -0
  14. package/dist/cli/renderer.d.ts +4 -0
  15. package/dist/cli/renderer.js +20 -13
  16. package/dist/cli/run-output.d.ts +13 -6
  17. package/dist/cli/run-output.js +36 -26
  18. package/dist/cli/runs.js +17 -11
  19. package/dist/cli/style.d.ts +26 -0
  20. package/dist/cli/style.js +48 -0
  21. package/dist/cli/terminal.d.ts +47 -0
  22. package/dist/cli/terminal.js +123 -0
  23. package/dist/cli/ticket.d.ts +89 -0
  24. package/dist/cli/ticket.js +218 -0
  25. package/dist/cli/try.d.ts +15 -8
  26. package/dist/cli/try.js +84 -65
  27. package/dist/executor.d.ts +8 -1
  28. package/dist/executor.js +20 -2
  29. package/dist/git-credentials.d.ts +106 -0
  30. package/dist/git-credentials.js +322 -0
  31. package/dist/harness/acp.js +3 -0
  32. package/dist/harness/claude-code.d.ts +7 -0
  33. package/dist/harness/claude-code.js +44 -3
  34. package/dist/harness/codex.js +3 -0
  35. package/dist/harness/pi.js +3 -0
  36. package/dist/harness/policy.d.ts +5 -0
  37. package/dist/harness/policy.js +14 -0
  38. package/dist/harness/sandbox-wrapper.d.ts +9 -2
  39. package/dist/harness/sandbox-wrapper.js +14 -4
  40. package/dist/harness/types.d.ts +10 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +2 -0
  43. package/dist/mermaid.d.ts +10 -5
  44. package/dist/mermaid.js +11 -13
  45. package/dist/safe-outputs.js +5 -0
  46. package/dist/skills/git-push.d.ts +62 -0
  47. package/dist/skills/git-push.js +132 -0
  48. package/dist/skills/github.d.ts +17 -0
  49. package/dist/skills/github.js +23 -0
  50. package/dist/theme.d.ts +185 -0
  51. package/dist/theme.js +183 -0
  52. package/dist/workflows/browser.js +2 -2
  53. package/dist/workflows/implement.yml +2 -1
  54. package/dist/workflows/triage.yml +2 -1
  55. package/package.json +6 -1
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Node progress lines for `sweny workflow run` and `sweny try`: a spinner on
3
+ * the running node when the output is live, then one settled line per node.
4
+ *
5
+ * ⠙ survey 7 tool calls (live, redrawn in place)
6
+ * ✓ survey 18s · 12 tool calls (settled)
7
+ *
8
+ * Not live (pipes, CI, --verbose): no redraws, no spinner, one line per event.
9
+ */
10
+ import { SPINNER_INTERVAL_MS } from "../theme.js";
11
+ import { glyphsFor, spinnerFramesFor } from "./terminal.js";
12
+ export function toolCallsText(n) {
13
+ return `${n} tool ${n === 1 ? "call" : "calls"}`;
14
+ }
15
+ export function createNodeProgress(o) {
16
+ const g = glyphsFor(o.unicode);
17
+ const frames = spinnerFramesFor(o.unicode);
18
+ const { paint } = o;
19
+ const pad = (id) => id.padEnd(o.idWidth ?? 0);
20
+ let timer;
21
+ let current;
22
+ let frame = 0;
23
+ const draw = () => {
24
+ if (!current)
25
+ return;
26
+ const calls = current.calls > 0 ? ` ${paint.muted(toolCallsText(current.calls))}` : "";
27
+ o.write(`\r\x1B[2K ${paint.brand(frames[frame++ % frames.length])} ${current.node}${calls}`);
28
+ };
29
+ const clear = () => {
30
+ if (timer)
31
+ clearInterval(timer);
32
+ timer = undefined;
33
+ if (current && o.live)
34
+ o.write("\r\x1B[2K");
35
+ current = undefined;
36
+ };
37
+ return {
38
+ enter(node) {
39
+ clear();
40
+ if (o.live) {
41
+ current = { node, calls: 0 };
42
+ frame = 0;
43
+ draw();
44
+ timer = setInterval(draw, SPINNER_INTERVAL_MS);
45
+ timer.unref?.();
46
+ }
47
+ else if (o.announce) {
48
+ o.write(` ${paint.muted(g.pending)} ${paint.muted(node)}${g.ellipsis}\n`);
49
+ }
50
+ },
51
+ tick(node) {
52
+ if (current && current.node === node)
53
+ current.calls++;
54
+ },
55
+ exit(node, status, detail) {
56
+ clear();
57
+ const glyph = status === "success"
58
+ ? paint.success(g.success)
59
+ : status === "failed"
60
+ ? paint.error(g.failure)
61
+ : paint.muted(g.skipped);
62
+ const tail = detail ? ` ${paint.muted(detail)}` : "";
63
+ o.write(` ${glyph} ${detail ? pad(node) : node}${tail}\n`);
64
+ },
65
+ stop() {
66
+ clear();
67
+ },
68
+ };
69
+ }
@@ -7,6 +7,10 @@ export interface DagRendererOptions {
7
7
  animate?: boolean;
8
8
  /** Stream to write to when animate=true or render() is called. Default: process.stderr. */
9
9
  stream?: NodeJS.WriteStream;
10
+ /** Show the status legend under the graph. Default: true. */
11
+ legend?: boolean;
12
+ /** Draw with Unicode box characters, else ASCII. Default: what the terminal supports. */
13
+ unicode?: boolean;
10
14
  }
11
15
  export declare class DagRenderer {
12
16
  private readonly workflow;
@@ -1,4 +1,6 @@
1
1
  import chalk from "chalk";
2
+ import { GLYPHS, ROLE_COLORS } from "../theme.js";
3
+ import { supportsUnicode, toAsciiDrawing } from "./terminal.js";
2
4
  // ── Helpers ──────────────────────────────────────────────────────
3
5
  /** Strip ANSI escape codes for accurate visible-width calculations. */
4
6
  export function stripAnsi(str) {
@@ -48,17 +50,18 @@ function topologicalOrder(workflow) {
48
50
  }
49
51
  return order;
50
52
  }
51
- // ── Status icons + colors ────────────────────────────────────────
53
+ // ── Status icons + colors (the theme glyph set, one role color each) ─
54
+ const G = GLYPHS.unicode;
52
55
  function statusIcon(state) {
53
56
  switch (state) {
54
57
  case "completed":
55
- return chalk.green("●");
58
+ return chalk.hex(ROLE_COLORS.success)(G.success);
56
59
  case "running":
57
- return chalk.yellow("◉");
60
+ return chalk.hex(ROLE_COLORS.brand)(G.running);
58
61
  case "pending":
59
- return chalk.gray("○");
62
+ return chalk.hex(ROLE_COLORS.muted)(G.pending);
60
63
  case "failed":
61
- return chalk.red("✕");
64
+ return chalk.hex(ROLE_COLORS.error)(G.failure);
62
65
  }
63
66
  }
64
67
  // ── Graph helpers ────────────────────────────────────────────────
@@ -143,7 +146,7 @@ function renderNodeBox(nodeName, nodeRenderState, innerWidth, indent, topConnect
143
146
  else {
144
147
  bottom = `${pad}└${"─".repeat(innerWidth)}┘`;
145
148
  }
146
- // Content lines — icon on first line, wrapped name
149
+ // Content lines: icon on first line, wrapped name
147
150
  // Available space for text: innerWidth - 4 (1 space + icon + 1 space + text + padding + 1 space)
148
151
  // "│ ○ Name...padding │" → icon(1) + spaces(2) + name + pad
149
152
  const iconVis = stripAnsi(icon); // 1 char
@@ -184,10 +187,10 @@ function renderNodeBox(nodeName, nodeRenderState, innerWidth, indent, topConnect
184
187
  // ── Legend ───────────────────────────────────────────────────────
185
188
  function renderLegend() {
186
189
  const items = [
187
- `${chalk.green("●")} completed`,
188
- `${chalk.yellow("◉")} running`,
189
- `${chalk.gray("○")} pending`,
190
- `${chalk.red("✕")} failed`,
190
+ `${statusIcon("completed")} completed`,
191
+ `${statusIcon("running")} running`,
192
+ `${statusIcon("pending")} pending`,
193
+ `${statusIcon("failed")} failed`,
191
194
  ];
192
195
  return `\n ${chalk.dim(items.join(" "))}`;
193
196
  }
@@ -202,6 +205,8 @@ export class DagRenderer {
202
205
  this.workflow = workflow;
203
206
  this.options = {
204
207
  animate: options.animate ?? false,
208
+ legend: options.legend ?? true,
209
+ unicode: options.unicode ?? supportsUnicode(),
205
210
  stream: options.stream ??
206
211
  (typeof process !== "undefined" ? process.stderr : undefined),
207
212
  };
@@ -298,7 +303,7 @@ export class DagRenderer {
298
303
  continue;
299
304
  const leftState = this.nodeStates.get(leftId) ?? { state: "pending", toolCallCount: 0 };
300
305
  const rightState = this.nodeStates.get(rightId) ?? { state: "pending", toolCallCount: 0 };
301
- // Determine child box widths — each child gets its own width based on its name
306
+ // Determine child box widths: each child gets its own width based on its name
302
307
  const leftKids = children.get(leftId) ?? [];
303
308
  const rightKids = children.get(rightId) ?? [];
304
309
  // For side-by-side, compute widths that fit
@@ -391,8 +396,10 @@ export class DagRenderer {
391
396
  }
392
397
  }
393
398
  }
394
- lines.push(renderLegend());
395
- return lines.join("\n");
399
+ if (this.options.legend)
400
+ lines.push(renderLegend());
401
+ const out = lines.join("\n");
402
+ return this.options.unicode ? out : toAsciiDrawing(out);
396
403
  }
397
404
  /**
398
405
  * Writes the current DAG render to the stream.
@@ -68,6 +68,11 @@ export interface RunPolicy {
68
68
  export declare function degradedKey(entry: string): string;
69
69
  /** Sum the per-node results into one run summary. */
70
70
  export declare function summarizeRun(results: Map<string, NodeResult>, durationMs: number, crashed?: boolean, trace?: ExecutionTrace): RunSummary;
71
+ /**
72
+ * The policy facts the run knew, as short phrases: `env scoped`, `sandbox on`,
73
+ * `1 output staged`. No phrase for a fact no node reported.
74
+ */
75
+ export declare function policyParts(p: RunPolicy | undefined): string[];
71
76
  /**
72
77
  * `policy: env scoped, sandbox on, 1 output staged`. Only what the run knew:
73
78
  * no segment for a fact no node reported. Undefined when there is nothing to say.
@@ -76,8 +81,12 @@ export declare function formatPolicySegment(p: RunPolicy | undefined): string |
76
81
  export declare function formatReceiptDuration(ms: number): string;
77
82
  export declare function formatTokenCount(n: number): string;
78
83
  export declare function formatCost(usd: number): string;
84
+ /** `tokens 52k of 50k`: the overrun, in the budget's unit. */
85
+ export declare function formatBudgetAmount(b: NonNullable<RunSummary["budget"]>): string;
79
86
  /** `budget exceeded: tokens 52k of 50k (run, at node gather)`. */
80
87
  export declare function formatBudgetOverrun(b: NonNullable<RunSummary["budget"]>): string;
88
+ /** `agreed 4/5`, or `fell through 2/2` when no decision passed the gates. */
89
+ export declare function formatDeciderCounts(d: NonNullable<RunSummary["decider"]>): string;
81
90
  /**
82
91
  * One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
83
92
  * Token and cost segments are omitted when the SDK reported none. A harness
@@ -86,15 +95,11 @@ export declare function formatBudgetOverrun(b: NonNullable<RunSummary["budget"]>
86
95
  * when writing to a TTY.
87
96
  */
88
97
  export declare function formatReceipt(s: RunSummary): string;
89
- /** Receipt for a terminal: colored only when `color` is true. */
98
+ /** Receipt for a terminal: in the success or error color only when `color` is true. */
90
99
  export declare function renderReceiptLine(s: RunSummary, color: boolean): string;
91
- /** Brand blue (blue-600 / blue-700), not indigo. Applied to successful nodes. */
92
- export declare const STEP_SUMMARY_CLASS_DEFS: {
93
- readonly success: "fill:#2563eb,stroke:#1d4ed8,color:#fff,stroke-width:2px";
94
- };
95
100
  /** Per-node Mermaid status from run results. */
96
101
  export declare function nodeStates(results: Map<string, NodeResult>): Record<string, NodeStatus>;
97
- /** Markdown for `$GITHUB_STEP_SUMMARY`: receipt + status-colored Mermaid DAG. */
102
+ /** Markdown for `$GITHUB_STEP_SUMMARY`: receipt + Mermaid DAG in the brand classDefs. */
98
103
  export declare function formatStepSummary(workflow: Workflow, results: Map<string, NodeResult>, summary: RunSummary, trace?: ExecutionTrace): string;
99
104
  /**
100
105
  * Append the step summary to the file named by GITHUB_STEP_SUMMARY.
@@ -116,6 +121,8 @@ export interface RunLogger extends Logger {
116
121
  export declare function createRunLogger(opts: {
117
122
  verbose: boolean;
118
123
  tty: boolean;
124
+ /** Color the markers. Default: `tty` and color is enabled for stderr. */
125
+ color?: boolean;
119
126
  write?: (s: string) => void;
120
127
  }): RunLogger;
121
128
  export declare const WORKFLOW_RUN_DESCRIPTION = "Run a workflow from a YAML or JSON file. With no file, batch-runs every workflow in .sweny/e2e/ (lists and confirms first; use --yes to skip the prompt).";
@@ -6,10 +6,11 @@
6
6
  * model prose: only counts, durations, token totals, and cost.
7
7
  */
8
8
  import fs from "node:fs";
9
- import chalk from "chalk";
10
9
  import { consoleLogger } from "../types.js";
11
10
  import { summarizeDecisions } from "../decider.js";
12
11
  import { toMermaidBlock } from "../mermaid.js";
12
+ import { createPaint } from "./style.js";
13
+ import { colorEnabled, glyphs } from "./terminal.js";
13
14
  /** The short key of a `degraded` entry: the text before its first colon. */
14
15
  export function degradedKey(entry) {
15
16
  const i = entry.indexOf(":");
@@ -90,12 +91,12 @@ function decisionCounts(trace) {
90
91
  return { compared: d.compared, agreed: d.agreed, fellThrough: d.fell_through };
91
92
  }
92
93
  /**
93
- * `policy: env scoped, sandbox on, 1 output staged`. Only what the run knew:
94
- * no segment for a fact no node reported. Undefined when there is nothing to say.
94
+ * The policy facts the run knew, as short phrases: `env scoped`, `sandbox on`,
95
+ * `1 output staged`. No phrase for a fact no node reported.
95
96
  */
96
- export function formatPolicySegment(p) {
97
+ export function policyParts(p) {
97
98
  if (!p)
98
- return undefined;
99
+ return [];
99
100
  const parts = [];
100
101
  if (p.envScope !== undefined)
101
102
  parts.push(p.envScope ? "env scoped" : "env unscoped");
@@ -113,6 +114,14 @@ export function formatPolicySegment(p) {
113
114
  if (p.outputs.applied > 0)
114
115
  parts.push(`${n(p.outputs.applied)} applied`);
115
116
  }
117
+ return parts;
118
+ }
119
+ /**
120
+ * `policy: env scoped, sandbox on, 1 output staged`. Only what the run knew:
121
+ * no segment for a fact no node reported. Undefined when there is nothing to say.
122
+ */
123
+ export function formatPolicySegment(p) {
124
+ const parts = policyParts(p);
116
125
  return parts.length > 0 ? `policy: ${parts.join(", ")}` : undefined;
117
126
  }
118
127
  export function formatReceiptDuration(ms) {
@@ -137,11 +146,19 @@ export function formatCost(usd) {
137
146
  return "<$0.01";
138
147
  return `$${usd.toFixed(2)}`;
139
148
  }
140
- /** `budget exceeded: tokens 52k of 50k (run, at node gather)`. */
141
- export function formatBudgetOverrun(b) {
149
+ /** `tokens 52k of 50k`: the overrun, in the budget's unit. */
150
+ export function formatBudgetAmount(b) {
142
151
  const amount = (n) => b.unit === "cost_usd" ? (n < 0.01 ? `$${n.toFixed(4)}` : formatCost(n)) : formatTokenCount(n);
143
152
  const unit = b.unit === "cost_usd" ? "cost" : "tokens";
144
- return `budget exceeded: ${unit} ${amount(b.spent)} of ${amount(b.limit)} (${b.scope}, at node ${b.node})`;
153
+ return `${unit} ${amount(b.spent)} of ${amount(b.limit)}`;
154
+ }
155
+ /** `budget exceeded: tokens 52k of 50k (run, at node gather)`. */
156
+ export function formatBudgetOverrun(b) {
157
+ return `budget exceeded: ${formatBudgetAmount(b)} (${b.scope}, at node ${b.node})`;
158
+ }
159
+ /** `agreed 4/5`, or `fell through 2/2` when no decision passed the gates. */
160
+ export function formatDeciderCounts(d) {
161
+ return d.compared > 0 ? `agreed ${d.agreed}/${d.compared}` : `fell through ${d.fellThrough}/${d.fellThrough}`;
145
162
  }
146
163
  /**
147
164
  * One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
@@ -162,28 +179,19 @@ export function formatReceipt(s) {
162
179
  ...(s.degraded && s.degraded.length > 0 ? [`degraded: ${s.degraded.join(", ")}`] : []),
163
180
  ...(formatPolicySegment(s.policy) ? [formatPolicySegment(s.policy)] : []),
164
181
  ...(s.budget ? [formatBudgetOverrun(s.budget)] : []),
165
- ...(s.decider
166
- ? [
167
- s.decider.compared > 0
168
- ? `decider agreed ${s.decider.agreed}/${s.decider.compared}`
169
- : `decider fell through ${s.decider.fellThrough}/${s.decider.fellThrough}`,
170
- ]
171
- : []),
182
+ ...(s.decider ? [`decider ${formatDeciderCounts(s.decider)}`] : []),
172
183
  ];
173
184
  return parts.join(" · ");
174
185
  }
175
- /** Receipt for a terminal: colored only when `color` is true. */
186
+ /** Receipt for a terminal: in the success or error color only when `color` is true. */
176
187
  export function renderReceiptLine(s, color) {
177
188
  const line = formatReceipt(s);
178
189
  if (!color)
179
190
  return line;
180
- return s.ok ? chalk.green(line) : chalk.red(line);
191
+ const paint = createPaint(true);
192
+ return s.ok ? paint.success(line) : paint.error(line);
181
193
  }
182
194
  // ── Step summary ────────────────────────────────────────────────
183
- /** Brand blue (blue-600 / blue-700), not indigo. Applied to successful nodes. */
184
- export const STEP_SUMMARY_CLASS_DEFS = {
185
- success: "fill:#2563eb,stroke:#1d4ed8,color:#fff,stroke-width:2px",
186
- };
187
195
  /** Per-node Mermaid status from run results. */
188
196
  export function nodeStates(results) {
189
197
  const state = {};
@@ -192,7 +200,7 @@ export function nodeStates(results) {
192
200
  }
193
201
  return state;
194
202
  }
195
- /** Markdown for `$GITHUB_STEP_SUMMARY`: receipt + status-colored Mermaid DAG. */
203
+ /** Markdown for `$GITHUB_STEP_SUMMARY`: receipt + Mermaid DAG in the brand classDefs. */
196
204
  export function formatStepSummary(workflow, results, summary, trace) {
197
205
  const state = nodeStates(results);
198
206
  return [
@@ -200,7 +208,7 @@ export function formatStepSummary(workflow, results, summary, trace) {
200
208
  "",
201
209
  `\`${formatReceipt(summary)}\``,
202
210
  "",
203
- toMermaidBlock(workflow, { state, trace, classDefs: STEP_SUMMARY_CLASS_DEFS }),
211
+ toMermaidBlock(workflow, { state, trace }),
204
212
  "",
205
213
  "",
206
214
  ].join("\n");
@@ -219,7 +227,7 @@ export function writeStepSummary(workflow, results, summary, trace, env = proces
219
227
  return true;
220
228
  }
221
229
  catch (err) {
222
- process.stderr.write(` ⚠ could not write GITHUB_STEP_SUMMARY: ${err instanceof Error ? err.message : err}\n`);
230
+ process.stderr.write(` ${glyphs().warning} could not write GITHUB_STEP_SUMMARY: ${err instanceof Error ? err.message : err}\n`);
223
231
  return false;
224
232
  }
225
233
  }
@@ -234,6 +242,8 @@ export function createRunLogger(opts) {
234
242
  const write = opts.write ?? ((s) => void process.stderr.write(s));
235
243
  if (opts.verbose)
236
244
  return { ...consoleLogger, flush() { } };
245
+ const paint = createPaint(opts.color ?? (opts.tty && colorEnabled(process.stderr)));
246
+ const g = glyphs();
237
247
  const held = [];
238
248
  const emit = (line) => {
239
249
  if (opts.tty)
@@ -245,8 +255,8 @@ export function createRunLogger(opts) {
245
255
  return {
246
256
  info: noop,
247
257
  debug: noop,
248
- warn: (msg) => emit(` ${chalk.yellow("⚠")} ${msg.trim()}\n`),
249
- error: (msg) => emit(` ${chalk.red("✗")} ${msg.trim()}\n`),
258
+ warn: (msg) => emit(` ${paint.warning(g.warning)} ${msg.trim()}\n`),
259
+ error: (msg) => emit(` ${paint.error(g.failure)} ${msg.trim()}\n`),
250
260
  flush() {
251
261
  while (held.length)
252
262
  write(held.shift());
package/dist/cli/runs.js CHANGED
@@ -2,9 +2,11 @@
2
2
  * `sweny runs` and `sweny runs diff`: read-only views over `.sweny/runs/`.
3
3
  * Metadata only. Color only when `color` is true (TTY).
4
4
  */
5
- import chalk from "chalk";
6
5
  import { findRun, listRuns } from "./run-history.js";
7
6
  import { formatCost, formatReceiptDuration, formatTokenCount } from "./run-output.js";
7
+ import { createPaint } from "./style.js";
8
+ import { colorEnabled } from "./terminal.js";
9
+ import { GLYPHS } from "../theme.js";
8
10
  // ── Small formatters ────────────────────────────────────────────
9
11
  export function formatAge(startedAt, nowMs = Date.now()) {
10
12
  const s = Math.max(0, Math.round((nowMs - Date.parse(startedAt)) / 1000));
@@ -18,15 +20,18 @@ export function formatAge(startedAt, nowMs = Date.now()) {
18
20
  return `${h}h ago`;
19
21
  return `${Math.floor(h / 24)}d ago`;
20
22
  }
21
- const ICON = { success: "✓", failed: "✗", crashed: "✗", skipped: "−" };
23
+ const G = GLYPHS.unicode;
24
+ const ICON = { success: G.success, failed: G.failure, crashed: G.failure, skipped: G.skipped };
25
+ /** Status text in its role color: success, error, or muted for skipped. */
22
26
  function paint(text, status, color) {
23
27
  if (!color)
24
28
  return text;
29
+ const p = createPaint(true);
25
30
  if (status === "success")
26
- return chalk.green(text);
31
+ return p.success(text);
27
32
  if (status === "skipped")
28
- return chalk.dim(text);
29
- return chalk.red(text);
33
+ return p.muted(text);
34
+ return p.error(text);
30
35
  }
31
36
  const tokensText = (n) => (n === null ? "-" : formatTokenCount(n));
32
37
  const costText = (n) => (n === null ? "-" : formatCost(n));
@@ -63,7 +68,8 @@ export function formatRunsTable(runs, color, nowMs = Date.now()) {
63
68
  .join(" ")
64
69
  .trimEnd();
65
70
  const headLine = line(head);
66
- return [color ? chalk.dim(headLine) : headLine, ...rows.map((r) => line(r.cells, r.status))].join("\n") + "\n";
71
+ return ([color ? createPaint(true).muted(headLine) : headLine, ...rows.map((r) => line(r.cells, r.status))].join("\n") +
72
+ "\n");
67
73
  }
68
74
  // ── sweny runs diff ─────────────────────────────────────────────
69
75
  const sign = (n) => (n > 0 ? "+" : n < 0 ? "-" : "");
@@ -94,7 +100,7 @@ function nodeChanges(a, b) {
94
100
  }
95
101
  const routeKey = (r) => `${r.from} → ${r.to}`;
96
102
  export function formatRunDiff(a, b, color) {
97
- const dim = (s) => (color ? chalk.dim(s) : s);
103
+ const dim = createPaint(color).muted;
98
104
  const out = [];
99
105
  const hdr = (tag, r) => ` ${tag} ${r.run_id} ${r.workflow_id} ${paint(`${ICON[r.status] ?? "?"} ${r.status}`, r.status, color)} ${formatReceiptDuration(r.duration_ms)}`;
100
106
  out.push(hdr("a", a), hdr("b", b), "");
@@ -183,7 +189,7 @@ export function registerRunsCommand(program) {
183
189
  .action((opts) => {
184
190
  const limit = Number(opts.limit ?? "20");
185
191
  if (!Number.isInteger(limit) || limit < 1) {
186
- console.error(chalk.red(" --limit must be a positive integer"));
192
+ console.error(createPaint(colorEnabled(process.stderr)).error(" --limit must be a positive integer"));
187
193
  process.exitCode = 1;
188
194
  return;
189
195
  }
@@ -195,7 +201,7 @@ export function registerRunsCommand(program) {
195
201
  process.stdout.write(JSON.stringify(runs, null, 2) + "\n");
196
202
  return;
197
203
  }
198
- process.stdout.write("\n" + formatRunsTable(runs, process.stdout.isTTY ?? false) + "\n");
204
+ process.stdout.write("\n" + formatRunsTable(runs, colorEnabled(process.stdout)) + "\n");
199
205
  });
200
206
  runsCmd
201
207
  .command("diff [a] [b]")
@@ -206,11 +212,11 @@ export function registerRunsCommand(program) {
206
212
  const runs = listRuns();
207
213
  const picked = pickRunsForDiff(runs, refs, opts.workflow);
208
214
  if ("error" in picked) {
209
- console.error(chalk.red(` ${picked.error}`));
215
+ console.error(createPaint(colorEnabled(process.stderr)).error(` ${picked.error}`));
210
216
  process.exitCode = 1;
211
217
  return;
212
218
  }
213
- process.stdout.write("\n" + formatRunDiff(picked.a, picked.b, process.stdout.isTTY ?? false) + "\n");
219
+ process.stdout.write("\n" + formatRunDiff(picked.a, picked.b, colorEnabled(process.stdout)) + "\n");
214
220
  });
215
221
  return runsCmd;
216
222
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Terminal painters built from the theme tokens. The only place the CLI
3
+ * turns a color role into ANSI.
4
+ *
5
+ * `createPaint(false)` returns identity painters, so a formatter takes one
6
+ * `Paint` and renders the same text with or without color.
7
+ */
8
+ export type Painter = (text: string) => string;
9
+ export interface Paint {
10
+ brand: Painter;
11
+ success: Painter;
12
+ warning: Painter;
13
+ error: Painter;
14
+ info: Painter;
15
+ muted: Painter;
16
+ /** Emphasis without a color: bold. */
17
+ strong: Painter;
18
+ link: Painter;
19
+ }
20
+ /** Painters for one output. `enabled: false` paints nothing. */
21
+ export declare function createPaint(enabled: boolean): Paint;
22
+ /**
23
+ * Honor `NO_COLOR` for every chalk call in the process, including the ones
24
+ * that do not go through `createPaint`. Call once at CLI startup.
25
+ */
26
+ export declare function applyNoColor(env?: Record<string, string | undefined>): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Terminal painters built from the theme tokens. The only place the CLI
3
+ * turns a color role into ANSI.
4
+ *
5
+ * `createPaint(false)` returns identity painters, so a formatter takes one
6
+ * `Paint` and renders the same text with or without color.
7
+ */
8
+ import chalk, { Chalk, chalkStderr } from "chalk";
9
+ import { ROLE_COLORS } from "../theme.js";
10
+ const identity = (s) => s;
11
+ /** Painters for one output. `enabled: false` paints nothing. */
12
+ export function createPaint(enabled) {
13
+ if (!enabled) {
14
+ return {
15
+ brand: identity,
16
+ success: identity,
17
+ warning: identity,
18
+ error: identity,
19
+ info: identity,
20
+ muted: identity,
21
+ strong: identity,
22
+ link: identity,
23
+ };
24
+ }
25
+ // Force a level so a caller that decided "color on" gets color even when
26
+ // chalk's own detection looked at a different stream.
27
+ const k = new Chalk({ level: Math.max(chalk.level, chalkStderr.level, 1) });
28
+ return {
29
+ brand: (s) => k.hex(ROLE_COLORS.brand)(s),
30
+ success: (s) => k.hex(ROLE_COLORS.success)(s),
31
+ warning: (s) => k.hex(ROLE_COLORS.warning)(s),
32
+ error: (s) => k.hex(ROLE_COLORS.error)(s),
33
+ info: (s) => k.hex(ROLE_COLORS.info)(s),
34
+ muted: (s) => k.hex(ROLE_COLORS.muted)(s),
35
+ strong: (s) => k.bold(s),
36
+ link: (s) => k.hex(ROLE_COLORS.info).underline(s),
37
+ };
38
+ }
39
+ /**
40
+ * Honor `NO_COLOR` for every chalk call in the process, including the ones
41
+ * that do not go through `createPaint`. Call once at CLI startup.
42
+ */
43
+ export function applyNoColor(env = process.env) {
44
+ if (env.NO_COLOR) {
45
+ chalk.level = 0;
46
+ chalkStderr.level = 0;
47
+ }
48
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What the terminal can show: color, Unicode, width, live redraws.
3
+ *
4
+ * Every check reads the env and stream passed in, so specs can pin each
5
+ * case. Defaults read the real process.
6
+ */
7
+ import { type GlyphSet, type TicketBox } from "../theme.js";
8
+ type Env = Record<string, string | undefined>;
9
+ /** The narrowest layout SWEny designs for. Narrower terminals still work, they just wrap. */
10
+ export declare const MIN_COLUMNS = 40;
11
+ /**
12
+ * True when the terminal can draw box-drawing characters and the glyph set.
13
+ *
14
+ * `SWENY_ASCII=1` forces ASCII; `SWENY_UNICODE=1` forces Unicode. Otherwise a
15
+ * locale that names a charset decides (`LC_ALL`, then `LC_CTYPE`, then
16
+ * `LANG`): UTF-8 is Unicode, `C`/`POSIX`/anything else is ASCII. The Linux
17
+ * console (`TERM=linux`) is ASCII. With no locale set, POSIX terminals are
18
+ * assumed Unicode; Windows is Unicode only in terminals known to support it.
19
+ */
20
+ export declare function supportsUnicode(env?: Env, platform?: string): boolean;
21
+ /**
22
+ * True when output to `stream` should carry ANSI color. `NO_COLOR` (any
23
+ * non-empty value) always wins; then `FORCE_COLOR` (anything but `0`); then a
24
+ * TTY that is not `TERM=dumb`.
25
+ */
26
+ export declare function colorEnabled(stream: {
27
+ isTTY?: boolean;
28
+ } | undefined, env?: Env): boolean;
29
+ /**
30
+ * True when `stream` gets the rich view (the ticket, redraws, a spinner):
31
+ * a TTY, not CI, not dumb. Everything else gets plain lines.
32
+ */
33
+ export declare function richOutput(stream: {
34
+ isTTY?: boolean;
35
+ } | undefined, env?: Env): boolean;
36
+ /** Usable width of `stream`: its column count, else `COLUMNS`, else 80. */
37
+ export declare function terminalColumns(stream: {
38
+ columns?: number;
39
+ } | undefined, env?: Env): number;
40
+ export declare function glyphsFor(unicode: boolean): GlyphSet;
41
+ export declare function ticketBoxFor(unicode: boolean): TicketBox;
42
+ export declare function spinnerFramesFor(unicode: boolean): readonly string[];
43
+ /** The glyphs for the current process. */
44
+ export declare function glyphs(env?: Env): GlyphSet;
45
+ /** Replace single-column Unicode drawing characters with their ASCII stand-ins. */
46
+ export declare function toAsciiDrawing(text: string): string;
47
+ export {};