@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.
@@ -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
+ }