@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,282 @@
|
|
|
1
|
+
import { fit, fitMiddle } from "../render/layout.js";
|
|
2
|
+
import { apiKeyEnvVar, effectiveSettings, envOverrideNames, loadConfig, } from "../config/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Settings view (P7 track 6): every effective setting, and — the part that
|
|
5
|
+
* makes it worth a pane — WHERE each one came from.
|
|
6
|
+
*
|
|
7
|
+
* READ-ONLY, DELIBERATELY. `cruxy config set` writes a value with validation
|
|
8
|
+
* and a file to blame, and `$EDITOR` writes the file itself; an editable pane
|
|
9
|
+
* would have to grow a form system (a focus ring, per-type input, validation
|
|
10
|
+
* surfacing, a write path, and an undo for the write) to arrive at what two
|
|
11
|
+
* existing surfaces already do correctly. What was actually missing was not
|
|
12
|
+
* editing — it was ANSWERING: `cruxy config list` prints a resolved blob in
|
|
13
|
+
* which a value someone set by hand is indistinguishable from one the schema
|
|
14
|
+
* defaulted, so "why is this on" ends in opening files and re-deriving the
|
|
15
|
+
* precedence by hand. Every row here names its origin.
|
|
16
|
+
*
|
|
17
|
+
* THIS SESSION'S VALUES, NOT THE DISK'S. Config is read once at startup and the
|
|
18
|
+
* session runs on that; a pane that re-read the files would show a value the
|
|
19
|
+
* running session is NOT using the moment someone edits one mid-session — a
|
|
20
|
+
* stale claim in the more dangerous direction, since it reads as confirmation
|
|
21
|
+
* that an edit took effect. So the rows are the startup snapshot, and
|
|
22
|
+
* {@link ViewSource.refresh} re-reads only to compare: when the disk has moved,
|
|
23
|
+
* the pane SAYS so and says that a restart is what applies it.
|
|
24
|
+
*
|
|
25
|
+
* NOTHING SECRET REACHES THIS FILE. Redaction happens in
|
|
26
|
+
* {@link effectiveSettings}, one layer down, so this module never holds a
|
|
27
|
+
* credential to leak — `credentials.json` is 0600 for a reason, and a pane on a
|
|
28
|
+
* shared screen is the least controlled surface in the product.
|
|
29
|
+
*/
|
|
30
|
+
/** Width of the origin column — `explicit` renders as `--config`, 8 chars. */
|
|
31
|
+
const ORIGIN_COLS = 8;
|
|
32
|
+
/** Bounds on the key column, so one long key can't squeeze out every value. */
|
|
33
|
+
const KEY_COLS_MIN = 12;
|
|
34
|
+
const KEY_COLS_MAX = 28;
|
|
35
|
+
/** The word each origin renders as. The file ones name the SCOPE (`global`,
|
|
36
|
+
* `project`); the explicit one names the FLAG that caused it, because that is
|
|
37
|
+
* what the user typed and what they would remove. */
|
|
38
|
+
const ORIGIN_WORDS = {
|
|
39
|
+
default: "default",
|
|
40
|
+
global: "global",
|
|
41
|
+
project: "project",
|
|
42
|
+
explicit: "--config",
|
|
43
|
+
env: "env",
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Colour by origin, but the WORD carries the meaning — this has to survive
|
|
47
|
+
* NO_COLOR and a screen reader, where "dimmer means default" says nothing.
|
|
48
|
+
*
|
|
49
|
+
* `env` is warned rather than accented, and it is the one origin that gets a
|
|
50
|
+
* colour of its own: a file override is durable and inspectable, while an
|
|
51
|
+
* environment override lives in one shell, is invisible everywhere else, and
|
|
52
|
+
* disappears on the next login — which makes it the likeliest answer to "why is
|
|
53
|
+
* this on, I never set that".
|
|
54
|
+
*/
|
|
55
|
+
function styleOrigin(origin, theme) {
|
|
56
|
+
const word = ORIGIN_WORDS[origin].padStart(ORIGIN_COLS);
|
|
57
|
+
switch (origin) {
|
|
58
|
+
case "default":
|
|
59
|
+
return theme.muted(word);
|
|
60
|
+
case "env":
|
|
61
|
+
return theme.warning(word);
|
|
62
|
+
default:
|
|
63
|
+
return theme.accent(word);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A value as one line of text.
|
|
68
|
+
*
|
|
69
|
+
* An empty string renders as `""` and an empty array/object as `[]`/`{}`
|
|
70
|
+
* because "" and unset are different states and a blank cell shows them the
|
|
71
|
+
* same way — `routing.map: {}` in particular is what a configured-but-inert
|
|
72
|
+
* feature looks like, and it has to be distinguishable from a key that is
|
|
73
|
+
* missing entirely.
|
|
74
|
+
*/
|
|
75
|
+
export function formatSettingValue(value) {
|
|
76
|
+
if (typeof value === "string")
|
|
77
|
+
return value === "" ? '""' : value;
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
return value.length === 0 ? "[]" : value.map(String).join(", ");
|
|
80
|
+
}
|
|
81
|
+
if (value === null)
|
|
82
|
+
return "null";
|
|
83
|
+
if (typeof value === "object")
|
|
84
|
+
return "{}";
|
|
85
|
+
return String(value);
|
|
86
|
+
}
|
|
87
|
+
/** ` provider cruxy default`. */
|
|
88
|
+
function settingRow(setting, keyLabel, keyCols, theme, cols) {
|
|
89
|
+
// Middle-truncated like the value: these labels are dot-paths, and the TAIL
|
|
90
|
+
// is the part that names the setting — `servers.remote.headers.Authorization`
|
|
91
|
+
// right-truncated to `servers.remote.headers.Auth…` has spent 28 columns
|
|
92
|
+
// saying which section it is in and none saying which key.
|
|
93
|
+
const key = fitMiddle(keyLabel, keyCols, theme.glyph.ellipsis).padEnd(keyCols);
|
|
94
|
+
const room = Math.max(8, cols - 2 - keyCols - 2 - ORIGIN_COLS - 1);
|
|
95
|
+
const raw = formatSettingValue(setting.value);
|
|
96
|
+
// Middle-truncated: these values are paths, URLs and commands, whose tail
|
|
97
|
+
// identifies them at least as much as their head — the rule `render/diff.ts`
|
|
98
|
+
// applies to paths, for the same reason.
|
|
99
|
+
const value = fitMiddle(raw, room, theme.glyph.ellipsis).padEnd(room);
|
|
100
|
+
const styled = setting.redacted ? theme.warning(value) : value;
|
|
101
|
+
const name = setting.origin === "default" ? theme.muted(key) : theme.strong(key);
|
|
102
|
+
return ` ${name} ${styled} ${styleOrigin(setting.origin, theme)}`;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The key column width: the longest label shown, clamped — and clamped again
|
|
106
|
+
* by what the pane can actually spare.
|
|
107
|
+
*
|
|
108
|
+
* The row degrades in a fixed order as the pane narrows: the VALUE gives up
|
|
109
|
+
* width first (it is truncated content, and `cruxy config get` prints it in
|
|
110
|
+
* full), then the KEY, and the origin column never yields at all — a row whose
|
|
111
|
+
* source has been squeezed off is a row with nothing this view was built to
|
|
112
|
+
* say. Below roughly 33 columns even the floors do not fit and the renderer
|
|
113
|
+
* reflows the row, which is what every other view's long lines already do.
|
|
114
|
+
*/
|
|
115
|
+
function keyColumn(labels, cols) {
|
|
116
|
+
const longest = labels.reduce((max, l) => Math.max(max, l.length), 0);
|
|
117
|
+
// 2 indent + key + 2 gap + value + 1 gap + origin, with the value at its floor.
|
|
118
|
+
const affordable = cols - 2 - 2 - 8 - 1 - ORIGIN_COLS;
|
|
119
|
+
return Math.max(KEY_COLS_MIN, Math.min(KEY_COLS_MAX, longest, Math.max(KEY_COLS_MIN, affordable)));
|
|
120
|
+
}
|
|
121
|
+
/** The label a row shows: its path minus the section heading above it. */
|
|
122
|
+
function rowLabel(path) {
|
|
123
|
+
const dot = path.indexOf(".");
|
|
124
|
+
return dot === -1 ? path : path.slice(dot + 1);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The provenance header: which files were read, and which variables are in
|
|
128
|
+
* play. Named even when absent — "(none found)" is an answer to "why is my
|
|
129
|
+
* project config being ignored", and a header that simply omitted the row
|
|
130
|
+
* would leave the question looking unasked.
|
|
131
|
+
*/
|
|
132
|
+
function sourceLines(snapshot, theme, cols) {
|
|
133
|
+
const lines = [];
|
|
134
|
+
const row = (label, value, muted = false) => {
|
|
135
|
+
const text = fitMiddle(value, Math.max(8, cols - 12), theme.glyph.ellipsis);
|
|
136
|
+
return ` ${theme.strong(label.padEnd(8))} ${muted ? theme.muted(text) : text}`;
|
|
137
|
+
};
|
|
138
|
+
const { global, project, explicit } = snapshot.sources;
|
|
139
|
+
lines.push(row("global", global ?? "(none found)", global === null));
|
|
140
|
+
if (explicit !== null) {
|
|
141
|
+
// An explicit --config REPLACES project discovery rather than layering over
|
|
142
|
+
// it, so the project row would be a lie about what was consulted.
|
|
143
|
+
lines.push(row("--config", explicit));
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
lines.push(row("project", project ?? "(none found)", project === null));
|
|
147
|
+
}
|
|
148
|
+
const env = envOverrideNames(snapshot);
|
|
149
|
+
if (env.length > 0)
|
|
150
|
+
lines.push(row("env", env.join(", ")));
|
|
151
|
+
return lines;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* How the provider API key resolves — PRESENCE and SOURCE, never the key.
|
|
155
|
+
*
|
|
156
|
+
* It belongs on this pane even though it is not a config value, and precisely
|
|
157
|
+
* because it is not: "why is this on" is asked about authentication more than
|
|
158
|
+
* anything else here, and config is secret-free by design, so a user scanning
|
|
159
|
+
* these rows for their key finds nothing and cannot tell whether that means
|
|
160
|
+
* unset or merely not-shown. This row says which.
|
|
161
|
+
*/
|
|
162
|
+
function apiKeyLine(provider, present, theme) {
|
|
163
|
+
const envVar = apiKeyEnvVar(provider);
|
|
164
|
+
const label = theme.strong("api key ".padEnd(8));
|
|
165
|
+
if (!present) {
|
|
166
|
+
return ` ${label} ${theme.warning("not set")} ${theme.muted(`— set ${envVar} or run \`cruxy login\``)}`;
|
|
167
|
+
}
|
|
168
|
+
const source = process.env[envVar] !== undefined && process.env[envVar] !== ""
|
|
169
|
+
? `${envVar} (env)`
|
|
170
|
+
: "credentials store (~/.cruxy/credentials.json, owner-only)";
|
|
171
|
+
return ` ${label} ${theme.success("set")} ${theme.muted(`— ${source}`)}`;
|
|
172
|
+
}
|
|
173
|
+
/** The whole Settings view, as lines. Pure: no probe, no disk, no clock. */
|
|
174
|
+
export function settingsViewLines(theme, cols, snapshot, opts) {
|
|
175
|
+
const lines = [theme.heading("settings")];
|
|
176
|
+
const settings = effectiveSettings(snapshot);
|
|
177
|
+
const fromFile = settings.filter((s) => s.origin !== "default" && s.origin !== "env").length;
|
|
178
|
+
const fromEnv = settings.filter((s) => s.origin === "env").length;
|
|
179
|
+
lines.push("");
|
|
180
|
+
const summary = [`${settings.length} settings`];
|
|
181
|
+
if (fromFile > 0)
|
|
182
|
+
summary.push(`${fromFile} from a file`);
|
|
183
|
+
if (fromEnv > 0)
|
|
184
|
+
summary.push(`${fromEnv} from the environment`);
|
|
185
|
+
if (fromFile === 0 && fromEnv === 0)
|
|
186
|
+
summary.push("all schema defaults");
|
|
187
|
+
lines.push(theme.muted(summary.join(theme.sep)));
|
|
188
|
+
lines.push("");
|
|
189
|
+
lines.push(...sourceLines(snapshot, theme, cols));
|
|
190
|
+
lines.push(apiKeyLine(snapshot.config.model.provider, opts.apiKeyPresent, theme));
|
|
191
|
+
// The two disk states, said as two different sentences. "Changed" means this
|
|
192
|
+
// session is running on something the files no longer say; "no longer loads"
|
|
193
|
+
// means the next session will not START until it is fixed — a much louder
|
|
194
|
+
// problem, and one the user would otherwise meet at the worst moment.
|
|
195
|
+
const drift = opts.drift;
|
|
196
|
+
if (drift?.error !== undefined) {
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push(theme.danger(fit(`config on disk no longer loads: ${drift.error}`, cols, theme.glyph.ellipsis)));
|
|
199
|
+
lines.push(theme.muted("the rows below are what this session loaded at start"));
|
|
200
|
+
}
|
|
201
|
+
else if (drift?.changed === true) {
|
|
202
|
+
lines.push("");
|
|
203
|
+
lines.push(theme.warning("config files have changed since this session started"));
|
|
204
|
+
lines.push(theme.muted("the rows below are what this session is RUNNING on — restart cruxy to pick the new values up"));
|
|
205
|
+
}
|
|
206
|
+
lines.push("");
|
|
207
|
+
lines.push(theme.muted("read-only — `cruxy config set <key> <value>` writes a value, or edit the file above"));
|
|
208
|
+
const keyCols = keyColumn(settings.map((s) => rowLabel(s.path)), cols);
|
|
209
|
+
let section = null;
|
|
210
|
+
for (const setting of settings) {
|
|
211
|
+
const dot = setting.path.indexOf(".");
|
|
212
|
+
if (dot === -1) {
|
|
213
|
+
// A top-level scalar (`logLevel`) heads no section: it IS the whole
|
|
214
|
+
// setting, and a one-row section named after its only row reads as an
|
|
215
|
+
// outline error rather than a group.
|
|
216
|
+
section = null;
|
|
217
|
+
lines.push("");
|
|
218
|
+
lines.push(settingRow(setting, setting.path, keyCols, theme, cols));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const group = setting.path.slice(0, dot);
|
|
222
|
+
if (group !== section) {
|
|
223
|
+
section = group;
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push(theme.strong(group));
|
|
226
|
+
}
|
|
227
|
+
lines.push(settingRow(setting, rowLabel(setting.path), keyCols, theme, cols));
|
|
228
|
+
}
|
|
229
|
+
lines.push("");
|
|
230
|
+
lines.push(theme.muted("secret-shaped values are never printed here — a config file holds no keys by design, and the credentials store is owner-only"));
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The Settings view as a registered source.
|
|
235
|
+
*
|
|
236
|
+
* `snapshot` is the SAME {@link LoadedConfig} the session was built from, not a
|
|
237
|
+
* fresh load — see the module comment. `apiKeyPresent` is a getter because
|
|
238
|
+
* onboarding can supply a key after startup, and the view is registered once.
|
|
239
|
+
*
|
|
240
|
+
* `reload` exists for the drift check and for tests; it defaults to the real
|
|
241
|
+
* loader. Its result is used ONLY for comparison — never to repaint a value —
|
|
242
|
+
* so a mid-session edit can change what the pane SAYS about the disk without
|
|
243
|
+
* ever changing what it claims this session is running.
|
|
244
|
+
*/
|
|
245
|
+
export function createSettingsView(snapshot, apiKeyPresent, reload = () => loadConfig()) {
|
|
246
|
+
// The startup config, serialized once, as the thing every later read is
|
|
247
|
+
// compared against. Comparing resolved configs rather than file bytes makes
|
|
248
|
+
// the check MEAN "would this session behave differently": a reformatted file,
|
|
249
|
+
// a reordered key or an added comment-shaped no-op is not drift, and
|
|
250
|
+
// reporting it as such would train the user to ignore the line.
|
|
251
|
+
const baseline = JSON.stringify(snapshot.config);
|
|
252
|
+
let drift = { changed: false };
|
|
253
|
+
return {
|
|
254
|
+
id: "settings",
|
|
255
|
+
label: "settings",
|
|
256
|
+
lines: (theme, cols) => settingsViewLines(theme, cols, snapshot, {
|
|
257
|
+
apiKeyPresent: apiKeyPresent(),
|
|
258
|
+
drift,
|
|
259
|
+
}),
|
|
260
|
+
refresh: async () => {
|
|
261
|
+
try {
|
|
262
|
+
drift = { changed: JSON.stringify(reload().config) !== baseline };
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
// A file that no longer parses or no longer validates is the loudest
|
|
266
|
+
// thing this view can report, and it must be reported rather than
|
|
267
|
+
// thrown: the renderer swallows a failed refresh, which would leave the
|
|
268
|
+
// pane silently claiming the disk still matches.
|
|
269
|
+
drift = {
|
|
270
|
+
changed: true,
|
|
271
|
+
// Flattened to one line: a config error carries a multi-line list of
|
|
272
|
+
// schema issues, and a view whose contract is `readonly string[]`
|
|
273
|
+
// would emit one "line" containing newlines — which the renderer
|
|
274
|
+
// windows as a single row and paints straight through the pane.
|
|
275
|
+
error: (err instanceof Error ? err.message : String(err))
|
|
276
|
+
.replace(/\s+/g, " ")
|
|
277
|
+
.trim(),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { fit } from "../render/layout.js";
|
|
2
|
+
import { formatTokens } from "../render/state.js";
|
|
3
|
+
import { isTerminal } from "../jobs/types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The status column, fixed width so the ids and labels align into something
|
|
6
|
+
* scannable — the same reason `git-view.ts` puts its verb in a column.
|
|
7
|
+
*
|
|
8
|
+
* `paused-needs-approval` shortens to `paused` because 21 characters of column
|
|
9
|
+
* would cost every other row its label. Nothing is lost: what it is paused FOR
|
|
10
|
+
* is the one thing that matters about the state, and that gets its own line.
|
|
11
|
+
*/
|
|
12
|
+
const STATUS_WORDS = {
|
|
13
|
+
queued: "queued",
|
|
14
|
+
running: "running",
|
|
15
|
+
"paused-needs-approval": "paused",
|
|
16
|
+
done: "done",
|
|
17
|
+
failed: "failed",
|
|
18
|
+
cancelled: "cancelled",
|
|
19
|
+
};
|
|
20
|
+
/** Width of the status column — `cancelled`, the longest word above. */
|
|
21
|
+
const STATUS_COLS = 9;
|
|
22
|
+
/**
|
|
23
|
+
* How many log lines each detailed job shows.
|
|
24
|
+
*
|
|
25
|
+
* A tail rather than the whole buffer: the buffer holds a thousand lines by
|
|
26
|
+
* default, five jobs may be live at once, and a pane that has to be scrolled
|
|
27
|
+
* past four jobs' full transcripts to reach the fifth is not a list any more.
|
|
28
|
+
* What the tail does NOT show is COUNTED, never silently dropped — see
|
|
29
|
+
* {@link tailNotice}.
|
|
30
|
+
*/
|
|
31
|
+
export const TASKS_VIEW_TAIL = 8;
|
|
32
|
+
/**
|
|
33
|
+
* How many jobs to list before summarising the rest.
|
|
34
|
+
*
|
|
35
|
+
* `maxJobs` caps how many may be LIVE (five by default), not how many a long
|
|
36
|
+
* session may have finished, and terminal jobs are never removed from the
|
|
37
|
+
* manager's map — so this list grows without bound over a day's work. The most
|
|
38
|
+
* recent are the ones being asked about; the older ones are counted and `/jobs`
|
|
39
|
+
* still lists every one.
|
|
40
|
+
*/
|
|
41
|
+
export const TASKS_VIEW_MAX_JOBS = 20;
|
|
42
|
+
/** Colour by state, so "what needs me" is legible before any word is read. */
|
|
43
|
+
function styleStatus(status, theme) {
|
|
44
|
+
const word = STATUS_WORDS[status].padEnd(STATUS_COLS);
|
|
45
|
+
switch (status) {
|
|
46
|
+
case "running":
|
|
47
|
+
return theme.accent(word);
|
|
48
|
+
case "paused-needs-approval":
|
|
49
|
+
return theme.warning(word);
|
|
50
|
+
case "failed":
|
|
51
|
+
return theme.danger(word);
|
|
52
|
+
case "done":
|
|
53
|
+
return theme.success(word);
|
|
54
|
+
default:
|
|
55
|
+
// queued and cancelled: nothing is happening and nothing is wrong.
|
|
56
|
+
return theme.muted(word);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** `running job-1-ab3c refactor the parser into two passes`. */
|
|
60
|
+
function jobRow(job, theme, cols) {
|
|
61
|
+
const head = `${styleStatus(job.status, theme)} ${theme.strong(job.id)} `;
|
|
62
|
+
const room = Math.max(8, cols - STATUS_COLS - 1 - job.id.length - 2);
|
|
63
|
+
return `${head}${fit(job.label, room, theme.glyph.ellipsis)}`;
|
|
64
|
+
}
|
|
65
|
+
/** `3 turns · ↑1.2k ↓340` — what the job has spent, when it has spent anything. */
|
|
66
|
+
function costLine(job, theme) {
|
|
67
|
+
const input = job.usage.input_tokens;
|
|
68
|
+
const output = job.usage.output_tokens;
|
|
69
|
+
if (job.iterations === 0 && input === 0 && output === 0)
|
|
70
|
+
return null;
|
|
71
|
+
const g = theme.glyph;
|
|
72
|
+
return [
|
|
73
|
+
`${job.iterations} ${job.iterations === 1 ? "turn" : "turns"}`,
|
|
74
|
+
`${g.caretUp}${formatTokens(input)} ${g.caretDown}${formatTokens(output)}`,
|
|
75
|
+
].join(theme.sep);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The two DIFFERENT truncations above a tail, said as different sentences.
|
|
79
|
+
*
|
|
80
|
+
* `dropped` is lines the ring buffer threw away — they are GONE, and no command
|
|
81
|
+
* will bring them back. `hidden` is lines the buffer still holds that this view
|
|
82
|
+
* chose not to show — `/logs <id>` prints them. Reporting only the first would
|
|
83
|
+
* let a view that is hiding 192 retained lines announce "0 rolled off" and read
|
|
84
|
+
* as the whole story; collapsing them into one number would tell a user to run
|
|
85
|
+
* a command that cannot recover what it promises. So: both, named.
|
|
86
|
+
*/
|
|
87
|
+
function tailNotice(dropped, hidden, id, ellipsis) {
|
|
88
|
+
const rolled = `${dropped} line(s) rolled off the buffer`;
|
|
89
|
+
if (dropped > 0 && hidden > 0) {
|
|
90
|
+
return `${ellipsis}${rolled} (gone), ${hidden} more retained — /logs ${id}`;
|
|
91
|
+
}
|
|
92
|
+
if (dropped > 0)
|
|
93
|
+
return `${ellipsis}${rolled} — gone, not hidden`;
|
|
94
|
+
if (hidden > 0)
|
|
95
|
+
return `${ellipsis}${hidden} earlier line(s) — /logs ${id}`;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether a job gets the log tail: the live ones, whose log is still moving,
|
|
100
|
+
* and the failed ones, whose row says what happened but not why.
|
|
101
|
+
*/
|
|
102
|
+
function showsTail(status) {
|
|
103
|
+
return !isTerminal(status) || status === "failed";
|
|
104
|
+
}
|
|
105
|
+
/** The log tail, indented one level deeper than the job's own detail lines. */
|
|
106
|
+
function tailLines(log, theme, cols, id) {
|
|
107
|
+
const lines = [];
|
|
108
|
+
const shown = log.lines.slice(-TASKS_VIEW_TAIL);
|
|
109
|
+
const notice = tailNotice(log.dropped, log.lines.length - shown.length, id, theme.glyph.ellipsis);
|
|
110
|
+
if (notice !== null)
|
|
111
|
+
lines.push(` ${theme.muted(notice)}`);
|
|
112
|
+
for (const line of shown) {
|
|
113
|
+
// Truncated, not wrapped: a log line that folds onto a second row breaks
|
|
114
|
+
// the indent that makes this read as one job's detail rather than the next
|
|
115
|
+
// job's row.
|
|
116
|
+
const text = fit(line.text, Math.max(8, cols - 4), theme.glyph.ellipsis);
|
|
117
|
+
lines.push(` ${line.stream === "err" ? theme.danger(text) : theme.muted(text)}`);
|
|
118
|
+
}
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
|
121
|
+
/** One job's block: its row, then whatever level 2 has to add. */
|
|
122
|
+
function jobLines(job, log, theme, cols) {
|
|
123
|
+
const lines = [jobRow(job, theme, cols)];
|
|
124
|
+
if (job.pendingApproval !== undefined) {
|
|
125
|
+
lines.push(` ${theme.warning(fit(`needs approval: ${job.pendingApproval}`, Math.max(8, cols - 2), theme.glyph.ellipsis))}`);
|
|
126
|
+
}
|
|
127
|
+
if (job.error !== undefined) {
|
|
128
|
+
lines.push(` ${theme.danger(fit(job.error, Math.max(8, cols - 2), theme.glyph.ellipsis))}`);
|
|
129
|
+
}
|
|
130
|
+
const cost = costLine(job, theme);
|
|
131
|
+
if (cost !== null)
|
|
132
|
+
lines.push(` ${theme.muted(cost)}`);
|
|
133
|
+
if (log !== undefined)
|
|
134
|
+
lines.push(...tailLines(log, theme, cols, job.id));
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The standing statement about what this list is and is not.
|
|
139
|
+
*
|
|
140
|
+
* On screen rather than only in a doc comment, and shown even when the list is
|
|
141
|
+
* EMPTY — empty is what every session starts with, and it is precisely then
|
|
142
|
+
* that a user wonders where yesterday's jobs went.
|
|
143
|
+
*/
|
|
144
|
+
function footerLines(theme) {
|
|
145
|
+
return [
|
|
146
|
+
"",
|
|
147
|
+
theme.muted("this session only — the list is never persisted, because a saved one would name jobs that session exit already killed"),
|
|
148
|
+
theme.muted("the work outlives it: a job's id is its checkpoint run id, so `cruxy rollback <id>` reaches a finished job's changes from any later session"),
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
/** The whole Tasks view, as lines. Pure: no probe, no disk, no clock. */
|
|
152
|
+
export function tasksViewLines(theme, cols, jobs) {
|
|
153
|
+
const lines = [theme.heading("tasks")];
|
|
154
|
+
// Disabled is not the same claim as "no jobs ran". Say which, and say how to
|
|
155
|
+
// change it — the same sentence `/jobs` prints, so the two cannot drift.
|
|
156
|
+
if (jobs === undefined) {
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push(theme.muted("background jobs are disabled — enable with `cruxy config set jobs.enabled true`"));
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
161
|
+
const all = jobs.list();
|
|
162
|
+
if (all.length === 0) {
|
|
163
|
+
lines.push("");
|
|
164
|
+
lines.push(theme.muted("no background jobs this session"));
|
|
165
|
+
lines.push(...footerLines(theme));
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
const live = all.filter((j) => !isTerminal(j.status)).length;
|
|
169
|
+
const needing = all.filter((j) => j.pendingApproval !== undefined).length;
|
|
170
|
+
const summary = [`${all.length} this session`, `${live} live`];
|
|
171
|
+
if (needing > 0)
|
|
172
|
+
summary.push(`${needing} needing approval`);
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push(theme.muted(summary.join(theme.sep)));
|
|
175
|
+
// Dispatch order, not most-recent-first: this is a surface someone watches
|
|
176
|
+
// for minutes at a time, and rows that reshuffle as jobs finish would move
|
|
177
|
+
// the one being read out from under them. `/jobs` orders the same way.
|
|
178
|
+
const shown = all.slice(-TASKS_VIEW_MAX_JOBS);
|
|
179
|
+
const older = all.length - shown.length;
|
|
180
|
+
if (older > 0) {
|
|
181
|
+
lines.push(theme.muted(`${theme.glyph.ellipsis}${older} older job(s) not shown — /jobs lists every one`));
|
|
182
|
+
}
|
|
183
|
+
for (const job of shown) {
|
|
184
|
+
lines.push("");
|
|
185
|
+
// `logs` is called only with an id this same snapshot yielded, and the
|
|
186
|
+
// manager never removes a job from its map — so this cannot throw on the
|
|
187
|
+
// paint path.
|
|
188
|
+
lines.push(...jobLines(job, showsTail(job.status) ? jobs.logs(job.id) : undefined, theme, cols));
|
|
189
|
+
}
|
|
190
|
+
lines.push(...footerLines(theme));
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The Tasks view as a registered source.
|
|
195
|
+
*
|
|
196
|
+
* `jobs` is undefined when the feature is off (`jobs.enabled = false`), and the
|
|
197
|
+
* view still registers: a nav whose rows appear and disappear with config makes
|
|
198
|
+
* "where is the tasks view" a question with no answer on screen, and a user who
|
|
199
|
+
* has never enabled background jobs learns here that they exist.
|
|
200
|
+
*
|
|
201
|
+
* No `refresh`. Everything the view reads is already in memory — see the module
|
|
202
|
+
* comment. What it needs instead is {@link ViewSource.live}: a job appends to
|
|
203
|
+
* its log while the foreground sits at the prompt, and no event the renderer can
|
|
204
|
+
* see accompanies it.
|
|
205
|
+
*/
|
|
206
|
+
export function createTasksView(jobs) {
|
|
207
|
+
return {
|
|
208
|
+
id: "tasks",
|
|
209
|
+
label: "tasks",
|
|
210
|
+
lines: (theme, cols) => tasksViewLines(theme, cols, jobs),
|
|
211
|
+
// `liveCount`, not a scan of `list()`: this runs on every paint, and the
|
|
212
|
+
// manager already keeps the count.
|
|
213
|
+
live: () => (jobs?.liveCount() ?? 0) > 0,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** The built-in view: the streaming conversation, and the default selection. */
|
|
2
|
+
export const CONVERSATION_VIEW = "conversation";
|
|
3
|
+
/**
|
|
4
|
+
* The selectable views, conversation first.
|
|
5
|
+
*
|
|
6
|
+
* Conversation is prepended here rather than registered by a caller because it
|
|
7
|
+
* is the one view the renderer already owns the content for (the scrollback
|
|
8
|
+
* buffer) and the one that must always exist: it is the default, and it is
|
|
9
|
+
* unclosable, so nothing may remove it from the list a user can get back to.
|
|
10
|
+
*/
|
|
11
|
+
export function viewOrder(sources) {
|
|
12
|
+
return [CONVERSATION_VIEW, ...sources.map((s) => s.id)];
|
|
13
|
+
}
|
|
14
|
+
/** Human label for an id — conversation's is built in, the rest self-describe. */
|
|
15
|
+
export function viewLabel(id, sources) {
|
|
16
|
+
if (id === CONVERSATION_VIEW)
|
|
17
|
+
return "conversation";
|
|
18
|
+
return sources.find((s) => s.id === id)?.label ?? id;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The id `steps` places after `from` in the nav order, wrapping.
|
|
22
|
+
*
|
|
23
|
+
* Wrapping (rather than stopping at the ends) because this is reached from a
|
|
24
|
+
* single cycling key: a ring the user can hold down to get anywhere beats two
|
|
25
|
+
* bindings that dead-end, and the list is short enough that overshooting costs
|
|
26
|
+
* one more press. Same reasoning as the mode ring in P5.
|
|
27
|
+
*/
|
|
28
|
+
export function cycleView(from, sources, steps) {
|
|
29
|
+
const order = viewOrder(sources);
|
|
30
|
+
const at = order.indexOf(from);
|
|
31
|
+
// An id that is no longer registered resolves to the conversation rather than
|
|
32
|
+
// to `order[-1]`: a view can be attached and re-attached, and the fallback has
|
|
33
|
+
// to be the one that always exists.
|
|
34
|
+
if (at < 0)
|
|
35
|
+
return CONVERSATION_VIEW;
|
|
36
|
+
const next = (at + (steps % order.length) + order.length) % order.length;
|
|
37
|
+
return order[next];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The sidebar's nav block: one row per view, the selected one marked.
|
|
41
|
+
*
|
|
42
|
+
* FOCUS IS DRAWN, not implied. When the sidebar holds the keyboard the heading
|
|
43
|
+
* says so and the selected row is highlighted rather than merely marked —
|
|
44
|
+
* otherwise "which keys go where" is invisible state, and the arrow keys
|
|
45
|
+
* silently mean two different things depending on something the screen never
|
|
46
|
+
* showed. The marker glyph stays in both states so a screen reader and a
|
|
47
|
+
* NO_COLOR terminal can still tell which row is current.
|
|
48
|
+
*/
|
|
49
|
+
export function navLines(theme, sources, selected, focused) {
|
|
50
|
+
// A WORD, not a glyph. This has to survive NO_COLOR, CRUXY_ASCII and a screen
|
|
51
|
+
// reader, and "focus" carries the meaning in all three where an arrow or a
|
|
52
|
+
// colour carries it in none. It also fits the 18-column sidebar.
|
|
53
|
+
const heading = focused ? "views (focus)" : "views";
|
|
54
|
+
const lines = [focused ? theme.accent(heading) : theme.strong(heading), ""];
|
|
55
|
+
for (const id of viewOrder(sources)) {
|
|
56
|
+
const label = viewLabel(id, sources);
|
|
57
|
+
const current = id === selected;
|
|
58
|
+
const row = `${current ? theme.glyph.pointer : " "} ${label}`;
|
|
59
|
+
if (!current) {
|
|
60
|
+
lines.push(theme.muted(row));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
lines.push(focused ? theme.accent(row) : theme.strong(row));
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { statfsSync } from "node:fs";
|
|
2
|
+
import { statfs } from "node:fs/promises";
|
|
3
|
+
const GIB = 1024 ** 3;
|
|
4
|
+
/** Below this, a run that shadow-copies a repo can plausibly fail. */
|
|
5
|
+
const LOW_FREE_BYTES = 5 * GIB;
|
|
6
|
+
/** Below this, assume the next sizeable write fails. */
|
|
7
|
+
const CRITICAL_FREE_BYTES = 1 * GIB;
|
|
8
|
+
export function capacityLevel(capacity) {
|
|
9
|
+
if (capacity.freeBytes < CRITICAL_FREE_BYTES)
|
|
10
|
+
return "critical";
|
|
11
|
+
if (capacity.freeBytes < LOW_FREE_BYTES)
|
|
12
|
+
return "low";
|
|
13
|
+
return "ok";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Turn a `statfs` result into a capacity, or `undefined` when it cannot say
|
|
17
|
+
* anything true.
|
|
18
|
+
*
|
|
19
|
+
* `blocks === 0` is the guard that matters: some pseudo-filesystems report a
|
|
20
|
+
* zero total, and a percentage derived from it is `NaN` or `Infinity` — a
|
|
21
|
+
* "0% free" that means "we have no idea" is the single worst thing this could
|
|
22
|
+
* render, because it is indistinguishable from a genuinely full disk.
|
|
23
|
+
*
|
|
24
|
+
* Exported for the test that pins that guard: it is the one branch here that
|
|
25
|
+
* cannot be reached through a real `statfs` on any machine CI runs on.
|
|
26
|
+
*/
|
|
27
|
+
export function capacityFromStats(stats) {
|
|
28
|
+
const blockSize = Number(stats.bsize);
|
|
29
|
+
const blocks = Number(stats.blocks);
|
|
30
|
+
const available = Number(stats.bavail);
|
|
31
|
+
if (!(blockSize > 0) || !(blocks > 0) || !(available >= 0))
|
|
32
|
+
return undefined;
|
|
33
|
+
const totalBytes = blocks * blockSize;
|
|
34
|
+
const freeBytes = available * blockSize;
|
|
35
|
+
return {
|
|
36
|
+
freeBytes,
|
|
37
|
+
totalBytes,
|
|
38
|
+
freePercent: Math.floor((freeBytes / totalBytes) * 100),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Capacity of the filesystem holding `path`, or `undefined` if it can't be
|
|
43
|
+
* read (a path that doesn't exist, a platform or mount that won't answer).
|
|
44
|
+
*
|
|
45
|
+
* ASYNC because a `statfs` is only microseconds on a local disk — the whole
|
|
46
|
+
* reason this is affordable — but can block for SECONDS on an unresponsive
|
|
47
|
+
* network mount, and someone will eventually run cruxy in a repo on NFS. The
|
|
48
|
+
* cost model that justifies the feature holds for the common case; the API is
|
|
49
|
+
* shaped for the uncommon one.
|
|
50
|
+
*/
|
|
51
|
+
export async function readDiskCapacity(path) {
|
|
52
|
+
try {
|
|
53
|
+
return capacityFromStats(await statfs(path));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The synchronous read, for the one caller that is allowed one: a command the
|
|
61
|
+
* user just typed, which has no frame to lose and every reason to print a real
|
|
62
|
+
* answer instead of "checking…". Never call this from a paint path.
|
|
63
|
+
*/
|
|
64
|
+
export function readDiskCapacitySync(path) {
|
|
65
|
+
try {
|
|
66
|
+
return capacityFromStats(statfsSync(path));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
|
73
|
+
/**
|
|
74
|
+
* `8.1 GiB`, `465 GiB`, `912 MiB`.
|
|
75
|
+
*
|
|
76
|
+
* Binary units with binary labels. `GB` for 2^30 is the ambiguity that makes
|
|
77
|
+
* people distrust a number they were about to act on; if the unit is 1024-based
|
|
78
|
+
* the label says so.
|
|
79
|
+
*/
|
|
80
|
+
export function formatBytes(bytes) {
|
|
81
|
+
let value = Math.max(0, bytes);
|
|
82
|
+
let unit = 0;
|
|
83
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
84
|
+
value /= 1024;
|
|
85
|
+
unit++;
|
|
86
|
+
}
|
|
87
|
+
// One decimal only while it carries information: "8.1 GiB" is a different
|
|
88
|
+
// amount from "8 GiB", "465.3 GiB" is not meaningfully different from "465".
|
|
89
|
+
const digits = unit > 0 && value < 10 ? 1 : 0;
|
|
90
|
+
return `${value.toFixed(digits)} ${UNITS[unit]}`;
|
|
91
|
+
}
|
|
92
|
+
/** `2% free · 8.1 GiB of 465 GiB` — the percentage first, since that's the read. */
|
|
93
|
+
export function formatCapacity(capacity) {
|
|
94
|
+
return `${capacity.freePercent}% free · ${formatBytes(capacity.freeBytes)} of ${formatBytes(capacity.totalBytes)}`;
|
|
95
|
+
}
|