@cruxy/cli 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
@@ -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.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;
@@ -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).
@@ -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 `{ tier, model }`: the tier for honest
77
- * surfacing (the U.4 state line), the wire model id for the request. The model
78
- * id comes from the internal {@link resolveModelId} — callers never touch that
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 { tier, model: resolveModelId(tier) };
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
+ }
@@ -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";