@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
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { resolveTheme } from "../theme/index.js";
2
2
  import { createFrame } from "./frame.js";
3
3
  import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
4
4
  /** Word-boundary characters that earn the boundary bonus for the NEXT char. */
@@ -69,16 +69,16 @@ export function rankItems(items, toLabel, query) {
69
69
  return ranked.map(({ item, label, match }) => ({ item, label, match }));
70
70
  }
71
71
  /**
72
- * Bold the matched characters of a label. With color off (NO_COLOR, pipe)
73
- * picocolors' disabled palette is the identity — plain text, zero ANSI.
72
+ * Bold the matched characters of a label via the theme's accent role. With
73
+ * color off (NO_COLOR, pipe) the roles are identity — plain text, zero ANSI.
74
74
  */
75
- export function highlightMatch(label, positions, colors) {
75
+ export function highlightMatch(label, positions, theme) {
76
76
  if (positions.length === 0)
77
77
  return label;
78
78
  const matched = new Set(positions);
79
79
  let out = "";
80
80
  for (let i = 0; i < label.length; i++) {
81
- out += matched.has(i) ? colors.bold(colors.cyan(label[i])) : label[i];
81
+ out += matched.has(i) ? theme.strong(theme.accent(label[i])) : label[i];
82
82
  }
83
83
  return out;
84
84
  }
@@ -94,7 +94,8 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
94
94
  return fallback;
95
95
  if (items.length === 0)
96
96
  return { kind: "cancelled" };
97
- const colors = pc.createColors(io.caps.color);
97
+ const t = resolveTheme(io.caps);
98
+ const g = t.glyph;
98
99
  const maxVisible = opts.maxVisible ?? 10;
99
100
  const frame = createFrame(io.write, io.caps);
100
101
  let query = "";
@@ -102,10 +103,10 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
102
103
  const paint = (ranked) => {
103
104
  const lines = [];
104
105
  if (opts.title)
105
- lines.push(colors.bold(opts.title));
106
- lines.push(`${colors.cyan("›")} ${query}${colors.dim("▏")}`);
106
+ lines.push(t.heading(opts.title));
107
+ lines.push(`${t.accent(g.caret)} ${query}${t.muted(g.cursorBar)}`);
107
108
  if (ranked.length === 0) {
108
- lines.push(colors.dim(" no results — backspace to widen"));
109
+ lines.push(t.muted(" no results — backspace to widen"));
109
110
  }
110
111
  else {
111
112
  // Keep the highlighted row inside the viewport.
@@ -113,13 +114,13 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
113
114
  const visible = ranked.slice(top, top + maxVisible);
114
115
  for (const [i, row] of visible.entries()) {
115
116
  const selected = top + i === cursor;
116
- const marker = selected ? colors.cyan("❯") : " ";
117
- const label = highlightMatch(row.label, row.match.positions, colors);
118
- lines.push(`${marker} ${selected ? label : colors.dim(label)}`);
117
+ const marker = selected ? t.accent(g.pointer) : " ";
118
+ const label = highlightMatch(row.label, row.match.positions, t);
119
+ lines.push(`${marker} ${selected ? label : t.muted(label)}`);
119
120
  }
120
121
  const hidden = ranked.length - visible.length;
121
122
  if (hidden > 0)
122
- lines.push(colors.dim(` ${hidden} more`));
123
+ lines.push(t.muted(` ${g.ellipsis} ${hidden} more`));
123
124
  }
124
125
  frame.render(lines);
125
126
  };
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { resolveTheme } from "../theme/index.js";
2
2
  import { createFrame } from "./frame.js";
3
3
  import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
4
4
  /**
@@ -15,26 +15,27 @@ export async function selectList(items, opts = {}, io = defaultComponentIO()) {
15
15
  if (items.length === 0)
16
16
  return { kind: "cancelled" };
17
17
  const toLabel = opts.toLabel ?? ((item) => String(item));
18
- const colors = pc.createColors(io.caps.color);
18
+ const t = resolveTheme(io.caps);
19
+ const g = t.glyph;
19
20
  const maxVisible = opts.maxVisible ?? 10;
20
21
  const frame = createFrame(io.write, io.caps);
21
22
  let cursor = Math.min(Math.max(opts.initialIndex ?? 0, 0), items.length - 1);
22
23
  const paint = () => {
23
24
  const lines = [];
24
25
  if (opts.title)
25
- lines.push(colors.bold(opts.title));
26
+ lines.push(t.heading(opts.title));
26
27
  const top = Math.min(Math.max(0, cursor - maxVisible + 1), Math.max(0, items.length - maxVisible));
27
28
  const visible = items.slice(top, top + maxVisible);
28
29
  for (const [i, item] of visible.entries()) {
29
30
  const selected = top + i === cursor;
30
- const marker = selected ? colors.cyan("❯") : " ";
31
+ const marker = selected ? t.accent(g.pointer) : " ";
31
32
  const label = toLabel(item);
32
- lines.push(`${marker} ${selected ? label : colors.dim(label)}`);
33
+ lines.push(`${marker} ${selected ? label : t.muted(label)}`);
33
34
  }
34
35
  const hidden = items.length - visible.length;
35
36
  if (hidden > 0)
36
- lines.push(colors.dim(` ${hidden} more`));
37
- lines.push(colors.dim(" ↑/↓ move · enter select · esc cancel"));
37
+ lines.push(t.muted(` ${g.ellipsis} ${hidden} more`));
38
+ lines.push(t.muted(` ${g.caretUp}/${g.caretDown} move ${g.sep} enter select ${g.sep} esc cancel`));
38
39
  frame.render(lines);
39
40
  };
40
41
  io.keys.begin();
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { themeForColor } from "../theme/index.js";
2
2
  /**
3
3
  * Decide whether to colorize: honor `NO_COLOR` (disable) and `FORCE_COLOR`
4
4
  * (enable), otherwise color only when writing to a TTY.
@@ -13,27 +13,27 @@ export function shouldUseColor(stream = process.stderr, env = process.env) {
13
13
  /** The default terminal formatter: title, cause, next steps, code (+ verbose). */
14
14
  export class TerminalFormatter {
15
15
  format(err, opts) {
16
- const c = pc.createColors(opts.color);
16
+ const t = themeForColor(opts.color);
17
17
  const lines = [];
18
18
  // 1. Title — one plain line, what failed.
19
- lines.push(c.red(c.bold(err.title)));
19
+ lines.push(t.danger(t.strong(err.title)));
20
20
  // 2. Cause — the specific reason, when known.
21
21
  if (err.cause)
22
- lines.push(`${c.dim("Cause:")} ${err.cause}`);
22
+ lines.push(`${t.muted("Cause:")} ${err.cause}`);
23
23
  // 3. Next step(s) — the concrete action(s) to take.
24
24
  if (err.nextSteps.length > 0) {
25
25
  lines.push("");
26
- lines.push(c.bold("Next steps:"));
26
+ lines.push(t.heading("Next steps:"));
27
27
  for (const step of err.nextSteps)
28
- lines.push(` ${c.cyan("→")} ${step}`);
28
+ lines.push(` ${t.accent(t.glyph.arrow)} ${step}`);
29
29
  }
30
30
  // 4. Code — the stable, greppable id.
31
31
  lines.push("");
32
- lines.push(c.dim(`[${err.code}]`));
32
+ lines.push(t.muted(`[${err.code}]`));
33
33
  // Verbose-only: the preserved underlying error.
34
34
  if (opts.verbose && err.underlying !== undefined) {
35
35
  lines.push("");
36
- lines.push(c.dim("Underlying error:"));
36
+ lines.push(t.muted("Underlying error:"));
37
37
  lines.push(indent(stackOf(err.underlying)));
38
38
  }
39
39
  return lines.join("\n");
@@ -1,5 +1,5 @@
1
1
  import { AuthError, NetworkError, createProvider } from "@cruxy/sdk";
2
- import pc from "picocolors";
2
+ import { themeForColor } from "../theme/index.js";
3
3
  import { resolveApiKey, writeCredential } from "../config/index.js";
4
4
  import { newOnboardingState, readOnboardingState, writeOnboardingState, } from "./detect.js";
5
5
  import { acquireKeyStep, firstWinStep, scaffoldStep } from "./steps.js";
@@ -11,8 +11,8 @@ import { acquireKeyStep, firstWinStep, scaffoldStep } from "./steps.js";
11
11
  */
12
12
  export async function runOnboarding(opts) {
13
13
  const { io, deps, provider } = opts;
14
- const col = pc.createColors(io.color);
15
- io.write(`${col.cyan(col.bold("Welcome to cruxy"))} — let's get you set up.\n`);
14
+ const t = themeForColor(io.color);
15
+ io.write(`${t.accent(t.strong("Welcome to cruxy"))} — let's get you set up.\n`);
16
16
  let state = deps.readState() ?? newOnboardingState();
17
17
  let apiKey = deps.resolveApiKey(provider);
18
18
  // ── key (mandatory; skipped if already resolvable unless forceKey) ─────────
@@ -24,7 +24,7 @@ export async function runOnboarding(opts) {
24
24
  if (result.status !== "ok") {
25
25
  // Failed (unreachable / rejected) — surface guidance, no marker.
26
26
  if (result.message)
27
- io.write(`${col.dim(result.message)}\n`);
27
+ io.write(`${t.muted(result.message)}\n`);
28
28
  return { completed: false, aborted: false };
29
29
  }
30
30
  apiKey = result.apiKey;
@@ -32,7 +32,7 @@ export async function runOnboarding(opts) {
32
32
  deps.writeState(state);
33
33
  }
34
34
  else {
35
- io.write(`${col.green("✓")} using your existing API key.\n`);
35
+ io.write(`${t.success(t.glyph.success)} using your existing API key.\n`);
36
36
  state = { ...state, keyConfigured: true };
37
37
  }
38
38
  // ── optional steps (Ctrl-C here just skips them; the key is already safe) ───
@@ -43,7 +43,7 @@ export async function runOnboarding(opts) {
43
43
  // ── complete ───────────────────────────────────────────────────────────────
44
44
  state = { ...state, completedAt: deps.now() };
45
45
  deps.writeState(state);
46
- io.write(`${col.green(col.bold("✓ all set"))} — happy hacking.\n`);
46
+ io.write(`${t.success(t.strong(`${t.glyph.success} all set`))} — happy hacking.\n`);
47
47
  return { completed: true, aborted: false, apiKey };
48
48
  }
49
49
  /**
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import pc from "picocolors";
3
+ import { themeForColor } from "../theme/index.js";
4
4
  import { CREATE_KEY_URL } from "../constants.js";
5
5
  import { loadProjectInstructions } from "../config/index.js";
6
6
  /**
@@ -9,7 +9,7 @@ import { loadProjectInstructions } from "../config/index.js";
9
9
  * or logged. Rendering is gated on `io.color` so output respects NO_COLOR / pipes.
10
10
  */
11
11
  const MAX_KEY_ATTEMPTS = 3;
12
- const c = (io) => pc.createColors(io.color);
12
+ const c = (io) => themeForColor(io.color);
13
13
  /**
14
14
  * Acquire and persist a provider key: print the create-key URL, read it masked,
15
15
  * validate it live, and **only then** write it to the credentials store. Loops on
@@ -17,23 +17,23 @@ const c = (io) => pc.createColors(io.color);
17
17
  */
18
18
  export async function acquireKeyStep(io, deps, provider) {
19
19
  const col = c(io);
20
- io.write(`\nYou'll need a Cruxy API key. Create one at ${col.cyan(CREATE_KEY_URL)}\n`);
20
+ io.write(`\nYou'll need a Cruxy API key. Create one at ${col.accent(CREATE_KEY_URL)}\n`);
21
21
  for (let attempt = 1; attempt <= MAX_KEY_ATTEMPTS; attempt++) {
22
- io.write(col.bold("Paste your API key: "));
22
+ io.write(col.strong("Paste your API key: "));
23
23
  const key = (await io.readSecret()).trim();
24
24
  if (key === "") {
25
25
  // Empty / Ctrl-C / EOF — treat as an abort of the mandatory step.
26
26
  return { status: "aborted" };
27
27
  }
28
- io.write(col.dim("validating…\n"));
28
+ io.write(col.muted("validating…\n"));
29
29
  const outcome = await deps.validateKey(provider, key);
30
30
  if (outcome === "valid") {
31
31
  deps.writeCredential(provider, key);
32
- io.write(`${col.green("✓")} key validated and saved to ~/.cruxy\n`);
32
+ io.write(`${col.success(col.glyph.success)} key validated and saved to ~/.cruxy\n`);
33
33
  return { status: "ok", apiKey: key };
34
34
  }
35
35
  if (outcome === "unreachable") {
36
- io.write(`${col.red("✗")} couldn't reach the gateway to validate the key.\n`);
36
+ io.write(`${col.danger(col.glyph.failure)} couldn't reach the gateway to validate the key.\n`);
37
37
  return {
38
38
  status: "failed",
39
39
  message: "network unreachable — try again with `cruxy login`",
@@ -41,7 +41,7 @@ export async function acquireKeyStep(io, deps, provider) {
41
41
  }
42
42
  // invalid
43
43
  const left = MAX_KEY_ATTEMPTS - attempt;
44
- io.write(`${col.red("✗")} that key was rejected${left > 0 ? ` (${left} ${left === 1 ? "try" : "tries"} left)` : ""}.\n`);
44
+ io.write(`${col.danger(col.glyph.failure)} that key was rejected${left > 0 ? ` (${left} ${left === 1 ? "try" : "tries"} left)` : ""}.\n`);
45
45
  }
46
46
  return { status: "failed", message: "key rejected after 3 attempts" };
47
47
  }
@@ -71,14 +71,14 @@ export async function scaffoldStep(io, cwd) {
71
71
  if (loadProjectInstructions(cwd) !== null) {
72
72
  return { status: "skipped" };
73
73
  }
74
- io.write(`\nScaffold a ${col.bold("CRUXY.md")} to guide cruxy in this project? ${col.dim("[y/N]")} `);
74
+ io.write(`\nScaffold a ${col.strong("CRUXY.md")} to guide cruxy in this project? ${col.muted("[y/N]")} `);
75
75
  const key = (await io.readKey()).toLowerCase();
76
76
  io.write("\n");
77
77
  if (key !== "y")
78
78
  return { status: "skipped" };
79
79
  const file = join(cwd, "CRUXY.md");
80
80
  writeFileSync(file, CRUXY_MD_TEMPLATE, "utf8");
81
- io.write(`${col.green("✓")} wrote ${col.bold("CRUXY.md")}\n`);
81
+ io.write(`${col.success(col.glyph.success)} wrote ${col.strong("CRUXY.md")}\n`);
82
82
  return { status: "ok" };
83
83
  }
84
84
  const FIRST_WIN_PROMPT = "Give me a concise 3-sentence summary of what this repository does, based on its README and structure.";
@@ -90,7 +90,7 @@ export async function firstWinStep(io, deps) {
90
90
  const col = c(io);
91
91
  if (!deps.runTask)
92
92
  return { status: "skipped" };
93
- io.write(`\nRun a quick demo now — let cruxy summarize this repo? ${col.dim("[Y/n]")} `);
93
+ io.write(`\nRun a quick demo now — let cruxy summarize this repo? ${col.muted("[Y/n]")} `);
94
94
  const key = (await io.readKey()).toLowerCase();
95
95
  io.write("\n");
96
96
  if (key === "n")
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { themeForColor } from "../theme/index.js";
2
2
  import { renderPlan } from "./render.js";
3
3
  /**
4
4
  * The plan-approval prompt (C.31). Renders the plan and reads a single-key,
@@ -9,11 +9,11 @@ import { renderPlan } from "./render.js";
9
9
  * Default-deny: EOF / Ctrl-C / any unrecognized key → `abort`.
10
10
  */
11
11
  export async function promptPlanDecision(plan, io) {
12
- const c = pc.createColors(io.color);
12
+ const t = themeForColor(io.color);
13
13
  io.write(renderPlan(plan, io.color));
14
14
  io.write("\n\n");
15
- io.write(` ${c.dim("Approving consents to the shape of the work — every action still asks before it runs.")}\n`);
16
- io.write(` ${c.dim("[a] approve ·")} [g] approve ${c.bold("+ allow the read/mutate steps")} this run ${c.dim("(destructive still confirms)")} ${c.dim( [e] reject & revise")} `);
15
+ io.write(` ${t.muted("Approving consents to the shape of the work — every action still asks before it runs.")}\n`);
16
+ io.write(` ${t.muted(`[a] approve ${t.glyph.sep}`)} [g] approve ${t.strong("+ allow the read/mutate steps")} this run ${t.muted("(destructive still confirms)")} ${t.muted(`${t.glyph.sep} [e] reject & revise`)} `);
17
17
  const key = (await io.readKey()).toLowerCase();
18
18
  io.write("\n");
19
19
  switch (key) {
@@ -38,8 +38,8 @@ export async function promptPlanDecision(plan, io) {
38
38
  * the run. Default-deny → abort (the safe choice: stop on failure).
39
39
  */
40
40
  export async function promptContinueAfterFailure(io) {
41
- const c = pc.createColors(io.color);
42
- io.write(` ${c.red("step failed.")} ${c.dim("[c] continue with the remaining steps · [any other key] abort")} `);
41
+ const t = themeForColor(io.color);
42
+ io.write(` ${t.danger("step failed.")} ${t.muted(`[c] continue with the remaining steps ${t.glyph.sep} [any other key] abort`)} `);
43
43
  const key = (await io.readKey()).toLowerCase();
44
44
  io.write("\n");
45
45
  return key === "c";
@@ -1,47 +1,55 @@
1
- import pc from "picocolors";
1
+ import { themeForColor } from "../theme/index.js";
2
+ /**
3
+ * Plan rendering (C.31): data → string so it's testable and color is gated on a
4
+ * boolean (NO_COLOR / non-TTY aware, passed in by the caller). Used both for the
5
+ * approval prompt and for live status during execution. Glyphs and colors are
6
+ * sourced from the theme (U.1); status is carried by the glyph (○/◐/✓/✗) and
7
+ * risk by the `[read]/[mutate]/[destructive]` label, so meaning survives
8
+ * NO_COLOR.
9
+ */
2
10
  /** Status glyph, colored when enabled. */
3
- function statusMark(status, c) {
11
+ function statusMark(status, t) {
4
12
  switch (status) {
5
13
  case "pending":
6
- return c.dim("○");
14
+ return t.muted(t.glyph.pending);
7
15
  case "running":
8
- return c.cyan("◐");
16
+ return t.accent(t.glyph.running);
9
17
  case "done":
10
- return c.green("✓");
18
+ return t.success(t.glyph.success);
11
19
  case "failed":
12
- return c.red("✗");
20
+ return t.danger(t.glyph.failure);
13
21
  }
14
22
  }
15
23
  /** A short tag for the step's risk estimate. */
16
- function kindTag(kind, c) {
24
+ function kindTag(kind, t) {
17
25
  switch (kind) {
18
26
  case "read":
19
- return c.dim("[read]");
27
+ return t.muted("[read]");
20
28
  case "mutate":
21
- return c.yellow("[mutate]");
29
+ return t.warning("[mutate]");
22
30
  case "destructive":
23
- return c.red(c.bold("[destructive]"));
31
+ return t.danger(t.strong("[destructive]"));
24
32
  }
25
33
  }
26
- function renderStep(step, c) {
27
- const head = ` ${statusMark(step.status, c)} ${c.bold(step.id + ".")} ${step.title} ${kindTag(step.kind, c)}`;
34
+ function renderStep(step, t) {
35
+ const head = ` ${statusMark(step.status, t)} ${t.strong(step.id + ".")} ${step.title} ${kindTag(step.kind, t)}`;
28
36
  const why = step.rationale.trim()
29
- ? `\n ${c.dim(step.rationale.trim())}`
37
+ ? `\n ${t.muted(step.rationale.trim())}`
30
38
  : "";
31
39
  return head + why;
32
40
  }
33
41
  /** Render the whole plan as a block (header + one line per step + rationale). */
34
42
  export function renderPlan(plan, color) {
35
- const c = pc.createColors(color);
43
+ const t = themeForColor(color);
36
44
  const lines = [
37
- c.bold(`Plan — ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"}:`),
45
+ t.heading(`Plan — ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"}:`),
38
46
  ];
39
47
  for (const step of plan.steps)
40
- lines.push(renderStep(step, c));
48
+ lines.push(renderStep(step, t));
41
49
  return lines.join("\n");
42
50
  }
43
51
  /** A one-line status update for a single step (used during execution). */
44
52
  export function renderStepStatus(step, color) {
45
- const c = pc.createColors(color);
46
- return `${statusMark(step.status, c)} ${c.bold(step.id + ".")} ${step.title}`;
53
+ const t = themeForColor(color);
54
+ return `${statusMark(step.status, t)} ${t.strong(step.id + ".")} ${step.title}`;
47
55
  }
@@ -1,4 +1,5 @@
1
1
  import { shouldUseColor } from "../errors/index.js";
2
+ import { detectUnicode } from "../theme/index.js";
2
3
  /**
3
4
  * Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
4
5
  * given its inputs (stream + env are injectable), so every row of the
@@ -20,6 +21,9 @@ export function detectCapabilities(stream = process.stdout, env = process.env) {
20
21
  // Same set-and-non-empty convention as NO_COLOR: any value disables.
21
22
  spinner: cursor &&
22
23
  !(env.CRUXY_NO_SPINNER !== undefined && env.CRUXY_NO_SPINNER !== ""),
24
+ // Unicode glyph safety (U.1) — independent of color. dumb / CRUXY_ASCII →
25
+ // ASCII glyphs; everything else (incl. pipes) keeps unicode.
26
+ unicode: detectUnicode(env),
23
27
  width: typeof stream.columns === "number" && stream.columns > 0
24
28
  ? stream.columns
25
29
  : 80,
@@ -1,14 +1,13 @@
1
- import pc from "picocolors";
2
1
  import type { ActionPreview } from "../tools/types.js";
2
+ import type { Theme } from "../theme/index.js";
3
3
  /**
4
4
  * The one diff/action-preview renderer (U.2). The approval prompt, PR preview,
5
5
  * and the streaming render path all draw diffs through here — there is no
6
- * second implementation to drift. Pure data → string; color is gated on a
7
- * picocolors instance, so NO_COLOR/non-TTY callers get symbol-only `+`/`-`
8
- * lines from the exact same code path.
6
+ * second implementation to drift. Pure data → string; color is sourced from
7
+ * the resolved {@link Theme} (U.1), so NO_COLOR/non-TTY callers get symbol-only
8
+ * `+`/`-` lines from the exact same code path. Diff add/remove intentionally
9
+ * reuse the `success`/`danger` hues (green/red), as before.
9
10
  */
10
- /** The picocolors instance type (colorless or not), from createColors. */
11
- export type Colors = ReturnType<typeof pc.createColors>;
12
11
  /** Cap on rendered preview lines before collapsing the rest. */
13
12
  export declare const PREVIEW_MAX_LINES = 40;
14
13
  /**
@@ -16,4 +15,4 @@ export declare const PREVIEW_MAX_LINES = 40;
16
15
  * patches, a create/overwrite listing for writes, the publish plan for PRs.
17
16
  * Long previews collapse past {@link PREVIEW_MAX_LINES}.
18
17
  */
19
- export declare function renderActionPreview(preview: ActionPreview | undefined, c: Colors): string;
18
+ export declare function renderActionPreview(preview: ActionPreview | undefined, c: Theme): string;
@@ -1,24 +1,32 @@
1
+ /**
2
+ * The one diff/action-preview renderer (U.2). The approval prompt, PR preview,
3
+ * and the streaming render path all draw diffs through here — there is no
4
+ * second implementation to drift. Pure data → string; color is sourced from
5
+ * the resolved {@link Theme} (U.1), so NO_COLOR/non-TTY callers get symbol-only
6
+ * `+`/`-` lines from the exact same code path. Diff add/remove intentionally
7
+ * reuse the `success`/`danger` hues (green/red), as before.
8
+ */
1
9
  /** Cap on rendered preview lines before collapsing the rest. */
2
10
  export const PREVIEW_MAX_LINES = 40;
3
11
  function diffLines(oldStr, newStr, c) {
4
- const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
5
- const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
12
+ const removed = oldStr.split("\n").map((l) => c.danger(`- ${l}`));
13
+ const added = newStr.split("\n").map((l) => c.success(`+ ${l}`));
6
14
  return [...removed, ...added];
7
15
  }
8
16
  function renderPatchFiles(files, c) {
9
17
  const out = [];
10
18
  for (const file of files) {
11
19
  if (file.op === "delete") {
12
- out.push(c.red(`delete ${file.path}`));
20
+ out.push(c.danger(`delete ${file.path}`));
13
21
  }
14
22
  else if (file.op === "create") {
15
- out.push(c.green(`create ${file.path}`));
16
- out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
23
+ out.push(c.success(`create ${file.path}`));
24
+ out.push(...file.lines.map((l) => c.success(`+ ${l}`)));
17
25
  if (file.omittedLines > 0)
18
- out.push(c.dim(` ...${file.omittedLines} more lines`));
26
+ out.push(c.muted(` ...${file.omittedLines} more lines`));
19
27
  }
20
28
  else {
21
- out.push(c.yellow(`update ${file.path}`));
29
+ out.push(c.warning(`update ${file.path}`));
22
30
  for (const hunk of file.hunks)
23
31
  out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
24
32
  }
@@ -28,16 +36,16 @@ function renderPatchFiles(files, c) {
28
36
  /** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
29
37
  function renderPrPreview(preview, c) {
30
38
  const out = [];
31
- out.push(`${c.bold("branch")} ${c.green(preview.branch)} ${preview.base}`);
39
+ out.push(`${c.strong("branch")} ${c.success(preview.branch)} ${c.glyph.arrow} ${preview.base}`);
32
40
  out.push("");
33
- out.push(c.bold("commit"));
41
+ out.push(c.strong("commit"));
34
42
  out.push(` ${preview.commitSubject}`);
35
43
  for (const line of bodyLines(preview.commitBody))
36
- out.push(c.dim(` ${line}`));
44
+ out.push(c.muted(` ${line}`));
37
45
  out.push("");
38
- out.push(`${c.bold("pull request")} ${preview.prTitle}`);
46
+ out.push(`${c.strong("pull request")} ${preview.prTitle}`);
39
47
  for (const line of bodyLines(preview.prBody))
40
- out.push(c.dim(` ${line}`));
48
+ out.push(c.muted(` ${line}`));
41
49
  return out;
42
50
  }
43
51
  /**
@@ -48,17 +56,17 @@ function renderPrPreview(preview, c) {
48
56
  */
49
57
  function renderRollbackPreview(preview, c) {
50
58
  const out = [];
51
- out.push(`${c.bold("restore checkpoint")} ${c.cyan(preview.checkpointId)} ${c.dim(`(${preview.createdAt})`)}`);
59
+ out.push(`${c.strong("restore checkpoint")} ${c.accent(preview.checkpointId)} ${c.muted(`(${preview.createdAt})`)}`);
52
60
  if (preview.runSummary)
53
- out.push(c.dim(`run: ${preview.runSummary}`));
54
- out.push(c.dim("working-tree files only — commits, pushes, and PRs made during the run are not undone"));
61
+ out.push(c.muted(`run: ${preview.runSummary}`));
62
+ out.push(c.muted("working-tree files only — commits, pushes, and PRs made during the run are not undone"));
55
63
  if (preview.externalPaths.length > 0) {
56
- out.push(c.red(c.bold("changed outside this run — rollback will overwrite these too:")));
64
+ out.push(c.danger(c.strong("changed outside this run — rollback will overwrite these too:")));
57
65
  for (const p of preview.externalPaths)
58
- out.push(c.red(`! ${p}`));
66
+ out.push(c.danger(`! ${p}`));
59
67
  }
60
68
  if (preview.attributionUnknown) {
61
- out.push(c.yellow("this run executed shell commands; some changes below may not have been made by the run"));
69
+ out.push(c.warning("this run executed shell commands; some changes below may not have been made by the run"));
62
70
  }
63
71
  out.push("");
64
72
  out.push(...renderPatchFiles(preview.files, c));
@@ -92,16 +100,19 @@ export function renderActionPreview(preview, c) {
92
100
  }
93
101
  else {
94
102
  const header = preview.exists
95
- ? c.yellow("OVERWRITE existing")
96
- : c.green("create");
103
+ ? c.warning("OVERWRITE existing")
104
+ : c.success("create");
97
105
  const body = preview.lines.map((l) => ` ${l}`);
98
106
  if (preview.omittedLines > 0)
99
- body.push(c.dim(` ...${preview.omittedLines} more lines`));
107
+ body.push(c.muted(` ...${preview.omittedLines} more lines`));
100
108
  lines = [header, ...body];
101
109
  }
102
110
  if (lines.length > PREVIEW_MAX_LINES) {
103
111
  const hidden = lines.length - PREVIEW_MAX_LINES;
104
- lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
112
+ lines = [
113
+ ...lines.slice(0, PREVIEW_MAX_LINES),
114
+ c.muted(`...${hidden} more`),
115
+ ];
105
116
  }
106
117
  return lines.map((l) => ` ${l}`).join("\n");
107
118
  }
@@ -1,4 +1,4 @@
1
- import type { Colors } from "./diff.js";
1
+ import type { Theme } from "../theme/index.js";
2
2
  /**
3
3
  * Best-effort, bounded syntax highlighting for fenced code blocks in streamed
4
4
  * markdown (U.2). Priorities, in order: never crash the stream, never hold the
@@ -38,10 +38,10 @@ export interface StreamHighlighter {
38
38
  * Create the per-segment streaming highlighter. `highlightLine` is injectable
39
39
  * for tests (e.g. to prove a throwing tokenizer degrades to plain text).
40
40
  */
41
- export declare function createStreamHighlighter(c: Colors, highlightLine?: LineHighlighter): StreamHighlighter;
41
+ export declare function createStreamHighlighter(theme: Theme, highlightLine?: LineHighlighter): StreamHighlighter;
42
42
  /**
43
43
  * Build the default per-line tokenizer over `c`. A plain left-to-right scan:
44
44
  * comments dim, strings green, keywords magenta, numbers yellow, everything
45
45
  * else untouched. Unknown language → identity.
46
46
  */
47
- export declare function defaultLineHighlighter(c: Colors): LineHighlighter;
47
+ export declare function defaultLineHighlighter(theme: Theme): LineHighlighter;