@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
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { PROJECT_INSTRUCTION_FILENAMES } from "../constants.js";
4
4
  /** Cap on instruction file size; the rest is dropped with a notice. */
@@ -34,3 +34,55 @@ export function loadProjectInstructions(cwd) {
34
34
  }
35
35
  return null;
36
36
  }
37
+ /**
38
+ * The starting `CRUXY.md` (P6 track 4).
39
+ *
40
+ * Deliberately a SKELETON with prompts rather than filled-in guesses. cruxy
41
+ * cannot know this project's conventions at scaffold time, and a template that
42
+ * asserted some would be injected into every subsequent turn's system prompt as
43
+ * though the user had written it — instructions the agent follows and nobody
44
+ * chose. Empty headings ask; invented content misleads.
45
+ */
46
+ export const PROJECT_INSTRUCTIONS_TEMPLATE = `# Project instructions for cruxy
47
+
48
+ These notes are loaded into cruxy's context on every run. Keep them short and
49
+ high-signal — conventions, where things live, how to build and test.
50
+
51
+ ## Conventions
52
+
53
+ - (e.g. language, style, naming rules the agent should follow)
54
+
55
+ ## Build & test
56
+
57
+ - (e.g. how to install deps, run the app, run the test suite)
58
+
59
+ ## Gotchas
60
+
61
+ - (e.g. anything non-obvious about this codebase)
62
+ `;
63
+ /**
64
+ * Write the starter `CRUXY.md` into `cwd`, unless project instructions already
65
+ * exist there.
66
+ *
67
+ * NEVER OVERWRITES, and the check is {@link loadProjectInstructions} rather than
68
+ * a bare `existsSync("CRUXY.md")` — a project whose instructions live in
69
+ * `AGENTS.md` already has them, and scaffolding a second file beside it would
70
+ * create two sources for one thing where the loader honours only the first.
71
+ *
72
+ * Extracted from the onboarding scaffold step (P6 track 4) so `/init` and
73
+ * `cruxy init` write the same file. The step's own y/N prompt and IO stay in
74
+ * `onboarding/steps.ts`, which is the part that genuinely differs between an
75
+ * onboarding flow and a slash command.
76
+ */
77
+ export function scaffoldProjectInstructions(cwd) {
78
+ if (loadProjectInstructions(cwd) !== null)
79
+ return { kind: "exists" };
80
+ const file = join(cwd, PROJECT_INSTRUCTION_FILENAMES[0]);
81
+ try {
82
+ writeFileSync(file, PROJECT_INSTRUCTIONS_TEMPLATE, "utf8");
83
+ }
84
+ catch (err) {
85
+ return { kind: "failed", message: err.message };
86
+ }
87
+ return { kind: "written", file };
88
+ }
@@ -49,8 +49,14 @@ export const AgentConfigSchema = z
49
49
  * cumulative across turns.
50
50
  */
51
51
  maxTokensPerTurn: z.number().int().nonnegative().default(0),
52
- /** Skip per-action confirmation prompts. */
53
- autoApprove: z.boolean().default(false),
52
+ // `autoApprove` used to sit here (P5 track 3 removed it). It had ZERO
53
+ // consumers — nothing in the codebase ever read it — while promising to
54
+ // "skip per-action confirmation prompts", and `ApprovalConfigSchema` below
55
+ // stated in the same file that no such mode exists. A flag on disk also
56
+ // disarms every approval in every session that loads it, with nothing on
57
+ // screen to say so. Auto-approve is now a runtime SESSION MODE
58
+ // (`agent/mode.ts`): chosen in the session it affects, shown while active,
59
+ // gone when the session ends.
54
60
  /** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
55
61
  planMode: z.boolean().default(false),
56
62
  })
@@ -120,9 +126,13 @@ export const ContextConfigSchema = z
120
126
  .strict();
121
127
  /**
122
128
  * How tool-action approval is resolved. Only `prompt` exists: ask interactively
123
- * and **deny by default** when non-interactive. There is deliberately no
124
- * auto-approve / skip mode — the policy seam for a future CI mode lives in
125
- * `src/approval` (see U.3), not behind a footgun flag.
129
+ * and **deny by default** when non-interactive.
130
+ *
131
+ * There is still deliberately no auto-approve mode HERE, and that is the point
132
+ * this schema has always made: not that unattended execution is forbidden, but
133
+ * that it must not be armed from a file. It is a runtime session mode
134
+ * (`agent/mode.ts`) reached through the policy seam in `src/approval` — chosen
135
+ * in the session it affects and visible the whole time it is on.
126
136
  */
