@cruxy/cli 0.11.0 → 0.12.0

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 (44) hide show
  1. package/dist/approval/prompt.js +17 -15
  2. package/dist/cli/commands/checkpoint.js +6 -4
  3. package/dist/cli/commands/config.js +10 -7
  4. package/dist/cli/commands/index.js +16 -15
  5. package/dist/cli/commands/init.js +5 -3
  6. package/dist/cli/commands/login.js +5 -3
  7. package/dist/cli/commands/pr.js +8 -7
  8. package/dist/cli/commands/rollback.js +7 -6
  9. package/dist/cli/commands/run.js +7 -6
  10. package/dist/cli/commands/skills.js +12 -10
  11. package/dist/cli/program.js +7 -6
  12. package/dist/cli/repl.js +11 -9
  13. package/dist/components/frame.js +3 -1
  14. package/dist/components/fuzzy.d.ts +4 -4
  15. package/dist/components/fuzzy.js +14 -13
  16. package/dist/components/select.js +8 -7
  17. package/dist/errors/format.js +8 -8
  18. package/dist/onboarding/flow.js +6 -6
  19. package/dist/onboarding/steps.js +11 -11
  20. package/dist/plan/approve.js +6 -6
  21. package/dist/plan/render.js +26 -18
  22. package/dist/render/capabilities.js +4 -0
  23. package/dist/render/diff.d.ts +6 -7
  24. package/dist/render/diff.js +33 -22
  25. package/dist/render/highlight.d.ts +3 -3
  26. package/dist/render/highlight.js +15 -15
  27. package/dist/render/index.d.ts +1 -1
  28. package/dist/render/plain-renderer.d.ts +2 -1
  29. package/dist/render/plain-renderer.js +7 -6
  30. package/dist/render/state.d.ts +7 -2
  31. package/dist/render/state.js +16 -10
  32. package/dist/render/tty-renderer.d.ts +2 -1
  33. package/dist/render/tty-renderer.js +20 -17
  34. package/dist/render/types.d.ts +7 -0
  35. package/dist/subagent/orchestrator.js +21 -6
  36. package/dist/theme/index.d.ts +2 -0
  37. package/dist/theme/index.js +2 -0
  38. package/dist/theme/resolve.d.ts +32 -0
  39. package/dist/theme/resolve.js +73 -0
  40. package/dist/theme/tokens.d.ts +104 -0
  41. package/dist/theme/tokens.js +52 -0
  42. package/dist/utils/logger.d.ts +2 -0
  43. package/dist/utils/logger.js +7 -4
  44. package/package.json +1 -1
