@cruxy/cli 1.2.1 → 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.
- package/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +374 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +755 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +27 -3
- package/dist/components/select.js +3 -3
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +198 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +50 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
package/dist/render/diff.js
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
135
|
+
out.push(c.danger(fileHeader("delete", file, c, width)));
|
|
30
136
|
}
|
|
31
137
|
else if (file.op === "create") {
|
|
32
|
-
out.push(c.success(
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
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,
|
|
160
|
-
c.muted(`...${
|
|
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
|
+
}
|
package/dist/render/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
73
|
-
//
|
|
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
|
+
}
|
package/dist/render/resize.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|