@cruxy/cli 1.4.0 → 1.6.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 (55) hide show
  1. package/dist/agent/session.js +75 -2
  2. package/dist/agent/status.js +41 -1
  3. package/dist/budget/index.js +9 -0
  4. package/dist/budget/session-budget.js +223 -0
  5. package/dist/checkpoint/diff.js +130 -0
  6. package/dist/checkpoint/git-store.js +52 -0
  7. package/dist/checkpoint/index.js +2 -0
  8. package/dist/checkpoint/run-rollback.js +100 -0
  9. package/dist/cli/command-catalog.js +144 -0
  10. package/dist/cli/commands/hooks.js +1 -1
  11. package/dist/cli/commands/rollback.js +21 -57
  12. package/dist/cli/commands/run.js +34 -3
  13. package/dist/cli/commands/test.js +28 -16
  14. package/dist/cli/session-commands.js +321 -70
  15. package/dist/cli/session-factory.js +13 -0
  16. package/dist/errors/constructors.js +111 -9
  17. package/dist/errors/types.js +15 -0
  18. package/dist/hooks/config.js +18 -0
  19. package/dist/hooks/index.js +1 -1
  20. package/dist/hooks/router.js +1 -1
  21. package/dist/hooks/service.js +4 -4
  22. package/dist/hooks/slash.js +10 -26
  23. package/dist/limits/cache.js +100 -0
  24. package/dist/limits/index.js +11 -0
  25. package/dist/limits/reduce.js +172 -0
  26. package/dist/limits/types.js +25 -0
  27. package/dist/memory/secrets.js +43 -0
  28. package/dist/onboarding/detect.js +95 -9
  29. package/dist/onboarding/types.js +29 -1
  30. package/dist/render/context-view.js +2 -2
  31. package/dist/render/plan-view.js +1 -1
  32. package/dist/render/status-view.js +56 -4
  33. package/dist/render/units.js +22 -0
  34. package/dist/session/index.js +1 -0
  35. package/dist/session/log.js +19 -0
  36. package/dist/session/redact.js +74 -0
  37. package/dist/session/replay.js +16 -0
  38. package/dist/session/resume.js +8 -0
  39. package/dist/session/types.js +38 -0
  40. package/dist/subagent/orchestrator.js +82 -5
  41. package/dist/theme/resolve.js +1 -0
  42. package/dist/theme/tokens.js +7 -0
  43. package/dist/tui/app.js +7 -4
  44. package/dist/tui/approval-overlay.js +7 -1
  45. package/dist/tui/disk-status.js +47 -0
  46. package/dist/tui/index.js +1 -0
  47. package/dist/tui/layout.js +8 -2
  48. package/dist/tui/limits-panel.js +247 -0
  49. package/dist/tui/overview.js +16 -4
  50. package/dist/tui/palette.js +11 -19
  51. package/dist/tui/panels.js +2 -0
  52. package/dist/tui/renderer.js +66 -0
  53. package/dist/usage/weighted.js +14 -0
  54. package/dist/utils/disk.js +103 -0
  55. package/package.json +3 -3
