@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
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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() {
|
package/dist/onboarding/types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/theme/tokens.js
CHANGED
|
@@ -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";
|
package/dist/tui/layout.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tui/overview.js
CHANGED
|
@@ -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
|
}
|
package/dist/tui/panels.js
CHANGED
|
@@ -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",
|