@@ -7,7 +7,7 @@ const FENCE_PLAUSIBLE = /^(?:`{1,2}|`{3,}[\w+#.-]*\s*)$/;
7
7
  * Create the per-segment streaming highlighter. `highlightLine` is injectable
8
8
  * for tests (e.g. to prove a throwing tokenizer degrades to plain text).
9
9
  */
10
- export function createStreamHighlighter(c, highlightLine = defaultLineHighlighter(c)) {
10
+ export function createStreamHighlighter(theme, highlightLine = defaultLineHighlighter(theme)) {
11
11
  let mode = "prose";
12
12
  let atLineStart = true;
13
13
  let lineBuf = "";
@@ -45,7 +45,7 @@ export function createStreamHighlighter(c, highlightLine = defaultLineHighlighte
45
45
  lang = m[2] ? m[2].toLowerCase() : null;
46
46
  carry = FRESH_CARRY;
47
47
  mode = "code";
48
- out += c.dim(lineBuf) + "\n";
48
+ out += theme.muted(lineBuf) + "\n";
49
49
  }
50
50
  else {
51
51
  mode = "prose";
@@ -70,7 +70,7 @@ export function createStreamHighlighter(c, highlightLine = defaultLineHighlighte
70
70
  if (ch === "\n") {
71
71
  if (/^`{3,}\s*$/.test(lineBuf)) {
72
72
  mode = "prose";
73
- out += c.dim(lineBuf) + "\n";
73
+ out += theme.muted(lineBuf) + "\n";
74
74
  }
75
75
  else {
76
76
  out += styleCodeLine(lineBuf) + "\n";
@@ -165,7 +165,7 @@ const WORD = /[A-Za-z0-9_$]/;
165
165
  * comments dim, strings green, keywords magenta, numbers yellow, everything
166
166
  * else untouched. Unknown language → identity.
167
167
  */
168
- export function defaultLineHighlighter(c) {
168
+ export function defaultLineHighlighter(theme) {
169
169
  return (line, lang, carry) => {
170
170
  const def = lang ? LANGS[lang] : undefined;
171
171
  if (!def)
@@ -179,37 +179,37 @@ export function defaultLineHighlighter(c) {
179
179
  if (next.blockComment && def.blockComment) {
180
180
  const close = line.indexOf(def.blockComment[1]);
181
181
  if (close === -1)
182
- return { text: c.dim(line), carry: next };
182
+ return { text: theme.syntax.comment(line), carry: next };
183
183
  const end = close + def.blockComment[1].length;
184
- out += c.dim(line.slice(0, end));
184
+ out += theme.syntax.comment(line.slice(0, end));
185
185
  i = end;
186
186
  next.blockComment = false;
187
187
  }
188
188
  else if (next.stringDelim) {
189
189
  const close = findStringEnd(line, 0, next.stringDelim);
190
190
  if (close === -1)
191
- return { text: c.green(line), carry: next };
192
- out += c.green(line.slice(0, close));
191
+ return { text: theme.syntax.string(line), carry: next };
192
+ out += theme.syntax.string(line.slice(0, close));
193
193
  i = close;
194
194
  next.stringDelim = null;
195
195
  }
196
196
  while (i < line.length) {
197
197
  const rest = line.slice(i);
198
198
  if (def.lineComment && rest.startsWith(def.lineComment)) {
199
- out += c.dim(rest);
199
+ out += theme.syntax.comment(rest);
200
200
  i = line.length;
201
201
  break;
202
202
  }
203
203
  if (def.blockComment && rest.startsWith(def.blockComment[0])) {
204
204
  const close = line.indexOf(def.blockComment[1], i + def.blockComment[0].length);
205
205
  if (close === -1) {
206
- out += c.dim(rest);
206
+ out += theme.syntax.comment(rest);
207
207
  next = { ...next, blockComment: true };
208
208
  i = line.length;
209
209
  break;
210
210
  }
211
211
  const end = close + def.blockComment[1].length;
212
- out += c.dim(line.slice(i, end));
212
+ out += theme.syntax.comment(line.slice(i, end));
213
213
  i = end;
214
214
  continue;
215
215
  }
@@ -217,13 +217,13 @@ export function defaultLineHighlighter(c) {
217
217
  if (quote) {
218
218
  const close = findStringEnd(line, i + quote.length, quote);
219
219
  if (close === -1) {
220
- out += c.green(rest);
220
+ out += theme.syntax.string(rest);
221
221
  if (def.multiline.includes(quote))
222
222
  next = { ...next, stringDelim: quote };
223
223
  i = line.length;
224
224
  break;
225
225
  }
226
- out += c.green(line.slice(i, close));
226
+ out += theme.syntax.string(line.slice(i, close));
227
227
  i = close;
228
228
  continue;
229
229
  }
@@ -234,9 +234,9 @@ export function defaultLineHighlighter(c) {
234
234
  j++;
235
235
  const word = line.slice(i, j);
236
236
  if (def.keywords.has(word))
237
- out += c.magenta(word);
237
+ out += theme.syntax.keyword(word);
238
238
  else if (/^\d/.test(word))
239
- out += c.yellow(word);
239
+ out += theme.syntax.number(word);
240
240
  else
241
241
  out += word;
242
242
  i = j;
@@ -2,7 +2,7 @@ import type { RenderStream, StreamRenderer } from "./types.js";
2
2
  export type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, TokenUsage, ToolLifecycleEvent, } from "./types.js";
3
3
  export { detectCapabilities } from "./capabilities.js";
4
4
  export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
5
- export { renderActionPreview, PREVIEW_MAX_LINES, type Colors } from "./diff.js";
5
+ export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
6
6
  export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
7
7
  export { PlainRenderer } from "./plain-renderer.js";
8
8
  export { TtyRenderer } from "./tty-renderer.js";
@@ -1,4 +1,5 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
+ import { type Theme } from "../theme/index.js";
2
3
  import type { RenderCapabilities, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
3
4
  /**
4
5
  * The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
@@ -15,7 +16,7 @@ export declare class PlainRenderer implements StreamRenderer {
15
16
  readonly caps: RenderCapabilities;
16
17
  private readonly out;
17
18
  private readonly err;
18
- private readonly colors;
19
+ readonly theme: Theme;
19
20
  /** Per-turn leading-newline trim; also tells endSegment whether to newline. */
20
21
  private print;
21
22
  private wroteInSegment;
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { resolveTheme } from "../theme/index.js";
2
2
  import { createStreamPrinter } from "../cli/stream-print.js";
3
3
  import { renderActionPreview } from "./diff.js";
4
4
  import { ELAPSED_AFTER_MS, formatElapsed } from "./state.js";
@@ -17,7 +17,7 @@ export class PlainRenderer {
17
17
  caps;
18
18
  out;
19
19
  err;
20
- colors;
20
+ theme;
21
21
  /** Per-turn leading-newline trim; also tells endSegment whether to newline. */
22
22
  print;
23
23
  wroteInSegment = false;
@@ -27,7 +27,7 @@ export class PlainRenderer {
27
27
  this.caps = caps;
28
28
  this.out = out;
29
29
  this.err = err;
30
- this.colors = pc.createColors(caps.color);
30
+ this.theme = resolveTheme(caps);
31
31
  this.print = this.newPrinter();
32
32
  }
33
33
  newPrinter() {
@@ -49,10 +49,10 @@ export class PlainRenderer {
49
49
  this.wroteInSegment = false;
50
50
  }
51
51
  note(text) {
52
- this.err.write(this.colors.dim(text) + "\n");
52
+ this.err.write(this.theme.muted(text) + "\n");
53
53
  }
54
54
  preview(preview) {
55
- const block = renderActionPreview(preview, this.colors);
55
+ const block = renderActionPreview(preview, this.theme);
56
56
  if (block)
57
57
  this.out.write(block + "\n");
58
58
  }
@@ -77,7 +77,8 @@ export class PlainRenderer {
77
77
  this.toolStart = null;
78
78
  const elapsed = started === null ? 0 : Date.now() - started.at;
79
79
  const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
80
- this.note(`${event.ok ? "✓" : "✗"} ${event.label}${suffix}`);
80
+ const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
81
+ this.note(`${mark} ${event.label}${suffix}`);
81
82
  }
82
83
  promptResolved() {
83
84
  // No live region to restore.
@@ -1,9 +1,14 @@
1
+ import { type ThemeGlyphs } from "../theme/index.js";
1
2
  import type { ProgressState, RenderPhase } from "./types.js";
2
3
  /**
3
4
  * The U.4 state→text mapping: pure data → string, like plan/render.ts and
4
5
  * diff.ts, so both renderers (and tests) share one composition with no
5
6
  * terminal in sight. Color is deliberately absent — the live line is drawn
6
7
  * dim as a whole by the TTY renderer; state text is content, not chrome.
8
+ *
9
+ * Glyphs (ellipsis, token arrows, the ` · ` joiner) come from the theme (U.1);
10
+ * they default to the unicode table so colorless/test callers are unchanged,
11
+ * and the TTY renderer passes its capability-resolved set for ASCII fallback.
7
12
  */
8
13
  /**
9
14
  * Threshold before elapsed time appears on a live state or a committed tool
@@ -16,7 +21,7 @@ export declare function formatTokens(n: number): string;
16
21
  /** `37s`, `2m08s` — durations at status-line width. */
17
22
  export declare function formatElapsed(ms: number): string;
18
23
  /** The live-line text for a phase. `awaiting-approval` never renders (the line hides). */
19
- export declare function describePhase(phase: RenderPhase): string;
24
+ export declare function describePhase(phase: RenderPhase, glyph?: ThemeGlyphs): string;
20
25
  /**
21
26
  * Identity key for the elapsed clock: the clock resets when the phase becomes
22
27
  * a *different activity*, not on every payload update — a thinking phase that
@@ -28,4 +33,4 @@ export declare function phaseIdentity(phase: RenderPhase | null): string;
28
33
  * Elapsed appears only past {@link ELAPSED_AFTER_MS} — callers pass it only
29
34
  * when they can keep it ticking honestly (no timer → no frozen number).
30
35
  */
31
- export declare function composeStatusLine(progress: ProgressState | null, phase: RenderPhase | null, elapsedMs?: number): string;
36
+ export declare function composeStatusLine(progress: ProgressState | null, phase: RenderPhase | null, elapsedMs?: number, glyph?: ThemeGlyphs): string;
@@ -1,8 +1,13 @@
1
+ import { UNICODE_GLYPHS } from "../theme/index.js";
1
2
  /**
2
3
  * The U.4 state→text mapping: pure data → string, like plan/render.ts and
3
4
  * diff.ts, so both renderers (and tests) share one composition with no
4
5
  * terminal in sight. Color is deliberately absent — the live line is drawn
5
6
  * dim as a whole by the TTY renderer; state text is content, not chrome.
7
+ *
8
+ * Glyphs (ellipsis, token arrows, the ` · ` joiner) come from the theme (U.1);
9
+ * they default to the unicode table so colorless/test callers are unchanged,
10
+ * and the TTY renderer passes its capability-resolved set for ASCII fallback.
6
11
  */
7
12
  /**
8
13
  * Threshold before elapsed time appears on a live state or a committed tool
@@ -28,23 +33,24 @@ export function formatElapsed(ms) {
28
33
  return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
29
34
  }
30
35
  /** The live-line text for a phase. `awaiting-approval` never renders (the line hides). */
31
- export function describePhase(phase) {
36
+ export function describePhase(phase, glyph = UNICODE_GLYPHS) {
37
+ const e = glyph.ellipsis;
32
38
  switch (phase.kind) {
33
39
  case "thinking": {
34
40
  const t = phase.tokens;
35
41
  // Honest numbers only: no usage yet → no figure at all.
36
42
  return t && t.input + t.output > 0
37
- ? `thinking · tokens ↑${formatTokens(t.input)} ↓${formatTokens(t.output)}`
38
- : "thinking…";
43
+ ? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
44
+ : `thinking${e}`;
39
45
  }
40
46
  case "calling-tool":
41
- return `${phase.label}…`;
47
+ return `${phase.label}${e}`;
42
48
  case "awaiting-approval":
43
- return "awaiting approval…";
49
+ return `awaiting approval${e}`;
44
50
  case "executing-step":
45
- return "working…";
51
+ return `working${e}`;
46
52
  case "subagent":
47
- return `subagent: ${phase.label}…`;
53
+ return `subagent: ${phase.label}${e}`;
48
54
  }
49
55
  }
50
56
  /**
@@ -69,13 +75,13 @@ export function phaseIdentity(phase) {
69
75
  * Elapsed appears only past {@link ELAPSED_AFTER_MS} — callers pass it only
70
76
  * when they can keep it ticking honestly (no timer → no frozen number).
71
77
  */
72
- export function composeStatusLine(progress, phase, elapsedMs) {
78
+ export function composeStatusLine(progress, phase, elapsedMs, glyph = UNICODE_GLYPHS) {
73
79
  const parts = [];
74
80
  if (progress)
75
81
  parts.push(`[${progress.step}/${progress.of}] ${progress.title}`);
76
82
  if (phase)
77
- parts.push(describePhase(phase));
78
- const line = parts.join(" · ");
83
+ parts.push(describePhase(phase, glyph));
84
+ const line = parts.join(` ${glyph.sep} `);
79
85
  if (elapsedMs !== undefined && elapsedMs >= ELAPSED_AFTER_MS) {
80
86
  return `${line} (${formatElapsed(elapsedMs)})`;
81
87
  }
@@ -1,4 +1,5 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
+ import { type Theme } from "../theme/index.js";
2
3
  import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
3
4
  /**
4
5
  * The interactive renderer: committed content is append-only; the one transient
@@ -28,7 +29,7 @@ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, Stre
28
29
  export declare class TtyRenderer implements StreamRenderer {
29
30
  readonly caps: RenderCapabilities;
30
31
  private readonly out;
31
- private readonly colors;
32
+ readonly theme: Theme;
32
33
  private print;
33
34
  private highlighter;
34
35
  private wroteInSegment;
@@ -1,13 +1,10 @@
1
- import pc from "picocolors";
1
+ import { resolveTheme } from "../theme/index.js";
2
2
  import { createStreamPrinter } from "../cli/stream-print.js";
3
3
  import { renderActionPreview } from "./diff.js";
4
4
  import { createStreamHighlighter, } from "./highlight.js";
5
5
  import { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
6
6
  /** Erase the current line and return the cursor to column 0. */
7
7
  const CLEAR_LINE = "\r\x1b[2K";
8
- /** Spinner frames (braille); a static glyph when animation is disabled. */
9
- const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
- const STATIC_FRAME = "◐";
11
8
  const SPINNER_INTERVAL_MS = 100;
12
9
  /**
13
10
  * The interactive renderer: committed content is append-only; the one transient
@@ -37,7 +34,7 @@ const SPINNER_INTERVAL_MS = 100;
37
34
  export class TtyRenderer {
38
35
  caps;
39
36
  out;
40
- colors;
37
+ theme;
41
38
  print;
42
39
  highlighter;
43
40
  wroteInSegment = false;
@@ -59,8 +56,8 @@ export class TtyRenderer {
59
56
  constructor(caps, out) {
60
57
  this.caps = caps;
61
58
  this.out = out;
62
- this.colors = pc.createColors(caps.color);
63
- this.highlighter = createStreamHighlighter(this.colors);
59
+ this.theme = resolveTheme(caps);
60
+ this.highlighter = createStreamHighlighter(this.theme);
64
61
  this.print = this.newPrinter();
65
62
  }
66
63
  newPrinter() {
@@ -115,7 +112,7 @@ export class TtyRenderer {
115
112
  const elapsed = this.phase !== null && this.caps.spinner
116
113
  ? Date.now() - this.phaseStartedAt
117
114
  : undefined;
118
- return composeStatusLine(this.progressState, this.phase, elapsed);
115
+ return composeStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph);
119
116
  }
120
117
  /** Redraw the live line from current state, or hide it when there is none. */
121
118
  refresh() {
@@ -138,16 +135,19 @@ export class TtyRenderer {
138
135
  }
139
136
  drawLine(text) {
140
137
  this.lineVisible = true;
138
+ const frames = this.theme.glyph.spinnerFrames;
141
139
  const glyph = this.caps.spinner
142
- ? FRAMES[this.frame % FRAMES.length]
143
- : STATIC_FRAME;
140
+ ? frames[this.frame % frames.length]
141
+ : this.theme.glyph.spinnerStatic;
144
142
  // Reserve glyph + space; truncate so the live line can never soft-wrap.
145
143
  const room = Math.max(1, this.caps.width - 2);
146
- const line = text.length > room ? text.slice(0, Math.max(0, room - 1)) + "…" : text;
147
- this.out.write(`${CLEAR_LINE}${this.colors.cyan(glyph)} ${this.colors.dim(line)}`);
144
+ const line = text.length > room
145
+ ? text.slice(0, Math.max(0, room - 1)) + this.theme.glyph.ellipsis
146
+ : text;
147
+ this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${this.theme.muted(line)}`);
148
148
  }
149
149
  beginTurn() {
150
- this.highlighter = createStreamHighlighter(this.colors);
150
+ this.highlighter = createStreamHighlighter(this.theme);
151
151
  this.print = this.newPrinter();
152
152
  this.wroteInSegment = false;
153
153
  }
@@ -169,13 +169,15 @@ export class TtyRenderer {
169
169
  if (this.closed)
170
170
  return;
171
171
  const room = Math.max(1, this.caps.width);
172
- const line = text.length > room ? text.slice(0, room - 1) + "…" : text;
173
- this.commit(this.colors.dim(line) + "\n");
172
+ const line = text.length > room
173
+ ? text.slice(0, room - 1) + this.theme.glyph.ellipsis
174
+ : text;
175
+ this.commit(this.theme.muted(line) + "\n");
174
176
  }
175
177
  preview(preview) {
176
178
  if (this.closed)
177
179
  return;
178
- const block = renderActionPreview(preview, this.colors);
180
+ const block = renderActionPreview(preview, this.theme);
179
181
  if (block)
180
182
  this.commit(block + "\n");
181
183
  }
@@ -237,7 +239,8 @@ export class TtyRenderer {
237
239
  // honest even with CRUXY_NO_SPINNER; shown only once it means something.
238
240
  const elapsed = started === null ? 0 : Date.now() - started.at;
239
241
  const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
240
- this.note(`${event.ok ? "✓" : "✗"} ${event.label}${suffix}`);
242
+ const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
243
+ this.note(`${mark} ${event.label}${suffix}`);
241
244
  }
242
245
  promptResolved() {
243
246
  if (this.closed)
@@ -1,4 +1,5 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
+ import type { Theme } from "../theme/index.js";
2
3
  /**
3
4
  * The streaming render seam (U.2): the agent loop talks to a
4
5
  * {@link StreamRenderer}, never to raw stdout. Two implementations exist —
@@ -21,6 +22,9 @@ export interface RenderCapabilities {
21
22
  cursor: boolean;
22
23
  /** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
23
24
  spinner: boolean;
25
+ /** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
26
+ * independent of `color`. Drives the theme's glyph table, not its stylers. */
27
+ unicode: boolean;
24
28
  /** Terminal columns; 80 when unknown (non-TTY). */
25
29
  width: number;
26
30
  }
@@ -97,6 +101,9 @@ export type ToolLifecycleEvent = {
97
101
  */
98
102
  export interface StreamRenderer {
99
103
  readonly caps: RenderCapabilities;
104
+ /** The one resolved design system (U.1) — glyphs/roles for chrome a
105
+ * surface emits through this renderer (e.g. the subagent trail notes). */
106
+ readonly theme: Theme;
100
107
  /** Start a user turn: reset leading-newline trim and code-fence state. */
101
108
  beginTurn(): void;
102
109
  /**
@@ -67,7 +67,9 @@ export class SubagentOrchestrator {
67
67
  },
68
68
  };
69
69
  const label = taskLabel(spec.task);
70
- deps.renderer?.note(`⏵ subagent: ${label}`);
70
+ if (deps.renderer) {
71
+ deps.renderer.note(`${deps.renderer.theme.glyph.play} subagent: ${label}`);
72
+ }
71
73
  deps.renderer?.setPhase({ kind: "subagent", label });
72
74
  // The isolation seam: a brand-new history seeded with ONLY the task. The
73
75
  // parent's messages are never in scope here, and this array dies with the
@@ -96,7 +98,9 @@ export class SubagentOrchestrator {
96
98
  deps.renderer?.setPhase(null);
97
99
  throw err;
98
100
  }
99
- deps.renderer?.note(`✗ subagent failed: ${label}`);
101
+ if (deps.renderer) {
102
+ deps.renderer.note(`${deps.renderer.theme.glyph.failure} subagent failed: ${label}`);
103
+ }
100
104
  deps.renderer?.setPhase(null);
101
105
  return {
102
106
  status: "failed",
@@ -123,7 +127,9 @@ export class SubagentOrchestrator {
123
127
  usage: run.usage,
124
128
  };
125
129
  if (run.stop === "completed") {
126
- this.deps.renderer?.note(`✓ subagent done: ${label}`);
130
+ const r = this.deps.renderer;
131
+ if (r)
132
+ r.note(`${r.theme.glyph.success} subagent done: ${label}`);
127
133
  return { status: "done", ...base };
128
134
  }
129
135
  // Both cap paths are the same outcome for the parent: a truncated, partial
@@ -132,7 +138,9 @@ export class SubagentOrchestrator {
132
138
  const reason = run.stop === "budget"
133
139
  ? (run.stopReason ?? "budget cap reached")
134
140
  : `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
135
- this.deps.renderer?.note(`✗ subagent stopped (budget): ${label}`);
141
+ const r = this.deps.renderer;
142
+ if (r)
143
+ r.note(`${r.theme.glyph.failure} subagent stopped (budget): ${label}`);
136
144
  return {
137
145
  status: "budget-exceeded",
138
146
  ...base,
@@ -195,12 +203,16 @@ function lastAssistantText(messages) {
195
203
  */
196
204
  class SubagentRenderer {
197
205
  caps;
206
+ theme;
198
207
  inner;
199
208
  label;
209
+ prefix;
200
210
  constructor(inner, label) {
201
211
  this.inner = inner;
202
212
  this.label = label;
203
213
  this.caps = inner.caps;
214
+ this.theme = inner.theme;
215
+ this.prefix = `subagent ${inner.theme.glyph.sep} `;
204
216
  }
205
217
  /** Turn framing belongs to the parent's turn — the child's is dropped. */
206
218
  beginTurn() { }
@@ -209,7 +221,7 @@ class SubagentRenderer {
209
221
  write() { }
210
222
  endSegment() { }
211
223
  note(text) {
212
- this.inner.note(`subagent · ${text}`);
224
+ this.inner.note(`${this.prefix}${text}`);
213
225
  }
214
226
  preview(preview) {
215
227
  this.inner.preview(preview);
@@ -231,7 +243,10 @@ class SubagentRenderer {
231
243
  /** The plan executor owns the progress register (C.31) — never the child. */
232
244
  progress() { }
233
245
  toolLifecycle(event) {
234
- this.inner.toolLifecycle({ ...event, label: `subagent · ${event.label}` });
246
+ this.inner.toolLifecycle({
247
+ ...event,
248
+ label: `${this.prefix}${event.label}`,
249
+ });
235
250
  }
236
251
  promptResolved() {
237
252
  this.inner.promptResolved();
@@ -0,0 +1,2 @@
1
+ export * from "./tokens.js";
2
+ export * from "./resolve.js";
@@ -0,0 +1,2 @@
1
+ export * from "./tokens.js";
2
+ export * from "./resolve.js";
@@ -0,0 +1,32 @@
1
+ import { type Theme, type ThemeCapabilities } from "./tokens.js";
2
+ /**
3
+ * Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
4
+ * fully independent by construction:
5
+ *
6
+ * - `color` drives the stylers only. `pc.createColors(false)` is the identity,
7
+ * so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
8
+ * tests.
9
+ * - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
10
+ * unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
11
+ * CRUXY_ASCII terminal prints a colored `[ok]`.
12
+ */
13
+ export declare function resolveTheme(caps: ThemeCapabilities): Theme;
14
+ /**
15
+ * Whether the terminal can render unicode glyphs. `TERM=dumb` and an explicit
16
+ * `CRUXY_ASCII` opt-in fall back to the ASCII table; everything else (real
17
+ * terminals AND pipes) keeps unicode — piping ✓/✗ to a file is fine, and this
18
+ * preserves pre-U.1 behavior for non-TTY output.
19
+ *
20
+ * The single source of the rule: `render/capabilities.ts` calls this to fill
21
+ * `RenderCapabilities.unicode`, and the boolean-seam surfaces (errors,
22
+ * approval, plan, onboarding, CLI commands) call it to build a theme without a
23
+ * signature change.
24
+ */
25
+ export declare function detectUnicode(env?: NodeJS.ProcessEnv): boolean;
26
+ /**
27
+ * Build a theme for a surface that only knows a `color` boolean (the U.3
28
+ * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
29
+ * from the environment so these surfaces still degrade on dumb terminals,
30
+ * with no change to their public boolean signatures.
31
+ */
32
+ export declare function themeForColor(color: boolean, env?: NodeJS.ProcessEnv): Theme;
@@ -0,0 +1,73 @@
1
+ import pc from "picocolors";
2
+ import { ASCII_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
3
+ /**
4
+ * Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
5
+ * fully independent by construction:
6
+ *
7
+ * - `color` drives the stylers only. `pc.createColors(false)` is the identity,
8
+ * so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
9
+ * tests.
10
+ * - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
11
+ * unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
12
+ * CRUXY_ASCII terminal prints a colored `[ok]`.
13
+ */
14
+ export function resolveTheme(caps) {
15
+ const c = pc.createColors(caps.color);
16
+ const glyph = caps.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
17
+ const strong = c.bold;
18
+ const indent = (text, level = 1) => {
19
+ const pad = " ".repeat(Math.max(0, level));
20
+ return text
21
+ .split("\n")
22
+ .map((line) => (line === "" ? line : pad + line))
23
+ .join("\n");
24
+ };
25
+ return {
26
+ danger: c.red,
27
+ warning: c.yellow,
28
+ success: c.green,
29
+ accent: c.cyan,
30
+ muted: c.dim,
31
+ strong,
32
+ syntax: {
33
+ keyword: c.magenta,
34
+ string: c.green,
35
+ number: c.yellow,
36
+ comment: c.dim,
37
+ },
38
+ glyph,
39
+ heading: strong,
40
+ indent,
41
+ kv: (key, value, keyWidth) => `${strong(keyWidth ? key.padEnd(keyWidth) : key)} ${value}`,
42
+ sep: ` ${glyph.sep} `,
43
+ color: caps.color,
44
+ unicode: caps.unicode,
45
+ };
46
+ }
47
+ /**
48
+ * Whether the terminal can render unicode glyphs. `TERM=dumb` and an explicit
49
+ * `CRUXY_ASCII` opt-in fall back to the ASCII table; everything else (real
50
+ * terminals AND pipes) keeps unicode — piping ✓/✗ to a file is fine, and this
51
+ * preserves pre-U.1 behavior for non-TTY output.
52
+ *
53
+ * The single source of the rule: `render/capabilities.ts` calls this to fill
54
+ * `RenderCapabilities.unicode`, and the boolean-seam surfaces (errors,
55
+ * approval, plan, onboarding, CLI commands) call it to build a theme without a
56
+ * signature change.
57
+ */
58
+ export function detectUnicode(env = process.env) {
59
+ if (env.CRUXY_ASCII !== undefined && env.CRUXY_ASCII !== "")
60
+ return false;
61
+ if (env.TERM === "dumb")
62
+ return false;
63
+ return true;
64
+ }
65
+ /**
66
+ * Build a theme for a surface that only knows a `color` boolean (the U.3
67
+ * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
68
+ * from the environment so these surfaces still degrade on dumb terminals,
69
+ * with no change to their public boolean signatures.
70
+ */
71
+ export function themeForColor(color, env = process.env) {
72
+ return resolveTheme({ color, unicode: detectUnicode(env) });
73
+ }