@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
|
@@ -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
|
+
}
|