@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.
@@ -1,36 +1,122 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { ONBOARDING_FILE_NAME } from "../constants.js";
4
4
  import { globalDir, resolveApiKey } from "../config/index.js";
5
+ import { ONBOARDING_VERSION, OnboardingStateSchema, } from "./types.js";
5
6
  /**
6
7
  * First-run detection + onboarding-state persistence (U.6). "First run" is
7
8
  * deliberately derived from observable facts (no key + no completion marker)
8
9
  * rather than a flag, and is **TTY-gated** so a non-interactive run never
9
10
  * branches into an interactive flow.
10
11
  */
11
- const ONBOARDING_VERSION = 1;
12
12
  /** `~/.cruxy/onboarding.json` */
13
13
  export function onboardingStatePath() {
14
14
  return join(globalDir(), ONBOARDING_FILE_NAME);
15
15
  }
16
- /** Read persisted onboarding state, or `null` if absent/unreadable. */
16
+ /**
17
+ * Read persisted onboarding state, or `null` if absent, unreadable, or not the
18
+ * shape this file is supposed to hold.
19
+ *
20
+ * VALIDATED, not cast. `as OnboardingState` accepted any object at all, so a
21
+ * truncated write or a hand-edit could hand the flow a `completedAt` that was a
22
+ * number — and the completion marker is the one fact that decides whether a
23
+ * user is walked through setup again or silently isn't.
24
+ *
25
+ * An invalid file reads as ABSENT rather than throwing, which is the same
26
+ * treatment corrupt JSON already got: the worst outcome of re-onboarding is one
27
+ * extra prompt, and the worst outcome of a hard error is a CLI that won't start
28
+ * because of a file that only exists to say "you've done this before".
29
+ */
17
30
  export function readOnboardingState(file = onboardingStatePath()) {
18
31
  if (!existsSync(file))
19
32
  return null;
33
+ let parsed;
20
34
  try {
21
- const parsed = JSON.parse(readFileSync(file, "utf8"));
22
- if (parsed && typeof parsed === "object")
23
- return parsed;
35
+ parsed = JSON.parse(readFileSync(file, "utf8"));
24
36
  }
25
37
  catch {
26
38
  // A corrupt marker is treated as absent — onboarding can rewrite it.
39
+ return null;
27
40
  }
28
- return null;
41
+ const result = OnboardingStateSchema.safeParse(parsed);
42
+ return result.success ? migrateState(result.data) : null;
43
+ }
44
+ /**
45
+ * The version branch the file has been writing a number for since U.6 and
46
+ * nothing has ever read.
47
+ *
48
+ * OLDER than this build: bring it forward. There is exactly one version today,
49
+ * so this is a re-stamp and nothing else — the point is that the next bump has
50
+ * one obvious place to add its step, instead of discovering at that moment that
51
+ * every reader assumed v1.
52
+ *
53
+ * NEWER than this build: return it untouched and keep trusting `completedAt`.
54
+ * Both alternatives are worse. Discarding it re-runs onboarding for someone who
55
+ * has plainly finished it, every time they use the older binary — running two
56
+ * cruxy versions against one home directory is ordinary, and being re-prompted
57
+ * for a key you already saved is not a reasonable punishment for it. Rewriting
58
+ * it down to our shape would destroy state we don't understand. `completedAt`
59
+ * is the marker in every version that has one; reading it across a version we
60
+ * don't know is a smaller assumption than either.
61
+ */
62
+ function migrateState(state) {
63
+ if (state.version >= ONBOARDING_VERSION)
64
+ return state;
65
+ return { ...state, version: ONBOARDING_VERSION };
29
66
  }