127
137
  export const ApprovalConfigSchema = z
128
138
  .object({
@@ -436,7 +446,13 @@ export const LspConfigSchema = z
436
446
  maxResults: z.number().int().positive().default(100),
437
447
  })
438
448
  .strict();
439
- /** A per-tier price, in the user's own currency, PER MILLION TOKENS (C.22). */
449
+ /**
450
+ * RETIRED (C.22): a per-tier price, in the user's own currency, PER MILLION
451
+ * TOKENS. Nothing reads it. Kept only so `usage.prices` still VALIDATES — see
452
+ * {@link UsageConfigSchema}.
453
+ *
454
+ * @deprecated cost comes from the gateway now; delete `usage.prices` from config.
455
+ */
440
456
  export const TierPriceSchema = z
441
457
  .object({
442
458
  /** Price per 1,000,000 input tokens. */
@@ -447,22 +463,39 @@ export const TierPriceSchema = z
447
463
  .strict();
448
464
  /**
449
465
  * Usage telemetry + cost tracking (C.22): LOCAL usage accounting only — nothing
450
- * here is ever transmitted. Costs are opt-in and honest: prices are per-TIER and
451
- * per-million-tokens, configured by the user; with none set, tokens are shown
452
- * and cost is omitted (cruxy ships no bundled price table). The `prices` keys are
453
- * the fixed {@link MODEL_TIERS}, so an upstream model name can never appear here
454
- * (U.8 gag holds by construction) and a mistyped tier is rejected at config load.
466
+ * here is ever transmitted.
467
+ *
468
+ * `currency` and `prices` are RETIRED and read by nothing. They modelled a
469
+ * per-million-token cost basis that no cruxy subscriber is billed on: the
470
+ * gateway meters a weighted token pool and quotes its own cost per request, and
471
+ * `cruxy usage` now shows both of those instead of arithmetic over a number the
472
+ * user typed in.
473
+ *
474
+ * They are still ACCEPTED, and deliberately so. This schema is `.strict()` and
475
+ * `loadConfig` turns any parse failure into a hard error, so deleting the keys
476
+ * would mean every user who had set them gets a CLI that refuses to start until
477
+ * they hand-edit a file — a broken binary as the punishment for having used a
478
+ * documented feature. Tolerating a dead key costs nothing; rejecting it costs
479
+ * the whole tool. `cruxy usage` tells anyone who still has them set that they no
480
+ * longer do anything, which is the part that actually needed saying.
481
+ *
482
+ * Both are now `.optional()` with NO default, so a freshly-initialized config
483
+ * doesn't write retired keys into a new file — absent is the new normal, present
484
+ * is the tolerated legacy.
455
485
  */
456
486
  export const UsageConfigSchema = z
457
487
  .object({
458
488
  /** Master switch. When false, no usage is collected, persisted, or shown. */
459
489
  enabled: z.boolean().default(true),
460
- /** Currency label to prefix costs with (e.g. "$", "€"). Empty ⇒ no symbol is
461
- * assumed a bare number is shown, since the user configured the prices. */
462
- currency: z.string().default(""),
490
+ /**
491
+ * @deprecated retired, unread. Cost is shown in the gateway's own currency.
492
+ */
493
+ currency: z.string().optional(),
463
494
  /** How many past runs to keep in the store; older ones are pruned oldest-first. */
464
495
  retention: z.number().int().positive().default(50),
465
- /** Per-tier prices. Any tier omitted is simply unpriced (cost omitted for it). */
496
+ /**
497
+ * @deprecated retired, unread. Cost comes from the gateway per request.
498
+ */
466
499
  prices: z
467
500
  .object({
468
501
  kavi: TierPriceSchema.optional(),
@@ -470,7 +503,7 @@ export const UsageConfigSchema = z
470
503
  mira: TierPriceSchema.optional(),
471
504
  })
472
505
  .strict()
473
- .default({}),
506
+ .optional(),
474
507
  })
475
508
  .strict();
476
509
  /**
@@ -1,4 +1,5 @@
1
1
  import { themeForColor } from "../theme/index.js";
2
+ import { changedSteps } from "../render/plan-view.js";
2
3
  /** Non-terminal capabilities: a background job renders to NOTHING on screen. */
3
4
  const OFFSCREEN_CAPS = {
4
5
  tty: false,
@@ -8,7 +9,12 @@ const OFFSCREEN_CAPS = {
8
9
  reducedMotion: true,
9
10
  screenReader: false,
10
11
  unicode: true,
12
+ // No stdin and no screen: a job is never interactive and never repaints, so
13
+ // both input-axis flags are false regardless of what the foreground has.
14
+ stdinTty: false,
15
+ interactive: false,
11
16
  width: 80,
17
+ height: 24,
12
18
  };
13
19
  /**
14
20
  * A {@link StreamRenderer} for a background job (C.28) that captures activity into
@@ -26,6 +32,8 @@ export class JobLogRenderer {
26
32
  caps = OFFSCREEN_CAPS;
27
33
  theme = themeForColor(false);
28
34
  pending = "";
35
+ /** Last plan snapshot logged, so `setPlan` records only what changed. */
36
+ planSteps = [];
29
37
  constructor(sink) {
30
38
  this.sink = sink;
31
39
  }
@@ -51,6 +59,14 @@ export class JobLogRenderer {
51
59
  note(text) {
52
60
  this.sink("out", text);
53
61
  }
62
+ /**
63
+ * Ignored: a job log is an append-only transcript, and a served tier is
64
+ * standing state rather than an event. The run's tier is recorded by the
65
+ * usage store, which is where a job's accounting is read from.
66
+ */
67
+ servedRouting() {
68
+ // no-op
69
+ }
54
70
  toolLifecycle(event) {
55
71
  if (event.event !== "end")
56
72
  return; // only the committed outcome is log-worthy
@@ -60,6 +76,37 @@ export class JobLogRenderer {
60
76
  endTurn() {
61
77
  this.endSegment();
62
78
  }
79
+ /**
80
+ * Plan steps are committed outcomes, so a background job's log records them —
81
+ * one line per step whose status actually changed (the whole list arrives on
82
+ * every transition). Status is a word, not a glyph, matching this log's
83
+ * `[ok]`/`[fail]` style: nothing here is a terminal, so a themed mark would
84
+ * only add bytes `cruxy logs` has to strip.
85
+ */
86
+ setPlan(steps) {
87
+ if (steps === null) {
88
+ this.planSteps = [];
89
+ return;
90
+ }
91
+ for (const step of changedSteps(this.planSteps, steps)) {
92
+ this.sink(step.status === "failed" ? "err" : "out", `[${step.status}] ${step.id}. ${step.title}`);
93
+ }
94
+ this.planSteps = steps.map((s) => ({ ...s }));
95
+ }
96
+ /**
97
+ * A job's test run is exactly the kind of outcome `cruxy logs` exists to
98
+ * show. Plain text, no theme glyphs, matching this log's `[ok]`/`[fail]`
99
+ * style — and no count this renderer was not given.
100
+ */
101
+ testResult(report) {
102
+ const counted = report.total !== undefined
103
+ ? ` ${report.failures.length}/${report.total} failed`
104
+ : "";
105
+ this.sink(report.passed ? "out" : "err", `[${report.passed ? "tests ok" : "tests failed"}]${report.passed ? "" : counted} ${report.command} (${Math.round(report.durationMs)}ms)`);
106
+ for (const f of report.failures) {
107
+ this.sink("err", ` ${f.name}${f.file === undefined ? "" : ` ${f.file}${f.line === undefined ? "" : `:${f.line}`}`}`);
108
+ }
109
+ }
63
110
  // Transient decor / previews have no place in an append-only job log.
64
111
  preview() { }
65
112
  status() { }
@@ -1,8 +1,6 @@
1
- import { writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { themeForColor } from "../theme/index.js";
4
2
  import { CREATE_KEY_URL } from "../constants.js";
5
- import { loadProjectInstructions } from "../config/index.js";
3
+ import { loadProjectInstructions, scaffoldProjectInstructions, } from "../config/index.js";
6
4
  /**
7
5
  * The individual onboarding steps (U.6). Each returns a {@link StepResult} and
8
6
  * never throws across its boundary; the secret is read masked and is never echoed
@@ -45,26 +43,14 @@ export async function acquireKeyStep(io, deps, provider) {
45
43
  }
46
44
  return { status: "failed", message: "key rejected after 3 attempts" };
47
45
  }
48
- const CRUXY_MD_TEMPLATE = `# Project instructions for cruxy
49
-
50
- These notes are loaded into cruxy's context on every run. Keep them short and
51
- high-signal — conventions, where things live, how to build and test.
52
-
53
- ## Conventions
54
-
55
- - (e.g. language, style, naming rules the agent should follow)
56
-
57
- ## Build & test
58
-
59
- - (e.g. how to install deps, run the app, run the test suite)
60
-
61
- ## Gotchas
62
-
63
- - (e.g. anything non-obvious about this codebase)
64
- `;
65
46
  /**
66
47
  * Offer to scaffold a project `CRUXY.md`. Skipped silently when one already
67
48
  * exists (or `AGENTS.md`); otherwise a `y` confirmation writes the template.
49
+ *
50
+ * The template and the write moved to `config/project.ts` (P6 track 4) so
51
+ * `/init` writes the same file. What stays here is the part that genuinely
52
+ * differs between an onboarding flow and a slash command: the y/N prompt and
53
+ * the IO it is drawn on.
68
54
  */
69
55
  export async function scaffoldStep(io, cwd) {
70
56
  const col = c(io);
@@ -76,8 +62,13 @@ export async function scaffoldStep(io, cwd) {
76
62
  io.write("\n");
77
63
  if (key !== "y")
78
64
  return { status: "skipped" };
79
- const file = join(cwd, "CRUXY.md");
80
- writeFileSync(file, CRUXY_MD_TEMPLATE, "utf8");
65
+ const outcome = scaffoldProjectInstructions(cwd);
66
+ if (outcome.kind === "failed") {
67
+ // Previously this threw out of the step on an unwritable directory. A step
68
+ // must not throw across its boundary, so a failed write is reported.
69
+ io.write(`${col.danger(col.glyph.failure)} could not write CRUXY.md — ${outcome.message}\n`);
70
+ return { status: "failed", message: outcome.message };
71
+ }
81
72
  io.write(`${col.success(col.glyph.success)} wrote ${col.strong("CRUXY.md")}\n`);
82
73
  return { status: "ok" };
83
74
  }
@@ -10,27 +10,33 @@ import { renderPlan } from "./render.js";
10
10
  */
11
11
  export async function promptPlanDecision(plan, io) {
12
12
  const t = themeForColor(io.color);
13
- io.write(renderPlan(plan, io.color));
14
- io.write("\n\n");
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
- const key = (await io.readKey()).toLowerCase();
18
- io.write("\n");
19
- switch (key) {
20
- case "a":
21
- return { kind: "approve" };
22
- case "g":
23
- return { kind: "approve-grant" };
24
- case "e":
25
- case "n": {
26
- io.write(" what should change about the plan? ");
27
- const feedback = (await io.readLine()).trim();
28
- // No feedback treat as an abort, not an empty revision request.
29
- return feedback ? { kind: "revise", feedback } : { kind: "abort" };
13
+ io.beginPrompt?.();
14
+ try {
15
+ io.write(renderPlan(plan, io.color));
16
+ io.write("\n\n");
17
+ io.write(` ${t.muted("Approving consents to the shape of the work — every action still asks before it runs.")}\n`);
18
+ 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`)} `);
19
+ const key = (await io.readKey()).toLowerCase();
20
+ io.write("\n");
21
+ switch (key) {
22
+ case "a":
23
+ return { kind: "approve" };
24
+ case "g":
25
+ return { kind: "approve-grant" };
26
+ case "e":
27
+ case "n": {
28
+ io.write(" what should change about the plan? ");
29
+ const feedback = (await io.readLine()).trim();
30
+ // No feedback ⇒ treat as an abort, not an empty revision request.
31
+ return feedback ? { kind: "revise", feedback } : { kind: "abort" };
32
+ }
33
+ default:
34
+ // Unrecognized key, empty, EOF, Ctrl-C → default-deny.
35
+ return { kind: "abort" };
30
36
  }
31
- default:
32
- // Unrecognized key, empty, EOF, Ctrl-C → default-deny.
33
- return { kind: "abort" };
37
+ }
38
+ finally {
39
+ io.endPrompt?.();
34
40
  }
35
41
  }
36
42
  /**
@@ -39,8 +45,14 @@ export async function promptPlanDecision(plan, io) {
39
45
  */
40
46
  export async function promptContinueAfterFailure(io) {
41
47
  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
- const key = (await io.readKey()).toLowerCase();
44
- io.write("\n");
45
- return key === "c";
48
+ io.beginPrompt?.();
49
+ try {
50
+ io.write(` ${t.danger("step failed.")} ${t.muted(`[c] continue with the remaining steps ${t.glyph.sep} [any other key] abort`)} `);
51
+ const key = (await io.readKey()).toLowerCase();
52
+ io.write("\n");
53
+ return key === "c";
54
+ }
55
+ finally {
56
+ io.endPrompt?.();
57
+ }
46
58
  }
@@ -1,6 +1,5 @@
1
1
  import { CruxyError } from "../errors/index.js";
2
2
  import { promptContinueAfterFailure } from "./approve.js";
3
- import { renderStepStatus } from "./render.js";
4
3
  export async function executePlan(plan, deps) {
5
4
  const { runStep, io, renderer } = deps;
6
5
  try {
@@ -13,20 +12,20 @@ export async function executePlan(plan, deps) {
13
12
  title: step.title,
14
13
  });
15
14
  renderer?.setPhase({ kind: "executing-step" });
16
- io.write(renderStepStatus(step, io.color) + "\n");
15
+ renderer?.setPlan(plan.steps);
17
16
  try {
18
17
  await runStep(step);
19
18
  step.status = "done";
20
- io.write(renderStepStatus(step, io.color) + "\n");
19
+ renderer?.setPlan(plan.steps);
21
20
  }
22
21
  catch (err) {
23
22
  step.status = "failed";
24
- io.write(renderStepStatus(step, io.color) + "\n");
23
+ renderer?.setPlan(plan.steps);
25
24
  // Surface the failure via the U.5 shape when we have it.
26
25
  const detail = err instanceof CruxyError
27
26
  ? `${err.title}${err.cause ? ` — ${err.cause}` : ""}`
28
27
  : err.message;
29
- io.write(` ${detail}\n`);
28
+ renderer?.note(` ${detail}`);
30
29
  const cont = await promptContinueAfterFailure(io);
31
30
  if (!cont) {
32
31
  return { completed: false, halted: true, failedStepId: step.id };
@@ -38,8 +37,11 @@ export async function executePlan(plan, deps) {
38
37
  return { completed, halted: false };
39
38
  }
40
39
  finally {
41
- // The plan owns the progress register; release it on every exit path so
42
- // no stale "[i/n]" prefix outlives the run.
40
+ // The plan owns both registers; release them on every exit path so no stale
41
+ // "[i/n]" prefix and no stale checklist outlives the run. A renderer that
42
+ // keeps its checklist live rather than committing each transition (the TUI)
43
+ // commits one final copy as it clears, so the record survives the release.
43
44
  renderer?.progress(null);
45
+ renderer?.setPlan(null);
44
46
  }
45
47
  }
@@ -1,25 +1,17 @@
1
1
  import { themeForColor } from "../theme/index.js";
2
+ import { statusMark } from "../render/index.js";
2
3
  /**
3
4
  * 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.
5
+ * boolean (NO_COLOR / non-TTY aware, passed in by the caller). This is the
6
+ * APPROVAL view the whole plan, with each step's risk tag and rationale,
7
+ * shown once before execution. Per-step status DURING execution is the
8
+ * renderer's `setPlan` surface (`render/plan-view.ts`), which shares the status
9
+ * glyph below so the two views mark a step identically.
10
+ *
11
+ * Glyphs and colors are sourced from the theme (U.1); status is carried by the
12
+ * glyph (○/◐/✓/✗) and risk by the `[read]/[mutate]/[destructive]` label, so
13
+ * meaning survives NO_COLOR.
9
14
  */
10
- /** Status glyph, colored when enabled. */
11
- function statusMark(status, t) {
12
- switch (status) {
13
- case "pending":
14
- return t.muted(t.glyph.pending);
15
- case "running":
16
- return t.accent(t.glyph.running);
17
- case "done":
18
- return t.success(t.glyph.success);
19
- case "failed":
20
- return t.danger(t.glyph.failure);
21
- }
22
- }
23
15
  /** A short tag for the step's risk estimate. */
24
16
  function kindTag(kind, t) {
25
17
  switch (kind) {
@@ -48,8 +40,3 @@ export function renderPlan(plan, color) {
48
40
  lines.push(renderStep(step, t));
49
41
  return lines.join("\n");
50
42
  }
51
- /** A one-line status update for a single step (used during execution). */
52
- export function renderStepStatus(step, color) {
53
- const t = themeForColor(color);
54
- return `${statusMark(step.status, t)} ${t.strong(step.id + ".")} ${step.title}`;
55
- }
@@ -86,7 +86,10 @@ export async function runPlanSession(args) {
86
86
  const plan = holder.plan;
87
87
  const decision = await promptPlanDecision(plan, args.io);
88
88
  if (decision.kind === "abort") {
89
- args.io.write("plan aborted nothing was executed.\n");
89
+ // Committed output goes through the renderer, not the prompt io (P3):
90
+ // the io is for asking, and a plain write on it bypasses the live region
91
+ // that owns the screen.
92
+ args.renderer?.note("plan aborted — nothing was executed.");
90
93
  return finish();
91
94
  }
92
95
  if (decision.kind !== "revise") {
@@ -6,6 +6,8 @@ function isSet(value) {
6
6
  }
7
7
  /** Fallback width when the terminal reports none (non-TTY, pipe, unknown). */
8
8
  export const DEFAULT_COLUMNS = 80;
9
+ /** Fallback height when the terminal reports none (non-TTY, pipe, unknown). */
10
+ export const DEFAULT_ROWS = 24;
9
11
  /**
10
12
  * Resolve the terminal width (U.12) — the single rule behind
11
13
  * {@link RenderCapabilities.width} and every resize recompute. `COLUMNS` wins
@@ -22,6 +24,20 @@ export function resolveColumns(stream = process.stdout, env = process.env) {
22
24
  return stream.columns;
23
25
  return DEFAULT_COLUMNS;
24
26
  }
27
+ /**
28
+ * Resolve the terminal height — the vertical twin of {@link resolveColumns},
29
+ * with the identical precedence rule: `LINES` wins when set (so `LINES=40 cruxy`
30
+ * and CI overrides work), then the stream's own `rows`, then
31
+ * {@link DEFAULT_ROWS}. Never returns a non-positive height.
32
+ */
33
+ export function resolveRows(stream = process.stdout, env = process.env) {
34
+ const fromEnv = env.LINES === undefined ? NaN : Number.parseInt(env.LINES, 10);
35
+ if (Number.isFinite(fromEnv) && fromEnv > 0)
36
+ return fromEnv;
37
+ if (typeof stream.rows === "number" && stream.rows > 0)
38
+ return stream.rows;
39
+ return DEFAULT_ROWS;
40
+ }
25
41
  /**
26
42
  * Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
27
43
  * knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
@@ -44,12 +60,19 @@ export function detectReducedMotion(env = process.env) {
44
60
  * The axes are independent (U.1/U.11): a NO_COLOR terminal still supports
45
61
  * in-place status updates; a dumb terminal supports neither; reduced motion and
46
62
  * screen-reader mode compose orthogonally with color and unicode.
63
+ *
64
+ * `stdin` is probed separately from `stream` because they are genuinely
65
+ * different channels: `echo hi | cruxy` has a piped stdin and a TTY stdout, and
66
+ * `cruxy > log` the reverse. Resolving both here — and their conjunction as
67
+ * `interactive` — is what lets every call site stop reading
68
+ * `process.stdin.isTTY` for itself.
47
69
  */
48
- export function detectCapabilities(stream = process.stdout, env = process.env) {
70
+ export function detectCapabilities(stream = process.stdout, env = process.env, stdin = process.stdin) {
49
71
  const tty = Boolean(stream.isTTY);
50
72
  const dumb = env.TERM === "dumb";
51
73
  const cursor = tty && !dumb;
52
74
  const reducedMotion = detectReducedMotion(env);
75
+ const stdinTty = Boolean(stdin.isTTY);
53
76
  return {
54
77
  tty,
55
78
  color: shouldUseColor(stream, env) && !dumb,
@@ -62,6 +85,12 @@ export function detectCapabilities(stream = process.stdout, env = process.env) {
62
85
  // Unicode glyph safety (U.1) — independent of color. dumb / CRUXY_ASCII →
63
86
  // ASCII glyphs; everything else (incl. pipes) keeps unicode.
64
87
  unicode: detectUnicode(env),
88
+ stdinTty,
89
+ // The one interactivity rule, resolved once: a key read needs a terminal to
90
+ // read FROM and a terminal that can be repainted. Either half missing and
91
+ // no component may draw a frame or block on a key.
92
+ interactive: stdinTty && cursor,
65
93
  width: resolveColumns(stream, env),
94
+ height: resolveRows(stream, env),
66
95
  };
67
96
  }
@@ -0,0 +1,106 @@
1
+ import { formatTokens } from "./state.js";
2
+ import { fit } from "./layout.js";
3
+ /**
4
+ * Rendering `/context` (P6 track 3) — the DETAIL VIEW behind the rail's two
5
+ * numbers, not a second copy of them.
6
+ *
7
+ * THE PANEL'S RULES STILL APPLY HERE, and nothing below may weaken them. The
8
+ * context panel's doc comment is explicit that two plain numbers are the honest
9
+ * presentation and that a progress bar would not be: a filled bar reads as a
10
+ * measurement, and this is a chars/4 heuristic measured against a local config
11
+ * default. So there is no bar here either, no percentage-of-window, and every
12
+ * figure carries a `~`. "budget", never "window".
13
+ *
14
+ * WHAT THIS ADDS is the two things that genuinely cannot live in a 24-column
15
+ * strip without implying a precision they do not have:
16
+ *
17
+ * 1. **Where the tokens are.** A breakdown by what the content IS — prompts,
18
+ * assistant text, tool calls, tool results, and summaries a previous
19
+ * compaction already folded in — plus the individual messages large enough
20
+ * to be worth naming. "60% of your context is one grep result" is
21
+ * actionable; a single occupancy figure is not.
22
+ * 2. **What compaction would actually drop.** Computed by the SAME `findCut`
23
+ * the seam uses, so the explanation cannot be subtly wrong exactly where it
24
+ * matters. Including the case that surprises people: a history with no safe
25
+ * cut point, where `/compact` will do nothing at all.
26
+ *
27
+ * The reserve is shown as its own row rather than folded into a total. It is
28
+ * part of `used`, it is present in no message, and a breakdown that omitted it
29
+ * would leave several thousand tokens looking unexplained.
30
+ */
31
+ /** `~34k`, `~900` — the leading tilde is not decoration. See the module note. */
32
+ function approx(n) {
33
+ return `~${formatTokens(n)}`;
34
+ }
35
+ /** `12%` — a share of the measured history, never of the model's real window. */
36
+ function share(tokens, of) {
37
+ if (of <= 0)
38
+ return "";
39
+ return `${Math.round((tokens / of) * 100)}%`;
40
+ }
41
+ /**
42
+ * The full `/context` report as lines to print.
43
+ *
44
+ * `width` fits each row so a narrow terminal truncates rather than soft-wrapping
45
+ * a table into a misaligned mess; the default is unbounded for callers that
46
+ * reflow themselves (the TUI's main column).
47
+ */
48
+ export function contextReportLines(report, t, width = Infinity) {
49
+ const { reading, compaction } = report;
50
+ const lines = [t.heading("context")];
51
+ // The headline, worded exactly as the panel words it — same estimate, same
52
+ // caveats, so the detail view can never read as the more authoritative one.
53
+ lines.push(`${t.strong(`${approx(reading.used)} / ${formatTokens(reading.total)} budget`)} ` +
54
+ t.muted(`(estimated · budget is a local setting, not the model's window)`));
55
+ lines.push(t.muted(`${report.messages} message${report.messages === 1 ? "" : "s"} · compacts above ${approx(reading.compactAt)}`));
56
+ // ── where the tokens are ──────────────────────────────────────────────────
57
+ const historyTokens = report.parts.reduce((sum, p) => sum + p.tokens, 0);
58
+ lines.push("");
59
+ lines.push(t.strong("where it is"));
60
+ if (report.parts.length === 0) {
61
+ lines.push(t.muted(" no conversation yet"));
62
+ }
63
+ else {
64
+ for (const part of report.parts) {
65
+ lines.push(` ${part.part.padEnd(13)} ${approx(part.tokens).padStart(7)} ` +
66
+ t.muted(`${share(part.tokens, historyTokens).padStart(4)} ${part.messages} msg`));
67
+ }
68
+ }
69
+ // Named separately because it is real, unavoidable, and in no message — the
70
+ // one part of the figure a user cannot shrink by pruning the conversation.
71
+ lines.push(` ${"reserve".padEnd(13)} ${approx(report.reserveTokens).padStart(7)} ` +
72
+ t.muted(" system prompt + tool schemas"));
73
+ // ── the biggest single messages ───────────────────────────────────────────
74
+ if (report.largest.length > 0) {
75
+ lines.push("");
76
+ lines.push(t.strong("largest messages"));
77
+ for (const c of report.largest) {
78
+ const head = ` ${String(c.position).padStart(3)}. ${approx(c.tokens).padStart(7)} ${c.label}`;
79
+ lines.push(`${head}${c.excerpt === "" ? "" : t.muted(` — ${c.excerpt}`)}`);
80
+ }
81
+ }
82
+ // ── what compaction would do ──────────────────────────────────────────────
83
+ lines.push("");
84
+ lines.push(t.strong("if you compact now"));
85
+ if (compaction.cut === null) {
86
+ // The case that surprises people, and the reason this section exists at all.
87
+ lines.push(t.muted(" nothing — there is no safe cut point yet. A summary can only replace"));
88
+ lines.push(t.muted(" whole completed turns, so an in-progress turn (or a history with no"));
89
+ lines.push(t.muted(" finished turn behind it) has nothing it can fold away."));
90
+ }
91
+ else {
92
+ lines.push(t.muted(` ${compaction.droppedMessages} message${compaction.droppedMessages === 1 ? "" : "s"} ` +
93
+ `(${approx(compaction.droppedTokens)}) folded into a summary; ` +
94
+ `${compaction.keptMessages} kept verbatim (${approx(compaction.keptTokens)})`));
95
+ // A ceiling, not a saving: the summary that replaces the prefix costs
96
+ // tokens of its own, and how many is not knowable until the model writes it.
97
+ lines.push(t.muted(` frees at most ${approx(compaction.droppedTokens)} — the summary that replaces them costs`));
98
+ lines.push(t.muted(" tokens of its own, which cannot be known in advance."));
99
+ }
100
+ lines.push(compaction.overThreshold
101
+ ? t.warning(" the next turn will compact on its own")
102
+ : t.muted(" below the threshold — the next turn will not compact"));
103
+ return Number.isFinite(width)
104
+ ? lines.map((l) => fit(l, width, t.glyph.ellipsis))
105
+ : lines;
106
+ }