@cruxy/cli 1.2.0 → 1.3.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 (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,755 @@
1
+ import { MODE_LABELS, SESSION_MODES, modeDescription, modeFromFlags, parseMode, } from "../agent/index.js";
2
+ import { existsSync, writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { contextReport, readContext } from "../agent/context.js";
5
+ import { scaffoldProjectInstructions } from "../config/index.js";
6
+ import { resolveSlash } from "../hooks/index.js";
7
+ import { contextReportLines } from "../render/context-view.js";
8
+ import { renderUnifiedDiff } from "../render/index.js";
9
+ import { sessionStatusLines } from "../render/status-view.js";
10
+ import { defaultExportName, exportMarkdown } from "../session/index.js";
11
+ import { getGitInfo } from "../utils/git.js";
12
+ import { currentBranch, diffAgainst, hasChanges } from "../vcs/git.js";
13
+ import { MODEL_CHOICES, describeModelChoice, parseModelChoice, } from "../routing/index.js";
14
+ import { loadUsage, runCountLabel, selectRuns, usageReport, } from "../usage/index.js";
15
+ import { runGatedShell } from "../tools/shell/exec.js";
16
+ import { addRootToWorkspace } from "../workspace/index.js";
17
+ import { formatError, fromUnknown, isVerbose } from "../errors/index.js";
18
+ /**
19
+ * The commands shared by both shells, in the order `/help` lists them.
20
+ *
21
+ * ONE catalogue, three consumers: the help text, Tab completion, and the
22
+ * command palette. They used to be three hand-maintained lists — which is how
23
+ * the TUI ended up exporting a command list nothing consumed while advertising
24
+ * a help text that named commands it could not run.
25
+ *
26
+ * Panel commands (`/close`, `/open`) are the TUI's alone and live in
27
+ * `tui/app.ts`; they have no meaning in a shell with no panels.
28
+ */
29
+ export const COMMAND_CATALOG = [
30
+ { name: "/help", summary: "show this help" },
31
+ {
32
+ name: "/clear",
33
+ summary: "clear the conversation history (keep the session)",
34
+ },
35
+ {
36
+ name: "/compact",
37
+ summary: "summarize older history to free up context now",
38
+ },
39
+ { name: "/init", summary: "scaffold a project CRUXY.md and load it" },
40
+ { name: "/reload", summary: "re-read project instructions (CRUXY.md)" },
41
+ { name: "/status", summary: "show what this session is set up to do" },
42
+ {
43
+ name: "/diff",
44
+ summary: "show uncommitted changes in the workspace",
45
+ args: "[ref]",
46
+ },
47
+ {
48
+ name: "/export",
49
+ summary: "write this conversation to a markdown file",
50
+ args: "[path]",
51
+ },
52
+ {
53
+ name: "/plan",
54
+ summary: "toggle plan mode (propose a plan before executing)",
55
+ },
56
+ {
57
+ name: "/mode",
58
+ summary: "show or set the session mode",
59
+ args: "[manual | auto-approve | plan | full-auto]",
60
+ },
61
+ {
62
+ name: "/model",
63
+ summary: "show or set the model for this session",
64
+ args: "[auto | kavi | vaani | mira]",
65
+ },
66
+ {
67
+ name: "/context",
68
+ summary: "show where the context budget is going, and what compaction would drop",
69
+ },
70
+ {
71
+ name: "/usage",
72
+ summary: "show token usage, weighted tokens and cost",
73
+ args: "[all | last <n>]",
74
+ },
75
+ { name: "/jobs", summary: "list background jobs and their status" },
76
+ { name: "/logs", summary: "show a background job's log", args: "<id>" },
77
+ { name: "/cancel", summary: "cancel a background job", args: "<id>" },
78
+ {
79
+ name: "/add-root",
80
+ summary: "declare another workspace root",
81
+ args: "<name> <path>",
82
+ },
83
+ { name: "/exit", summary: "leave cruxy" },
84
+ { name: "/quit", summary: "leave cruxy" },
85
+ ];
86
+ /**
87
+ * Line cap for `/diff`. Generous compared to a preview block — this is a command
88
+ * whose entire purpose is the diff, not a block competing for space with a
89
+ * conversation — but still bounded, and the marker says how many were dropped.
90
+ */
91
+ const DIFF_MAX_LINES = 400;
92
+ /** Just the names — the Tab-completion candidate set. */
93
+ export const SHARED_COMMANDS = COMMAND_CATALOG.map((c) => c.name);
94
+ /** Render one catalogue entry as a `/help` row, aligned on a fixed gutter. */
95
+ export function helpRow(command, gutter = 18) {
96
+ const left = command.args ? `${command.name} ${command.args}` : command.name;
97
+ return ` ${left.padEnd(gutter)} ${command.summary}`;
98
+ }
99
+ /** The shared half of `/help`, minus each shell's own additions. */
100
+ export const SHARED_HELP = COMMAND_CATALOG.map((c) => helpRow(c));
101
+ /**
102
+ * Dispatch one submitted line.
103
+ *
104
+ * Returns `{kind:"turn"}` for anything this does not own — including an unknown
105
+ * `/…` string, which falls through to the model exactly as it always has rather
106
+ * than being rejected. A user who types a command that does not exist gets an
107
+ * answer, not a scolding.
108
+ */
109
+ export async function dispatchCommand(input, ctx) {
110
+ const trimmed = input.trim();
111
+ const { session, out } = ctx;
112
+ const t = out.theme;
113
+ if (trimmed === "")
114
+ return { kind: "handled" };
115
+ if (trimmed === "/exit" || trimmed === "/quit")
116
+ return { kind: "exit" };
117
+ if (trimmed === "/clear") {
118
+ session.clear();
119
+ out.print(t.muted("history cleared"));
120
+ return { kind: "handled" };
121
+ }
122
+ if (trimmed === "/compact") {
123
+ try {
124
+ const n = await session.compact();
125
+ out.print(t.muted(n ? `compacted ${n} older messages` : "nothing to compact yet"));
126
+ }
127
+ catch (err) {
128
+ printCommandError(out, err);
129
+ }
130
+ return { kind: "handled" };
131
+ }
132
+ if (trimmed === "/reload") {
133
+ const loaded = session.reloadProjectInstructions();
134
+ out.print(t.muted(loaded
135
+ ? "reloaded project instructions (CRUXY.md)"
136
+ : "no project instructions found"));
137
+ return { kind: "handled" };
138
+ }
139
+ if (trimmed === "/plan") {
140
+ // Toggling plan is a MOVE within the mode ring, not a boolean flip: it
141
+ // preserves whatever the auto-approve half was, so leaving plan mode from
142
+ // `full-auto` lands on `auto-approve` rather than silently re-arming every
143
+ // prompt the user had turned off.
144
+ announceMode(out, session.setMode(modeFromFlags(!session.getPlanMode(), session.getAutoApprove())));
145
+ return { kind: "handled" };
146
+ }
147
+ if (trimmed === "/mode" || trimmed.startsWith("/mode ")) {
148
+ handleMode(trimmed, ctx);
149
+ return { kind: "handled" };
150
+ }
151
+ if (trimmed === "/model" || trimmed.startsWith("/model ")) {
152
+ await handleModel(trimmed, ctx);
153
+ return { kind: "handled" };
154
+ }
155
+ if (trimmed === "/init") {
156
+ handleInit(ctx);
157
+ return { kind: "handled" };
158
+ }
159
+ if (trimmed === "/status") {
160
+ handleStatus(ctx);
161
+ return { kind: "handled" };
162
+ }
163
+ if (trimmed === "/diff" || trimmed.startsWith("/diff ")) {
164
+ handleDiff(trimmed, ctx);
165
+ return { kind: "handled" };
166
+ }
167
+ if (trimmed === "/export" || trimmed.startsWith("/export ")) {
168
+ handleExport(trimmed, ctx);
169
+ return { kind: "handled" };
170
+ }
171
+ if (trimmed === "/context") {
172
+ handleContext(ctx);
173
+ return { kind: "handled" };
174
+ }
175
+ if (trimmed === "/usage" || trimmed.startsWith("/usage ")) {
176
+ handleUsage(trimmed, ctx);
177
+ return { kind: "handled" };
178
+ }
179
+ if (trimmed === "/jobs") {
180
+ handleJobsList(ctx);
181
+ return { kind: "handled" };
182
+ }
183
+ if (trimmed === "/logs" || trimmed.startsWith("/logs ")) {
184
+ handleJobLogs(trimmed, ctx);
185
+ return { kind: "handled" };
186
+ }
187
+ if (trimmed === "/cancel" || trimmed.startsWith("/cancel ")) {
188
+ await handleJobCancel(trimmed, ctx);
189
+ return { kind: "handled" };
190
+ }
191
+ if (trimmed === "/add-root" || trimmed.startsWith("/add-root ")) {
192
+ await handleAddRoot(trimmed, ctx);
193
+ return { kind: "handled" };
194
+ }
195
+ // Custom slash commands (C.19) — consulted AFTER builtins, so a custom
196
+ // command can never shadow /help, /exit, etc. A `prompt` command expands to
197
+ // text fed to the agent (safe); a `shell` command runs through the SAME gate
198
+ // + sandbox as any command (never a bypass). Unknown "/…" input falls through
199
+ // to a normal turn, preserving prior behaviour.
200
+ const slash = resolveSlash(trimmed, ctx.slashCommands);
201
+ if (slash.kind === "prompt")
202
+ return { kind: "turn", text: slash.prompt };
203
+ if (slash.kind === "shell") {
204
+ await runSlashShell(slash.spec, ctx);
205
+ return { kind: "handled" };
206
+ }
207
+ return { kind: "turn", text: input };
208
+ }
209
+ /**
210
+ * Say what the mode now IS and what it will do.
211
+ *
212
+ * The description is not decoration. The objection to an auto-approve config
213
+ * flag was that it disarmed the gate with nothing on screen; a runtime mode
214
+ * announcing itself only as "auto-approve on" would reproduce that, slower.
215
+ */
216
+ export function announceMode(out, mode) {
217
+ out.print(out.theme.muted(`mode: ${MODE_LABELS[mode]} — ${modeDescription(mode)}`));
218
+ }
219
+ /** `/mode` (show + list) and `/mode <name>` (set). */
220
+ function handleMode(input, ctx) {
221
+ const { out, session } = ctx;
222
+ const arg = input.slice("/mode".length).trim();
223
+ if (arg === "") {
224
+ announceMode(out, session.getMode());
225
+ out.print(out.theme.muted(` available: ${SESSION_MODES.join(" · ")}`));
226
+ return;
227
+ }
228
+ const want = parseMode(arg);
229
+ if (want === null) {
230
+ // Never a guess: a typo must not silently arm auto-approve.
231
+ out.print(out.theme.muted(`unknown mode "${arg}" — try ${SESSION_MODES.join(" | ")}`));
232
+ return;
233
+ }
234
+ const effective = session.setMode(want);
235
+ if (effective !== want) {
236
+ // Say so rather than reporting the request as if it took.
237
+ out.print(out.theme.muted(`plan mode is unavailable in this session — using ${effective}`));
238
+ }
239
+ announceMode(out, effective);
240
+ }
241
+ /**
242
+ * Say what the model now IS, and — once the choice has actually taken — what
243
+ * that means. `auto` gets its explanation every time rather than only on the
244
+ * first set: it is the one choice whose visible value will not match what runs,
245
+ * and a user who reads `model: auto` and then sees `mira` in the rail has to be
246
+ * able to connect the two without going looking.
247
+ */
248
+ function announceModel(out, choice) {
249
+ out.print(out.theme.muted(`model: ${choice} — ${describeModelChoice(choice)}`));
250
+ }
251
+ /**
252
+ * `/model` (show, or pick), `/model <name>` (set).
253
+ *
254
+ * The choice is SESSION state, never written to config — the same rule P5
255
+ * track 3 applied to auto-approve, and for a weaker but real version of the
256
+ * same reason: a setting persisted from a file re-applies itself to every
257
+ * future session with nothing on screen saying it did. `config.model.model`
258
+ * stays the durable knob and the starting value.
259
+ */
260
+ async function handleModel(input, ctx) {
261
+ const { out, session } = ctx;
262
+ const t = out.theme;
263
+ const model = session.model;
264
+ // No tiers to choose between. Said plainly rather than by offering a menu
265
+ // that could not take effect — and WITHOUT naming the configured upstream
266
+ // model, which is the one string the U.8 gag exists to keep off screen.
267
+ if (!model) {
268
+ out.print(t.muted("this session runs on a bring-your-own provider, which has no cruxy tiers to choose between — " +
269
+ "set `model.model` in your config to change it"));
270
+ return;
271
+ }
272
+ const arg = input.slice("/model".length).trim();
273
+ if (arg === "") {
274
+ // No argument: offer the picker when the shell can host one, else say what
275
+ // is set and what else is available. Both paths end at the same place — a
276
+ // user who cannot get a picker can still type `/model kavi`.
277
+ const picked = await ctx.pick?.(MODEL_CHOICES, {
278
+ title: "model",
279
+ toLabel: (choice) => `${choice} — ${describeModelChoice(choice)}`,
280
+ initialIndex: MODEL_CHOICES.indexOf(model.current()),
281
+ });
282
+ if (picked == null) {
283
+ announceModel(out, model.current());
284
+ for (const choice of MODEL_CHOICES) {
285
+ if (choice === model.current())
286
+ continue;
287
+ out.print(out.fit(t.muted(` ${choice} — ${describeModelChoice(choice)}`)));
288
+ }
289
+ return;
290
+ }
291
+ applyModel(ctx, picked);
292
+ return;
293
+ }
294
+ const want = parseModelChoice(arg);
295
+ if (want === null) {
296
+ // Never a nearest match: silently routing a user to a tier they did not
297
+ // name is exactly the substitution `ConfigRouter` refuses to make.
298
+ out.print(t.muted(`unknown model "${arg}" — try ${MODEL_CHOICES.join(" | ")}`));
299
+ return;
300
+ }
301
+ applyModel(ctx, want);
302
+ }
303
+ /** Adopt a picked choice and report it — including the case where nothing moved. */
304
+ function applyModel(ctx, choice) {
305
+ const { out, session } = ctx;
306
+ const model = session.model;
307
+ if (!model.set(choice)) {
308
+ out.print(out.theme.muted(`model is already ${choice}`));
309
+ return;
310
+ }
311
+ announceModel(out, choice);
312
+ // A configured routing table is quietly bypassed by an explicit pick, so say
313
+ // so. The user asked for one model; the table would have sent some task
314
+ // classes elsewhere, and finding that out from a usage breakdown later is
315
+ // worse than one line here.
316
+ if (choice !== "auto" && model.hasRoutingTable) {
317
+ out.print(out.theme.muted(" (overrides your configured routing table for this session)"));
318
+ }
319
+ }
320
+ /**
321
+ * `/init` (P6 track 4) — scaffold a project `CRUXY.md` and load it immediately.
322
+ *
323
+ * NOT a reuse of `cruxy init`. That command is an onboarding flow: it acquires
324
+ * an API key if one is missing, offers a scaffold, and offers a first-win demo
325
+ * run — none of which makes sense inside a session that is already
326
+ * authenticated and already running. The one genuinely shared piece is the
327
+ * template and the write, which now live in `config/project.ts`.
328
+ *
329
+ * The `/reload` afterwards is what makes this useful rather than ceremonial:
330
+ * project instructions are folded into every turn's system prompt and read once
331
+ * at session start, so a `CRUXY.md` written mid-session would otherwise sit
332
+ * there doing nothing until the next launch.
333
+ */
334
+ function handleInit(ctx) {
335
+ const { out, session } = ctx;
336
+ const t = out.theme;
337
+ const outcome = scaffoldProjectInstructions(session.toolContext.cwd);
338
+ if (outcome.kind === "exists") {
339
+ // Never overwrites — and says which file already covers it, since the
340
+ // loader honours CRUXY.md or AGENTS.md and the user may have forgotten
341
+ // which one this project uses.
342
+ out.print(t.muted("project instructions already exist here — edit them and run /reload"));
343
+ return;
344
+ }
345
+ if (outcome.kind === "failed") {
346
+ out.print(t.danger(`could not write CRUXY.md — ${outcome.message}`));
347
+ return;
348
+ }
349
+ out.print(t.muted(`wrote ${outcome.file}`));
350
+ session.reloadProjectInstructions();
351
+ out.print(t.muted("loaded into this session — it is a skeleton, so fill it in and /reload"));
352
+ }
353
+ /**
354
+ * `/status` (P6 track 4) — what this session is set up to do, in one screen.
355
+ *
356
+ * The facts exist; they are just scattered. The mode is a chip on the input row,
357
+ * the model and git live in rail panels the REPL does not have, the multi-root
358
+ * banner is printed once at startup and then gone, and whether a sandbox is
359
+ * actually engaged is visible nowhere after the first line of output. The two
360
+ * that matter most for safety are exactly the two with no persistent surface in
361
+ * the REPL at all.
362
+ *
363
+ * Every value is read from the object that owns it rather than re-derived, so
364
+ * this can never disagree with the surface it summarises.
365
+ */
366
+ function handleStatus(ctx) {
367
+ const { out, session } = ctx;
368
+ const toolCtx = session.toolContext;
369
+ const config = toolCtx.config;
370
+ const mode = session.getMode();
371
+ // A real user turn is `role: "user"` with STRING content — tool results are
372
+ // also role "user" but carry blocks, so this counts what the human said.
373
+ const turns = session.messages.filter((m) => m.role === "user" && typeof m.content === "string").length;
374
+ const roots = toolCtx.workspace.roots().map((r) => {
375
+ // A synchronous probe, on an explicitly-requested command. The rail's rule
376
+ // (never probe on the paint path) is about frames, not about a user who
377
+ // just asked; there is no frame budget to blow here.
378
+ const git = getGitInfo(r.absPath);
379
+ return {
380
+ name: r.name,
381
+ path: r.absPath,
382
+ primary: r.primary,
383
+ ...(git === null ? {} : { branch: git.branch, changed: git.changed }),
384
+ };
385
+ });
386
+ const jobs = session.jobs?.list();
387
+ for (const line of sessionStatusLines({
388
+ sessionId: session.sessionId,
389
+ turns,
390
+ mode,
391
+ modeDescription: modeDescription(mode),
392
+ ...(session.model ? { model: session.model.current() } : {}),
393
+ provider: config.model.provider,
394
+ roots,
395
+ context: readContext(session.messages, config.context),
396
+ sandboxEnabled: Boolean(toolCtx.sandbox),
397
+ ...(toolCtx.sandbox
398
+ ? { sandboxRuntime: toolCtx.sandbox.runtimeName }
399
+ : {}),
400
+ checkpoints: Boolean(toolCtx.checkpointsActive),
401
+ ...(jobs
402
+ ? {
403
+ jobs: {
404
+ total: jobs.length,
405
+ running: jobs.filter((j) => j.status === "running").length,
406
+ needingApproval: jobs.filter((j) => j.pendingApproval).length,
407
+ },
408
+ }
409
+ : {}),
410
+ tools: toolCount(ctx),
411
+ }, out.theme)) {
412
+ out.print(out.fit(line));
413
+ }
414
+ }
415
+ /** Tools advertised to the model this session — the registry the loop dispatches against. */
416
+ function toolCount(ctx) {
417
+ return ctx.session.toolRegistry.list().length;
418
+ }
419
+ /**
420
+ * `/diff` (P6 track 4) — what has changed on disk, without leaving the session.
421
+ *
422
+ * Defaults to the working tree against `HEAD`: mid-session, the question is
423
+ * almost always "what has this run actually done to my files", and uncommitted
424
+ * changes are that answer. `/diff <ref>` widens it to any commit-ish, which is
425
+ * how you see a whole branch's worth.
426
+ *
427
+ * EVERY ROOT, not just the primary (C.26). Writes fan every declared root when
428
+ * checkpoints are on, so a diff that showed only the primary would under-report
429
+ * exactly the multi-root case that is hardest to keep track of by hand.
430
+ */
431
+ function handleDiff(input, ctx) {
432
+ const { out, session } = ctx;
433
+ const t = out.theme;
434
+ const ref = input.slice("/diff".length).trim() || "HEAD";
435
+ const roots = session.toolContext.workspace.roots();
436
+ let any = false;
437
+ for (const root of roots) {
438
+ if (roots.length > 1)
439
+ out.print(t.strong(`${root.name}`));
440
+ // `null` branch means not a repo (or detached) — either way there is
441
+ // nothing to diff against, and saying so beats an empty block.
442
+ if (currentBranch(root.absPath) === null && !hasChanges(root.absPath)) {
443
+ out.print(t.muted(" not a git repo (or nothing to compare)"));
444
+ continue;
445
+ }
446
+ const diff = diffAgainst(root.absPath, ref);
447
+ if (diff.trim() === "") {
448
+ out.print(t.muted(` no changes against ${ref}`));
449
+ continue;
450
+ }
451
+ any = true;
452
+ for (const line of renderUnifiedDiff(diff, t, {
453
+ maxLines: DIFF_MAX_LINES,
454
+ })) {
455
+ out.print(out.fit(line));
456
+ }
457
+ }
458
+ if (any) {
459
+ // A working-tree diff says nothing about what has been committed or pushed,
460
+ // and a reader who has just watched an agent work may assume otherwise.
461
+ out.print(t.muted(`(working tree vs ${ref} — commits, pushes and PRs are not shown)`));
462
+ }
463
+ }
464
+ /**
465
+ * `/export` (P6 track 4) — write this conversation to a Markdown file.
466
+ *
467
+ * REFUSES TO OVERWRITE, always, with no `--force`. The whole value of an export
468
+ * is that it is a record; a command that can silently replace one is a command
469
+ * that can destroy the thing it exists to produce. Picking a new name is one
470
+ * keystroke; recovering a clobbered transcript is not possible.
471
+ *
472
+ * This writes a file WITHOUT going through the approval gate, and that is
473
+ * correct rather than an omission: the gate exists to put a human between the
474
+ * MODEL and the filesystem, and the model cannot reach slash commands — they are
475
+ * dispatched from the input loop, never from the tool registry. `/add-root`
476
+ * makes the same argument for the same reason.
477
+ */
478
+ function handleExport(input, ctx) {
479
+ const { out, session } = ctx;
480
+ const t = out.theme;
481
+ const cwd = session.toolContext.cwd;
482
+ const arg = input.slice("/export".length).trim();
483
+ const target = resolve(cwd, arg === "" ? defaultExportName(session.sessionId) : arg);
484
+ if (existsSync(target)) {
485
+ out.print(t.muted(`${target} already exists — pass a different path to /export`));
486
+ return;
487
+ }
488
+ const document = exportMarkdown(session.messages, {
489
+ sessionId: session.sessionId,
490
+ exportedAt: new Date().toISOString(),
491
+ cwd,
492
+ provider: session.toolContext.config.model.provider,
493
+ ...(session.model ? { model: session.model.current() } : {}),
494
+ });
495
+ try {
496
+ writeFileSync(target, document, "utf8");
497
+ }
498
+ catch (err) {
499
+ out.print(t.danger(`could not write ${target} — ${err.message}`));
500
+ return;
501
+ }
502
+ out.print(t.muted(`wrote ${target} (${session.messages.length} message${session.messages.length === 1 ? "" : "s"})`));
503
+ }
504
+ /**
505
+ * `/context` (P6 track 3) — the detail view behind the rail's two numbers.
506
+ *
507
+ * Deliberately NOT a text copy of the panel. The panel's doc comment argues
508
+ * that two plain numbers are the honest presentation and a progress bar is not,
509
+ * and reproducing it here in a wider space would be the same claim with more
510
+ * room to look authoritative. What this adds is the two things a 24-column strip
511
+ * genuinely cannot carry: where the tokens are, and what compaction would drop —
512
+ * the latter computed by the same `findCut` the seam uses.
513
+ */
514
+ function handleContext(ctx) {
515
+ const { out, session } = ctx;
516
+ const report = contextReport(session.messages, session.toolContext.config.context);
517
+ for (const line of contextReportLines(report, out.theme)) {
518
+ out.print(out.fit(line));
519
+ }
520
+ }
521
+ /**
522
+ * `/usage` (P6 track 2) — the richest data cruxy holds, and until now the only
523
+ * way to see it was to leave the session and run a second process.
524
+ *
525
+ * `/usage` scopes to THIS session, `/usage all` to everything retained, and
526
+ * `/usage last <n>` to the newest N runs. The session scope is exact rather than
527
+ * inferred: `cruxy usage --session` has to guess the session from the newest run
528
+ * in the store, because a command line has none of its own — which quietly
529
+ * reports someone else's runs as soon as two sessions interleave. Here the id is
530
+ * known, so it is used.
531
+ *
532
+ * The report is the SAME renderer `cruxy usage` prints, deliberately including
533
+ * the paragraph explaining what the weighted figure is and is not. That copy is
534
+ * load-bearing — a weighted total read as a remaining balance is a correct
535
+ * number that has misled someone — so it is not abbreviated for being in-session.
536
+ */
537
+ function handleUsage(input, ctx) {
538
+ const { out, session } = ctx;
539
+ const t = out.theme;
540
+ const arg = input.slice("/usage".length).trim().toLowerCase();
541
+ let scope;
542
+ let scopeLabel;
543
+ if (arg === "" || arg === "session") {
544
+ scope = { sessionId: session.sessionId };
545
+ scopeLabel = "this session";
546
+ }
547
+ else if (arg === "all") {
548
+ scope = {};
549
+ scopeLabel = "all retained runs";
550
+ }
551
+ else if (arg.startsWith("last")) {
552
+ const n = Number(arg.slice("last".length).trim());
553
+ if (!Number.isInteger(n) || n <= 0) {
554
+ out.print(t.muted("usage: /usage last <n> (n a positive integer)"));
555
+ return;
556
+ }
557
+ scope = { last: n };
558
+ scopeLabel = `last ${runCountLabel(n)}`;
559
+ }
560
+ else {
561
+ out.print(t.muted("usage: /usage [all | last <n>]"));
562
+ return;
563
+ }
564
+ const { data, error } = loadUsage();
565
+ if (error) {
566
+ // A corrupt store is a warning here, never fatal: `cruxy usage` can exit on
567
+ // it, but a shell must survive a failed command and return to the prompt.
568
+ printCommandError(out, error);
569
+ return;
570
+ }
571
+ const runs = selectRuns(data.runs, scope);
572
+ const enabled = session.toolContext.config.usage.enabled;
573
+ // Nothing on disk for this session, but the run that just finished is still
574
+ // in memory. Showing it beats the literal-but-useless "no usage recorded yet"
575
+ // — that answer is only the whole truth when tracking is ON.
576
+ if (runs.length === 0 && !enabled && session.lastRun) {
577
+ for (const line of usageReport([session.lastRun], t, {
578
+ scopeLabel: "the last run (not being recorded)",
579
+ trackingEnabled: enabled,
580
+ })) {
581
+ for (const row of line.split("\n"))
582
+ out.print(out.fit(row));
583
+ }
584
+ out.print(t.muted("usage tracking is off (usage.enabled = false) — nothing is saved"));
585
+ return;
586
+ }
587
+ const config = session.toolContext.config.usage;
588
+ for (const line of usageReport(runs, t, {
589
+ scopeLabel,
590
+ trackingEnabled: enabled,
591
+ legacyPriceConfig: config.currency !== undefined || config.prices !== undefined,
592
+ })) {
593
+ // The report's paragraphs carry their own newlines; each row is fitted
594
+ // separately so the REPL truncates per LINE rather than mangling a block.
595
+ for (const row of line.split("\n"))
596
+ out.print(out.fit(row));
597
+ }
598
+ }
599
+ /** Render the background-job list (`/jobs`). */
600
+ function handleJobsList(ctx) {
601
+ const { out, session } = ctx;
602
+ const t = out.theme;
603
+ const jobs = session.jobs;
604
+ if (!jobs) {
605
+ out.print(t.muted("background jobs are disabled — enable with `cruxy config set jobs.enabled true`"));
606
+ return;
607
+ }
608
+ const list = jobs.list();
609
+ if (list.length === 0) {
610
+ out.print(t.muted("no background jobs this session"));
611
+ return;
612
+ }
613
+ for (const j of list) {
614
+ const status = j.status === "failed" ? t.danger(j.status) : t.accent(j.status);
615
+ const pending = j.pendingApproval
616
+ ? t.muted(` — needs approval: ${j.pendingApproval}`)
617
+ : "";
618
+ const err = j.error ? t.muted(` (${j.error})`) : "";
619
+ // Fit id-first so the job id + status always survive; the label/notes tail
620
+ // truncates with an honest ellipsis at narrow width (U.12).
621
+ out.print(out.fit(`${t.strong(j.id)} ${status} ${j.label}${pending}${err}`));
622
+ }
623
+ }
624
+ /** Print one job's log (`/logs <id>`). */
625
+ function handleJobLogs(input, ctx) {
626
+ const { out, session } = ctx;
627
+ const t = out.theme;
628
+ const jobs = session.jobs;
629
+ if (!jobs) {
630
+ out.print(t.muted("background jobs are disabled"));
631
+ return;
632
+ }
633
+ const id = input.slice("/logs".length).trim();
634
+ if (!id) {
635
+ out.print(t.muted("usage: /logs <id>"));
636
+ return;
637
+ }
638
+ try {
639
+ const log = jobs.logs(id);
640
+ if (log.dropped > 0) {
641
+ out.print(t.muted(`… ${log.dropped} earlier line(s) rolled off`));
642
+ }
643
+ for (const line of log.lines) {
644
+ out.print(out.fit(line.stream === "err" ? t.danger(line.text) : line.text));
645
+ }
646
+ out.print(t.muted(`(${log.status})`));
647
+ }
648
+ catch (err) {
649
+ printCommandError(out, err);
650
+ }
651
+ }
652
+ /** Cancel a job (`/cancel <id>`). */
653
+ async function handleJobCancel(input, ctx) {
654
+ const { out, session } = ctx;
655
+ const jobs = session.jobs;
656
+ if (!jobs) {
657
+ out.print(out.theme.muted("background jobs are disabled"));
658
+ return;
659
+ }
660
+ const id = input.slice("/cancel".length).trim();
661
+ if (!id) {
662
+ out.print(out.theme.muted("usage: /cancel <id>"));
663
+ return;
664
+ }
665
+ try {
666
+ const cancelled = jobs.cancel(id);
667
+ out.print(out.theme.muted(cancelled
668
+ ? `cancelling ${id} (its process tree is killed; any checkpoint survives for rollback)`
669
+ : `${id} is already finished`));
670
+ }
671
+ catch (err) {
672
+ printCommandError(out, err);
673
+ }
674
+ }
675
+ /**
676
+ * `/add-root <name> <path>` (C.26 step 5) — an explicit, human-only way to
677
+ * declare another workspace root. This is a shell command, NOT a model tool: the
678
+ * model can only reach the tool registry, and nothing named `add_root` is
679
+ * registered there, so the allowlist argument is unchanged. It validates the
680
+ * addition (TTY-only; same existence/name/overlap refusal as `--root`) and, on
681
+ * success, tells the user to relaunch with `--root` to activate it — a
682
+ * mid-session hot-swap would leave the checkpoint gate + hook router (both wired
683
+ * from the session-start workspace) half-attributed, so activation is deferred
684
+ * to a clean relaunch. A newly declared root always starts untrusted.
685
+ */
686
+ async function handleAddRoot(input, ctx) {
687
+ const { out, session } = ctx;
688
+ const parts = input
689
+ .slice("/add-root".length)
690
+ .trim()
691
+ .split(/\s+/)
692
+ .filter(Boolean);
693
+ if (parts.length !== 2) {
694
+ out.print(out.theme.muted("usage: /add-root <name> <path>"));
695
+ return;
696
+ }
697
+ const [name, rootPath] = parts;
698
+ const toolCtx = session.toolContext;
699
+ try {
700
+ const next = await addRootToWorkspace(toolCtx.workspace, { name, path: rootPath }, { cwd: toolCtx.cwd, tty: ctx.tty });
701
+ const abs = next.rootByName(name).absPath;
702
+ out.print(out.theme.muted(`validated root "${name}" (${abs}). relaunch with \`--root ${name}=${rootPath}\` to activate it — ` +
703
+ `it starts untrusted (its hooks and project memory stay inert until you run \`cruxy hooks/memory trust\`).`));
704
+ }
705
+ catch (err) {
706
+ printCommandError(out, err);
707
+ }
708
+ }
709
+ /**
710
+ * Run a shell-bound custom slash command (C.19) through the SAME gated +
711
+ * sandboxed path as `run_command` (`runGatedShell` over the session's tool
712
+ * context) — never a privileged route. Prints the result like a shell run; a
713
+ * gate rejection or a thrown coded error (e.g. sandbox) is surfaced, not fatal.
714
+ */
715
+ async function runSlashShell(spec, ctx) {
716
+ const { out, session } = ctx;
717
+ const t = out.theme;
718
+ out.print(t.muted(`running /${spec.name}${t.glyph.ellipsis}`));
719
+ try {
720
+ const outcome = await runGatedShell(spec.command ?? "", session.toolContext);
721
+ if (!outcome.approved) {
722
+ out.print(t.muted(outcome.rejection ?? "command denied by the user"));
723
+ return;
724
+ }
725
+ const e = outcome.exec;
726
+ if (e.timedOut) {
727
+ out.print(t.warning("timed out"));
728
+ return;
729
+ }
730
+ if (e.spawnError !== undefined) {
731
+ out.print(t.danger(e.spawnError));
732
+ return;
733
+ }
734
+ for (const line of `exit code ${e.exitCode ?? e.signal ?? "unknown"}\n${e.output}`.split("\n")) {
735
+ out.print(line);
736
+ }
737
+ }
738
+ catch (err) {
739
+ printCommandError(out, err);
740
+ }
741
+ }
742
+ /**
743
+ * Render a non-fatal error inline (classified + formatted, the same 4-part
744
+ * shape as the fatal boundary) and return to the prompt — a shell must survive
745
+ * a failed command rather than exit.
746
+ */
747
+ export function printCommandError(out, err) {
748
+ const cruxy = fromUnknown(err);
749
+ const text = formatError(cruxy, {
750
+ verbose: isVerbose(),
751
+ color: out.theme.color,
752
+ });
753
+ for (const line of text.split("\n"))
754
+ out.print(line);
755
+ }