@cruxy/cli 1.3.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 +96 -0
- package/dist/cli/commands/run.js +54 -3
- package/dist/cli/session-commands.js +16 -49
- package/dist/components/keys.js +38 -0
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- 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/diff.js +7 -1
- package/dist/render/status-view.js +58 -6
- package/dist/theme/tokens.js +7 -0
- package/dist/tui/app.js +125 -2
- package/dist/tui/disk-status.js +47 -0
- package/dist/tui/git-status.js +46 -1
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +8 -2
- package/dist/tui/layout.js +46 -0
- package/dist/tui/limits-panel.js +255 -0
- package/dist/tui/overview.js +61 -0
- package/dist/tui/panels.js +2 -0
- package/dist/tui/renderer.js +431 -20
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/views.js +66 -0
- package/dist/utils/disk.js +95 -0
- package/dist/utils/git.js +113 -0
- package/package.json +2 -2
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { formatStat } from "../render/diff.js";
|
|
2
|
+
import { fitMiddle } from "../render/layout.js";
|
|
3
|
+
/** Verb column, fixed width so the paths align into a scannable column. */
|
|
4
|
+
const VERBS = {
|
|
5
|
+
added: "added",
|
|
6
|
+
modified: "modified",
|
|
7
|
+
deleted: "deleted",
|
|
8
|
+
renamed: "renamed",
|
|
9
|
+
untracked: "new",
|
|
10
|
+
conflicted: "conflict",
|
|
11
|
+
};
|
|
12
|
+
const VERB_COLS = 8;
|
|
13
|
+
/**
|
|
14
|
+
* How many files to list per root before summarising the rest.
|
|
15
|
+
*
|
|
16
|
+
* A cap rather than the whole list because a `node_modules` accident produces
|
|
17
|
+
* thousands of untracked paths, and a pane that has to be scrolled past them to
|
|
18
|
+
* reach the next root is worse than one that says how many it held back. The
|
|
19
|
+
* remainder is COUNTED, never silently dropped.
|
|
20
|
+
*/
|
|
21
|
+
export const GIT_VIEW_MAX_FILES = 200;
|
|
22
|
+
/** Colour by what happened, so the shape of a change is legible before the path. */
|
|
23
|
+
function styleVerb(status, theme) {
|
|
24
|
+
const verb = VERBS[status].padEnd(VERB_COLS);
|
|
25
|
+
switch (status) {
|
|
26
|
+
case "deleted":
|
|
27
|
+
return theme.danger(verb);
|
|
28
|
+
case "added":
|
|
29
|
+
case "untracked":
|
|
30
|
+
return theme.success(verb);
|
|
31
|
+
case "conflicted":
|
|
32
|
+
return theme.warning(verb);
|
|
33
|
+
default:
|
|
34
|
+
return theme.muted(verb);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** `modified src/x.ts +12/-3` — verb, path, then the counts when known. */
|
|
38
|
+
function fileRow(file, theme, cols) {
|
|
39
|
+
const stat = formatStat(file, theme);
|
|
40
|
+
// The path is middle-truncated so the basename — the part that identifies the
|
|
41
|
+
// file — survives a narrow pane, the same rule `render/diff.ts` applies.
|
|
42
|
+
const room = Math.max(8, cols - VERB_COLS - 2 - (stat === "" ? 0 : 9));
|
|
43
|
+
const path = fitMiddle(file.path, room, theme.glyph.ellipsis);
|
|
44
|
+
return `${styleVerb(file.status, theme)} ${path}${stat === "" ? "" : ` ${stat}`}`;
|
|
45
|
+
}
|
|
46
|
+
/** One root's block: its heading, then its files or why there are none. */
|
|
47
|
+
function rootLines(root, theme, cols, multiRoot) {
|
|
48
|
+
const lines = [];
|
|
49
|
+
if (multiRoot) {
|
|
50
|
+
const mark = root.primary ? theme.accent(theme.glyph.pointer) : " ";
|
|
51
|
+
lines.push(`${mark} ${theme.strong(root.name)}`);
|
|
52
|
+
}
|
|
53
|
+
// The tri-state, drawn as three different sentences. "Not probed" and "not a
|
|
54
|
+
// repo" are not the same claim, and neither is "clean" — see `status-view.ts`.
|
|
55
|
+
if (root.state === undefined) {
|
|
56
|
+
lines.push(theme.muted(` checking${theme.glyph.ellipsis}`));
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
if (root.state === null) {
|
|
60
|
+
lines.push(theme.muted(" not a git repo"));
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
const { branch, files } = root.state;
|
|
64
|
+
lines.push(` ${theme.strong(branch)}`);
|
|
65
|
+
if (files === undefined) {
|
|
66
|
+
// The cheap probe ran, so dirtiness is known but the list was never asked
|
|
67
|
+
// for. Say that rather than render an empty list as "nothing changed".
|
|
68
|
+
lines.push(theme.muted(root.state.changed > 0
|
|
69
|
+
? ` ${root.state.changed} changed — file list not loaded`
|
|
70
|
+
: " clean"));
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
if (files.length === 0) {
|
|
74
|
+
lines.push(theme.success(" clean"));
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
for (const file of files.slice(0, GIT_VIEW_MAX_FILES)) {
|
|
78
|
+
lines.push(` ${fileRow(file, theme, Math.max(8, cols - 2))}`);
|
|
79
|
+
}
|
|
80
|
+
const hidden = files.length - GIT_VIEW_MAX_FILES;
|
|
81
|
+
if (hidden > 0) {
|
|
82
|
+
lines.push(theme.muted(` ${theme.glyph.ellipsis}${hidden} more`));
|
|
83
|
+
}
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
/** The whole Git view, as lines. Pure: no probe, no disk, no clock. */
|
|
87
|
+
export function gitViewLines(theme, cols, roots) {
|
|
88
|
+
const lines = [theme.heading("git")];
|
|
89
|
+
if (roots.length === 0) {
|
|
90
|
+
lines.push(theme.muted("no declared roots"));
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
const multiRoot = roots.length > 1;
|
|
94
|
+
for (const root of roots) {
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(...rootLines(root, theme, cols, multiRoot));
|
|
97
|
+
}
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The Git view as a registered source.
|
|
102
|
+
*
|
|
103
|
+
* `roots` is a callback rather than an array so `/add-root` mid-session is
|
|
104
|
+
* reflected without re-registering the view — the workspace is the owner of
|
|
105
|
+
* that list, and copying it here would make this the second place it lives.
|
|
106
|
+
*/
|
|
107
|
+
export function createGitView(roots, git) {
|
|
108
|
+
return {
|
|
109
|
+
id: "git",
|
|
110
|
+
label: "git",
|
|
111
|
+
lines: (theme, cols) => gitViewLines(theme, cols, roots().map((r) => ({
|
|
112
|
+
name: r.name,
|
|
113
|
+
primary: r.primary,
|
|
114
|
+
state: git.current(r.absPath),
|
|
115
|
+
}))),
|
|
116
|
+
refresh: async () => {
|
|
117
|
+
git.invalidate();
|
|
118
|
+
await git.refresh();
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/dist/tui/index.js
CHANGED
|
@@ -2,8 +2,14 @@ export { budgetColumns, bodyRows, columnOf, composeScreen, droppedForWidth, fitB
|
|
|
2
2
|
export { COLUMN_LABELS, PANEL_LABELS, contextPanelLines, gitPanelLines, headerModel, modelPanelLines, mainWelcome, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
|
|
3
3
|
export { ContextGauge, readContext, } from "./context-gauge.js";
|
|
4
4
|
export { ToolVersions, parseVersion, } from "./tool-versions.js";
|
|
5
|
-
export { GitStatusCache, } from "./git-status.js";
|
|
6
|
-
export {
|
|
5
|
+
export { GitStatusCache, WorkspaceGitCache, } from "./git-status.js";
|
|
6
|
+
export { WorkspaceDiskCache } from "./disk-status.js";
|
|
7
|
+
export { createOverviewView } from "./overview.js";
|
|
8
|
+
export { createGitView, gitViewLines, GIT_VIEW_MAX_FILES, } from "./git-view.js";
|
|
9
|
+
export { createTasksView, tasksViewLines, TASKS_VIEW_MAX_JOBS, TASKS_VIEW_TAIL, } from "./tasks-view.js";
|
|
10
|
+
export { createSettingsView, settingsViewLines, formatSettingValue, } from "./settings-view.js";
|
|
11
|
+
export { TuiRenderer, PAINT_INTERVAL_MS, VIEW_PULSE_MS, SCROLL_PAGE_OVERLAP, SCROLLBACK_LINES, } from "./renderer.js";
|
|
12
|
+
export { CONVERSATION_VIEW, cycleView, navLines, viewLabel, viewOrder, } from "./views.js";
|
|
7
13
|
export { runTui, renderInput, TUI_COMMANDS } from "./app.js";
|
|
8
14
|
export { openPalette, paletteItems, paletteInsertion, paletteLabel, } from "./palette.js";
|
|
9
15
|
export { canOverlay, createKeyLease, createOverlayFrame, createOverlayIO, } from "./overlay.js";
|
package/dist/tui/layout.js
CHANGED
|
@@ -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",
|
|
@@ -123,6 +124,51 @@ export function fitBlock(lines, rows, width) {
|
|
|
123
124
|
out.push(" ".repeat(Math.max(0, width)));
|
|
124
125
|
return out;
|
|
125
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Window `rows` lines out of a block, `offset` display lines up from the end
|
|
129
|
+
* (P7 track 1). `offset === 0` is the tail view {@link fitBlock} gives, and the
|
|
130
|
+
* two agree line-for-line there.
|
|
131
|
+
*
|
|
132
|
+
* The offset is measured from the BOTTOM because that is the edge the user is
|
|
133
|
+
* anchored to: the live tail is the home position, and "how far back am I" is
|
|
134
|
+
* the question both the clamp and the notice need answered. Measuring from the
|
|
135
|
+
* top would make every append renumber the position.
|
|
136
|
+
*
|
|
137
|
+
* Clamping belongs here rather than at the key handler because only this
|
|
138
|
+
* function knows the content: the handler would have to re-derive the wrapped
|
|
139
|
+
* line count to bound a keypress, which is the calculation this already did.
|
|
140
|
+
*/
|
|
141
|
+
export function scrollWindow(lines, rows, offset) {
|
|
142
|
+
const visible = Math.max(0, rows);
|
|
143
|
+
// Everything above the last `visible` lines is reachable, and no further: the
|
|
144
|
+
// top of the document is the top of the window, never a screen of blank rows
|
|
145
|
+
// above it.
|
|
146
|
+
const maxOffset = Math.max(0, lines.length - visible);
|
|
147
|
+
const clamped = Math.min(Math.max(0, Math.floor(offset)), maxOffset);
|
|
148
|
+
const end = lines.length - clamped;
|
|
149
|
+
const start = Math.max(0, end - visible);
|
|
150
|
+
return {
|
|
151
|
+
lines: lines.slice(start, end),
|
|
152
|
+
offset: clamped,
|
|
153
|
+
hiddenBelow: clamped,
|
|
154
|
+
hiddenAbove: start,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The scrollback notice: how far back the view is, and how to get out of it.
|
|
159
|
+
*
|
|
160
|
+
* Shown only while scrolled, and it costs a row from the window rather than
|
|
161
|
+
* overlaying one — the same trade {@link stackPanels} makes for its overflow
|
|
162
|
+
* notice, for the same reason: silently hiding a line to report hidden lines
|
|
163
|
+
* would be the one dishonest way to draw this.
|
|
164
|
+
*
|
|
165
|
+
* It names the key because a scrolled view is a mode, and a mode the user
|
|
166
|
+
* cannot see the exit from is a trap — there is no scrollbar here to drag.
|
|
167
|
+
*/
|
|
168
|
+
export function scrollNotice(hiddenBelow, theme) {
|
|
169
|
+
const glyph = theme.unicode ? "↓" : "v";
|
|
170
|
+
return theme.warning(`${glyph} ${hiddenBelow} more line${hiddenBelow === 1 ? "" : "s"} below · PgDn / Esc to return`);
|
|
171
|
+
}
|
|
126
172
|
/** Rows the overflow notice costs when at least one panel is dropped. */
|
|
127
173
|
const OVERFLOW_ROWS = 1;
|
|
128
174
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { buildSessionStatus } from "../agent/status.js";
|
|
2
|
+
import { sessionStatusLines } from "../render/status-view.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Overview view (P7 track 3) — the first concrete {@link ViewSource}.
|
|
5
|
+
*
|
|
6
|
+
* There is almost nothing here, and that is the result rather than an accident.
|
|
7
|
+
* `sessionStatusLines` already composed this screen for `/status`, and
|
|
8
|
+
* `buildSessionStatus` already assembles what it renders; the only thing that
|
|
9
|
+
* was missing was somewhere for it to LIVE. As a command it printed once into
|
|
10
|
+
* the scrollback and was gone by the next turn — a standing description of the
|
|
11
|
+
* session that could not stand.
|
|
12
|
+
*
|
|
13
|
+
* LIVE VS PER-PAINT, decided per fact rather than per view:
|
|
14
|
+
*
|
|
15
|
+
* - **Per-paint** — turns, mode, model, served tier, context occupancy,
|
|
16
|
+
* sandbox, checkpoints, job counts, tool count. Every one is a field read or
|
|
17
|
+
* a filter over an in-memory array, so recomputing them at 30fps costs
|
|
18
|
+
* nothing measurable and they are never a frame stale.
|
|
19
|
+
* - **Cached, off the paint path** — branch and change count, per root. These
|
|
20
|
+
* cost two subprocesses each, ~45ms warm and worse on Windows, and the pane
|
|
21
|
+
* repaints on every frame and every resize. {@link WorkspaceGitCache} does
|
|
22
|
+
* the probing after a turn; {@link lines} only ever reads the last settled
|
|
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.
|
|
25
|
+
*
|
|
26
|
+
* The rule the split enforces: nothing in `lines` may spawn, block, or touch
|
|
27
|
+
* the disk. Anything that needs to is a cache read here and a `refresh` there.
|
|
28
|
+
*/
|
|
29
|
+
export function createOverviewView(session, git,
|
|
30
|
+
/**
|
|
31
|
+
* The tier the gateway last said served a request — a getter, not a value,
|
|
32
|
+
* because it changes per turn and the view is registered once. Supplied by
|
|
33
|
+
* the renderer, the only object that sees the stream's routing frame.
|
|
34
|
+
*/
|
|
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) {
|
|
42
|
+
return {
|
|
43
|
+
id: "overview",
|
|
44
|
+
label: "overview",
|
|
45
|
+
lines: (theme, cols) => sessionStatusLines(buildSessionStatus(session, (absPath) => git.current(absPath), servedTier(), disk ? (path) => disk.current(path) : undefined), theme, cols),
|
|
46
|
+
/**
|
|
47
|
+
* Re-probe every root after a turn. Invalidate-then-refresh because a turn
|
|
48
|
+
* is exactly when the tree may have moved; the cache coalesces, so the ten
|
|
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.
|
|
55
|
+
*/
|
|
56
|
+
refresh: async () => {
|
|
57
|
+
git.invalidate();
|
|
58
|
+
await Promise.all([git.refresh(), disk?.refresh()]);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
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",
|