@@ -0,0 +1,100 @@
1
+ import path from "node:path";
2
+ import { CheckpointService } from "./service.js";
3
+ import { listSets } from "./set.js";
4
+ import { applySet, buildSetPreview, setIsNoop, validateSet, } from "./set-rollback.js";
5
+ /**
6
+ * Restore every touched root of one run's {@link CheckpointSet} as a single gated
7
+ * operation (C.26): validate-all up front, ONE combined preview behind ONE
8
+ * approval, then a sequential apply that stops and reports on first failure.
9
+ */
10
+ export async function rollbackSet(set, deps) {
11
+ const { report } = deps;
12
+ const t = report.theme;
13
+ // Validate-all BEFORE any apply — a missing/corrupt member throws here.
14
+ const members = await validateSet(set, deps.config);
15
+ if (setIsNoop(members)) {
16
+ report.print(t.muted(`working tree already matches run ${set.runId} — nothing to roll back`));
17
+ return { kind: "noop" };
18
+ }
19
+ const decision = await deps.requestApproval({
20
+ kind: "rollback",
21
+ preview: buildSetPreview(set, members),
22
+ });
23
+ if (!decision.allow) {
24
+ report.print(t.muted("rollback declined — nothing was changed"));
25
+ return { kind: "declined" };
26
+ }
27
+ const applied = await applySet(set, members);
28
+ const parts = applied.restored.map((name) => {
29
+ const counts = applied.perRoot[name];
30
+ return `${name} (${counts.reverted} reverted, ${counts.recreated} recreated, ${counts.deleted} deleted)`;
31
+ });
32
+ report.print(`${t.success(t.glyph.success)} restored run ${t.accent(set.runId)} across ` +
33
+ `${applied.restored.length} root${applied.restored.length === 1 ? "" : "s"} — ${parts.join("; ")}`);
34
+ printCaveat(report);
35
+ return { kind: "applied", roots: applied.restored.length };
36
+ }
37
+ /**
38
+ * Single-root rollback (C.32): restore ONE root to one checkpoint. Reached for an
39
+ * explicit checkpoint id (the per-member escape hatch — including when the
40
+ * primary root was removed mid-session and its set index is gone, ⚖︎#7) and as
41
+ * the JC-F back-compat fallback for a run that predates set manifests.
42
+ *
43
+ * `id` omitted → the newest checkpoint for that root.
44
+ */
45
+ export async function rollbackCheckpoint(root, id, deps) {
46
+ const { report } = deps;
47
+ const t = report.theme;
48
+ const service = new CheckpointService({ root, config: deps.config });
49
+ const result = await service.rollback(id, {
50
+ requestApproval: deps.requestApproval,
51
+ interactive: deps.interactive,
52
+ });
53
+ if (result.kind === "noop") {
54
+ report.print(t.muted(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
55
+ return { kind: "noop" };
56
+ }
57
+ if (result.kind === "rejected") {
58
+ report.print(t.muted("rollback declined — nothing was changed"));
59
+ return { kind: "declined" };
60
+ }
61
+ const { recreated, reverted, deleted } = result.applied;
62
+ report.print(`${t.success(t.glyph.success)} restored checkpoint ${t.accent(result.checkpoint.id)} — ` +
63
+ `${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
64
+ printCaveat(report);
65
+ return { kind: "applied", roots: 1 };
66
+ }
67
+ /**
68
+ * Roll back the MOST RECENT run under `primaryRoot`, with no picker and no id to
69
+ * supply — the shape `/undo-last` needs, and the shape `cruxy rollback` already
70
+ * had for its no-argument case once its picker declines to appear.
71
+ *
72
+ * Prefers the set manifest, which is the only record that knows how many roots a
73
+ * run touched; falls back to the newest single-root checkpoint (logged through
74
+ * the reporter, never silently) for a run that predates set manifests.
75
+ *
76
+ * Returns `null` when there is nothing recorded at all — a fact the caller
77
+ * phrases, because "no checkpoints yet" means something different at a shell
78
+ * prompt than it does three turns into a session.
79
+ */
80
+ export async function rollbackLatestRun(primaryRoot, deps) {
81
+ const sets = await listSets(primaryRoot); // newest first
82
+ if (sets.length > 0)
83
+ return rollbackSet(sets[0], deps);
84
+ const service = new CheckpointService({
85
+ root: primaryRoot,
86
+ config: deps.config,
87
+ });
88
+ if ((await service.list()).length === 0)
89
+ return null;
90
+ deps.report.print(deps.report.theme.muted(`no set manifest — single-root rollback against ${path.basename(primaryRoot)}`));
91
+ return rollbackCheckpoint(primaryRoot, undefined, deps);
92
+ }
93
+ /**
94
+ * The one thing a successful rollback must always say. A user who has just
95
+ * watched a run be undone will reasonably assume it was undone; the parts that
96
+ * left the working tree were never in the checkpoint to begin with.
97
+ */
98
+ function printCaveat(report) {
99
+ report.print(report.theme.muted("note: commits, pushes, and PRs made during the run are not undone"));
100
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The catalogue of commands the shells dispatch themselves, and the reserved
3
+ * name set derived from it (P10 track 0).
4
+ *
5
+ * A LEAF MODULE, deliberately: it imports nothing. The reserved set has to be
6
+ * readable from two places that cannot see each other — `cli/session-commands.ts`,
7
+ * which dispatches these names, and `hooks/config.ts`, which loads the custom
8
+ * commands that must not collide with them. Leaving the catalogue in
9
+ * `session-commands.ts` and importing it from `hooks/` would close a cycle
10
+ * (`session-commands` → `hooks/index` → `hooks/slash` → `session-commands`) and
11
+ * put a module-scope `const` in its own TDZ. So the data lives here and both
12
+ * sides import down into it.
13
+ *
14
+ * WHY THIS EXISTS AT ALL: `hooks/slash.ts` used to carry its own hand-written
15
+ * list of seven "builtins", while `dispatchCommand` intercepted nineteen names
16
+ * and the TUI three more. The other fifteen were reserved in practice and
17
+ * reserved nowhere in code: a `.cruxy/commands/status.md` loaded cleanly, listed
18
+ * itself in `cruxy hooks list` and in the palette, and then never ran, because
19
+ * `/status` was handled several `if`s before the custom catalogue was consulted.
20
+ * No warning at load, none at use, and nothing on screen to connect the two.
21
+ * Deriving the set from the dispatchers instead of restating it is what stops
22
+ * that gap reopening the next time a command is added.
23
+ */
24
+ /**
25
+ * The commands shared by both shells, in the order `/help` lists them.
26
+ *
27
+ * ONE catalogue, four consumers: the help text, Tab completion, the command
28
+ * palette, and now the reserved set. They used to be three hand-maintained
29
+ * lists — which is how the TUI ended up exporting a command list nothing
30
+ * consumed while advertising a help text that named commands it could not run.
31
+ */
32
+ export const COMMAND_CATALOG = [
33
+ { name: "/help", summary: "show this help" },
34
+ {
35
+ name: "/clear",
36
+ summary: "clear the conversation history (keep the session)",
37
+ },
38
+ {
39
+ name: "/compact",
40
+ summary: "summarize older history to free up context now",
41
+ },
42
+ { name: "/init", summary: "scaffold a project CRUXY.md and load it" },
43
+ { name: "/reload", summary: "re-read project instructions (CRUXY.md)" },
44
+ { name: "/status", summary: "show what this session is set up to do" },
45
+ {
46
+ name: "/diff",
47
+ summary: "show uncommitted changes in the workspace",
48
+ args: "[ref | --since <checkpoint>]",
49
+ },
50
+ {
51
+ name: "/undo-last",
52
+ summary: "roll back the most recent checkpointed set of file changes",
53
+ },
54
+ {
55
+ name: "/redact",
56
+ summary: "mask secrets in this conversation so the model stops seeing them",
57
+ },
58
+ {
59
+ name: "/export",
60
+ summary: "write this conversation to a markdown file",
61
+ args: "[path]",
62
+ },
63
+ {
64
+ name: "/plan",
65
+ summary: "toggle plan mode (propose a plan before executing)",
66
+ },
67
+ {
68
+ name: "/mode",
69
+ summary: "show or set the session mode",
70
+ args: "[manual | auto-approve | plan | full-auto]",
71
+ },
72
+ {
73
+ name: "/model",
74
+ summary: "show or set the model for this session",
75
+ args: "[auto | kavi | vaani | mira]",
76
+ },
77
+ {
78
+ name: "/context",
79
+ summary: "show where the context budget is going, and what compaction would drop",
80
+ },
81
+ {
82
+ name: "/usage",
83
+ summary: "show token usage, weighted tokens and cost",
84
+ args: "[all | last <n>]",
85
+ },
86
+ {
87
+ name: "/budget",
88
+ summary: "show or set this session's weighted-token budget",
89
+ args: "[<n> | off]",
90
+ },
91
+ { name: "/jobs", summary: "list background jobs and their status" },
92
+ { name: "/logs", summary: "show a background job's log", args: "<id>" },
93
+ { name: "/cancel", summary: "cancel a background job", args: "<id>" },
94
+ {
95
+ name: "/add-root",
96
+ summary: "declare another workspace root",
97
+ args: "<name> <path>",
98
+ },
99
+ { name: "/exit", summary: "leave cruxy" },
100
+ { name: "/quit", summary: "leave cruxy" },
101
+ ];
102
+ /**
103
+ * The three the TUI dispatches before the shared set, and the REPL has no
104
+ * meaning for. They live HERE rather than in `tui/app.ts` so there is one
105
+ * reserved set rather than two — a custom `/view` that worked in the REPL and
106
+ * was inert in the TUI is exactly the split this file exists to prevent, and
107
+ * the default shell is the one where it would be inert.
108
+ */
109
+ export const TUI_ONLY_COMMANDS = [
110
+ {
111
+ name: "/close",
112
+ summary: "hide a panel or the whole rail",
113
+ args: "<sidebar | context | model | git | tools | rail>",
114
+ },
115
+ {
116
+ name: "/open",
117
+ summary: "show a hidden panel",
118
+ args: "<sidebar | context | model | git | tools | rail>",
119
+ },
120
+ {
121
+ name: "/view",
122
+ summary: "show a main-pane view, or list them",
123
+ args: "[name]",
124
+ },
125
+ ];
126
+ /**
127
+ * Every name a shell dispatches itself, without its leading slash — sorted, so
128
+ * it reads as a list rather than as dispatch order.
129
+ *
130
+ * A custom command may not take any of these: it would load, list, and then
131
+ * never run. The loader rejects the collision by name (see `hooks/config.ts`),
132
+ * which is the only place a user finds out early enough to rename the file.
133
+ */
134
+ export const RESERVED_SLASH_NAMES = [
135
+ ...COMMAND_CATALOG,
136
+ ...TUI_ONLY_COMMANDS,
137
+ ]
138
+ .map((c) => c.name.slice(1))
139
+ .sort();
140
+ const RESERVED = new Set(RESERVED_SLASH_NAMES);
141
+ /** Is `name` (no leading slash) dispatched by a shell, and so unavailable? */
142
+ export function isReservedSlash(name) {
143
+ return RESERVED.has(name);
144
+ }
@@ -76,7 +76,7 @@ function printCommands(commands, t) {
76
76
  function printErrors(errors, t) {
77
77
  if (errors.length === 0)
78
78
  return;
79
- logger.print(`\n${t.danger(t.heading("malformed (excluded, never run):"))}`);
79
+ logger.print(`\n${t.danger(t.heading("excluded (never run):"))}`);
80
80
  for (const e of errors) {
81
81
  logger.print(` ${t.muted(`[${e.source}]`)} ${t.strong(e.name)} — ${e.message}`);
82
82
  }
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { Command } from "commander";
3
3
  import { themeForColor } from "../../theme/index.js";
4
4
  import { loadConfig } from "../../config/index.js";
5
- import { CheckpointService, applySet, buildSetPreview, listSets, setIsNoop, validateSet, } from "../../checkpoint/index.js";
5
+ import { CheckpointService, listSets, rollbackCheckpoint, rollbackSet, } from "../../checkpoint/index.js";
6
6
  import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
7
7
  import { fuzzyFind, selectList } from "../../components/index.js";
8
8
  import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
@@ -42,14 +42,18 @@ async function pickCheckpoint(service) {
42
42
  return result.kind === "selected" ? result.value : null;
43
43
  }
44
44
  /**
45
- * Legacy single-root rollback (C.32): restore one root's checkpoint. Reached for
45
+ * Legacy single-root rollback (C.32) with this surface's picker in front of it:
46
46
  * an explicit `cruxy rollback <id>` (the per-member escape hatch — including when
47
- * the primary root was removed mid-session and its set index is gone, ⚖︎#7) and as
48
- * the JC-F back-compat fallback for a pre-set-manifest run.
47
+ * the primary root was removed mid-session and its set index is gone, ⚖︎#7), or
48
+ * the JC-F back-compat fallback for a pre-set-manifest run, where a command line
49
+ * that named nothing gets to choose.
50
+ *
51
+ * The rollback ITSELF is `rollbackCheckpoint` (P10 track 1) — shared with
52
+ * `/undo-last`. Only the picker is this command's.
49
53
  */
50
- async function legacyRollback(root, config, approval, interactive, id, t) {
51
- const service = new CheckpointService({ root, config });
54
+ async function pickAndRollback(root, id, deps, t) {
52
55
  if (id === undefined) {
56
+ const service = new CheckpointService({ root, config: deps.config });
53
57
  const picked = await pickCheckpoint(service);
54
58
  if (picked === null) {
55
59
  logger.print(t.muted("rollback cancelled — nothing was changed"));
@@ -57,53 +61,7 @@ async function legacyRollback(root, config, approval, interactive, id, t) {
57
61
  }
58
62
  id = picked?.id;
59
63
  }
60
- const result = await service.rollback(id, {
61
- requestApproval: (action) => approval.requestApproval(action),
62
- interactive,
63
- });
64
- if (result.kind === "noop") {
65
- logger.print(t.muted(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
66
- return;
67
- }
68
- if (result.kind === "rejected") {
69
- logger.print(t.muted("rollback declined — nothing was changed"));
70
- return;
71
- }
72
- const { recreated, reverted, deleted } = result.applied;
73
- logger.print(`${t.success(t.glyph.success)} restored checkpoint ${t.accent(result.checkpoint.id)} — ` +
74
- `${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
75
- logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
76
- }
77
- /**
78
- * Set-based rollback (C.26): restore every touched root of the latest run as one
79
- * gated operation. Validate-ALL members up front (missing/corrupt →
80
- * `CHECKPOINT_SET_INCOMPLETE`, nothing applied), one combined per-root preview and
81
- * one U.3 approval, then a sequential apply that stops on first failure
82
- * (`CHECKPOINT_SET_PARTIAL`, R3). Both coded errors propagate to the boundary.
83
- */
84
- async function setRollback(config, approval, t, set) {
85
- // Validate-all BEFORE any apply — a missing/corrupt member throws here.
86
- const members = await validateSet(set, config);
87
- if (setIsNoop(members)) {
88
- logger.print(t.muted(`working tree already matches run ${set.runId} — nothing to roll back`));
89
- return;
90
- }
91
- const decision = await approval.requestApproval({
92
- kind: "rollback",
93
- preview: buildSetPreview(set, members),
94
- });
95
- if (!decision.allow) {
96
- logger.print(t.muted("rollback declined — nothing was changed"));
97
- return;
98
- }
99
- const applied = await applySet(set, members);
100
- const parts = applied.restored.map((name) => {
101
- const counts = applied.perRoot[name];
102
- return `${name} (${counts.reverted} reverted, ${counts.recreated} recreated, ${counts.deleted} deleted)`;
103
- });
104
- logger.print(`${t.success(t.glyph.success)} restored run ${t.accent(set.runId)} across ` +
105
- `${applied.restored.length} root${applied.restored.length === 1 ? "" : "s"} — ${parts.join("; ")}`);
106
- logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
64
+ await rollbackCheckpoint(root, id, deps);
107
65
  }
108
66
  /**
109
67
  * `cruxy rollback [id]` (C.32/C.26) — restore the working tree to a run's
@@ -139,6 +97,12 @@ export function rollbackCommand() {
139
97
  interactive,
140
98
  io: defaultPromptIO(shouldUseColor()),
141
99
  });
100
+ const deps = {
101
+ config,
102
+ requestApproval: (action) => approval.requestApproval(action),
103
+ interactive,
104
+ report: { print: (line) => logger.print(line), theme: t },
105
+ };
142
106
  const sets = await listSets(primaryRoot); // newest first
143
107
  if (id !== undefined) {
144
108
  // A job/run id first: if `<id>` names a set manifest (a background job's
@@ -146,19 +110,19 @@ export function rollbackCommand() {
146
110
  // it as a single checkpoint id (the per-member escape hatch).
147
111
  const jobSet = sets.find((s) => s.runId === id);
148
112
  if (jobSet) {
149
- await setRollback(config, approval, t, jobSet);
113
+ await rollbackSet(jobSet, deps);
150
114
  return;
151
115
  }
152
- await legacyRollback(primaryRoot, config, approval, interactive, id, t);
116
+ await pickAndRollback(primaryRoot, id, deps, t);
153
117
  return;
154
118
  }
155
119
  // No set manifest → JC-F: fall back to legacy single-root, logged (never
156
120
  // silent). The primary root name matches single-root workspace naming.
157
121
  if (sets.length === 0) {
158
122
  logger.info(`no set manifest — single-root rollback against ${path.basename(primaryRoot)}`);
159
- await legacyRollback(primaryRoot, config, approval, interactive, undefined, t);
123
+ await pickAndRollback(primaryRoot, undefined, deps, t);
160
124
  return;
161
125
  }
162
- await setRollback(config, approval, t, sets[0]);
126
+ await rollbackSet(sets[0], deps);
163
127
  });
164
128
  }
@@ -1,10 +1,12 @@
1
1
  import { Command } from "commander";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { LimitsClient } from "@cruxy/sdk";
4
+ import { LimitsCache } from "../../limits/index.js";
3
5
  import { logger } from "../../utils/logger.js";
4
6
  import { SessionLog, listSessions, resumeById, resumePicker, shortId, } from "../../session/index.js";
5
7
  /** Sessions shown in the TUI sidebar — the same depth as the resume picker. */
6
8
  const SIDEBAR_SESSIONS = 10;
7
- import { loadConfig, resolveApiKey } from "../../config/index.js";
9
+ import { globalDir, loadConfig, resolveApiKey } from "../../config/index.js";
8
10
  import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
9
11
  import { createRenderer } from "../../render/index.js";
10
12
  import { themeForColor } from "../../theme/index.js";
@@ -14,7 +16,7 @@ import { SandboxService } from "../../sandbox/index.js";
14
16
  import { buildHooksService, buildHooksRouter } from "../../hooks/index.js";
15
17
  import { DEFAULT_MODE, } from "../../agent/index.js";
16
18
  import { runInteractive } from "../repl.js";
17
- import { ContextGauge, createKeyLease, createGitView, createOverviewView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceGitCache, } from "../../tui/index.js";
19
+ import { ContextGauge, createKeyLease, createGitView, createOverviewView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceDiskCache, WorkspaceGitCache, } from "../../tui/index.js";
18
20
  import { buildAgentSession } from "../session-factory.js";
19
21
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
20
22
  import { resetLspServices } from "../../lsp/index.js";
@@ -340,6 +342,28 @@ export async function executeRun(promptParts, opts) {
340
342
  // provider, where the constructor's static value stands and `/model` declines.
341
343
  if (session.model)
342
344
  tui?.attachModel(session.model);
345
+ // The limits rail panel (P9) — the account's headroom, read from the gateway
346
+ // that enforces it.
347
+ //
348
+ // ONLY ON THE CRUXY PROVIDER, and only with a key. `/limits` is a cruxy
349
+ // gateway endpoint; a bring-your-own-provider session has no such notion, and
350
+ // pointing this at someone else's base URL would be a request to a stranger.
351
+ // The cache is still attached without a key so the panel can say "not signed
352
+ // in" — a fact worth stating, and one that costs no request to establish.
353
+ const limitsKey = config.model.provider === "cruxy" ? apiKey : undefined;
354
+ const limits = new LimitsCache(limitsKey === undefined
355
+ ? undefined
356
+ : (signal) => new LimitsClient({
357
+ apiKey: limitsKey,
358
+ gatewayUrl: config.cruxy.gatewayUrl,
359
+ }).read(signal));
360
+ tui?.attachLimits(limits);
361
+ // The SAME cache backs the session's weighted-token budget (P10 track 3), so
362
+ // the rail's headroom bar and `/budget`'s server line are one reading rather
363
+ // than two probes that can disagree — and so admission control at the fan-out
364
+ // seam bounds against the window the user can actually see. Attached even
365
+ // without a TUI: the REPL has no rail, but it has `/budget` and it fans out.
366
+ session.budget?.attachLimits(limits);
343
367
  // The main-pane views (P7). Registered here — the one place that has both the
344
368
  // renderer and the session — and after the session exists, because a view
345
369
  // reads its live state. Each root gets its own cached git probe: writes fan
@@ -350,8 +374,15 @@ export async function executeRun(promptParts, opts) {
350
374
  // a second instance would double the subprocesses to hold two copies of an
351
375
  // answer that must agree anyway.
352
376
  const workspaceGit = new WorkspaceGitCache(session.toolContext.workspace.roots().map((r) => r.absPath));
377
+ // Every root, plus `~/.cruxy` — the two kinds of place a run fills up. The
378
+ // home directory is the one nobody watches: checkpoints shadow-copy the
379
+ // tree once per run, and they accumulate there rather than in the repo.
380
+ const workspaceDisk = new WorkspaceDiskCache([
381
+ ...session.toolContext.workspace.roots().map((r) => r.absPath),
382
+ globalDir(),
383
+ ]);
353
384
  tui.attachViews([
354
- createOverviewView(session, workspaceGit, () => tui.servedTier()),
385
+ createOverviewView(session, workspaceGit, () => tui.servedTier(), workspaceDisk),
355
386
  createGitView(() => session.toolContext.workspace.roots(), workspaceGit),
356
387
  // Registered even when background jobs are disabled — the view says so,
357
388
  // and a nav whose rows appear and vanish with config is worse than one
@@ -1,7 +1,8 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
3
2
  import { loadConfig } from "../../config/index.js";
4
- import { testCommandNotFound } from "../../errors/index.js";
3
+ import { shouldUseColor, testCommandNotFound } from "../../errors/index.js";
4
+ import { testResultLines } from "../../render/index.js";
5
+ import { themeForColor } from "../../theme/index.js";
5
6
  import { CommandTestRunner, detectTestCommand } from "../../testing/index.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  /**
@@ -10,38 +11,49 @@ import { logger } from "../../utils/logger.js";
10
11
  * user-invoked, so there is no approval gate (typing the command IS the
11
12
  * consent — same as running the suite by hand); the process exit code mirrors
12
13
  * the suite's pass/fail so scripts and CI can branch on it.
14
+ *
15
+ * The RESULT is rendered by `render/test-view.ts` (P11 track 2), the same
16
+ * formatter behind `StreamRenderer.testResult`, rather than by a picocolors
17
+ * path of its own. This command used to print `pc.green("✓")` directly, which
18
+ * made it the one surface in the CLI that reported a test run differently from
19
+ * every other — and, because the marks were literals rather than theme tokens,
20
+ * the one that still printed `✓`/`✗` under `CRUXY_ASCII` and read them out as
21
+ * bare punctuation to a screen reader. Routing through the shared view fixes
22
+ * both at once and means the honesty rules encoded there (never infer a passed
23
+ * count, never imply an empty failure list is exhaustive) apply here too.
13
24
  */
14
25
  export function testCommand() {
15
26
  return new Command("test")
16
27
  .description("run the project's test suite once and show the parsed result")
17
28
  .action(async () => {
29
+ const t = themeForColor(shouldUseColor(process.stdout));
18
30
  const { config } = loadConfig();
19
31
  const cwd = process.cwd();
20
32
  const detected = detectTestCommand(cwd, config);
21
33
  if (detected === null)
22
34
  throw testCommandNotFound();
23
- logger.print(pc.dim(`running: ${detected.command} [${detected.source}]`));
35
+ logger.print(t.muted(`running: ${detected.command} [${detected.source}]`));
24
36
  const result = await new CommandTestRunner().run(detected.command, {
25
37
  cwd,
26
38
  timeoutMs: config.shell.timeoutMs,
27
39
  captureBytes: config.test.captureBytes,
28
40
  shell: config.shell,
29
41
  });
30
- const seconds = (result.durationMs / 1000).toFixed(1);
31
- if (result.passed) {
32
- logger.print(`${pc.green("✓")} tests passed${result.total !== undefined ? ` (${result.total})` : ""} in ${seconds}s`);
33
- return;
34
- }
35
- logger.print(`${pc.red("✗")} tests failed (exit ${result.exitCode ?? "signal"}) in ${seconds}s`);
36
- for (const failure of result.failures) {
37
- const where = failure.file !== undefined
38
- ? pc.dim(` ${failure.file}${failure.line !== undefined ? `:${failure.line}` : ""}`)
39
- : "";
40
- logger.print(` ${pc.red("✗")} ${failure.name}${where}`);
42
+ // The runner's result IS a TestReport plus execution detail (`exitCode`,
43
+ // the captured `output`) the view has no use for; the command supplies the
44
+ // one field the runner does not carry, which is what it ran.
45
+ for (const line of testResultLines({ ...result, command: detected.command }, t)) {
46
+ logger.print(line);
41
47
  }
48
+ if (result.passed)
49
+ return;
50
+ // Nothing parseable — show the honest tail beneath the view's own "no
51
+ // individual failures were recognized" line. The view says WHAT is
52
+ // missing; a directly-invoked command can also afford to show the raw
53
+ // output it was missing from, which is the next thing the user would ask
54
+ // for and the reason to run `cruxy test` by hand rather than read a log.
42
55
  if (result.failures.length === 0) {
43
- // Nothing parseable — show the honest tail instead of fake structure.
44
- logger.print(pc.dim(result.output.trimEnd()));
56
+ logger.print(t.muted(result.output.trimEnd()));
45
57
  }
46
58
  process.exitCode = 1;
47
59
  });