@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
@@ -7,8 +7,109 @@ 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
+ /** `+12/-3`, `~+1/-1`, `+8` — omitting a side the preview cannot count. */
103
+ export function formatStat(stat, c) {
104
+ const parts = [];
105
+ if (stat.added !== null && stat.added > 0)
106
+ parts.push(c.success(`+${stat.added}`));
107
+ if (stat.removed !== null && stat.removed > 0)
108
+ parts.push(c.danger(`-${stat.removed}`));
109
+ if (parts.length === 0)
110
+ return "";
111
+ return `${stat.approximate ? "~" : ""}${parts.join("/")}`;
112
+ }
12
113
  function diffLines(oldStr, newStr, c) {
13
114
  const removed = oldStr.split("\n").map((l) => c.danger(`- ${l}`));
14
115
  const added = newStr.split("\n").map((l) => c.success(`+ ${l}`));
@@ -22,20 +123,25 @@ function diffLines(oldStr, newStr, c) {
22
123
  function fitPath(path, c, width) {
23
124
  return fitMiddle(path, Math.max(1, width - 10), c.glyph.ellipsis);
24
125
  }
126
+ /** `update src/x.ts +12/-3` — the verb, the path, then the counts (P3). */
127
+ function fileHeader(verb, file, c, width) {
128
+ const stat = formatStat(patchFileStat(file), c);
129
+ return `${verb} ${fitPath(file.path, c, width)}${stat === "" ? "" : ` ${stat}`}`;
130
+ }
25
131
  function renderPatchFiles(files, c, width = Infinity) {
26
132
  const out = [];
27
133
  for (const file of files) {
28
134
  if (file.op === "delete") {
29
- out.push(c.danger(`delete ${fitPath(file.path, c, width)}`));
135
+ out.push(c.danger(fileHeader("delete", file, c, width)));
30
136
  }
31
137
  else if (file.op === "create") {
32
- out.push(c.success(`create ${fitPath(file.path, c, width)}`));
138
+ out.push(c.success(fileHeader("create", file, c, width)));
33
139
  out.push(...file.lines.map((l) => c.success(`+ ${l}`)));
34
140
  if (file.omittedLines > 0)
35
141
  out.push(c.muted(` ...${file.omittedLines} more lines`));
36
142
  }
37
143
  else {
38
- out.push(c.warning(`update ${fitPath(file.path, c, width)}`));
144
+ out.push(c.warning(fileHeader("update", file, c, width)));
39
145
  for (const hunk of file.hunks)
40
146
  out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
41
147
  }
@@ -123,14 +229,25 @@ function bodyLines(body) {
123
229
  /**
124
230
  * Render any {@link ActionPreview} as an indented block: a diff for edits and
125
231
  * patches, a create/overwrite listing for writes, the publish plan for PRs.
126
- * Long previews collapse past {@link PREVIEW_MAX_LINES}.
232
+ *
233
+ * `maxLines` defaults to {@link PREVIEW_MAX_LINES} so existing callers are
234
+ * unchanged, but it is a PARAMETER because the right collapse point belongs to
235
+ * whatever will draw the block — the TUI passes the rows its main column
236
+ * actually has, so a preview no longer collapses at 40 lines in a pane that can
237
+ * show 12 or one that could show 80.
127
238
  */
128
- export function renderActionPreview(preview, c, width = Infinity) {
239
+ export function renderActionPreview(preview, c, width = Infinity, maxLines = PREVIEW_MAX_LINES) {
129
240
  if (!preview)
130
241
  return "";
131
242
  let lines;
132
243
  if (preview.type === "edit") {
133
- lines = diffLines(preview.oldStr, preview.newStr, c);
244
+ // A headline stat, so a one-line swap is legible without counting the
245
+ // diff rows — and `~` marks it as lines touched, not lines gained/lost.
246
+ const stat = formatStat(previewStats(preview)[0], c);
247
+ lines = [
248
+ ...(stat === "" ? [] : [c.muted(`edit ${stat}`)]),
249
+ ...diffLines(preview.oldStr, preview.newStr, c),
250
+ ];
134
251
  }
135
252
  else if (preview.type === "patch") {
136
253
  lines = renderPatchFiles(preview.files, c, width);
@@ -145,19 +262,25 @@ export function renderActionPreview(preview, c, width = Infinity) {
145
262
  lines = renderRollbackSetPreview(preview, c);
146
263
  }
147
264
  else {
148
- const header = preview.exists
265
+ const stat = formatStat(previewStats(preview)[0], c);
266
+ const label = preview.exists
149
267
  ? c.warning("OVERWRITE existing")
150
268
  : c.success("create");
269
+ const header = stat === "" ? label : `${label} ${stat}`;
151
270
  const body = preview.lines.map((l) => ` ${l}`);
152
271
  if (preview.omittedLines > 0)
153
272
  body.push(c.muted(` ...${preview.omittedLines} more lines`));
154
273
  lines = [header, ...body];
155
274
  }
156
- if (lines.length > PREVIEW_MAX_LINES) {
157
- const hidden = lines.length - PREVIEW_MAX_LINES;
275
+ // Collapse against the CALLER's budget, marker INCLUDED — a block handed a
276
+ // pane's height must fit that pane. With only one row to spend, the marker
277
+ // wins: "...60 more" is the honest answer, where a single arbitrary diff row
278
+ // would read as the whole change.
279
+ if (Number.isFinite(maxLines) && lines.length > maxLines) {
280
+ const kept = Math.max(0, maxLines - 1);
158
281
  lines = [
159
- ...lines.slice(0, PREVIEW_MAX_LINES),
160
- c.muted(`...${hidden} more`),
282
+ ...lines.slice(0, kept),
283
+ c.muted(`...${lines.length - kept} more`),
161
284
  ];
162
285
  }
163
286
  // Horizontal fit at the single choke point (U.12): every content line is
@@ -169,3 +292,66 @@ export function renderActionPreview(preview, c, width = Infinity) {
169
292
  const room = Math.max(1, width - 2);
170
293
  return lines.map((l) => ` ${fit(l, room, c.glyph.ellipsis)}`).join("\n");
171
294
  }
295
+ /**
296
+ * Colour a UNIFIED diff — git's own output — for `/diff` (P6 track 4).
297
+ *
298
+ * A second diff renderer would be exactly what this module's header rules out,
299
+ * so it lives here beside the others and reuses their palette: add/remove take
300
+ * the same `success`/`danger` hues an `ActionPreview` does, so a change looks
301
+ * the same whether you saw it in an approval prompt or asked for it afterwards.
302
+ *
303
+ * What differs is the INPUT. Everything else here renders a structured
304
+ * `ActionPreview` cruxy built itself; this takes text git produced, which means
305
+ * the file/hunk structure arrives as line prefixes rather than as fields. So the
306
+ * rule is deliberately narrow — style by leading marker, change nothing else:
307
+ *
308
+ * - `+++` / `---` before `+` / `-`, or every file header reads as a hunk;
309
+ * - `` is git's own note, not a removal;
310
+ * - the body is passed through verbatim. No re-wrapping, no re-indenting, no
311
+ * tab expansion. A diff whose whitespace has been "tidied" is a diff you
312
+ * cannot trust to show you a whitespace bug.
313
+ *
314
+ * Truncation is from the TOP of the tail, not the bottom: the marker says how
315
+ * many lines were dropped, and the lines kept are the FIRST ones, because a diff
316
+ * read from the middle is worse than a diff read from the start and stopped.
317
+ */
318
+ export function renderUnifiedDiff(diff, c, opts = {}) {
319
+ const maxLines = opts.maxLines ?? PREVIEW_MAX_LINES;
320
+ const width = opts.width ?? Infinity;
321
+ const body = diff.replace(/\n+$/, "");
322
+ // `"".split("\n")` is `[""]`, not `[]` — an empty diff would otherwise render
323
+ // as one blank row. The caller guards this today; the renderer should not
324
+ // depend on that.
325
+ if (body === "")
326
+ return [];
327
+ const raw = body.split("\n");
328
+ const truncated = raw.length > maxLines;
329
+ // Reserve the marker's own row, so the block fits the budget it was given.
330
+ const kept = truncated ? raw.slice(0, Math.max(0, maxLines - 1)) : raw;
331
+ const styled = kept.map((line) => {
332
+ if (line.startsWith("diff --git") || line.startsWith("index ")) {
333
+ return c.strong(line);
334
+ }
335
+ // Before the +/- tests: a `+++ b/file` header is a file marker, not an
336
+ // addition, and colouring it green makes every header read as one.
337
+ if (line.startsWith("+++") || line.startsWith("---"))
338
+ return c.muted(line);
339
+ if (line.startsWith("@@"))
340
+ return c.accent(line);
341
+ // git's own note about a missing trailing newline. It starts with a
342
+ // backslash, but it is prose about the diff rather than part of it.
343
+ if (line.startsWith("\\"))
344
+ return c.muted(line);
345
+ if (line.startsWith("+"))
346
+ return c.success(line);
347
+ if (line.startsWith("-"))
348
+ return c.danger(line);
349
+ return line;
350
+ });
351
+ if (truncated) {
352
+ styled.push(c.muted(`...${raw.length - kept.length} more diff lines`));
353
+ }
354
+ return Number.isFinite(width)
355
+ ? styled.map((l) => fit(l, width, c.glyph.ellipsis))
356
+ : styled;
357
+ }
@@ -3,32 +3,58 @@ import { PlainRenderer } from "./plain-renderer.js";
3
3
  import { attachResize } from "./resize.js";
4
4
  import { ScreenReaderRenderer } from "./screen-reader-renderer.js";
5
5
  import { TtyRenderer } from "./tty-renderer.js";
6
- export { detectCapabilities, detectReducedMotion, resolveColumns, DEFAULT_COLUMNS, } from "./capabilities.js";
6
+ // Imported from the concrete modules (not `../tui/index.js`) to keep the
7
+ // factory's runtime graph narrow — the TUI's app loop is not pulled in here.
8
+ import { TuiRenderer } from "../tui/renderer.js";
9
+ import { GitStatusCache } from "../tui/git-status.js";
10
+ import { ToolVersions } from "../tui/tool-versions.js";
11
+ import { supportsTui } from "../tui/supports.js";
12
+ export { detectCapabilities, detectReducedMotion, resolveColumns, resolveRows, DEFAULT_COLUMNS, DEFAULT_ROWS, } from "./capabilities.js";
7
13
  export { attachResize, processResizeSignal, } from "./resize.js";
8
14
  export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, } from "./layout.js";
9
15
  export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
10
16
  export { createFrameClock, inTransition, intervalFrameTimer, spinnerGlyph, FRAME_INTERVAL_MS, TRANSITION_TICKS, } from "./motion.js";
11
- export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
17
+ export { renderActionPreview, renderUnifiedDiff, PREVIEW_MAX_LINES, } from "./diff.js";
18
+ export { changedSteps, planChecklist, statusMark, stepStatusLine, } from "./plan-view.js";
19
+ export { formatDuration, testResultLines, MAX_LISTED_FAILURES, } from "./test-view.js";
12
20
  export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
13
21
  export { PlainRenderer } from "./plain-renderer.js";
14
22
  export { ScreenReaderRenderer } from "./screen-reader-renderer.js";
15
23
  export { TtyRenderer } from "./tty-renderer.js";
16
24
  /**
17
- * Build the renderer for the detected environment (U.11 adds the first branch):
25
+ * Build the renderer for the detected environment (U.11 adds the screen-reader
26
+ * branch; P1 adds the TUI one):
18
27
  * - `screenReader` → the linear, worded {@link ScreenReaderRenderer}, regardless
19
28
  * of cursor support (a screen-reader TTY must not get the live region);
29
+ * - else TUI requested *and* supported → the full-viewport {@link TuiRenderer};
20
30
  * - else cursor-safe → the managed-live-region {@link TtyRenderer} (static, no
21
31
  * timer, when `reducedMotion`);
22
32
  * - else → the append-only {@link PlainRenderer} (pipes, CI, `TERM=dumb`).
23
33
  * Everything downstream talks to {@link StreamRenderer} and never re-probes.
24
34
  */
25
- export function createRenderer(out = process.stdout, err = process.stderr, env = process.env) {
26
- const caps = detectCapabilities(out, env);
35
+ export function createRenderer(out = process.stdout, err = process.stderr, env = process.env, opts = {}) {
36
+ const caps = detectCapabilities(out, env, opts.stdin);
27
37
  // Wire resize reactivity onto the one caps object before any surface reads
28
38
  // its width (U.12). A no-op for non-TTY streams; the live region subscribes.
29
39
  attachResize(caps, out, env);
30
40
  if (caps.screenReader)
31
41
  return new ScreenReaderRenderer(caps, out, err);
42
+ if (opts.tui && supportsTui(caps)) {
43
+ return new TuiRenderer(caps, out, {
44
+ headerRight: opts.headerRight,
45
+ // Constructed only on the TUI branch: the cache probes nothing until the
46
+ // renderer asks it to, so an unused one would still be a wasted object on
47
+ // every piped run.
48
+ ...(opts.gitCwd === undefined
49
+ ? {}
50
+ : { git: new GitStatusCache(opts.gitCwd) }),
51
+ ...(opts.provider === undefined ? {} : { provider: opts.provider }),
52
+ ...(opts.model === undefined ? {} : { model: opts.model }),
53
+ // Constructed, not started: `ToolVersions` probes nothing until the
54
+ // renderer paints the panel, so this costs an object and no subprocess.
55
+ tools: new ToolVersions(),
56
+ });
57
+ }
32
58
  return caps.cursor
33
59
  ? new TtyRenderer(caps, out)
34
60
  : new PlainRenderer(caps, out, err);
@@ -2,6 +2,8 @@ import { resolveTheme } from "../theme/index.js";
2
2
  import { createStreamPrinter } from "../cli/stream-print.js";
3
3
  import { renderActionPreview } from "./diff.js";
4
4
  import { ELAPSED_AFTER_MS, formatElapsed } from "./state.js";
5
+ import { changedSteps, stepStatusLine } from "./plan-view.js";
6
+ import { testResultLines } from "./test-view.js";
5
7
  /**
6
8
  * The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
7
9
  * cursor-control sequences ever, and no color unless the capabilities say so
@@ -23,6 +25,8 @@ export class PlainRenderer {
23
25
  wroteInSegment = false;
24
26
  /** In-flight tool call (serial by contract) for the end-note duration. */
25
27
  toolStart = null;
28
+ /** Last plan snapshot committed, so `setPlan` prints only what changed. */
29
+ planSteps = [];
26
30
  constructor(caps, out, err) {
27
31
  this.caps = caps;
28
32
  this.out = out;
@@ -69,11 +73,43 @@ export class PlainRenderer {
69
73
  return;
70
74
  }
71
75
  progress(state) {
72
- // The committed plan trail (C.31, via PromptIO) is the record in this
73
- // medium; a live [i/n] prefix would just duplicate it line by line.
76
+ // `setPlan`'s committed trail is the record in this medium (C.31/P3); a
77
+ // live [i/n] prefix would just duplicate it line by line.
74
78
  if (state !== null)
75
79
  return;
76
80
  }
81
+ /**
82
+ * Commit one line per step whose status actually changed (P3). The whole list
83
+ * arrives on every transition, so without the diff this would reprint the
84
+ * entire plan once per step.
85
+ *
86
+ * `ScreenReaderRenderer` inherits this unchanged: the line is already a
87
+ * committed announcement, and its glyph table renders the status marks as
88
+ * words (`done 2. fix the assertion`), so there is nothing to special-case.
89
+ */
90
+ setPlan(steps) {
91
+ if (steps === null) {
92
+ this.planSteps = [];
93
+ return;
94
+ }
95
+ for (const step of changedSteps(this.planSteps, steps)) {
96
+ this.err.write(stepStatusLine(step, this.theme) + "\n");
97
+ }
98
+ this.planSteps = steps.map((s) => ({ ...s }));
99
+ }
100
+ /** Committed, to `err` with the rest of the chrome. */
101
+ /**
102
+ * Ignored: a served tier is standing state, not an event. Committing a line
103
+ * per request would be noise on an append-only surface, and the run's tier is
104
+ * already reported by `cruxy usage`.
105
+ */
106
+ servedRouting() {
107
+ // no-op
108
+ }
109
+ testResult(report) {
110
+ for (const line of testResultLines(report, this.theme))
111
+ this.err.write(line + "\n");
112
+ }
77
113
  toolLifecycle(event) {
78
114
  if (event.event === "start") {
79
115
  // Silent: the end note is the single durable line per call — a start
@@ -0,0 +1,108 @@
1
+ import { fit } from "./layout.js";
2
+ /**
3
+ * Rendering the executing plan (P3): the shared formatter behind
4
+ * {@link StreamRenderer.setPlan}, so the full-viewport checklist and the
5
+ * append-only trail draw the same step the same way.
6
+ *
7
+ * Pure data → string[], like `render/diff.ts` and `tui/layout.ts`. Meaning is
8
+ * carried by the glyph (○/◐/✓/✗), never by color alone, so NO_COLOR and
9
+ * screen-reader mode (whose glyph table words them) lose nothing.
10
+ */
11
+ /** Status glyph for a step, colored when the theme colors. */
12
+ export function statusMark(status, t) {
13
+ switch (status) {
14
+ case "pending":
15
+ return t.muted(t.glyph.pending);
16
+ case "running":
17
+ return t.accent(t.glyph.running);
18
+ case "done":
19
+ return t.success(t.glyph.success);
20
+ case "failed":
21
+ return t.danger(t.glyph.failure);
22
+ }
23
+ }
24
+ /** One step as a committed trail line: `✓ 2. fix the assertion`. */
25
+ export function stepStatusLine(step, t) {
26
+ return `${statusMark(step.status, t)} ${t.strong(step.id + ".")} ${step.title}`;
27
+ }
28
+ /**
29
+ * The steps whose status differs between two snapshots — what an append-only
30
+ * medium must commit. `setPlan` re-sends the WHOLE list on every transition, so
31
+ * without this a trail renderer would reprint every step on every step.
32
+ *
33
+ * A step absent from `before` (a plan that grew, or the first call) counts as
34
+ * changed: its status has not been reported yet.
35
+ */
36
+ export function changedSteps(before, after) {
37
+ const previous = new Map(before.map((s) => [s.id, s.status]));
38
+ return after.filter((s) => previous.get(s.id) !== s.status);
39
+ }
40
+ /** Index of the step a reader cares about: the running one, else the last
41
+ * finished one, so the window follows execution rather than pinning to the top. */
42
+ function focusIndex(steps) {
43
+ const running = steps.findIndex((s) => s.status === "running");
44
+ if (running !== -1)
45
+ return running;
46
+ const lastSettled = steps.reduce((acc, s, i) => (s.status === "done" || s.status === "failed" ? i : acc), -1);
47
+ return lastSettled === -1 ? 0 : lastSettled;
48
+ }
49
+ /**
50
+ * The live checklist block, at most `maxRows` rows including its header.
51
+ *
52
+ * A plan longer than the room available is WINDOWED around the active step
53
+ * rather than truncated to its head: during execution the interesting rows are
54
+ * the ones near what is running. Hidden steps are counted in an explicit
55
+ * `↑ N more` / `↓ N more` marker — a silently clipped checklist would read as a
56
+ * complete one, which is exactly the lie the collapse markers in
57
+ * `render/diff.ts` exist to avoid.
58
+ */
59
+ export function planChecklist(steps, t, maxRows = Infinity, width = Infinity) {
60
+ if (steps.length === 0)
61
+ return [];
62
+ const done = steps.filter((s) => s.status === "done").length;
63
+ const failed = steps.filter((s) => s.status === "failed").length;
64
+ const header = t.strong(`plan ${done}/${steps.length}${failed > 0 ? t.danger(` · ${failed} failed`) : ""}`);
65
+ const finish = (lines) => Number.isFinite(width)
66
+ ? lines.map((l) => fit(l, width, t.glyph.ellipsis))
67
+ : lines;
68
+ const more = (n, where) => t.muted(`${t.glyph.ellipsis} ${n} more ${where}`);
69
+ // The header always costs one row; `room` is what is left for steps AND any
70
+ // elision markers. Markers are part of the budget, not an overflow of it —
71
+ // getting that wrong is how a "max 2 rows" checklist renders 4.
72
+ const room = (Number.isFinite(maxRows) ? maxRows : Infinity) - 1;
73
+ if (room <= 0)
74
+ return finish([header]);
75
+ if (steps.length <= room)
76
+ return finish([header, ...steps.map((s) => stepStatusLine(s, t))]);
77
+ const centered = (size) => {
78
+ const start = Math.min(Math.max(0, focusIndex(steps) - Math.floor(size / 2)), steps.length - size);
79
+ return [start, start + size];
80
+ };
81
+ const markersFor = (start, end) => (start > 0 ? 1 : 0) + (end < steps.length ? 1 : 0);
82
+ // Take the largest window that still leaves room for the markers it actually
83
+ // needs. One marker is always needed here (something is elided by
84
+ // definition), so only two candidate sizes exist.
85
+ for (const size of [room - 1, room - 2]) {
86
+ if (size < 1)
87
+ continue;
88
+ const [start, end] = centered(size);
89
+ if (size + markersFor(start, end) > room)
90
+ continue;
91
+ return finish([
92
+ header,
93
+ ...(start > 0 ? [more(start, "above")] : []),
94
+ ...steps.slice(start, end).map((s) => stepStatusLine(s, t)),
95
+ ...(end < steps.length ? [more(steps.length - end, "below")] : []),
96
+ ]);
97
+ }
98
+ // Too tight for any window plus its markers. Keep the step that matters and
99
+ // one honest count, or — with a single row left — just the count. Never a
100
+ // silently clipped list.
101
+ return finish(room >= 2
102
+ ? [
103
+ header,
104
+ stepStatusLine(steps[focusIndex(steps)], t),
105
+ more(steps.length - 1, "hidden"),
106
+ ]
107
+ : [header, t.muted(`${t.glyph.ellipsis} ${steps.length} steps`)]);
108
+ }
@@ -1,4 +1,4 @@
1
- import { resolveColumns } from "./capabilities.js";
1
+ import { resolveColumns, resolveRows } from "./capabilities.js";
2
2
  /** The real signal: Node emits `SIGWINCH` on the process when a TTY resizes. */
3
3
  export const processResizeSignal = {
4
4
  on(listener) {
@@ -24,9 +24,14 @@ export function attachResize(caps, stream = process.stdout, env = process.env, s
24
24
  let off = null;
25
25
  const onSignal = () => {
26
26
  const next = resolveColumns(stream, env);
27
- if (next === caps.width)
27
+ const nextRows = resolveRows(stream, env);
28
+ // Height moves independently of width — a vertical-only drag changes rows
29
+ // and not columns, and a full-viewport surface must still reflow for it.
30
+ // So both are refreshed, and either change notifies.
31
+ if (next === caps.width && nextRows === caps.height)
28
32
  return;
29
33
  caps.width = next; // the single source stays current — no second copy.
34
+ caps.height = nextRows;
30
35
  for (const l of [...listeners])
31
36
  l(next);
32
37
  };
@@ -0,0 +1,66 @@
1
+ import { fit } from "./layout.js";
2
+ import { formatTokens } from "./state.js";
3
+ /** `key value`, aligned on a fixed gutter so the column is scannable. */
4
+ function row(key, value, t) {
5
+ return ` ${t.muted(key.padEnd(11))} ${value}`;
6
+ }
7
+ /** The full `/status` block as lines to print. */
8
+ export function sessionStatusLines(status, t, width = Infinity) {
9
+ const lines = [t.heading("status")];
10
+ lines.push(row("session", `${status.sessionId.slice(0, 8)} ${t.muted(`· ${status.turns} turn${status.turns === 1 ? "" : "s"}`)}`, t));
11
+ // The mode leads the safety half, and carries its description rather than its
12
+ // name alone. The objection that removed the auto-approve config flag was that
13
+ // it disarmed the gate with nothing on screen saying so; a status screen
14
+ // reporting "mode: full-auto" and no more would repeat that in miniature.
15
+ lines.push(row("mode", `${t.strong(status.mode)} ${t.muted(`— ${status.modeDescription}`)}`, t));
16
+ const model = status.model === undefined
17
+ ? t.muted(`${status.provider} (no cruxy tiers)`)
18
+ : status.servedTier === undefined || status.servedTier === status.model
19
+ ? `${status.provider}/${status.servedTier ?? status.model}`
20
+ : `${status.provider}/${status.model} ${t.glyph.arrow} ${status.servedTier}`;
21
+ lines.push(row("model", model, t));
22
+ if (status.context) {
23
+ const { used, total, compactAt } = status.context;
24
+ lines.push(row("context", `${t.strong(`~${formatTokens(used)} / ${formatTokens(total)} budget`)} ` +
25
+ t.muted(`· compacts above ~${formatTokens(compactAt)}`), t));
26
+ }
27
+ // A sandbox that is ON but whose runtime we cannot name is reported as on
28
+ // WITHOUT a name, rather than omitted — the safety-relevant half is that it
29
+ // is engaged at all.
30
+ lines.push(row("sandbox", status.sandboxEnabled
31
+ ? t.warning(status.sandboxRuntime ?? "on")
32
+ : t.muted("off — commands run on this host"), t));
33
+ lines.push(row("checkpoints", status.checkpoints
34
+ ? t.muted("on — `cruxy rollback` can undo a run's file changes")
35
+ : t.warning("off — file changes are not restorable"), t));
36
+ if (status.jobs) {
37
+ const { total, running, needingApproval } = status.jobs;
38
+ const detail = total === 0
39
+ ? t.muted("none this session")
40
+ : `${total} · ${running} running` +
41
+ (needingApproval > 0
42
+ ? t.warning(` · ${needingApproval} awaiting approval`)
43
+ : "");
44
+ lines.push(row("jobs", detail, t));
45
+ }
46
+ lines.push(row("tools", t.muted(`${status.tools} available to the model`), t));
47
+ // Roots last: one line each, so a multi-root session shows which repo each
48
+ // change lands in — the fact the startup banner states once and then loses.
49
+ lines.push("");
50
+ lines.push(t.strong(status.roots.length === 1 ? "root" : "roots"));
51
+ for (const r of status.roots) {
52
+ const mark = r.primary ? t.accent(t.glyph.pointer) : " ";
53
+ const git = r.branch === undefined
54
+ ? t.muted("not a git repo")
55
+ : `${t.strong(r.branch)} ${r.changed === undefined
56
+ ? ""
57
+ : r.changed > 0
58
+ ? t.warning(`${r.changed} changed`)
59
+ : t.success("clean")}`;
60
+ lines.push(`${mark} ${r.name.padEnd(12)} ${git}`);
61
+ lines.push(` ${t.muted(r.path)}`);
62
+ }
63
+ return Number.isFinite(width)
64
+ ? lines.map((l) => fit(l, width, t.glyph.ellipsis))
65
+ : lines;
66
+ }