@cruxy/cli 1.2.1 → 1.4.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 (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
@@ -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
+ }
@@ -7,8 +7,115 @@ import { fit, fitMiddle } from "./layout.js";
7
7
  * `+`/`-` lines from the exact same code path. Diff add/remove intentionally
8
8
  * reuse the `success`/`danger` hues (green/red), as before.
9
9
  */
10
- /** Cap on rendered preview lines before collapsing the rest. */
10
+ /** Default cap on rendered preview lines before collapsing the rest. A DEFAULT,
11
+ * not a constant of the format: a surface that owns a sized region (the TUI's
12
+ * main column) passes its own cap, so the collapse point matches the space the
13
+ * block will actually be drawn into. */
11
14
  export const PREVIEW_MAX_LINES = 40;
15
+ /** Lines a replacement string contributes: "" → 0, "a\nb" and "a\nb\n" → 2. */
16
+ function lineCount(text) {
17
+ if (text === "")
18
+ return 0;
19
+ const body = text.endsWith("\n") ? text.slice(0, -1) : text;
20
+ return body.split("\n").length;
21
+ }
22
+ /**
23
+ * Whether a replacement covers whole lines, which is what makes its count
24
+ * exact. An `edit_file` swap is an arbitrary substring — usually neither empty
25
+ * nor newline-terminated — so most edits are honestly approximate.
26
+ */
27
+ function lineAligned(text) {
28
+ return text === "" || text.endsWith("\n");
29
+ }
30
+ /** One file's stat from a patch entry. */
31
+ function patchFileStat(file) {
32
+ if (file.op === "delete") {
33
+ // Only a path is recorded, so the removed count is unknowable here.
34
+ return {
35
+ path: file.path,
36
+ op: "delete",
37
+ added: 0,
38
+ removed: null,
39
+ approximate: false,
40
+ };
41
+ }
42
+ if (file.op === "create") {
43
+ return {
44
+ path: file.path,
45
+ op: "create",
46
+ // The pre-capped listing plus what the cap dropped: the whole file.
47
+ added: file.lines.length + file.omittedLines,
48
+ removed: 0,
49
+ approximate: false,
50
+ };
51
+ }
52
+ let added = 0;
53
+ let removed = 0;
54
+ let approximate = false;
55
+ for (const hunk of file.hunks) {
56
+ added += lineCount(hunk.newStr);
57
+ removed += lineCount(hunk.oldStr);
58
+ if (!lineAligned(hunk.oldStr) || !lineAligned(hunk.newStr))
59
+ approximate = true;
60
+ }
61
+ return { path: file.path, op: "update", added, removed, approximate };
62
+ }
63
+ /**
64
+ * Per-file line counts for any preview — the structured form, so a caller can
65
+ * lay out a summary rather than scrape it back out of rendered text.
66
+ *
67
+ * Empty for `pr`, `rollback` and `rollback-set`: the first changes no files
68
+ * here, and the latter two are restores whose own renderers already lead with
69
+ * their blast radius.
70
+ */
71
+ export function previewStats(preview) {
72
+ if (!preview)
73
+ return [];
74
+ if (preview.type === "patch")
75
+ return preview.files.map(patchFileStat);
76
+ if (preview.type === "edit") {
77
+ return [
78
+ {
79
+ path: "",
80
+ op: "update",
81
+ added: lineCount(preview.newStr),
82
+ removed: lineCount(preview.oldStr),
83
+ approximate: !lineAligned(preview.oldStr) || !lineAligned(preview.newStr),
84
+ },
85
+ ];
86
+ }
87
+ if (preview.type === "write") {
88
+ return [
89
+ {
90
+ path: "",
91
+ op: preview.exists ? "update" : "create",
92
+ added: preview.lines.length + preview.omittedLines,
93
+ // Overwriting discards the previous contents, whose length the preview
94
+ // does not record.
95
+ removed: preview.exists ? null : 0,
96
+ approximate: false,
97
+ },
98
+ ];
99
+ }
100
+ return [];
101
+ }
102
+ /**
103
+ * `+12/-3`, `~+1/-1`, `+8` — omitting a side the preview cannot count.
104
+ *
105
+ * Takes only the three fields it reads rather than a whole {@link DiffFileStat},
106
+ * so P7's git-status changes render through this same formatter instead of a
107
+ * second one that would eventually disagree with it about what `null` means.
108
+ */
109
+ export function formatStat(stat, c) {
110
+ const parts = [];
111
+ if (stat.added !== null && stat.added > 0)
112
+ parts.push(c.success(`+${stat.added}`));
113
+ if (stat.removed !== null && stat.removed > 0)
114
+ parts.push(c.danger(`-${stat.removed}`));
115
+ if (parts.length === 0)
116
+ return "";
117
+ return `${stat.approximate ? "~" : ""}${parts.join("/")}`;
118
+ }
12
119
  function diffLines(oldStr, newStr, c) {
13
120
  const removed = oldStr.split("\n").map((l) => c.danger(`- ${l}`));
14
121
  const added = newStr.split("\n").map((l) => c.success(`+ ${l}`));
@@ -22,20 +129,25 @@ function diffLines(oldStr, newStr, c) {
22
129
  function fitPath(path, c, width) {
23
130
  return fitMiddle(path, Math.max(1, width - 10), c.glyph.ellipsis);
24
131
  }
132
+ /** `update src/x.ts +12/-3` — the verb, the path, then the counts (P3). */
133
+ function fileHeader(verb, file, c, width) {
134
+ const stat = formatStat(patchFileStat(file), c);
135
+ return `${verb} ${fitPath(file.path, c, width)}${stat === "" ? "" : ` ${stat}`}`;
136
+ }
25
137
  function renderPatchFiles(files, c, width = Infinity) {
26
138
  const out = [];
27
139
  for (const file of files) {
28
140
  if (file.op === "delete") {
29
- out.push(c.danger(`delete ${fitPath(file.path, c, width)}`));
141
+ out.push(c.danger(fileHeader("delete", file, c, width)));
30
142
  }
31
143
  else if (file.op === "create") {
32
- out.push(c.success(`create ${fitPath(file.path, c, width)}`));
144
+ out.push(c.success(fileHeader("create", file, c, width)));
33
145
  out.push(...file.lines.map((l) => c.success(`+ ${l}`)));
34
146
  if (file.omittedLines > 0)
35
147
  out.push(c.muted(` ...${file.omittedLines} more lines`));
36
148
  }
37
149
  else {
38
- out.push(c.warning(`update ${fitPath(file.path, c, width)}`));
150
+ out.push(c.warning(fileHeader("update", file, c, width)));
39
151
  for (const hunk of file.hunks)
40
152
  out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
41
153
  }
@@ -123,14 +235,25 @@ function bodyLines(body) {
123
235
  /**
124
236
  * Render any {@link ActionPreview} as an indented block: a diff for edits and
125
237
  * patches, a create/overwrite listing for writes, the publish plan for PRs.
126
- * Long previews collapse past {@link PREVIEW_MAX_LINES}.
238
+ *
239
+ * `maxLines` defaults to {@link PREVIEW_MAX_LINES} so existing callers are
240
+ * unchanged, but it is a PARAMETER because the right collapse point belongs to
241
+ * whatever will draw the block — the TUI passes the rows its main column
242
+ * actually has, so a preview no longer collapses at 40 lines in a pane that can
243
+ * show 12 or one that could show 80.
127
244
  */
128
- export function renderActionPreview(preview, c, width = Infinity) {
245
+ export function renderActionPreview(preview, c, width = Infinity, maxLines = PREVIEW_MAX_LINES) {
129
246
  if (!preview)
130
247
  return "";
131
248
  let lines;
132
249
  if (preview.type === "edit") {
133
- lines = diffLines(preview.oldStr, preview.newStr, c);
250
+ // A headline stat, so a one-line swap is legible without counting the
251
+ // diff rows — and `~` marks it as lines touched, not lines gained/lost.
252
+ const stat = formatStat(previewStats(preview)[0], c);
253
+ lines = [
254
+ ...(stat === "" ? [] : [c.muted(`edit ${stat}`)]),
255
+ ...diffLines(preview.oldStr, preview.newStr, c),
256
+ ];
134
257
  }
135
258
  else if (preview.type === "patch") {
136
259
  lines = renderPatchFiles(preview.files, c, width);
@@ -145,19 +268,25 @@ export function renderActionPreview(preview, c, width = Infinity) {
145
268
  lines = renderRollbackSetPreview(preview, c);
146
269
  }
147
270
  else {
148
- const header = preview.exists
271
+ const stat = formatStat(previewStats(preview)[0], c);
272
+ const label = preview.exists
149
273
  ? c.warning("OVERWRITE existing")
150
274
  : c.success("create");
275
+ const header = stat === "" ? label : `${label} ${stat}`;
151
276
  const body = preview.lines.map((l) => ` ${l}`);
152
277
  if (preview.omittedLines > 0)
153
278
  body.push(c.muted(` ...${preview.omittedLines} more lines`));
154
279
  lines = [header, ...body];
155
280
  }
156
- if (lines.length > PREVIEW_MAX_LINES) {
157
- const hidden = lines.length - PREVIEW_MAX_LINES;
281
+ // Collapse against the CALLER's budget, marker INCLUDED — a block handed a
282
+ // pane's height must fit that pane. With only one row to spend, the marker
283
+ // wins: "...60 more" is the honest answer, where a single arbitrary diff row
284
+ // would read as the whole change.
285
+ if (Number.isFinite(maxLines) && lines.length > maxLines) {
286
+ const kept = Math.max(0, maxLines - 1);
158
287
  lines = [
159
- ...lines.slice(0, PREVIEW_MAX_LINES),
160
- c.muted(`...${hidden} more`),
288
+ ...lines.slice(0, kept),
289
+ c.muted(`...${lines.length - kept} more`),
161
290
  ];
162
291
  }
163
292
  // Horizontal fit at the single choke point (U.12): every content line is
@@ -169,3 +298,66 @@ export function renderActionPreview(preview, c, width = Infinity) {
169
298
  const room = Math.max(1, width - 2);
170
299
  return lines.map((l) => ` ${fit(l, room, c.glyph.ellipsis)}`).join("\n");
171
300
  }
301
+ /**
302
+ * Colour a UNIFIED diff — git's own output — for `/diff` (P6 track 4).
303
+ *
304
+ * A second diff renderer would be exactly what this module's header rules out,
305
+ * so it lives here beside the others and reuses their palette: add/remove take
306
+ * the same `success`/`danger` hues an `ActionPreview` does, so a change looks
307
+ * the same whether you saw it in an approval prompt or asked for it afterwards.
308
+ *
309
+ * What differs is the INPUT. Everything else here renders a structured
310
+ * `ActionPreview` cruxy built itself; this takes text git produced, which means
311
+ * the file/hunk structure arrives as line prefixes rather than as fields. So the
312
+ * rule is deliberately narrow — style by leading marker, change nothing else:
313
+ *
314
+ * - `+++` / `---` before `+` / `-`, or every file header reads as a hunk;
315
+ * - `` is git's own note, not a removal;
316
+ * - the body is passed through verbatim. No re-wrapping, no re-indenting, no
317
+ * tab expansion. A diff whose whitespace has been "tidied" is a diff you
318
+ * cannot trust to show you a whitespace bug.
319
+ *
320
+ * Truncation is from the TOP of the tail, not the bottom: the marker says how
321
+ * many lines were dropped, and the lines kept are the FIRST ones, because a diff
322
+ * read from the middle is worse than a diff read from the start and stopped.
323
+ */
324
+ export function renderUnifiedDiff(diff, c, opts = {}) {
325
+ const maxLines = opts.maxLines ?? PREVIEW_MAX_LINES;
326
+ const width = opts.width ?? Infinity;
327
+ const body = diff.replace(/\n+$/, "");
328
+ // `"".split("\n")` is `[""]`, not `[]` — an empty diff would otherwise render
329
+ // as one blank row. The caller guards this today; the renderer should not
330
+ // depend on that.
331
+ if (body === "")
332
+ return [];
333
+ const raw = body.split("\n");
334
+ const truncated = raw.length > maxLines;
335
+ // Reserve the marker's own row, so the block fits the budget it was given.
336
+ const kept = truncated ? raw.slice(0, Math.max(0, maxLines - 1)) : raw;
337
+ const styled = kept.map((line) => {
338
+ if (line.startsWith("diff --git") || line.startsWith("index ")) {
339
+ return c.strong(line);
340
+ }
341
+ // Before the +/- tests: a `+++ b/file` header is a file marker, not an
342
+ // addition, and colouring it green makes every header read as one.
343
+ if (line.startsWith("+++") || line.startsWith("---"))
344
+ return c.muted(line);
345
+ if (line.startsWith("@@"))
346
+ return c.accent(line);
347
+ // git's own note about a missing trailing newline. It starts with a
348
+ // backslash, but it is prose about the diff rather than part of it.
349
+ if (line.startsWith("\\"))
350
+ return c.muted(line);
351
+ if (line.startsWith("+"))
352
+ return c.success(line);
353
+ if (line.startsWith("-"))
354
+ return c.danger(line);
355
+ return line;
356
+ });
357
+ if (truncated) {
358
+ styled.push(c.muted(`...${raw.length - kept.length} more diff lines`));
359
+ }
360
+ return Number.isFinite(width)
361
+ ? styled.map((l) => fit(l, width, c.glyph.ellipsis))
362
+ : styled;
363
+ }