@cruxy/cli 1.4.0 → 1.5.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/status.js +41 -1
- package/dist/cli/commands/run.js +27 -3
- package/dist/cli/session-commands.js +6 -1
- package/dist/errors/constructors.js +90 -10
- package/dist/limits/cache.js +100 -0
- package/dist/limits/index.js +11 -0
- package/dist/limits/reduce.js +172 -0
- package/dist/limits/types.js +25 -0
- package/dist/onboarding/detect.js +95 -9
- package/dist/onboarding/types.js +29 -1
- package/dist/render/status-view.js +52 -0
- package/dist/theme/tokens.js +7 -0
- package/dist/tui/disk-status.js +47 -0
- package/dist/tui/index.js +1 -0
- package/dist/tui/layout.js +1 -0
- package/dist/tui/limits-panel.js +255 -0
- package/dist/tui/overview.js +16 -4
- package/dist/tui/panels.js +2 -0
- package/dist/tui/renderer.js +66 -0
- package/dist/utils/disk.js +95 -0
- package/package.json +2 -2
package/dist/tui/renderer.js
CHANGED
|
@@ -11,6 +11,7 @@ import { ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, phaseIdentity, } from "
|
|
|
11
11
|
import { budgetColumns, bodyRows, composeScreen, droppedForWidth, fitOverlay, overlayRows, scrollNotice, scrollWindow, stackPanels, CLOSABLE_PANELS, } from "./layout.js";
|
|
12
12
|
import { CONVERSATION_VIEW, cycleView, navLines, } from "./views.js";
|
|
13
13
|
import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelLines, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
|
|
14
|
+
import { limitsPanelLines } from "./limits-panel.js";
|
|
14
15
|
/**
|
|
15
16
|
* The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
|
|
16
17
|
* only one that owns the whole screen rather than a single managed line.
|
|
@@ -93,6 +94,10 @@ export class TuiRenderer {
|
|
|
93
94
|
git;
|
|
94
95
|
/** Context-budget reading for the context panel (P4 track 3); absent → unwired. */
|
|
95
96
|
context;
|
|
97
|
+
/** The account's headroom for the limits panel (P9); absent → panel unwired. */
|
|
98
|
+
limits;
|
|
99
|
+
/** Whether the first limits read has been kicked off (see {@link startLimits}). */
|
|
100
|
+
limitsStarted = false;
|
|
96
101
|
/**
|
|
97
102
|
* The configured model/tier as known at construction — the renderer is built
|
|
98
103
|
* before the session exists, so this covers the window before {@link attachModel}
|
|
@@ -196,6 +201,20 @@ export class TuiRenderer {
|
|
|
196
201
|
gauge.sample();
|
|
197
202
|
this.schedulePaint();
|
|
198
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Attach the limits cache (P9). Set after construction like the context gauge,
|
|
206
|
+
* because the credential it reads with is resolved alongside the session.
|
|
207
|
+
*
|
|
208
|
+
* NO PROBE HAPPENS HERE. The first read is deferred to the first paint that
|
|
209
|
+
* actually shows the panel — the rule the tool probes already follow — so a
|
|
210
|
+
* user who keeps `limits` closed, or who runs a one-shot that never paints a
|
|
211
|
+
* rail, never makes the request at all. The panel is a status surface; it does
|
|
212
|
+
* not get to spend a round trip on someone who is not looking at it.
|
|
213
|
+
*/
|
|
214
|
+
attachLimits(limits) {
|
|
215
|
+
this.limits = limits;
|
|
216
|
+
this.schedulePaint();
|
|
217
|
+
}
|
|
199
218
|
/**
|
|
200
219
|
* Adopt the session's live model choice (P6 track 1). Set after construction
|
|
201
220
|
* for the same reason the context gauge is: the choice belongs to the session,
|
|
@@ -233,6 +252,24 @@ export class TuiRenderer {
|
|
|
233
252
|
* nothing after the first; the repaint callback is what lets each row appear
|
|
234
253
|
* as its own probe lands rather than all at once at the end.
|
|
235
254
|
*/
|
|
255
|
+
/**
|
|
256
|
+
* Kick off the first limits read, at the first paint that actually SHOWS the
|
|
257
|
+
* panel. Returns the cache so the paint path can read it in one expression.
|
|
258
|
+
*
|
|
259
|
+
* Guarded by its own flag rather than by the cache's interval floor: the floor
|
|
260
|
+
* exists to coalesce refreshes AFTER a reading exists, and would not stop the
|
|
261
|
+
* paint path from firing a request on every frame before the first one lands.
|
|
262
|
+
*/
|
|
263
|
+
startLimits(limits) {
|
|
264
|
+
if (!this.limitsStarted) {
|
|
265
|
+
this.limitsStarted = true;
|
|
266
|
+
void limits.refresh().then(() => {
|
|
267
|
+
if (!this.closed)
|
|
268
|
+
this.schedulePaint();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return limits;
|
|
272
|
+
}
|
|
236
273
|
startTools(tools) {
|
|
237
274
|
tools.start(() => {
|
|
238
275
|
if (!this.closed)
|
|
@@ -639,8 +676,29 @@ export class TuiRenderer {
|
|
|
639
676
|
this.context?.sample();
|
|
640
677
|
this.paintNow();
|
|
641
678
|
this.refreshGit();
|
|
679
|
+
this.refreshLimits();
|
|
642
680
|
this.refreshViews();
|
|
643
681
|
}
|
|
682
|
+
/**
|
|
683
|
+
* Re-read the account's headroom after a turn (P9) — the one moment it is
|
|
684
|
+
* KNOWN to have moved, because this process just spent some of it.
|
|
685
|
+
*
|
|
686
|
+
* Same rules as the git probe: never awaited, a failure leaves the last good
|
|
687
|
+
* reading standing, and the cache coalesces. Unlike git it is also floored by
|
|
688
|
+
* a minimum interval, because this one crosses the network — and unlike git it
|
|
689
|
+
* is skipped entirely while the panel is closed, since a user who has hidden
|
|
690
|
+
* the figures has no use for the request that fetches them.
|
|
691
|
+
*/
|
|
692
|
+
refreshLimits() {
|
|
693
|
+
const limits = this.limits;
|
|
694
|
+
if (limits === undefined || this.closed || !this.open.has("limits"))
|
|
695
|
+
return;
|
|
696
|
+
void limits.refresh().then(() => {
|
|
697
|
+
if (this.closed)
|
|
698
|
+
return;
|
|
699
|
+
this.schedulePaint();
|
|
700
|
+
});
|
|
701
|
+
}
|
|
644
702
|
/**
|
|
645
703
|
* Re-probe the working tree, off the paint path, and repaint when it lands.
|
|
646
704
|
*
|
|
@@ -1043,6 +1101,14 @@ export class TuiRenderer {
|
|
|
1043
1101
|
...(this.context === undefined
|
|
1044
1102
|
? {}
|
|
1045
1103
|
: { context: contextPanelLines(this.theme, this.context.current()) }),
|
|
1104
|
+
// Like the tool probes: the first network read starts at the first paint
|
|
1105
|
+
// that SHOWS this panel, never at startup and never at all while it is
|
|
1106
|
+
// closed. `startLimits` is idempotent, so painting it costs one request.
|
|
1107
|
+
...(this.limits === undefined || !this.open.has("limits")
|
|
1108
|
+
? {}
|
|
1109
|
+
: {
|
|
1110
|
+
limits: limitsPanelLines(this.theme, this.startLimits(this.limits).current()),
|
|
1111
|
+
}),
|
|
1046
1112
|
...(this.git === undefined
|
|
1047
1113
|
? {}
|
|
1048
1114
|
: { git: gitPanelLines(this.theme, this.git.current()) }),
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { statfsSync } from "node:fs";
|
|
2
|
+
import { statfs } from "node:fs/promises";
|
|
3
|
+
const GIB = 1024 ** 3;
|
|
4
|
+
/** Below this, a run that shadow-copies a repo can plausibly fail. */
|
|
5
|
+
const LOW_FREE_BYTES = 5 * GIB;
|
|
6
|
+
/** Below this, assume the next sizeable write fails. */
|
|
7
|
+
const CRITICAL_FREE_BYTES = 1 * GIB;
|
|
8
|
+
export function capacityLevel(capacity) {
|
|
9
|
+
if (capacity.freeBytes < CRITICAL_FREE_BYTES)
|
|
10
|
+
return "critical";
|
|
11
|
+
if (capacity.freeBytes < LOW_FREE_BYTES)
|
|
12
|
+
return "low";
|
|
13
|
+
return "ok";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Turn a `statfs` result into a capacity, or `undefined` when it cannot say
|
|
17
|
+
* anything true.
|
|
18
|
+
*
|
|
19
|
+
* `blocks === 0` is the guard that matters: some pseudo-filesystems report a
|
|
20
|
+
* zero total, and a percentage derived from it is `NaN` or `Infinity` — a
|
|
21
|
+
* "0% free" that means "we have no idea" is the single worst thing this could
|
|
22
|
+
* render, because it is indistinguishable from a genuinely full disk.
|
|
23
|
+
*
|
|
24
|
+
* Exported for the test that pins that guard: it is the one branch here that
|
|
25
|
+
* cannot be reached through a real `statfs` on any machine CI runs on.
|
|
26
|
+
*/
|
|
27
|
+
export function capacityFromStats(stats) {
|
|
28
|
+
const blockSize = Number(stats.bsize);
|
|
29
|
+
const blocks = Number(stats.blocks);
|
|
30
|
+
const available = Number(stats.bavail);
|
|
31
|
+
if (!(blockSize > 0) || !(blocks > 0) || !(available >= 0))
|
|
32
|
+
return undefined;
|
|
33
|
+
const totalBytes = blocks * blockSize;
|
|
34
|
+
const freeBytes = available * blockSize;
|
|
35
|
+
return {
|
|
36
|
+
freeBytes,
|
|
37
|
+
totalBytes,
|
|
38
|
+
freePercent: Math.floor((freeBytes / totalBytes) * 100),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Capacity of the filesystem holding `path`, or `undefined` if it can't be
|
|
43
|
+
* read (a path that doesn't exist, a platform or mount that won't answer).
|
|
44
|
+
*
|
|
45
|
+
* ASYNC because a `statfs` is only microseconds on a local disk — the whole
|
|
46
|
+
* reason this is affordable — but can block for SECONDS on an unresponsive
|
|
47
|
+
* network mount, and someone will eventually run cruxy in a repo on NFS. The
|
|
48
|
+
* cost model that justifies the feature holds for the common case; the API is
|
|
49
|
+
* shaped for the uncommon one.
|
|
50
|
+
*/
|
|
51
|
+
export async function readDiskCapacity(path) {
|
|
52
|
+
try {
|
|
53
|
+
return capacityFromStats(await statfs(path));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The synchronous read, for the one caller that is allowed one: a command the
|
|
61
|
+
* user just typed, which has no frame to lose and every reason to print a real
|
|
62
|
+
* answer instead of "checking…". Never call this from a paint path.
|
|
63
|
+
*/
|
|
64
|
+
export function readDiskCapacitySync(path) {
|
|
65
|
+
try {
|
|
66
|
+
return capacityFromStats(statfsSync(path));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
|
73
|
+
/**
|
|
74
|
+
* `8.1 GiB`, `465 GiB`, `912 MiB`.
|
|
75
|
+
*
|
|
76
|
+
* Binary units with binary labels. `GB` for 2^30 is the ambiguity that makes
|
|
77
|
+
* people distrust a number they were about to act on; if the unit is 1024-based
|
|
78
|
+
* the label says so.
|
|
79
|
+
*/
|
|
80
|
+
export function formatBytes(bytes) {
|
|
81
|
+
let value = Math.max(0, bytes);
|
|
82
|
+
let unit = 0;
|
|
83
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
84
|
+
value /= 1024;
|
|
85
|
+
unit++;
|
|
86
|
+
}
|
|
87
|
+
// One decimal only while it carries information: "8.1 GiB" is a different
|
|
88
|
+
// amount from "8 GiB", "465.3 GiB" is not meaningfully different from "465".
|
|
89
|
+
const digits = unit > 0 && value < 10 ? 1 : 0;
|
|
90
|
+
return `${value.toFixed(digits)} ${UNITS[unit]}`;
|
|
91
|
+
}
|
|
92
|
+
/** `2% free · 8.1 GiB of 465 GiB` — the percentage first, since that's the read. */
|
|
93
|
+
export function formatCapacity(capacity) {
|
|
94
|
+
return `${capacity.freePercent}% free · ${formatBytes(capacity.freeBytes)} of ${formatBytes(capacity.totalBytes)}`;
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cruxy/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "an agentic coding CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"undici": "^6.21.0",
|
|
37
37
|
"zod": "^3.23.8",
|
|
38
38
|
"zod-to-json-schema": "^3.23.5",
|
|
39
|
-
"@cruxy/sdk": "0.
|
|
39
|
+
"@cruxy/sdk": "0.5.0"
|
|
40
40
|
},
|
|
41
41
|
"optionalDependencies": {
|
|
42
42
|
"better-sqlite3": "^12.11.1"
|