@cruxy/cli 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,73 @@
1
+ import { fuzzyFind } from "../components/fuzzy.js";
2
+ import { COMMAND_CATALOG } from "../cli/session-commands.js";
3
+ import { canOverlay, createOverlayIO } from "./overlay.js";
4
+ /** The TUI's own commands, which the shared catalogue deliberately excludes. */
5
+ const PANEL_COMMANDS = [
6
+ {
7
+ name: "/close",
8
+ summary: "hide a panel or the whole rail",
9
+ args: "<sidebar | context | model | git | tools | rail>",
10
+ },
11
+ {
12
+ name: "/open",
13
+ summary: "show a hidden panel",
14
+ args: "<sidebar | context | model | git | tools | rail>",
15
+ },
16
+ ];
17
+ /**
18
+ * Everything the palette offers: the shared catalogue, this shell's panel
19
+ * commands, and the project's own slash commands.
20
+ *
21
+ * Custom commands come LAST and are labelled. `resolveSlash` consults builtins
22
+ * first, so a custom command named `clear` can never shadow `/clear` — listing
23
+ * it above the builtin would show an order the dispatcher does not honour.
24
+ */
25
+ export function paletteItems(slashCommands = []) {
26
+ const builtins = [...COMMAND_CATALOG, ...PANEL_COMMANDS].map((c) => ({
27
+ name: c.name,
28
+ summary: c.summary,
29
+ ...(c.args === undefined ? {} : { args: c.args }),
30
+ }));
31
+ const custom = slashCommands.map((c) => ({
32
+ name: `/${c.name}`,
33
+ summary: c.description,
34
+ custom: true,
35
+ }));
36
+ return [...builtins, ...custom];
37
+ }
38
+ /**
39
+ * The text a picked item puts in the input buffer.
40
+ *
41
+ * A command that takes arguments gets a trailing space, so the cursor lands
42
+ * where the user has to type next rather than flush against the name.
43
+ */
44
+ export function paletteInsertion(item) {
45
+ return item.args === undefined ? item.name : `${item.name} `;
46
+ }
47
+ /** The row a picked item is matched and displayed as. */
48
+ export function paletteLabel(item) {
49
+ const left = item.args === undefined ? item.name : `${item.name} ${item.args}`;
50
+ return `${left} — ${item.summary}${item.custom ? " (project)" : ""}`;
51
+ }
52
+ /**
53
+ * Open the palette. Resolves the text to insert into the input buffer, or null
54
+ * when the user cancelled (Esc / Ctrl-C / EOF) — which must leave the buffer
55
+ * exactly as it was.
56
+ *
57
+ * Returns null immediately on a terminal with no room for a drawer, rather than
58
+ * painting into zero rows: a modal that renders nothing while still consuming
59
+ * every keystroke is indistinguishable from a hang.
60
+ */
61
+ export async function openPalette(renderer, lease, slashCommands = []) {
62
+ if (!canOverlay(renderer))
63
+ return null;
64
+ const items = paletteItems(slashCommands);
65
+ const result = await fuzzyFind(items, {
66
+ toLabel: paletteLabel,
67
+ title: "commands",
68
+ // The drawer's own budget, minus the query row, the title and the key
69
+ // hint `fuzzyFind` draws around the list.
70
+ maxVisible: Math.max(1, renderer.overlayRows() - 3),
71
+ }, createOverlayIO(renderer, lease));
72
+ return result.kind === "selected" ? paletteInsertion(result.value) : null;
73
+ }
@@ -0,0 +1,235 @@
1
+ import { relativeAge, shortId } from "../session/index.js";
2
+ import { RAIL_PANELS, } from "./layout.js";
3
+ /**
4
+ * Panel content.
5
+ *
6
+ * Each builder is a pure `(theme, state) => string[]`, so swapping a
7
+ * placeholder for the real thing is a body change with no reach into layout or
8
+ * paint. Every builder takes its own state explicitly — the shape `sidebarLines`
9
+ * established — rather than reading a shared blob, so a panel can only render
10
+ * what it was actually handed.
11
+ *
12
+ * A panel whose state is absent says so rather than showing invented data: a
13
+ * fabricated token count or branch name is indistinguishable from a broken real
14
+ * one, and the whole rail is a claim about live state.
15
+ */
16
+ /** Human labels for the panels, used by `/close`, `/open`, and `/help`. */
17
+ export const PANEL_LABELS = {
18
+ sidebar: "sidebar",
19
+ context: "context",
20
+ model: "model",
21
+ git: "git",
22
+ tools: "tools",
23
+ };
24
+ /** Human labels for the columns, for messages that are about width. */
25
+ export const COLUMN_LABELS = {
26
+ sidebar: "sidebar",
27
+ main: "main",
28
+ rail: "rail",
29
+ };
30
+ /** Titles drawn at the top of each rail panel. */
31
+ const RAIL_TITLES = {
32
+ context: "context",
33
+ model: "model",
34
+ git: "git",
35
+ tools: "tools",
36
+ };
37
+ /** A panel's title row, styled as a heading. */
38
+ function title(text, theme) {
39
+ return [theme.strong(text), ""];
40
+ }
41
+ /**
42
+ * Left column — the project's saved sessions (P2), newest first.
43
+ *
44
+ * Reads the same `listSessions` the `--resume` picker does, so the sidebar and
45
+ * the picker can never disagree about what exists or in what order. The active
46
+ * session is marked, so "which of these am I in" is answerable at a glance.
47
+ *
48
+ * The column is narrow (18 columns), so each session takes two lines: its short
49
+ * id and age, then its title. The layout truncates per line, which keeps the id
50
+ * — the part you would type into `--resume` — always fully visible.
51
+ */
52
+ export function sidebarLines(theme, sessions = [], activeSessionId, now = Date.now()) {
53
+ const lines = title("sessions", theme);
54
+ if (sessions.length === 0) {
55
+ lines.push(theme.muted("no saved sessions"));
56
+ lines.push(theme.muted("for this project yet."));
57
+ return lines;
58
+ }
59
+ for (const s of sessions) {
60
+ const active = s.sessionId === activeSessionId;
61
+ const mark = active ? theme.accent(theme.glyph.pointer) : " ";
62
+ const head = `${mark} ${shortId(s.sessionId)} ${relativeAge(s.updatedAt, now)}`;
63
+ lines.push(active ? theme.strong(head) : head);
64
+ lines.push(theme.muted(` ${s.title}`));
65
+ }
66
+ return lines;
67
+ }
68
+ /** A panel with no state yet: says so, rather than inventing a plausible value. */
69
+ function pending(theme) {
70
+ return [theme.muted("not wired yet")];
71
+ }
72
+ /** One rail panel: its title, then whatever its own track supplied. */
73
+ function railPanel(id, theme, lines) {
74
+ const body = lines !== undefined && lines.length > 0 ? [...lines] : pending(theme);
75
+ return { id, lines: [theme.strong(RAIL_TITLES[id]), ...body] };
76
+ }
77
+ /**
78
+ * Right column — the four panels, in draw order, for the ones the user has open.
79
+ *
80
+ * Returns BLOCKS rather than a flat line array: the rail is a stack of fixed
81
+ * panels, and the caller has to be able to drop whole panels (and say how many)
82
+ * when the terminal is too short. Flattening here would throw away exactly the
83
+ * boundary that makes honest overflow reporting possible — see `stackPanels`.
84
+ */
85
+ export function railBlocks(theme, state = {}, open = new Set(RAIL_PANELS)) {
86
+ return RAIL_PANELS.filter((id) => open.has(id)).map((id) => railPanel(id, theme, state[id]));
87
+ }
88
+ /**
89
+ * The git panel's body (P4 track 2): branch, then clean-or-changed.
90
+ *
91
+ * Two lines rather than one because they cannot share a row honestly at
92
+ * {@link RAIL_COLS}: a branch alone can use the full width, and pairing it with
93
+ * a count would truncate whichever came second. Branch first — it says WHERE the
94
+ * work is happening, which is the question the panel exists to answer.
95
+ *
96
+ * The dirty state is a WORD, not a colour or a glyph: colour is unavailable
97
+ * under NO_COLOR and a bare marker is meaningless to a screen reader, so
98
+ * "clean" / "3 changed" carries the meaning and the styling only reinforces it.
99
+ *
100
+ * `undefined` (not probed yet) and `null` (not a repo) are different facts and
101
+ * are said differently — a repo whose probe has not landed must not read as
102
+ * "not a repo".
103
+ */
104
+ export function gitPanelLines(theme, state) {
105
+ if (state === undefined)
106
+ return [theme.muted(`checking${theme.glyph.ellipsis}`)];
107
+ if (state === null)
108
+ return [theme.muted("not a git repo")];
109
+ const { branch, dirty, changed } = state;
110
+ return [
111
+ theme.strong(branch),
112
+ dirty ? theme.warning(`${changed} changed`) : theme.success(`clean`),
113
+ ];
114
+ }
115
+ /**
116
+ * How the served tier was chosen, in words. The wire values (`explicit` /
117
+ * `auto` / `auto_degraded`) are not shown raw — `auto_degraded` in particular
118
+ * is the one line here a user must be able to act on, and it has to say that a
119
+ * budget downgrade happened rather than leave them to decode an enum.
120
+ */
121
+ function describeRoutingMode(mode) {
122
+ switch (mode) {
123
+ case "explicit":
124
+ return "as configured";
125
+ case "auto":
126
+ return "auto-routed";
127
+ case "auto_degraded":
128
+ return "downgraded (budget)";
129
+ default:
130
+ // A mode the gateway did not send, or one added after this build. The
131
+ // tier is still real; only the explanation is missing.
132
+ return "served";
133
+ }
134
+ }
135
+ /**
136
+ * The model panel's body (P4 track 4): which tier is actually running, and why.
137
+ *
138
+ * BEFORE THE FIRST TURN there is no served tier — the gateway has not answered
139
+ * — so the panel shows the CONFIGURED value and says it is unresolved. Blank
140
+ * would be worse than useless here: `auto` is the default, and a user looking
141
+ * at an empty model panel cannot tell configuration from breakage.
142
+ */
143
+ export function modelPanelLines(theme, state) {
144
+ const { configured, served } = state;
145
+ if (served === undefined) {
146
+ return [theme.strong(configured), theme.muted("not resolved yet")];
147
+ }
148
+ return [
149
+ theme.strong(served.tier),
150
+ // A budget downgrade is the one case the user may need to act on, so it
151
+ // reads as a warning; everything else is ordinary reporting.
152
+ served.mode === "auto_degraded"
153
+ ? theme.warning(describeRoutingMode(served.mode))
154
+ : theme.muted(describeRoutingMode(served.mode)),
155
+ ];
156
+ }
157
+ /**
158
+ * The TUI header's right side: provider, what was configured, and — once the
159
+ * gateway has answered with something different — what actually ran.
160
+ *
161
+ * `cruxy/auto` is what P1 shipped and it never changed for the life of the
162
+ * process, so a run routed to `kavi` still read `auto` forever. Showing both
163
+ * sides of an `auto → kavi` resolution keeps the configured value visible
164
+ * (it is what the user set) while making the real one legible.
165
+ */
166
+ export function headerModel(theme, provider, state) {
167
+ const { configured, served } = state;
168
+ if (served === undefined || served.tier === configured) {
169
+ return `${provider}/${served?.tier ?? configured}`;
170
+ }
171
+ return `${provider}/${configured} ${theme.glyph.arrow} ${served.tier}`;
172
+ }
173
+ /**
174
+ * The tools panel's body (P4 track 5): one row per tool, each independent.
175
+ *
176
+ * A row is one of three things and they are three different claims: a version
177
+ * (probed and found), "…" (still probing), or "—" (probed and absent). Merging
178
+ * the last two would tell a user their toolchain is missing while the probe is
179
+ * still running, which is the one wrong answer this panel can give.
180
+ *
181
+ * Rows fill in as their own probes land, so a slow pnpm never holds back git.
182
+ */
183
+ export function toolsPanelLines(theme, rows) {
184
+ return rows.map((row) => {
185
+ if (row.version === undefined) {
186
+ return theme.muted(`${row.name} ${theme.glyph.ellipsis}`);
187
+ }
188
+ if (row.version === null) {
189
+ // Words, not a dash. "git —" is ambiguous to anyone — it could as easily
190
+ // read as "no version reported" as "absent" — and it carries nothing at
191
+ // all to a screen reader. "not found" fits the 24-column rail anyway.
192
+ return theme.muted(`${row.name} not found`);
193
+ }
194
+ return `${theme.strong(row.name)} ${theme.muted(row.version)}`;
195
+ });
196
+ }
197
+ /** Compact token count for the narrow rail: 34_512 → "34k", 900 → "900". */
198
+ function shortTokens(n) {
199
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`;
200
+ }
201
+ /**
202
+ * The context panel's body (P4 track 3).
203
+ *
204
+ * THE WORDING IS THE FEATURE. Both numbers are estimates — a chars/4 heuristic
205
+ * over a budget that is a local config default, not the served model's real
206
+ * window — so the panel is written to claim exactly that much and no more:
207
+ *
208
+ * - "~" on the figure, because the numerator is a heuristic;
209
+ * - "budget", never "window", because the denominator is a setting in this
210
+ * CLI's config. Under `model: "auto"` the served tier varies per request and
211
+ * the real window varies with it; calling 100k "the window" would assert
212
+ * something no request has to honour;
213
+ * - no progress bar. A filled bar reads as a measurement, and this is not one.
214
+ * Two plain numbers can be wrong without also looking precise.
215
+ *
216
+ * The compaction threshold is shown because it is the only actionable thing
217
+ * here: it says when the CLI will start folding history away.
218
+ */
219
+ export function contextPanelLines(theme, reading) {
220
+ if (reading === undefined) {
221
+ return [theme.muted(`measuring${theme.glyph.ellipsis}`)];
222
+ }
223
+ const { used, total, compactAt } = reading;
224
+ const figure = `~${shortTokens(used)} / ${shortTokens(total)} budget`;
225
+ // Past the threshold the next turn compacts, which is worth flagging — but as
226
+ // a statement of what happens next, not as an alarm about a guessed number.
227
+ // Compared on `used`, not the clamped fraction: the clamp is for display, and
228
+ // a history that has overrun the budget must not compare as merely "at" it.
229
+ const style = used >= compactAt ? theme.warning : theme.strong;
230
+ return [style(figure), theme.muted(`compacts at ${shortTokens(compactAt)}`)];
231
+ }
232
+ /** The opening lines of the main column, before any turn has run. */
233
+ export function mainWelcome(theme, hint) {
234
+ return [theme.muted(hint), ""];
235
+ }