@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.
@@ -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.4.0",
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.4.1"
39
+ "@cruxy/sdk": "0.5.0"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "better-sqlite3": "^12.11.1"