30
- /** Persist onboarding state (creates `~/.cruxy` if needed). */
67
+ /**
68
+ * Persist onboarding state (creates `~/.cruxy` if needed), owner-only.
69
+ *
70
+ * MERGED over what is on disk, not written over it. The schema passes unknown
71
+ * keys through, so a field a newer cruxy wrote survives a run of an older one
72
+ * — tolerating it on read and then flattening it on the next write would lose
73
+ * the data anyway, just one write later.
74
+ *
75
+ * The version is the MAX of the two for the same reason `migrateState` doesn't
76
+ * discard a future file: this build preserved the newer keys, so stamping our
77
+ * own lower number over them would tell the newer cruxy that its migration
78
+ * hasn't run when its data is in fact all still there.
79
+ */
31
80
  export function writeOnboardingState(state, file = onboardingStatePath()) {
81
+ const existing = readOnboardingState(file);
82
+ const merged = {
83
+ ...existing,
84
+ ...state,
85
+ version: Math.max(existing?.version ?? 0, state.version),
86
+ };
32
87
  mkdirSync(dirname(file), { recursive: true });
33
- writeFileSync(file, JSON.stringify(state, null, 2) + "\n", "utf8");
88
+ writeFileSync(file, JSON.stringify(merged, null, 2) + "\n", {
89
+ encoding: "utf8",
90
+ mode: 0o600,
91
+ });
92
+ restrictToOwner(file);
93
+ }
94
+ /**
95
+ * Make the marker owner-only. It was the one store under `~/.cruxy` still at
96
+ * `0644` — usage, memory trust, MCP trust and hook trust are all `0600` — and
97
+ * while it holds no secret, it does record that this machine's user has a cruxy
98
+ * key configured and when they set it up. That is nobody else's business on a
99
+ * shared box, and "every file cruxy writes to your home directory is yours
100
+ * alone" is a rule worth being able to state without an exception.
101
+ *
102
+ * The `mode` on the write above only applies when the file is CREATED, so it
103
+ * does nothing for the `0644` files already out there; this fixes those on the
104
+ * next write. Best-effort and non-fatal, deliberately: unlike the credential
105
+ * store — which refuses to persist a secret it cannot secure — refusing to
106
+ * write this one would mean re-running onboarding forever on a filesystem
107
+ * without modes. Windows is skipped because POSIX modes are meaningless there
108
+ * (see `config/owner-only.ts`), and an `icacls` edit is more than a marker
109
+ * warrants.
110
+ */
111
+ function restrictToOwner(file) {
112
+ if (process.platform === "win32")
113
+ return;
114
+ try {
115
+ chmodSync(file, 0o600);
116
+ }
117
+ catch {
118
+ // A filesystem that can't do modes still gets a working marker.
119
+ }
34
120
  }
35
121
  /** A fresh state object. */
