@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.
- 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/agent/status.js +56 -0
- 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 +401 -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 +717 -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 +65 -3
- package/dist/components/select.js +3 -3
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- 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 +204 -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 +508 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +108 -0
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +15 -0
- package/dist/tui/layout.js +314 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +1121 -0
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/tui/views.js +66 -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 +163 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
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.git === undefined
|
|
54
|
+
? t.muted(`checking${t.glyph.ellipsis}`)
|
|
55
|
+
: r.git === null
|
|
56
|
+
? t.muted("not a git repo")
|
|
57
|
+
: `${t.strong(r.git.branch)} ${r.git.changed > 0
|
|
58
|
+
? t.warning(`${r.git.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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { fit } from "./layout.js";
|
|
2
|
+
/**
|
|
3
|
+
* Rendering a test run (P3): the shared formatter behind
|
|
4
|
+
* {@link StreamRenderer.testResult}, so every medium reports the same run the
|
|
5
|
+
* same way.
|
|
6
|
+
*
|
|
7
|
+
* The honesty rules are `testing/`'s, carried through rather than re-derived:
|
|
8
|
+
* `passed` comes from the exit code and nothing else, `total` exists only when
|
|
9
|
+
* a parser confidently extracted it, and `failures` can be empty even for a
|
|
10
|
+
* failing run. So this NEVER computes a count it was not given — no
|
|
11
|
+
* `total - failures.length` passed-count, no "1 test failed" inferred from a
|
|
12
|
+
* non-zero exit. When the numbers are absent it reports the outcome and the
|
|
13
|
+
* duration, and says that it could not recognize the details.
|
|
14
|
+
*/
|
|
15
|
+
/** Failures listed before the rest are summarized. */
|
|
16
|
+
export const MAX_LISTED_FAILURES = 5;
|
|
17
|
+
/** `4.2s`, `380ms` — a measured duration, never estimated. */
|
|
18
|
+
export function formatDuration(ms) {
|
|
19
|
+
if (ms < 1000)
|
|
20
|
+
return `${Math.round(ms)}ms`;
|
|
21
|
+
const seconds = ms / 1000;
|
|
22
|
+
return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The count clause, or "" when nothing countable was extracted.
|
|
26
|
+
*
|
|
27
|
+
* `total` is the only number a parser is allowed to produce, and `failures` is
|
|
28
|
+
* a best-effort list — so "N of M failed" is claimable only when both are
|
|
29
|
+
* present, and a bare failure list is reported as "N failures" without implying
|
|
30
|
+
* it is exhaustive.
|
|
31
|
+
*/
|
|
32
|
+
function counts(report, t) {
|
|
33
|
+
const failed = report.failures.length;
|
|
34
|
+
if (report.total !== undefined) {
|
|
35
|
+
if (report.passed)
|
|
36
|
+
return t.muted(`${report.total} tests`);
|
|
37
|
+
if (failed > 0)
|
|
38
|
+
return t.danger(`${failed} of ${report.total} failed`);
|
|
39
|
+
return t.muted(`${report.total} tests`);
|
|
40
|
+
}
|
|
41
|
+
if (!report.passed && failed > 0)
|
|
42
|
+
return t.danger(`${failed} failure${failed === 1 ? "" : "s"}`);
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
/** One failure: its name, then `file:line` when the parser recognized them. */
|
|
46
|
+
function failureLine(failure, t) {
|
|
47
|
+
const where = failure.file === undefined
|
|
48
|
+
? ""
|
|
49
|
+
: ` ${t.muted(failure.file + (failure.line === undefined ? "" : `:${failure.line}`))}`;
|
|
50
|
+
return ` ${t.danger(t.glyph.failure)} ${failure.name}${where}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A test run as committed lines: a headline, then the recognized failures.
|
|
54
|
+
*
|
|
55
|
+
* This is committed output, not live state — a run is an event that happened,
|
|
56
|
+
* so every medium keeps it rather than redrawing it.
|
|
57
|
+
*/
|
|
58
|
+
export function testResultLines(report, t, width = Infinity) {
|
|
59
|
+
const mark = report.passed
|
|
60
|
+
? t.success(t.glyph.success)
|
|
61
|
+
: t.danger(t.glyph.failure);
|
|
62
|
+
const headline = report.passed
|
|
63
|
+
? t.success("tests passed")
|
|
64
|
+
: t.danger("tests failed");
|
|
65
|
+
const parts = [
|
|
66
|
+
counts(report, t),
|
|
67
|
+
t.muted(formatDuration(report.durationMs)),
|
|
68
|
+
t.muted(report.command),
|
|
69
|
+
].filter((p) => p !== "");
|
|
70
|
+
const lines = [
|
|
71
|
+
`${mark} ${headline} ${t.muted(t.glyph.sep)} ${parts.join(t.muted(` ${t.glyph.sep} `))}`,
|
|
72
|
+
];
|
|
73
|
+
for (const failure of report.failures.slice(0, MAX_LISTED_FAILURES))
|
|
74
|
+
lines.push(failureLine(failure, t));
|
|
75
|
+
if (report.failures.length > MAX_LISTED_FAILURES) {
|
|
76
|
+
lines.push(t.muted(` ${t.glyph.ellipsis} ${report.failures.length - MAX_LISTED_FAILURES} more`));
|
|
77
|
+
}
|
|
78
|
+
// Say what is NOT known, rather than letting a bare "tests failed" imply the
|
|
79
|
+
// details were simply absent from the run.
|
|
80
|
+
if (!report.passed && report.failures.length === 0) {
|
|
81
|
+
lines.push(t.muted(" no individual failures were recognized in the output"));
|
|
82
|
+
}
|
|
83
|
+
if (report.outputTruncated && report.failures.length > 0) {
|
|
84
|
+
lines.push(t.muted(" output was capped — the failures above may be incomplete"));
|
|
85
|
+
}
|
|
86
|
+
return Number.isFinite(width)
|
|
87
|
+
? lines.map((l) => fit(l, width, t.glyph.ellipsis))
|
|
88
|
+
: lines;
|
|
89
|
+
}
|
|
@@ -5,6 +5,8 @@ import { createStreamHighlighter, } from "./highlight.js";
|
|
|
5
5
|
import { fit } from "./layout.js";
|
|
6
6
|
import { createFrameClock, inTransition, spinnerGlyph, } from "./motion.js";
|
|
7
7
|
import { ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, phaseIdentity, } from "./state.js";
|
|
8
|
+
import { changedSteps, stepStatusLine } from "./plan-view.js";
|
|
9
|
+
import { testResultLines } from "./test-view.js";
|
|
8
10
|
/** Erase the current line and return the cursor to column 0. */
|
|
9
11
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
10
12
|
/**
|
|
@@ -52,6 +54,8 @@ export class TtyRenderer {
|
|
|
52
54
|
rawStatus = null;
|
|
53
55
|
phase = null;
|
|
54
56
|
progressState = null;
|
|
57
|
+
/** Last plan snapshot committed, so `setPlan` notes only what changed. */
|
|
58
|
+
planSteps = [];
|
|
55
59
|
/** When the current phase *identity* began — drives honest elapsed display. */
|
|
56
60
|
phaseStartedAt = 0;
|
|
57
61
|
/** In-flight tool call (serial by contract) for end-note duration. */
|
|
@@ -277,6 +281,42 @@ export class TtyRenderer {
|
|
|
277
281
|
this.progressState = state;
|
|
278
282
|
this.refresh();
|
|
279
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Commit one trail line per step whose status actually changed (P3). This
|
|
286
|
+
* medium owns a single live line, which `progress` already uses for the
|
|
287
|
+
* `[i/n] title` prefix — the checklist itself belongs in committed output,
|
|
288
|
+
* exactly where the plan executor used to write it through the approval io.
|
|
289
|
+
*
|
|
290
|
+
* `note` is the right sink: it hides the live line, writes, and lets the next
|
|
291
|
+
* transition redraw — so a step line can never land inside the managed row.
|
|
292
|
+
*/
|
|
293
|
+
setPlan(steps) {
|
|
294
|
+
if (this.closed)
|
|
295
|
+
return;
|
|
296
|
+
if (steps === null) {
|
|
297
|
+
this.planSteps = [];
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
for (const step of changedSteps(this.planSteps, steps)) {
|
|
301
|
+
this.note(stepStatusLine(step, this.theme));
|
|
302
|
+
}
|
|
303
|
+
this.planSteps = steps.map((s) => ({ ...s }));
|
|
304
|
+
}
|
|
305
|
+
/** Committed via `note`, so the live line yields before the block lands. */
|
|
306
|
+
/**
|
|
307
|
+
* Ignored: a served tier is standing state, not an event. Committing a line
|
|
308
|
+
* per request would be noise on an append-only surface, and the run's tier is
|
|
309
|
+
* already reported by `cruxy usage`.
|
|
310
|
+
*/
|
|
311
|
+
servedRouting() {
|
|
312
|
+
// no-op
|
|
313
|
+
}
|
|
314
|
+
testResult(report) {
|
|
315
|
+
if (this.closed)
|
|
316
|
+
return;
|
|
317
|
+
for (const line of testResultLines(report, this.theme, this.caps.width))
|
|
318
|
+
this.note(line);
|
|
319
|
+
}
|
|
280
320
|
toolLifecycle(event) {
|
|
281
321
|
if (this.closed)
|
|
282
322
|
return;
|
package/dist/routing/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
2
|
export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
|
|
3
|
+
export { MODEL_CHOICES, SessionModel, describeModelChoice, parseModelChoice, } from "./session-model.js";
|
|
3
4
|
// `resolve.ts` (tier → wire model-id) is deliberately NOT re-exported: the
|
|
4
5
|
// mapping is internal to routing, so it can never be reached from a user-facing
|
|
5
6
|
// render path (the internal-mapping-isolation guarantee, U.8).
|
package/dist/routing/router.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { MODEL_TIERS } from "../brand/voice.js";
|
|
2
2
|
import { routingTierUnavailable } from "../errors/index.js";
|
|
3
3
|
import { resolveModelId } from "./resolve.js";
|
|
4
|
+
import { AUTO_MODEL, } from "./types.js";
|
|
4
5
|
/**
|
|
5
6
|
* The tier a config resolves to when nothing else pins one down — mirrors the
|
|
6
7
|
* gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
|
|
@@ -73,12 +74,20 @@ export function routerForConfig(config) {
|
|
|
73
74
|
});
|
|
74
75
|
}
|
|
75
76
|
/**
|
|
76
|
-
* Resolve a declared task class to
|
|
77
|
-
* surfacing (the U.4 state line)
|
|
78
|
-
*
|
|
77
|
+
* Resolve a declared task class to the wire model for the request, plus the tier
|
|
78
|
+
* for honest surfacing (the U.4 state line) when there is one. The model id
|
|
79
|
+
* comes from the internal {@link resolveModelId} — callers never touch that
|
|
79
80
|
* mapping directly, so it stays the single source of truth.
|
|
81
|
+
*
|
|
82
|
+
* `tier` is ABSENT exactly when the router declined and the request is going out
|
|
83
|
+
* as {@link AUTO_MODEL}: no tier has been chosen client-side, so there is
|
|
84
|
+
* nothing honest to put on the status line until the gateway's opening frame
|
|
85
|
+
* says what it actually served. Absent is the whole point — a fabricated
|
|
86
|
+
* "probably vaani" here is precisely the claim `auto` cannot support.
|
|
80
87
|
*/
|
|
81
88
|
export function resolveTaskModel(router, taskClass) {
|
|
82
89
|
const tier = router.select(taskClass);
|
|
83
|
-
return
|
|
90
|
+
return tier === null
|
|
91
|
+
? { model: AUTO_MODEL }
|
|
92
|
+
: { tier, model: resolveModelId(tier) };
|
|
84
93
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { MODEL_TIERS } from "../brand/voice.js";
|
|
2
|
+
import { multiplierForTier } from "../usage/weighted.js";
|
|
3
|
+
import { AUTO_MODEL } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Everything `/model` offers, in the order it lists them. `auto` leads because
|
|
6
|
+
* it is the default and because it is the only entry that is not a tier — a
|
|
7
|
+
* list that buried it among the three would read as a four-tier menu.
|
|
8
|
+
*/
|
|
9
|
+
export const MODEL_CHOICES = [
|
|
10
|
+
AUTO_MODEL,
|
|
11
|
+
...MODEL_TIERS,
|
|
12
|
+
];
|
|
13
|
+
/** Parse a user-typed model name, or `null` — never a guess, never a nearest match. */
|
|
14
|
+
export function parseModelChoice(text) {
|
|
15
|
+
const want = text.trim().toLowerCase();
|
|
16
|
+
return MODEL_CHOICES.includes(want)
|
|
17
|
+
? want
|
|
18
|
+
: null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One line describing a choice, for the picker and for `/model` with no picker.
|
|
22
|
+
*
|
|
23
|
+
* THE ONLY THING SAID ABOUT A TIER IS ITS WEIGHT, and that is deliberate. cruxy
|
|
24
|
+
* publishes no capability ordering for `kavi`/`vaani`/`mira` — nothing in this
|
|
25
|
+
* codebase knows which is "smarter" — so a description like "fastest" or "best
|
|
26
|
+
* for hard problems" would be invented at the exact moment a user is deciding
|
|
27
|
+
* on it. The weighted multiplier is a real, checkable number this CLI already
|
|
28
|
+
* meters with (`TIER_MULTIPLIERS`, mirrored from the gateway's budget config),
|
|
29
|
+
* so it is what the picker says. A tier this build has no multiplier for gets no
|
|
30
|
+
* claim at all rather than a made-up one.
|
|
31
|
+
*/
|
|
32
|
+
export function describeModelChoice(choice) {
|
|
33
|
+
if (choice === AUTO_MODEL) {
|
|
34
|
+
return "the gateway picks a tier per request (not a tier itself)";
|
|
35
|
+
}
|
|
36
|
+
const multiplier = multiplierForTier(choice);
|
|
37
|
+
return multiplier === undefined
|
|
38
|
+
? "a routing tier"
|
|
39
|
+
: `a routing tier · ${multiplier}× weighted`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The session's model choice, and the router every part of the session selects
|
|
43
|
+
* through.
|
|
44
|
+
*/
|
|
45
|
+
export class SessionModel {
|
|
46
|
+
choice;
|
|
47
|
+
base;
|
|
48
|
+
listeners = new Set();
|
|
49
|
+
/**
|
|
50
|
+
* @param initial the starting choice — `config.model.model` when it names one
|
|
51
|
+
* of {@link MODEL_CHOICES}, else `auto`.
|
|
52
|
+
* @param base the config-driven router (`routing.default` / `routing.map`),
|
|
53
|
+
* or null when the user configured none. Consulted ONLY while
|
|
54
|
+
* the choice is `auto`: a tier picked at runtime is an explicit
|
|
55
|
+
* instruction for this session and outranks a config table, the
|
|
56
|
+
* same precedence a CLI flag has over a config file.
|
|
57
|
+
*/
|
|
58
|
+
constructor(initial, base = null) {
|
|
59
|
+
this.choice = initial;
|
|
60
|
+
this.base = base;
|
|
61
|
+
}
|
|
62
|
+
/** The current choice. The ONE read every surface makes. */
|
|
63
|
+
current() {
|
|
64
|
+
return this.choice;
|
|
65
|
+
}
|
|
66
|
+
/** Whether a config routing table is in play — so `/model` can say when a pick overrides it. */
|
|
67
|
+
get hasRoutingTable() {
|
|
68
|
+
return this.base !== null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Adopt a new choice. Returns whether anything changed, so a caller can stay
|
|
72
|
+
* quiet about a no-op rather than announcing a switch that did not happen.
|
|
73
|
+
* Listeners fire only on a real change.
|
|
74
|
+
*/
|
|
75
|
+
set(choice) {
|
|
76
|
+
if (choice === this.choice)
|
|
77
|
+
return false;
|
|
78
|
+
this.choice = choice;
|
|
79
|
+
for (const listener of this.listeners)
|
|
80
|
+
listener();
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Subscribe to changes; returns the unsubscribe. The renderer uses this to
|
|
85
|
+
* drop the served tier the moment the choice moves — see the note on
|
|
86
|
+
* `TuiRenderer.attachModel`.
|
|
87
|
+
*/
|
|
88
|
+
onChange(listener) {
|
|
89
|
+
this.listeners.add(listener);
|
|
90
|
+
return () => {
|
|
91
|
+
this.listeners.delete(listener);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A pinned tier wins for every task class. `auto` defers to the config table
|
|
96
|
+
* when there is one (unchanged C.30 behaviour) and otherwise declines, which
|
|
97
|
+
* sends the request out as `auto` for the gateway to route.
|
|
98
|
+
*
|
|
99
|
+
* A pinned tier deliberately flattens the per-task map. The user named one
|
|
100
|
+
* model for this session; honouring `routing.map` underneath would quietly
|
|
101
|
+
* send some of their work somewhere else, which is the silent-substitution
|
|
102
|
+
* failure `ConfigRouter` already refuses to make.
|
|
103
|
+
*/
|
|
104
|
+
select(taskClass) {
|
|
105
|
+
if (this.choice !== AUTO_MODEL)
|
|
106
|
+
return this.choice;
|
|
107
|
+
return this.base === null ? null : this.base.select(taskClass);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/routing/types.js
CHANGED
|
@@ -25,3 +25,17 @@ export const TASK_CLASSES = [
|
|
|
25
25
|
/** Context compaction / summarization. */
|
|
26
26
|
"summarize",
|
|
27
27
|
];
|
|
28
|
+
/**
|
|
29
|
+
* The server-routing sentinel: a valid `model.model` (and cruxy's default), but
|
|
30
|
+
* deliberately **not** a {@link Tier} and never a member of `MODEL_TIERS`.
|
|
31
|
+
*
|
|
32
|
+
* The distinction is load-bearing rather than pedantic. A tier names one model
|
|
33
|
+
* for the whole run, fixed before `provider.stream` opens. `auto` names no model
|
|
34
|
+
* at all — the gateway resolves it PER REQUEST and may downgrade it under budget
|
|
35
|
+
* pressure, which is why the served tier arrives on the stream's opening frame
|
|
36
|
+
* and why `auto` can never be reported as "the tier this run used". Anything
|
|
37
|
+
* that shows a model choice has to say which of the two it is holding; a picker
|
|
38
|
+
* that listed `auto` alongside the tiers as though it were a fourth one would be
|
|
39
|
+
* asserting a fixed model where there is none.
|
|
40
|
+
*/
|
|
41
|
+
export const AUTO_MODEL = "auto";
|