claude-code-runrate 0.2.4 → 0.3.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,249 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/pane-blob.js — THE verifier. Between a blob file's bytes and any renderer
4
+ // sits exactly this function, and it returns either a validated v1 blob or one
5
+ // named failure. No renderer ever sees unvalidated input.
6
+ // Contract: docs/PANE-CONTRACT.md § The verifier.
7
+ //
8
+ // Three properties this file is built around, all of them load-bearing:
9
+ //
10
+ // TOTAL. Nothing here throws. The sidebar is ONE pane: an exception raised
11
+ // into the draw loop would take the burn-rate display down with it, so a
12
+ // malformed blob must cost a pane state and never the sidecar.
13
+ //
14
+ // WHITELIST-CONSTRUCT. Every returned object is built fresh from the fields
15
+ // v1 names. The parsed input is never spread, never Object.assign'd, never
16
+ // merged. That is the prototype-pollution path, and it is the one
17
+ // injection-style attack a JSON consumer in Node gets handed for free.
18
+ //
19
+ // TYPES CHECKED, NOT COERCED. A JSON file chooses its own value types, so
20
+ // `typeof` is the only thing that makes "this field is a string" true. (The
21
+ // sidecar learned this the hard way elsewhere: an array where a string was
22
+ // expected put raw terminal escapes on screen. See src/sanitize.js.)
23
+
24
+ const fs = require('node:fs');
25
+ const { stripControl } = require('./sanitize');
26
+
27
+ const BLOB_VERSION = 1;
28
+ const MAX_BLOB_BYTES = 256 * 1024;
29
+ const MAX_ROWS = 256;
30
+ const MAX_FIELD_CHARS = 512;
31
+ const MAX_SPARK = 32;
32
+
33
+ /** The closed row-status enum. Anything else renders as `dark` (never green). */
34
+ const ROW_STATUSES = new Set(['ok', 'warn', 'alert', 'dark', 'off']);
35
+
36
+ /**
37
+ * A display string: sanitized, then truncated. Order matters — validation
38
+ * checks shape, never bytes, so the strip is unconditional and comes after.
39
+ * @param {string} s
40
+ * @returns {string}
41
+ */
42
+ const display = (s) => {
43
+ const clean = String(stripControl(s) ?? '');
44
+ return clean.length > MAX_FIELD_CHARS ? clean.slice(0, MAX_FIELD_CHARS) : clean;
45
+ };
46
+
47
+ const isStr = (/** @type {any} */ v) => typeof v === 'string';
48
+
49
+ /**
50
+ * Read the blob file safely enough to name WHY it could not be read.
51
+ *
52
+ * This does not use readTextCapped: that collapses every failure to null, and
53
+ * the contract requires "cannot-read" (a chmod mistake, a fifo, a symlink) to
54
+ * be visibly distinct from "waiting" (the producer simply hasn't run yet).
55
+ * Conflating them would make a permissions bug look like patience.
56
+ *
57
+ * @param {string} file
58
+ * @returns {{ ok: true, text: string, mtimeMs: number }
59
+ * | { ok: false, state: 'waiting' }
60
+ * | { ok: false, state: 'cannot-read', reason: string }
61
+ * | { ok: false, state: 'oversized' }}
62
+ */
63
+ function readBlobFile(file) {
64
+ let st;
65
+ try {
66
+ // lstat, not stat: a symlink must be REFUSED rather than followed, and a
67
+ // fifo must be identified without opening it (opening one blocks forever).
68
+ st = fs.lstatSync(file);
69
+ } catch (e) {
70
+ const code = e && /** @type {any} */ (e).code;
71
+ if (code === 'ENOENT') return { ok: false, state: 'waiting' };
72
+ return { ok: false, state: 'cannot-read', reason: code === 'EACCES' ? 'permission' : 'unavailable' };
73
+ }
74
+ if (st.isSymbolicLink()) return { ok: false, state: 'cannot-read', reason: 'symlink' };
75
+ if (st.isDirectory()) return { ok: false, state: 'cannot-read', reason: 'directory' };
76
+ if (!st.isFile()) return { ok: false, state: 'cannot-read', reason: 'not a regular file' };
77
+ if (st.size > MAX_BLOB_BYTES) return { ok: false, state: 'oversized' };
78
+
79
+ // The lstat above is ADVISORY, not a guarantee: the path can be replaced
80
+ // between the check and the open, and a producer that writes atomically is
81
+ // renaming over this path constantly, so a swap looks like normal operation.
82
+ // The open itself must therefore be safe on its own terms:
83
+ // O_NOFOLLOW — refuse a symlink at open time, so "symlinks are refused"
84
+ // is enforced by the kernel rather than by a stale stat.
85
+ // O_NONBLOCK — a FIFO opened for reading blocks until a writer appears,
86
+ // which in this single-threaded draw loop means FOREVER: no
87
+ // render, no heartbeat, no recovery. With O_NONBLOCK the open
88
+ // returns immediately (ENXIO) instead of hanging.
89
+ // Both flags are absent on Windows; `|| 0` degrades to the old behaviour
90
+ // there, where neither fifos nor symlinks-without-privilege are a concern.
91
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
92
+ const O_NONBLOCK = fs.constants.O_NONBLOCK || 0;
93
+ let fd;
94
+ try {
95
+ fd = fs.openSync(file, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
96
+ } catch (e) {
97
+ const code = e && /** @type {any} */ (e).code;
98
+ if (code === 'ELOOP') return { ok: false, state: 'cannot-read', reason: 'symlink' };
99
+ if (code === 'ENXIO' || code === 'EWOULDBLOCK' || code === 'EAGAIN') {
100
+ return { ok: false, state: 'cannot-read', reason: 'not a regular file' };
101
+ }
102
+ if (code === 'ENOENT') return { ok: false, state: 'waiting' };
103
+ return { ok: false, state: 'cannot-read', reason: code === 'EACCES' ? 'permission' : 'unavailable' };
104
+ }
105
+ try {
106
+ // Re-stat from the descriptor: the file may have been replaced since the
107
+ // lstat, and this describes the bytes actually held open.
108
+ const fst = fs.fstatSync(fd);
109
+ if (!fst.isFile()) return { ok: false, state: 'cannot-read', reason: 'not a regular file' };
110
+ if (fst.size > MAX_BLOB_BYTES) return { ok: false, state: 'oversized' };
111
+ const buf = Buffer.alloc(Math.min(fst.size, MAX_BLOB_BYTES));
112
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
113
+ return { ok: true, text: buf.subarray(0, n).toString('utf8'), mtimeMs: fst.mtimeMs };
114
+ } catch {
115
+ return { ok: false, state: 'cannot-read', reason: 'unavailable' };
116
+ } finally {
117
+ try { fs.closeSync(fd); } catch { /* already closed */ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Validate one row. Returns null when the row itself is malformed (which makes
123
+ * the whole blob invalid — a row without a value is a shape violation, not a
124
+ * decoration). An UNRECOGNIZED status is not a violation: it renders as `dark`,
125
+ * because a producer naming a state ccr doesn't know must never come out green.
126
+ * @param {any} r
127
+ * @returns {{ label: string, value: string, status: string, detail: string|null, spark: number[]|null }|null}
128
+ */
129
+ function validateRow(r) {
130
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
131
+ if (!isStr(r.label) || !isStr(r.value) || !isStr(r.status)) return null;
132
+
133
+ // Spark is DECORATION, so it degrades locally: a non-conforming spark drops
134
+ // the sparkline and keeps the row. Ruled 2026-08-02 — a decoration never
135
+ // costs more than itself, the same principle as clamping an overlong field.
136
+ /** @type {number[]|null} */
137
+ let spark = null;
138
+ if (Array.isArray(r.spark) && r.spark.length && r.spark.length <= MAX_SPARK
139
+ && r.spark.every((/** @type {any} */ n) => typeof n === 'number' && Number.isFinite(n))) {
140
+ spark = r.spark.slice();
141
+ }
142
+
143
+ return {
144
+ label: display(r.label),
145
+ value: display(r.value),
146
+ status: ROW_STATUSES.has(r.status) ? r.status : 'dark',
147
+ detail: isStr(r.detail) ? display(r.detail) : null,
148
+ spark,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Turn a parsed blob into a validated v1 blob, or name the single failure.
154
+ * Split out from the file read so it is directly testable and so the render
155
+ * path can never reach a shape that skipped it.
156
+ * @param {any} input
157
+ * @returns {{ state: 'ok', blob: any } | { state: 'invalid' }
158
+ * | { state: 'unsupported', version: number|null } | { state: 'oversized-rows' }}
159
+ */
160
+ function validateBlob(input) {
161
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return { state: 'invalid' };
162
+
163
+ // Version first: an unrecognized `v` is its own named state, so it must be
164
+ // decided before any other field can call the blob "invalid".
165
+ if (!Number.isInteger(input.v)) return { state: 'invalid' };
166
+ if (input.v !== BLOB_VERSION) return { state: 'unsupported', version: input.v };
167
+
168
+ if (!isStr(input.tool) || !isStr(input.title) || !isStr(input.status)) return { state: 'invalid' };
169
+ if (input.status !== 'ok' && input.status !== 'broken') return { state: 'invalid' };
170
+
171
+ const basis = input.basis;
172
+ if (!basis || typeof basis !== 'object' || Array.isArray(basis)) return { state: 'invalid' };
173
+ if (!isStr(basis.label) || !isStr(basis.at)) return { state: 'invalid' };
174
+
175
+ // A broken blob must carry a non-empty message. Without one it would render
176
+ // as a failure with nothing to say, which is indistinguishable from a bug in
177
+ // ccr — so it is invalid rather than a silent half-render.
178
+ const broken = input.status === 'broken';
179
+ if (broken && !(isStr(input.message) && input.message.trim())) return { state: 'invalid' };
180
+
181
+ if (!Array.isArray(input.rows)) return { state: 'invalid' };
182
+ if (input.rows.length > MAX_ROWS) return { state: 'oversized-rows' };
183
+
184
+ /** @type {any[]} */
185
+ const rows = [];
186
+ // A broken blob's rows are IGNORED per the contract — not validated, not
187
+ // rendered. Validating them anyway would let a stray row turn a producer's
188
+ // honest failure report into "invalid", burying the message it exists to show.
189
+ if (!broken) {
190
+ for (const r of input.rows) {
191
+ const row = validateRow(r);
192
+ if (!row) return { state: 'invalid' };
193
+ rows.push(row);
194
+ }
195
+ }
196
+
197
+ return {
198
+ state: 'ok',
199
+ blob: {
200
+ v: BLOB_VERSION,
201
+ tool: display(input.tool),
202
+ title: display(input.title),
203
+ status: input.status,
204
+ basis: { label: display(basis.label), at: display(basis.at) },
205
+ message: isStr(input.message) ? display(input.message) : null,
206
+ rows,
207
+ },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * The whole pipeline for one configured pane, once per tick.
213
+ * @param {string} file absolute path from config
214
+ * @param {{ now?: number }} [opts]
215
+ * @returns {{ state: string, blob?: any, version?: number|null, reason?: string, ageMs?: number }}
216
+ */
217
+ function loadPaneBlob(file, opts = {}) {
218
+ const read = readBlobFile(file);
219
+ if (!read.ok) {
220
+ // `strict` is off in jsconfig.json, and without strictNullChecks TypeScript
221
+ // will not narrow this union by its boolean `ok` discriminant — the whole
222
+ // union survives into this branch. So name the failure shape once here
223
+ // instead of re-testing it at runtime: the runtime predicate stays `ok`,
224
+ // which is the property readBlobFile actually guarantees, and this replaces
225
+ // the `any` cast that was already covering the same gap for `reason`.
226
+ const fail = /** @type {{ ok: false, state: string, reason?: string }} */ (read);
227
+ return fail.state === 'cannot-read'
228
+ ? { state: 'cannot-read', reason: fail.reason }
229
+ : { state: fail.state };
230
+ }
231
+ if (!read.text.trim()) return { state: 'waiting' };
232
+
233
+ let parsed;
234
+ try { parsed = JSON.parse(read.text); } catch { return { state: 'unreadable' }; }
235
+
236
+ const now = opts.now != null ? opts.now : Date.now();
237
+ const ageMs = Math.max(0, now - read.mtimeMs);
238
+
239
+ const v = validateBlob(parsed);
240
+ if (v.state === 'ok') return { state: 'ok', blob: v.blob, ageMs };
241
+ if (v.state === 'unsupported') return { state: 'unsupported', version: v.version, ageMs };
242
+ if (v.state === 'oversized-rows') return { state: 'oversized', ageMs };
243
+ return { state: 'invalid', ageMs };
244
+ }
245
+
246
+ module.exports = {
247
+ loadPaneBlob, validateBlob, readBlobFile,
248
+ BLOB_VERSION, MAX_BLOB_BYTES, MAX_ROWS, MAX_FIELD_CHARS, MAX_SPARK, ROW_STATUSES,
249
+ };
@@ -0,0 +1,109 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/pane-config.js — where the list of pane blob paths comes from.
4
+ //
5
+ // RULED 2026-08-02 (the question left open since session e2994e0d):
6
+ //
7
+ // Location: $XDG_CONFIG_HOME/ccr/config.json, defaulting to
8
+ // ~/.config/ccr/config.json. Overridable by CCR_CONFIG for tests and for
9
+ // users who keep dotfiles elsewhere.
10
+ //
11
+ // NOT ccr's state dir (~/.ccr): that holds state ccr writes, and mixing
12
+ // user-authored configuration into a directory the program rewrites invites
13
+ // exactly one accident — clobbering it. NOT repo-local, ever: a config file
14
+ // discovered by walking up from the working directory would let anyone who
15
+ // can land a PR add a pane path to a teammate's sidecar. That is the same
16
+ // reasoning that removed configurable prompt files (see the contract's
17
+ // ruling log); config is the user's, and only the user's.
18
+ //
19
+ // Format: JSON. ccr already parses JSON at every ingestion point, so this
20
+ // adds no new parser and no new attack surface, and the verifier discipline
21
+ // (whitelist-construct, types checked not coerced, total function) applies
22
+ // here unchanged. A bespoke line format would need all of that written again.
23
+ //
24
+ // Shape (v1):
25
+ // { "panes": [ { "path": "~/code/app/.gherkin-trace/sidecar.json" } ] }
26
+ //
27
+ // Entries are OBJECTS rather than bare strings so a later optional key is an
28
+ // additive change rather than a format break. Order is significant (it is the
29
+ // cycle order). Two entries naming the same path are two panes, per the
30
+ // contract — this never de-duplicates.
31
+ //
32
+ // Config is trusted more than a blob (the user wrote it) but is still parsed
33
+ // defensively: a malformed config yields NO panes rather than throwing into
34
+ // the draw loop. The sidecar's own panel must survive a typo in a config file.
35
+
36
+ const path = require('node:path');
37
+ const os = require('node:os');
38
+ const { readTextCapped } = require('./safe-read');
39
+
40
+ /** Config is small; this is a sanity bound, not a policy. */
41
+ const MAX_CONFIG_BYTES = 64 * 1024;
42
+
43
+ /**
44
+ * The config file path, without touching the filesystem.
45
+ * @param {Record<string, string|undefined>} [env]
46
+ * @returns {string}
47
+ */
48
+ function configPath(env) {
49
+ const e = env || process.env;
50
+ if (e.CCR_CONFIG) return e.CCR_CONFIG;
51
+ const xdg = e.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
52
+ return path.join(xdg, 'ccr', 'config.json');
53
+ }
54
+
55
+ /**
56
+ * Expand a leading `~`, then resolve relative paths against `baseDir` — the
57
+ * config file's own directory, per the contract ("a relative path resolves
58
+ * against the config file's directory"). Resolving against the CWD instead
59
+ * would make a pane's identity depend on where the sidecar happened to start.
60
+ * @param {string} p
61
+ * @param {string} baseDir
62
+ * @param {string} home
63
+ * @returns {string}
64
+ */
65
+ function resolvePanePath(p, baseDir, home) {
66
+ let out = p;
67
+ if (out === '~') out = home;
68
+ else if (out.startsWith('~/')) out = path.join(home, out.slice(2));
69
+ return path.resolve(baseDir, out);
70
+ }
71
+
72
+ /**
73
+ * Load the configured pane list. Never throws: a missing, unreadable, or
74
+ * malformed config is "no panes configured", which renders as the plain
75
+ * economy sidebar exactly as before this feature existed.
76
+ *
77
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
78
+ * @returns {{ panes: Array<{ path: string, source: string }>, configPath: string }}
79
+ * `path` is absolute and ready to read; `source` is the string the user wrote
80
+ * (what error states name, so the message matches their config, not ours).
81
+ */
82
+ function loadPaneConfig(opts = {}) {
83
+ const env = opts.env || process.env;
84
+ const home = opts.home || os.homedir();
85
+ const file = configPath(env);
86
+ const empty = { panes: [], configPath: file };
87
+
88
+ const raw = readTextCapped(file, MAX_CONFIG_BYTES);
89
+ if (raw == null || !raw.trim()) return empty;
90
+
91
+ let parsed;
92
+ try { parsed = JSON.parse(raw); } catch { return empty; }
93
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.panes)) return empty;
94
+
95
+ const baseDir = path.dirname(file);
96
+ /** @type {Array<{ path: string, source: string }>} */
97
+ const panes = [];
98
+ for (const entry of parsed.panes) {
99
+ // Whitelist-construct: read the one field v1 names, off a fresh object.
100
+ // Never spread the entry — same rule the blob verifier follows.
101
+ if (!entry || typeof entry !== 'object' || typeof entry.path !== 'string') continue;
102
+ const source = entry.path;
103
+ if (!source.trim()) continue;
104
+ panes.push({ path: resolvePanePath(source, baseDir, home), source });
105
+ }
106
+ return { panes, configPath: file };
107
+ }
108
+
109
+ module.exports = { loadPaneConfig, configPath, resolvePanePath, MAX_CONFIG_BYTES };
@@ -16,10 +16,20 @@ const { stripControl } = require('./sanitize');
16
16
  const FIVE = 300, WEEK = 10080, MONTH = 43200;
17
17
 
18
18
  // Known keys get exact labels/windows; everything else falls back to heuristics.
19
- const KNOWN = {
19
+ //
20
+ // NULL PROTOTYPE, deliberately: bucket names arrive from the status JSON, so a
21
+ // bucket named `toString`, `constructor`, or `__proto__` must MISS this table
22
+ // and fall through to the heuristics below. A plain object literal inherits
23
+ // those names from Object.prototype and hands back a function, whose `.label`
24
+ // is undefined — so labelFor would return undefined while its own contract
25
+ // promises a string. That only stayed harmless because every consumer happens
26
+ // to write `wd.label || wd.key`; a consumer trusting the declared type would
27
+ // break. Structural fix rather than a guard at each lookup.
28
+ /** @type {Record<string, { label: string, windowMinutes: number }>} */
29
+ const KNOWN = Object.assign(Object.create(null), {
20
30
  five_hour: { label: '5h', windowMinutes: FIVE },
21
31
  seven_day: { label: 'weekly', windowMinutes: WEEK },
22
- };
32
+ });
23
33
 
24
34
  /** @param {string} key → 'Sonnet' | 'Opus' | 'Haiku' | null */
25
35
  function modelScope(key) {
@@ -28,7 +28,9 @@ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolea
28
28
  const dotColor = row.resetsFirst ? green : bandColor[b];
29
29
  const dot = (row.binding && b === 'imminent') ? flash(tick, '●') : dotColor('●');
30
30
 
31
- const labelTxt = row.label.padEnd(labelW);
31
+ // Truncate as well as pad: labelW is capped, so a longer label must be cut to
32
+ // the column rather than pushing every sibling row out of alignment.
33
+ const labelTxt = (row.label.length > labelW ? row.label.slice(0, labelW - 1) + '…' : row.label).padEnd(labelW);
32
34
  const label = row.binding ? bold(bandColor[b](labelTxt)) : dim(labelTxt);
33
35
  // Time-to-exhaust carries no word: the sibling "resets …" is self-labelling,
34
36
  // so a bare "~8h43m" reads unambiguously as remaining budget.
@@ -59,7 +61,13 @@ function renderEconomy(view, opts = {}) {
59
61
  const out = [bold('economy') + dim(' ' + (view.model || '')), ''];
60
62
 
61
63
  const { rows, next } = classifyWindows(view);
62
- const labelW = Math.max(8, ...rows.map((/** @type {any} */ r) => r.label.length));
64
+ // Cap the label column. `labelW` multiplies: every row pads to it, so cost is
65
+ // rows × longest-label, and BOTH come from the snapshot's rate_limits keys. A
66
+ // planted file with many buckets and one very long key built a string large
67
+ // enough to throw RangeError and blank the panel — an amplifier, not a leak,
68
+ // but it costs the whole display. 18 columns fits every real bucket name.
69
+ const LABEL_MAX = 18;
70
+ const labelW = Math.min(LABEL_MAX, Math.max(8, ...rows.map((/** @type {any} */ r) => r.label.length)));
63
71
 
64
72
  // HERO
65
73
  if (!rows.length) {
@@ -0,0 +1,186 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/render/pane.js — draw one external tool pane from a VALIDATED blob.
4
+ // Contract: docs/PANE-CONTRACT.md § ccr (consumer) obligations.
5
+ //
6
+ // This renderer only ever sees output from src/pane-blob.js, so it does no
7
+ // validation and no sanitizing of its own: every string reaching it has already
8
+ // been stripped and truncated at the choke point. It does clamp to the cell,
9
+ // which is layout, not safety.
10
+ //
11
+ // The honesty rules live here, and they are the reason the pane is worth
12
+ // looking at: `dark` renders as visibly not-green, `off` renders as present-but
13
+ // -disabled, a broken blob shows its producer's message instead of stale rows,
14
+ // hidden rows collapse into a line that inherits the WORST status they carried,
15
+ // and every pane — healthy or not — carries its tool, its basis, and its age.
16
+
17
+ const { dim, bold, green, red, yellow, cyan, clampVisible } = require('./shared');
18
+ const { stripControl } = require('../sanitize');
19
+
20
+ /** Per-status marker. `dark` must never be green and never blank; `off` is dim. */
21
+ const MARKERS = {
22
+ ok: () => green('●'),
23
+ warn: () => yellow('●'),
24
+ alert: () => red('●'),
25
+ dark: () => cyan('◌'), // hollow: "cannot tell", visibly not a light
26
+ off: () => dim('·'), // present, deliberately disabled
27
+ };
28
+
29
+ /** Worst-first precedence for the overflow line (contract § in-pane overflow). */
30
+ const SEVERITY = ['alert', 'dark', 'warn', 'ok', 'off'];
31
+
32
+ const SPARK_GLYPHS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
33
+
34
+ /**
35
+ * File write age on the contract's unit ladder: Xs / Xm / Xh / Xd.
36
+ * @param {number} ms
37
+ * @returns {string}
38
+ */
39
+ function writeAge(ms) {
40
+ const s = Math.max(0, Math.floor(ms / 1000));
41
+ if (s < 60) return `${s}s`;
42
+ const m = Math.floor(s / 60);
43
+ if (m < 60) return `${m}m`;
44
+ const h = Math.floor(m / 60);
45
+ if (h < 24) return `${h}h`;
46
+ return `${Math.floor(h / 24)}d`;
47
+ }
48
+
49
+ /**
50
+ * Normalize a row's spark to its OWN min-max (never a cross-row or cross-run
51
+ * scale — that would make one row's shape depend on another's data).
52
+ * @param {number[]} spark
53
+ * @returns {string}
54
+ */
55
+ function sparkline(spark) {
56
+ const lo = Math.min(...spark);
57
+ const hi = Math.max(...spark);
58
+ const span = hi - lo;
59
+ return spark.map((n) => {
60
+ // A flat series has no shape to show; put it on the floor rather than
61
+ // inventing a peak by dividing by zero.
62
+ const idx = span === 0 ? 0 : Math.round(((n - lo) / span) * (SPARK_GLYPHS.length - 1));
63
+ return SPARK_GLYPHS[Math.max(0, Math.min(SPARK_GLYPHS.length - 1, idx))];
64
+ }).join('');
65
+ }
66
+
67
+ /** @param {string} status */
68
+ const marker = (status) => (MARKERS[/** @type {keyof MARKERS} */ (status)] || MARKERS.dark)();
69
+
70
+ /**
71
+ * The chrome every pane carries, in every state. `tool` and `basis` are what
72
+ * make a claim attributable; the age is what makes it dateable. A pane missing
73
+ * any of them manufactures currency, which is why they are not optional even on
74
+ * the error states.
75
+ * @param {{ title?: string, tool?: string, basis?: any, ageMs?: number, position?: string }} p
76
+ * @returns {string[]}
77
+ */
78
+ function chromeLines(p) {
79
+ const head = bold(p.title || 'pane') + (p.tool ? dim(' ' + p.tool) : '')
80
+ + (p.position ? dim(' ' + p.position) : '');
81
+ const lines = [head];
82
+ const bits = [];
83
+ // basis.at is OPAQUE — displayed verbatim, never parsed. Currency comes from
84
+ // the file's mtime below, not from anything the producer wrote.
85
+ if (p.basis && p.basis.label) bits.push(p.basis.label);
86
+ if (p.basis && p.basis.at) bits.push(p.basis.at);
87
+ if (p.ageMs != null) bits.push(`blob written ${writeAge(p.ageMs)} ago`);
88
+ if (bits.length) lines.push(' ' + dim(bits.join(' · ')));
89
+ return lines;
90
+ }
91
+
92
+ /**
93
+ * Render one row: marker, label, value, then optional spark and detail.
94
+ * @param {any} row
95
+ * @param {number} labelW
96
+ * @returns {string}
97
+ */
98
+ function rowLine(row, labelW) {
99
+ // Cut by CODE POINT: slicing UTF-16 units splits an astral character in half
100
+ // and emits a lone surrogate — the same bug clampVisible documents fixing,
101
+ // and this text is untrusted blob content.
102
+ const cps = [...row.label];
103
+ const label = cps.length > labelW ? cps.slice(0, labelW - 1).join('') + '…' : row.label;
104
+ const body = ' ' + marker(row.status) + ' ' + label.padEnd(labelW) + ' '
105
+ + (row.status === 'off' ? dim(row.value) : row.value);
106
+ const spark = row.spark ? ' ' + cyan(sparkline(row.spark)) : '';
107
+ const detail = row.detail ? dim(' ' + row.detail) : '';
108
+ return body + spark + detail;
109
+ }
110
+
111
+ /**
112
+ * Render a validated pane blob, or a named non-healthy state, as a whole pane.
113
+ *
114
+ * Error states name the configured PATH and nothing else — never file bytes,
115
+ * never a parser message, because a parser message quotes the input that caused
116
+ * it and the input is exactly what must not reach the terminal.
117
+ *
118
+ * @param {{ state: string, blob?: any, version?: number|null, reason?: string, ageMs?: number }} res
119
+ * the verifier's result for this pane
120
+ * @param {{ source: string, position?: string, width?: number, maxRows?: number }} opts
121
+ * `source` is the path AS THE USER WROTE IT in config — error states quote
122
+ * their config, not ccr's resolution of it.
123
+ * @returns {string}
124
+ */
125
+ function renderPane(res, opts) {
126
+ const width = opts.width && opts.width > 0 ? opts.width : 48;
127
+ const src = opts.source;
128
+ /** @type {string[]} */
129
+ let lines;
130
+
131
+ if (res.state === 'ok' && res.blob) {
132
+ const b = res.blob;
133
+ lines = chromeLines({ title: b.title, tool: b.tool, basis: b.basis, ageMs: res.ageMs, position: opts.position });
134
+ lines.push('');
135
+
136
+ if (b.status === 'broken') {
137
+ // Confession, not stale health: the message, prominently, with the chrome.
138
+ // Rows are ignored — showing them would present data the producer has just
139
+ // told us it could not stand behind.
140
+ lines.push(' ' + red(bold('broken')));
141
+ lines.push(' ' + (b.message || ''));
142
+ } else if (!b.rows.length) {
143
+ lines.push(' ' + dim('no rows reported'));
144
+ } else {
145
+ const labelW = Math.min(20, Math.max(6, ...b.rows.map((/** @type {any} */ r) => r.label.length)));
146
+ // Reserve a line for the overflow notice only when one is actually needed.
147
+ const budget = opts.maxRows && opts.maxRows > 0 ? opts.maxRows : b.rows.length;
148
+ const overflow = b.rows.length > budget;
149
+ const shownCount = overflow ? Math.max(0, budget - 1) : b.rows.length;
150
+ for (const r of b.rows.slice(0, shownCount)) lines.push(rowLine(r, labelW));
151
+ if (overflow) {
152
+ const hidden = b.rows.slice(shownCount);
153
+ // The collapsed line inherits the WORST hidden status, so a hidden
154
+ // `dark` row still reads as darkness rather than vanishing into a count.
155
+ const worst = SEVERITY.find((s) => hidden.some((/** @type {any} */ r) => r.status === s)) || 'off';
156
+ lines.push(' ' + marker(worst) + ' ' + dim(`+${hidden.length} more`));
157
+ }
158
+ }
159
+ } else {
160
+ /** @type {Record<string, string>} */
161
+ const named = {
162
+ waiting: 'waiting for first blob',
163
+ unreadable: 'blob unreadable',
164
+ invalid: 'blob invalid',
165
+ oversized: 'blob oversized',
166
+ 'cannot-read': `cannot read blob${res.reason ? ' (' + res.reason + ')' : ''}`,
167
+ unsupported: `unsupported blob version ${res.version == null ? '?' : res.version}`,
168
+ };
169
+ // Age chrome belongs on the error states too. A pane stuck on `invalid` for
170
+ // three days must not look like one that broke a second ago — that is the
171
+ // "manufactures currency" failure obligation 3 names, and dropping the age
172
+ // here contradicted this file's own docstring. `waiting`/`cannot-read` have
173
+ // no readable file and so legitimately have no age.
174
+ lines = chromeLines({ title: 'pane', position: opts.position, ageMs: res.ageMs });
175
+ lines.push('');
176
+ lines.push(' ' + (res.state === 'waiting' ? dim(named.waiting) : yellow(named[res.state] || 'blob unavailable')));
177
+ // The configured path is USER-authored, not blob-authored — but it is still
178
+ // text from a file on disk, and a config carrying an escape sequence would
179
+ // otherwise put it straight on the terminal from six different states.
180
+ lines.push(' ' + dim(String(stripControl(src) ?? '')));
181
+ }
182
+
183
+ return lines.map((l) => clampVisible(l, width)).join('\n');
184
+ }
185
+
186
+ module.exports = { renderPane, writeAge, sparkline, SEVERITY, MARKERS };