@cruxy/cli 0.11.0 → 0.13.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 (71) 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 +26 -7
  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/cli/session-factory.d.ts +2 -1
  14. package/dist/cli/session-factory.js +10 -3
  15. package/dist/components/frame.js +3 -1
  16. package/dist/components/fuzzy.d.ts +4 -4
  17. package/dist/components/fuzzy.js +14 -13
  18. package/dist/components/select.js +8 -7
  19. package/dist/config/schema.d.ts +123 -0
  20. package/dist/config/schema.js +40 -0
  21. package/dist/errors/constructors.d.ts +21 -0
  22. package/dist/errors/constructors.js +58 -0
  23. package/dist/errors/format.js +8 -8
  24. package/dist/errors/types.d.ts +5 -0
  25. package/dist/errors/types.js +11 -0
  26. package/dist/onboarding/flow.js +6 -6
  27. package/dist/onboarding/steps.js +11 -11
  28. package/dist/plan/approve.js +6 -6
  29. package/dist/plan/render.js +26 -18
  30. package/dist/render/capabilities.js +4 -0
  31. package/dist/render/diff.d.ts +6 -7
  32. package/dist/render/diff.js +33 -22
  33. package/dist/render/highlight.d.ts +3 -3
  34. package/dist/render/highlight.js +15 -15
  35. package/dist/render/index.d.ts +1 -1
  36. package/dist/render/plain-renderer.d.ts +2 -1
  37. package/dist/render/plain-renderer.js +7 -6
  38. package/dist/render/state.d.ts +7 -2
  39. package/dist/render/state.js +16 -10
  40. package/dist/render/tty-renderer.d.ts +2 -1
  41. package/dist/render/tty-renderer.js +20 -17
  42. package/dist/render/types.d.ts +7 -0
  43. package/dist/sandbox/detect.d.ts +22 -0
  44. package/dist/sandbox/detect.js +67 -0
  45. package/dist/sandbox/docker-runtime.d.ts +32 -0
  46. package/dist/sandbox/docker-runtime.js +263 -0
  47. package/dist/sandbox/index.d.ts +7 -0
  48. package/dist/sandbox/index.js +5 -0
  49. package/dist/sandbox/policy.d.ts +17 -0
  50. package/dist/sandbox/policy.js +90 -0
  51. package/dist/sandbox/service.d.ts +57 -0
  52. package/dist/sandbox/service.js +64 -0
  53. package/dist/sandbox/types.d.ts +114 -0
  54. package/dist/sandbox/types.js +17 -0
  55. package/dist/subagent/orchestrator.d.ts +7 -0
  56. package/dist/subagent/orchestrator.js +22 -6
  57. package/dist/testing/run-tests-tool.d.ts +5 -1
  58. package/dist/testing/run-tests-tool.js +8 -1
  59. package/dist/testing/sandbox-runner.d.ts +16 -0
  60. package/dist/testing/sandbox-runner.js +47 -0
  61. package/dist/theme/index.d.ts +2 -0
  62. package/dist/theme/index.js +2 -0
  63. package/dist/theme/resolve.d.ts +32 -0
  64. package/dist/theme/resolve.js +73 -0
  65. package/dist/theme/tokens.d.ts +104 -0
  66. package/dist/theme/tokens.js +52 -0
  67. package/dist/tools/shell/run-command.js +35 -1
  68. package/dist/tools/types.d.ts +10 -0
  69. package/dist/utils/logger.d.ts +2 -0
  70. package/dist/utils/logger.js +7 -4
  71. package/package.json +1 -1
@@ -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;
@@ -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
  }