36
122
  export function newOnboardingState() {
@@ -1,6 +1,34 @@
1
+ import { z } from "zod";
1
2
  /**
2
3
  * Types for the first-run onboarding flow (U.6). The flow is built from injected
3
4
  * dependencies (validation, persistence, the first-win runner) so every step is
4
5
  * unit-testable with scripted IO and no real network or filesystem.
5
6
  */
6
- export {};
7
+ /**
8
+ * Bump when the on-disk onboarding state shape changes. The number is only
9
+ * worth writing if something READS it — see `migrateState` in `detect.ts`,
10
+ * which is where the branch it enables lives.
11
+ */
12
+ export const ONBOARDING_VERSION = 1;
13
+ /**
14
+ * The persisted state (`~/.cruxy/onboarding.json`), as a schema rather than a
15
+ * cast. It was read with `as OnboardingState` — a cast validates nothing, so
16
+ * `{"completedAt": 42}` or a JSON array parsed clean and the completion marker
17
+ * became whatever the file happened to contain.
18
+ *
19
+ * `.passthrough()`, for the reason `usage/types.ts` sets out at length: a read
20
+ * that can't validate is a read that discards, and a store hostile to its own
21
+ * future silently drops what a newer cruxy wrote the moment an older one runs
22
+ * against the same home directory. Passthrough both TOLERATES an unknown key
23
+ * and PRESERVES it, so `writeOnboardingState`'s merge hands it back intact.
24
+ */
25
+ export const OnboardingStateSchema = z
26
+ .object({
27
+ /** Shape version — see {@link ONBOARDING_VERSION}. */
28
+ version: z.number().int().nonnegative(),
29
+ /** ISO timestamp; **its presence is the completion marker**. */
30
+ completedAt: z.string().min(1).optional(),
31
+ /** Whether a key has been successfully configured at least once. */
32
+ keyConfigured: z.boolean().optional(),
33
+ })
34
+ .passthrough();
@@ -1,9 +1,55 @@
1
+ import { capacityLevel, formatCapacity, } from "../utils/disk.js";
1
2
  import { fit } from "./layout.js";
2
3
  import { formatTokens } from "./state.js";
3
4
  /** `key value`, aligned on a fixed gutter so the column is scannable. */
4
5
  function row(key, value, t) {
5
6
  return ` ${t.muted(key.padEnd(11))} ${value}`;
6
7
  }
8
+ /**
9
+ * Collapse locations that report identical figures into one row, naming all of
10
+ * them.
11
+ *
12
+ * A project under `~` and `~/.cruxy` are usually the same filesystem, and two
13
+ * rows saying `54% free · 251 GiB of 465 GiB` twice is noise dressed as detail.
14
+ * Grouping is on the RENDERED numbers rather than on mount identity because
15
+ * `statfs` gives no filesystem id to compare — and grouping on the numbers is
16
+ * the honest test anyway: if two locations report the same total and the same
17
+ * free bytes, either they are one filesystem or they are two the reader has no
18
+ * way to tell apart, and in both cases one row is the whole of what's known.
19
+ */
20
+ function groupLocations(locations) {
21
+ const groups = new Map();
22
+ for (const loc of locations) {
23
+ const key = `${loc.capacity.totalBytes}:${loc.capacity.freeBytes}`;
24
+ const existing = groups.get(key);
25
+ if (existing)
26
+ existing.labels.push(loc.label);
27
+ else
28
+ groups.set(key, { labels: [loc.label], capacity: loc.capacity });
29
+ }
30
+ return [...groups.values()];
31
+ }
32
+ /**
33
+ * The disk rows: `disk cli, ~/.cruxy 54% free · 251 GiB of 465 GiB`.
34
+ *
35
+ * Coloured by {@link capacityLevel}, which judges absolute bytes — so a nearly
36
+ * full 4 TB disk with plenty left in it is not dressed up as an emergency, and
37
+ * a small VM disk with 800 MB left is, whatever percentage that happens to be.
38
+ * Only the first row carries the key; the rest align under it, because a `disk`
39
+ * label repeated down the column reads as several different facts.
40
+ */
41
+ function diskLines(locations, t) {
42
+ return groupLocations(locations).map((group, i) => {
43
+ const text = formatCapacity(group.capacity);
44
+ const level = capacityLevel(group.capacity);
45
+ const value = level === "critical"
46
+ ? t.danger(text)
47
+ : level === "low"
48
+ ? t.warning(text)
49
+ : t.muted(text);
50
+ return row(i === 0 ? "disk" : "", `${group.labels.join(", ")} ${value}`, t);
51
+ });
52
+ }
7
53
  /** The full `/status` block as lines to print. */
8
54
  export function sessionStatusLines(status, t, width = Infinity) {
9
55
  const lines = [t.heading("status")];
@@ -33,6 +79,12 @@ export function sessionStatusLines(status, t, width = Infinity) {
33
79
  lines.push(row("checkpoints", status.checkpoints
34
80
  ? t.muted("on — `cruxy rollback` can undo a run's file changes")
35
81
  : t.warning("off — file changes are not restorable"), t));
82
+ // Next to checkpoints, the feature that turns a turn into a shadow copy of
83
+ // the tree: the two rows answer one question between them — will this
84
+ // session's writes land.
85
+ if (status.disk && status.disk.length > 0) {
86
+ lines.push(...diskLines(status.disk, t));
87
+ }
36
88
  if (status.jobs) {
37
89
  const { total, running, needingApproval } = status.jobs;
38
90
  const detail = total === 0
@@ -22,6 +22,8 @@ export const UNICODE_GLYPHS = {
22
22
  caretDown: "↓",
23
23
  cached: "↻",
24
24
  cursorBar: "▏",
25
+ barFilled: "█",
26
+ barEmpty: "░",
25
27
  bullet: "•",
26
28
  sep: "·",
27
29
  ellipsis: "…",
@@ -45,6 +47,8 @@ export const ASCII_GLYPHS = {
45
47
  caretDown: "v",
46
48
  cached: "",
47
49
  cursorBar: "|",
50
+ barFilled: "#",
51
+ barEmpty: ".",
48
52
  bullet: "*",
49
53
  sep: "-",
50
54
  ellipsis: "...",
@@ -72,6 +76,9 @@ export const SCREEN_READER_GLYPHS = {
72
76
  caretDown: "down",
73
77
  cached: "",
74
78
  cursorBar: "",
79
+ // A bar announces as nothing; the percentage beside it says everything.
80
+ barFilled: "",
81
+ barEmpty: "",
75
82
  bullet: "-",
76
83
  sep: "-",
77
84
  ellipsis: "...",
@@ -0,0 +1,47 @@
1
+ import { readDiskCapacity } from "../utils/disk.js";
2
+ export class WorkspaceDiskCache {
3
+ paths;
4
+ probe;
5
+ values = new Map();
6
+ /** The refresh in flight, so concurrent triggers share one pass. */
7
+ inFlight = null;
8
+ constructor(paths, probe = readDiskCapacity) {
9
+ // Deduplicated: the same path twice would be the same syscall twice for the
10
+ // same answer. Two DIFFERENT paths on one filesystem are NOT collapsed here
11
+ // — that is a question about the numbers, and it is answered where they are
12
+ // rendered (see `status-view.ts`), not by guessing at mount identity.
13
+ this.paths = [...new Set(paths)];
14
+ this.probe = probe;
15
+ }
16
+ /**
17
+ * The last known capacity for one path — a map read, safe from the paint
18
+ * path. `undefined` means not probed yet, probed and unreadable, or a path
19
+ * this cache was never given; every one of those renders as nothing, so they
20
+ * do not need telling apart the way the git tri-state does.
21
+ */
22
+ current(path) {
23
+ return this.values.get(path);
24
+ }
25
+ /** Re-probe every path, concurrently, and resolve when all have settled. */
26
+ async refresh() {
27
+ if (this.inFlight)
28
+ return this.inFlight;
29
+ this.inFlight = this.run();
30
+ try {
31
+ await this.inFlight;
32
+ }
33
+ finally {
34
+ this.inFlight = null;
35
+ }
36
+ }
37
+ async run() {
38
+ await Promise.all(this.paths.map(async (path) => {
39
+ const value = await this.probe(path);
40
+ // A failed probe keeps the last good reading rather than blanking the
41
+ // row. A transient failure is not evidence that the disk changed, and a
42
+ // figure that flickers in and out is read as a bug in the tool.
43
+ if (value)
44
+ this.values.set(path, value);
45
+ }));
46
+ }
47
+ }
package/dist/tui/index.js CHANGED
@@ -3,6 +3,7 @@ export { COLUMN_LABELS, PANEL_LABELS, contextPanelLines, gitPanelLines, headerMo
3
3
  export { ContextGauge, readContext, } from "./context-gauge.js";
4
4
  export { ToolVersions, parseVersion, } from "./tool-versions.js";
5
5
  export { GitStatusCache, WorkspaceGitCache, } from "./git-status.js";
6
+ export { WorkspaceDiskCache } from "./disk-status.js";
6
7
  export { createOverviewView } from "./overview.js";
7
8
  export { createGitView, gitViewLines, GIT_VIEW_MAX_FILES, } from "./git-view.js";
8
9
  export { createTasksView, tasksViewLines, TASKS_VIEW_MAX_JOBS, TASKS_VIEW_TAIL, } from "./tasks-view.js";
@@ -1,6 +1,7 @@
1
1
  import { fit, visibleWidth } from "../render/layout.js";
2
2
  export const RAIL_PANELS = [
3
3
  "context",
4
+ "limits",
4
5
  "model",
5
6
  "git",
6
7
  "tools",
@@ -0,0 +1,255 @@
1
+ import { bindingWindow, usedFraction } from "../limits/index.js";
2
+ /**
3
+ * The limits panel (P9): what this credential may spend, and how much is left.
4
+ *
5
+ * THE PANEL IS DRIVEN BY THE BUCKET, NEVER BY THE LOGIN. Which numbers exist —
6
+ * and whether a fraction exists at all — is decided by the billing bucket the
7
+ * gateway reports, because that is the only thing that actually decides it. The
8
+ * inference this replaces (a CLI login is an apikey, so it is metered and
9
+ * ungated) was true when it was written and silently stopped being true when the
10
+ * gateway changed what a login mints (cruxy-ai/api#174), with nothing locally
11
+ * observable changing. So there are four bodies here, one per shape, and the
12
+ * type system hands each of them only the numbers that shape may state.
13
+ *
14
+ * AND THIS ONE GETS A BAR. The context panel refuses one under a locking test,
15
+ * for reasons that are exactly right there: its numerator is a chars/4 estimate
16
+ * and its denominator a local config default, so a filled bar would render a
17
+ * guess as a measurement. Every number here is the opposite — server-authored,
18
+ * in the meter's own unit, peeked from the same buckets the gate enforces on, so
19
+ * the fraction the bar fills IS the fraction the next request is judged against.
20
+ * A bar is the right claim for a real measurement, and only for one.
21
+ *
22
+ * The corollary is the rule the rest of this file exists to keep: NO CAP, NO
23
+ * FRACTION. An enterprise pool reports `{enforced: false}` and a developer key
24
+ * reports `metered: true`; neither has an allowance, so neither gets a bar, a
25
+ * percentage, or an X-of-Y — not even a reassuring full one. Spend caps are not
26
+ * a substitute, because they appear only when someone set one.
27
+ */
28
+ /** Cells in the bar. Sized so `bar + " " + "100%"` clears {@link RAIL_COLS}. */
29
+ const BAR_CELLS = 12;
30
+ /**
31
+ * A proportion bar. Empty under the screen-reader glyph table (both cells are
32
+ * ""), which is deliberate — a run of blocks announces as nothing, and the
33
+ * percentage that follows it carries the entire meaning.
34
+ */
35
+ export function bar(theme, fraction, cells = BAR_CELLS) {
36
+ const filled = Math.round(Math.min(1, Math.max(0, fraction)) * cells);
37
+ return (theme.glyph.barFilled.repeat(filled) +
38
+ theme.glyph.barEmpty.repeat(cells - filled));
39
+ }
40
+ /**
41
+ * The styler for a window's state. `low` and `blocked` are the two the user can
42
+ * act on, and they are different acts — one is "wrap up", the other is "you are
43
+ * already being refused" — so they are not merged into one warning.
44
+ */
45
+ function stateStyle(theme, state) {
46
+ if (state === "blocked")
47
+ return theme.danger;
48
+ if (state === "low")
49
+ return theme.warning;
50
+ return theme.strong;
51
+ }
52
+ /** 14_000_000 → "14M", 8_700_000 → "8.7M", 125_000 → "125k", 900 → "900". */
53
+ export function compactTokens(n) {
54
+ const abs = Math.abs(n);
55
+ if (abs >= 1_000_000)
56
+ return `${trimZero(n / 1_000_000)}M`;
57
+ if (abs >= 1_000)
58
+ return `${trimZero(n / 1_000)}k`;
59
+ return `${Math.round(n)}`;
60
+ }
61
+ /** One decimal, but only when it says something: 8.7 stays, 14.0 becomes 14. */
62
+ function trimZero(n) {
63
+ const one = n.toFixed(1);
64
+ return one.endsWith(".0") ? one.slice(0, -2) : one;
65
+ }
66
+ /** 27.77 → "$27.77", 29 → "$29", 0.5 → "$0.50". */
67
+ export function usd(n) {
68
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
69
+ }
70
+ /**
71
+ * How long until an ISO instant, compactly: "18d", "4h", "12m", "now".
72
+ * `undefined` for an absent or unparseable one — a reset we cannot state is
73
+ * simply not stated, never guessed at.
74
+ */
75
+ export function untilLabel(iso, now) {
76
+ if (!iso)
77
+ return undefined;
78
+ const at = Date.parse(iso);
79
+ if (Number.isNaN(at))
80
+ return undefined;
81
+ const ms = at - now;
82
+ if (ms <= 0)
83
+ return "now";
84
+ const minutes = Math.floor(ms / 60_000);
85
+ if (minutes < 60)
86
+ return `${Math.max(1, minutes)}m`;
87
+ const hours = Math.floor(minutes / 60);
88
+ if (hours < 24)
89
+ return `${hours}h`;
90
+ return `${Math.floor(hours / 24)}d`;
91
+ }
92
+ /** A percentage for display: 0.618 → "62%". */
93
+ function pct(fraction) {
94
+ return `${Math.round(fraction * 100)}%`;
95
+ }
96
+ /**
97
+ * Beyond this, the reading is called out as old rather than presented as
98
+ * current. The pool is shared with every other surface on the account, so an
99
+ * idle CLI's figures can go stale without this process doing anything at all —
100
+ * which is precisely the case a timestamp has to cover.
101
+ */
102
+ const STALE_AFTER_MS = 5 * 60_000;
103
+ /**
104
+ * The subscription pool: a bar on the BINDING window, the same one the gate
105
+ * decides against.
106
+ *
107
+ * Drawing the month instead — the bigger, friendlier number — would routinely
108
+ * show a comfortable 6% while the trailing-12h burst, a quarter of the month's
109
+ * cap on every self-serve tier, is the window actually about to refuse the next
110
+ * request. The bar is the thing that stops you, and the line under it names
111
+ * which window that is so the figure is never ambiguous.
112
+ */
113
+ function poolLines(theme, pool, now) {
114
+ const binding = bindingWindow(pool.monthly, pool.burst);
115
+ // Unreachable via `reduceLimits` (a pool with no readable window reduces to
116
+ // `unknown`), but the type permits it and inventing a bar is the one thing
117
+ // this panel must never do on the way to being defensive.
118
+ if (!binding)
119
+ return [theme.muted("figures not reported")];
120
+ const { window: w, name } = binding;
121
+ const fraction = usedFraction(w) ?? 0;
122
+ const style = stateStyle(theme, w.state ?? pool.state);
123
+ const lines = [
124
+ `${style(bar(theme, fraction))} ${style(pct(fraction))}`,
125
+ `${compactTokens(w.used)} / ${compactTokens(w.cap)} ${name}`,
126
+ ];
127
+ // The window that is NOT binding, so both dimensions are visible — a user
128
+ // whose burst is tight still needs to know the month is nearly gone too.
129
+ const other = name === "burst" ? pool.monthly : pool.burst;
130
+ const otherName = name === "burst" ? "month" : "burst";
131
+ const until = untilLabel(w.resetsAt, now);
132
+ if (other) {
133
+ const otherPct = pct(usedFraction(other) ?? 0);
134
+ lines.push(theme.muted(until
135
+ ? `${otherName} ${otherPct} ${theme.sep}${until}`
136
+ : `${otherName} ${otherPct}`));
137
+ }
138
+ else if (until) {
139
+ lines.push(theme.muted(`resets ${until}`));
140
+ }
141
+ // Only when it is actionable. A blocked model list is the answer to "why did
142
+ // that request fail"; mira's remaining requests are the answer to "what can I
143
+ // still run" — and neither question is being asked while the pool is healthy.
144
+ if (pool.blockedModels.length > 0) {
145
+ lines.push(theme.danger(`no ${pool.blockedModels.join(" ")}`));
146
+ }
147
+ else if (pool.state === "low" && pool.mira) {
148
+ lines.push(theme.muted(`mira ${compactTokens(pool.mira.remaining)} left`));
149
+ }
150
+ return lines;
151
+ }
152
+ /** A spend cap line. Text, never a bar: the bar belongs to the allowance. */
153
+ function spendCapLine(theme, label, cap) {
154
+ return theme.muted(`${label} ${usd(cap.remaining)} of ${usd(cap.cap)}`);
155
+ }
156
+ /** How long ago, compactly: the mirror of {@link untilLabel}. */
157
+ function agoLabel(ms) {
158
+ const minutes = Math.floor(ms / 60_000);
159
+ if (minutes < 60)
160
+ return `${Math.max(1, minutes)}m`;
161
+ const hours = Math.floor(minutes / 60);
162
+ return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
163
+ }
164
+ /** The body for a ready reading, chosen by the budget shape. */
165
+ function readingLines(theme, reading, now) {
166
+ const lines = [];
167
+ const budget = reading.budget;
168
+ switch (budget.kind) {
169
+ case "pool":
170
+ lines.push(...poolLines(theme, budget, now));
171
+ break;
172
+ // Enterprise. NO fraction, and no consolation bar drawn at 0% either: a full
173
+ // green bar and "no limit" are read the same way at a glance, and only one
174
+ // of them is true.
175
+ case "unenforced":
176
+ lines.push(theme.strong(reading.tier));
177
+ lines.push(theme.muted("pool not enforced"));
178
+ break;
179
+ // A developer key: billed per token, gated by nothing it draws down. Saying
180
+ // "no pool" explicitly matters here — an empty budget section would read as
181
+ // a figure that failed to load rather than as a figure that does not exist.
182
+ case "metered":
183
+ lines.push(`${theme.strong(reading.tier)}${theme.muted(" metered")}`);
184
+ lines.push(theme.muted("no pool cap"));
185
+ break;
186
+ case "credits": {
187
+ const fraction = budget.granted > 0
188
+ ? Math.min(1, Math.max(0, budget.used / budget.granted))
189
+ : undefined;
190
+ if (fraction !== undefined) {
191
+ const style = fraction >= 0.9 ? theme.warning : theme.strong;
192
+ lines.push(`${style(bar(theme, fraction))} ${style(pct(fraction))}`);
193
+ }
194
+ lines.push(`${usd(budget.remaining)} of ${usd(budget.granted)}`);
195
+ const until = untilLabel(budget.resetsAt, now);
196
+ if (until)
197
+ lines.push(theme.muted(`resets ${until}`));
198
+ break;
199
+ }
200
+ // The bucket, or the pool inside it, is one this build cannot read. Both
201
+ // facts it DOES have are real and server-resolved, so both are shown; what
202
+ // it does not have, it declines to imply.
203
+ case "unknown":
204
+ lines.push(`${theme.strong(reading.tier)}${theme.muted(` ${theme.glyph.sep} ${reading.bucket}`)}`);
205
+ lines.push(theme.muted("budget not reported"));
206
+ break;
207
+ }
208
+ // Rate and spend caps hang off every bucket, so they are appended once here
209
+ // rather than repeated per branch. They are the ONLY ceilings a metered key
210
+ // has — and they appear only when they exist, which is why they can never
211
+ // stand in for the pool a metered key does not have.
212
+ if (budget.kind === "metered" && reading.chat) {
213
+ const { remaining, limit } = reading.chat.perKey;
214
+ lines.push(theme.muted(`${remaining} / ${limit} rpm`));
215
+ }
216
+ if (reading.keySpendCap) {
217
+ lines.push(spendCapLine(theme, "key", reading.keySpendCap));
218
+ }
219
+ if (reading.workspaceSpendCap) {
220
+ lines.push(spendCapLine(theme, "ws", reading.workspaceSpendCap));
221
+ }
222
+ // The cache keeps the last good reading through a failed refresh, so the panel
223
+ // owes the user the age of what it is showing rather than the impression that
224
+ // it is live.
225
+ const age = now - reading.readAt;
226
+ if (age > STALE_AFTER_MS) {
227
+ lines.push(theme.muted(`as of ${agoLabel(age)} ago`));
228
+ }
229
+ return lines;
230
+ }
231
+ /**
232
+ * The limits panel's body.
233
+ *
234
+ * `pending` and each error reason are said differently, on the tri-state
235
+ * discipline the git panel established: "still asking" must not read as "no
236
+ * limits", and neither may read as "your key is bad".
237
+ */
238
+ export function limitsPanelLines(theme, state, now = Date.now()) {
239
+ if (state === undefined || state.status === "pending") {
240
+ return [theme.muted(`checking${theme.glyph.ellipsis}`)];
241
+ }
242
+ if (state.status === "error") {
243
+ switch (state.reason) {
244
+ case "unauthenticated":
245
+ return [theme.muted("not signed in"), theme.muted("run cruxy login")];
246
+ case "unsupported":
247
+ // The gateway answered — it simply has no limits to report. Sending this
248
+ // user to debug their network would be the wrong errand entirely.
249
+ return [theme.muted("gateway reports"), theme.muted("no limits")];
250
+ case "unreachable":
251
+ return [theme.muted("gateway unreachable")];
252
+ }
253
+ }
254
+ return readingLines(theme, state.reading, now);
255
+ }
@@ -20,7 +20,8 @@ import { sessionStatusLines } from "../render/status-view.js";
20
20
  * cost two subprocesses each, ~45ms warm and worse on Windows, and the pane
21
21
  * repaints on every frame and every resize. {@link WorkspaceGitCache} does
22
22
  * the probing after a turn; {@link lines} only ever reads the last settled
23
- * value.
23
+ * value. Free space joins them: microseconds on a local disk, and an
24
+ * unbounded kernel block on a network mount that has stopped answering.
24
25
  *
25
26
  * The rule the split enforces: nothing in `lines` may spawn, block, or touch
26
27
  * the disk. Anything that needs to is a cache read here and a `refresh` there.
@@ -31,19 +32,30 @@ export function createOverviewView(session, git,
31
32
  * because it changes per turn and the view is registered once. Supplied by
32
33
  * the renderer, the only object that sees the stream's routing frame.
33
34
  */
34
- servedTier = () => undefined) {
35
+ servedTier = () => undefined,
36
+ /**
37
+ * Free space where cruxy writes. Optional so a caller that has no disk cache
38
+ * gets a status screen without disk rows rather than a probe it didn't ask
39
+ * for — the same shape `servedTier` uses for a fact only some callers have.
40
+ */
41
+ disk) {
35
42
  return {
36
43
  id: "overview",
37
44
  label: "overview",
38
- lines: (theme, cols) => sessionStatusLines(buildSessionStatus(session, (absPath) => git.current(absPath), servedTier()), theme, cols),
45
+ lines: (theme, cols) => sessionStatusLines(buildSessionStatus(session, (absPath) => git.current(absPath), servedTier(), disk ? (path) => disk.current(path) : undefined), theme, cols),
39
46
  /**
40
47
  * Re-probe every root after a turn. Invalidate-then-refresh because a turn
41
48
  * is exactly when the tree may have moved; the cache coalesces, so the ten
42
49
  * writes a turn makes still cost one probe per root.
50
+ *
51
+ * Disk refreshes alongside, and unconditionally — it has no invalidate,
52
+ * because free space moves for reasons that have nothing to do with this
53
+ * session (see `disk-status.ts`). Concurrently, not in sequence: they are
54
+ * independent reads and neither should wait on the other's slowest mount.
43
55
  */
44
56
  refresh: async () => {
45
57
  git.invalidate();
46
- await git.refresh();
58
+ await Promise.all([git.refresh(), disk?.refresh()]);
47
59
  },
48
60
  };
49
61
  }
@@ -17,6 +17,7 @@ import { RAIL_PANELS, } from "./layout.js";
17
17
  export const PANEL_LABELS = {
18
18
  sidebar: "sidebar",
19
19
  context: "context",
20
+ limits: "limits",
20
21
  model: "model",
21
22
  git: "git",
22
23
  tools: "tools",
@@ -30,6 +31,7 @@ export const COLUMN_LABELS = {
30
31
  /** Titles drawn at the top of each rail panel. */
31
32
  const RAIL_TITLES = {
32
33
  context: "context",
34
+ limits: "limits",
33
35
  model: "model",
34
36
  git: "git",
35
37
  tools: "tools",