cawdev-cli 1.1.1 → 1.2.1

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,448 @@
1
+ // Advanced mode's sessions as CARDS — one small box per session, not its log.
2
+ //
3
+ // A session's transcript scrolling past in a quarter of the screen was neither
4
+ // readable nor meant to be read live: what somebody glances at is what kind of
5
+ // session it is, where it stands and whether it waits on them. So a card says
6
+ // those three and one line of what the session is doing now, and the log is
7
+ // `/view`'s, over the whole screen (attach.mjs `readerScreen`).
8
+ //
9
+ // The space a card does not need for words is RAIN — falling glyphs whose
10
+ // colour and speed are the session's status: green and fast while it runs,
11
+ // amber while it waits on you, slow and grey while it is queued, a slow drizzle
12
+ // once it ended, red when it stopped. It is the status said a second way, read
13
+ // out of the corner of an eye; `/matrix off` stills it.
14
+ //
15
+ // Everything here is pure — the grid, a card, the rain at a given tick — so it
16
+ // can be rendered at four sizes and read, as split.mjs is. The rain is a
17
+ // function of (seed, tick): the same session draws the same drops, and a test
18
+ // can name the frame it wants.
19
+ //
20
+ // Zero dependencies, like everything in tools/.
21
+
22
+ import { clip, padVisible, stripAnsi, visibleWidth } from '../lib/ansi.mjs';
23
+ import { builtNothing } from './split.mjs';
24
+
25
+ const RESET = '\x1b[0m';
26
+
27
+ /** Narrower than this a card cannot hold its badge, card and status. */
28
+ export const CARD_MIN_WIDTH = 38;
29
+ /** Border, the agent line, the stage line, the `now:` line, the footer. */
30
+ export const CARD_MIN_HEIGHT = 6;
31
+ /** Taller than this a card is all rain: the rest goes to the prompt's messages. */
32
+ export const CARD_MAX_HEIGHT = 11;
33
+ /** At most this many cards side by side, however wide the terminal is. */
34
+ export const CARD_MAX_COLUMNS = 3;
35
+
36
+ /**
37
+ * Where the cards go, for `count` sessions in `width`×`room` rows.
38
+ *
39
+ * As many columns as fit at `CARD_MIN_WIDTH` (three at most), as many rows of
40
+ * cards as fit at `CARD_MIN_HEIGHT`, each row as tall as the room allows up to
41
+ * `CARD_MAX_HEIGHT`. What does not fit is counted in `hidden`, for the tab
42
+ * line. `height` is the rows the cards take; the prompt has the rest.
43
+ *
44
+ * @returns `{ zones: [{ top, left, width, height }], hidden, height }`, 0-based.
45
+ */
46
+ export function cardGrid(width, room, count) {
47
+ if (count <= 0 || room < CARD_MIN_HEIGHT || width < 10) {
48
+ return { zones: [], hidden: Math.max(0, count), height: 0 };
49
+ }
50
+ const columns = Math.max(1, Math.min(CARD_MAX_COLUMNS, count, Math.floor(width / CARD_MIN_WIDTH)));
51
+ const fit = Math.floor(room / CARD_MIN_HEIGHT);
52
+ const rows = Math.min(fit, Math.ceil(count / columns));
53
+ const tall = Math.min(CARD_MAX_HEIGHT, Math.floor(room / rows));
54
+ const shown = Math.min(count, rows * columns);
55
+ const zones = [];
56
+ for (let i = 0; i < shown; i++) {
57
+ const row = Math.floor(i / columns);
58
+ // The last row's cards are as wide as the others: a lone card at the end
59
+ // keeps its column rather than stretching across the screen.
60
+ const [left, w] = spread(width, columns)[i % columns];
61
+ zones.push({ top: row * tall, left, width: w, height: tall });
62
+ }
63
+ return { zones, hidden: count - shown, height: rows * tall };
64
+ }
65
+
66
+ /** `n` columns across `width`, the remainder given to the leftmost ones. */
67
+ function spread(width, n) {
68
+ const base = Math.floor(width / n);
69
+ const extra = width - base * n;
70
+ const out = [];
71
+ let left = 0;
72
+ for (let i = 0; i < n; i++) {
73
+ const w = base + (i < extra ? 1 : 0);
74
+ out.push([left, w]);
75
+ left += w;
76
+ }
77
+ return out;
78
+ }
79
+
80
+ // --- what a card says ----------------------------------------------------------
81
+
82
+ /**
83
+ * A profile's badge: its word and its colour. The badge is drawn REVERSED
84
+ * (`badgeOf`), so it reads as a label and never as a status, whose colours
85
+ * (green, amber, red, grey) none of these share.
86
+ */
87
+ const BADGES = {
88
+ PLAN: ['PLAN', 'violetLight'],
89
+ CODE: ['DEV', 'accent'],
90
+ REVIEW: ['REVIEW', 'text'],
91
+ MERGE: ['MERGE', 'text'],
92
+ RELEASE: ['RELEASE', 'text'],
93
+ };
94
+
95
+ /** ` DEV ` reversed in its colour; any other profile is its own word, grey. */
96
+ export function badgeOf(profile, ink) {
97
+ const [word, paint] = BADGES[profile] ?? [String(profile ?? 'session').toUpperCase(), 'muted'];
98
+ return ink.reverse(ink.bold((ink[paint] ?? ink.muted)(` ${word} `)));
99
+ }
100
+
101
+ /**
102
+ * Where a session stands, as a card says it: `key` (what the frame and the
103
+ * rain are coloured and paced by), its `mark` and its `word`.
104
+ *
105
+ * Waiting on a person comes first whatever else is true — that is the one a
106
+ * card exists to make impossible to miss.
107
+ *
108
+ * @param meta what the terminal knows of the session (attach.mjs `sessions`)
109
+ * @param waiting a word when it waits on you (`waiting for an answer`), else null
110
+ * @param now ms, for how long a live session has run
111
+ */
112
+ export function cardStatus(meta, waiting = null, now = Date.now()) {
113
+ if (waiting) {
114
+ return { key: 'waiting', mark: '◆', word: waiting };
115
+ }
116
+ if (!meta.ended) {
117
+ if (meta.state === 'queued' || meta.state === 'claiming') {
118
+ return { key: 'queued', mark: '◌', word: meta.state };
119
+ }
120
+ const since = Date.parse(meta.startedAt ?? '');
121
+ const ran = Number.isFinite(since) ? ` · ${elapsed(now - since)}` : '';
122
+ return { key: 'running', mark: '●', word: `${meta.state ?? 'running'}${ran}` };
123
+ }
124
+ const state = meta.view?.state;
125
+ if (!state || meta.view?.live) {
126
+ return { key: 'ended', mark: '…', word: 'ended' };
127
+ }
128
+ if (state === 'FINISHED') {
129
+ // A plan that finished is a card planned: the robot's, not the rain's.
130
+ if (meta.view.profile === 'PLAN') {
131
+ return { key: 'planned', mark: '✓', word: 'planned' };
132
+ }
133
+ return builtNothing(meta)
134
+ ? { key: 'idle', mark: '✓', word: 'finished · nothing built' }
135
+ : { key: 'done', mark: '✓', word: 'finished' };
136
+ }
137
+ return { key: 'stopped', mark: '✕', word: String(state).toLowerCase().replace(/_/g, ' ') };
138
+ }
139
+
140
+ /** `42s`, `12m`, `2h05`. */
141
+ export function elapsed(ms) {
142
+ const seconds = Math.max(0, Math.floor(ms / 1000));
143
+ if (seconds < 60) return `${seconds}s`;
144
+ const minutes = Math.floor(seconds / 60);
145
+ if (minutes < 60) return `${minutes}m`;
146
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`;
147
+ }
148
+
149
+ /** The colour of each status — the frame's, the mark's and the rain's. */
150
+ const STATUS_PAINT = {
151
+ running: 'success', waiting: 'warn', queued: 'muted', done: 'success', planned: 'success', merging: 'muted',
152
+ idle: 'warn', stopped: 'danger', ended: 'muted',
153
+ };
154
+
155
+ export function statusPaint(key, ink) {
156
+ return ink[STATUS_PAINT[key]] ?? ink.muted;
157
+ }
158
+
159
+ /**
160
+ * `plan ✓ ─ implement ● ─ test ○` — the stages a live session walks and the
161
+ * one it is on. Too long for `width`, it says the count and the stage instead,
162
+ * which still answers "how far has it got".
163
+ */
164
+ export function stageWalk(stages, current, width, ink) {
165
+ if (!Array.isArray(stages) || !stages.length || !current) return '';
166
+ const at = stages.indexOf(current);
167
+ const words = stages.map((name, i) => {
168
+ const word = String(name).toLowerCase();
169
+ if (at < 0 || i > at) return ink.muted(`${word} ○`);
170
+ return i === at ? ink.bold(`${word} ●`) : ink.muted(`${word} ✓`);
171
+ });
172
+ const line = words.join(ink.muted(' ─ '));
173
+ if (visibleWidth(line) <= width) return line;
174
+ return at < 0
175
+ ? ink.bold(String(current).toLowerCase())
176
+ : `${ink.muted(`stage ${at + 1} of ${stages.length} ·`)} ${ink.bold(String(current).toLowerCase())}`;
177
+ }
178
+
179
+ /**
180
+ * The one line of what a session is doing — never its log. The last tool call
181
+ * (`describeCall`'s words: `Edit pay.js`, `Bash npm test`) while it works,
182
+ * else the last thing it said; for an ended session that failed or built
183
+ * nothing, the first line of why.
184
+ *
185
+ * @param lines the session's transcript lines, `{ kind, body }`
186
+ */
187
+ export function nowLine(meta, lines = []) {
188
+ const view = meta.view ?? {};
189
+ if (meta.ended && view.exitSummary && view.state !== 'FINISHED') {
190
+ return `why: ${firstLine(view.exitSummary)}`;
191
+ }
192
+ if (builtNothing(meta) && view.exitSummary) {
193
+ return `why: ${firstLine(view.exitSummary)}`;
194
+ }
195
+ if (!meta.ended && (meta.state === 'queued' || meta.state === 'claiming')) {
196
+ return meta.why ? `waiting — ${meta.why}` : '';
197
+ }
198
+ // The last ACT, even with words said since: what it last did is what it is
199
+ // doing, and a sentence is the log's. Before its first tool, what it said.
200
+ const tool = lines.findLast((line) => line?.kind === 'TOOL');
201
+ if (tool) return `now: ${firstLine(tool.body)}`;
202
+ const spoke = lines.findLast((line) => line?.kind === 'ASSISTANT' && firstLine(line.body));
203
+ if (spoke) return `said: ${firstLine(spoke.body)}`;
204
+ return meta.ended ? '' : 'starting…';
205
+ }
206
+
207
+ /** The first line with words in it, escapes and control bytes gone: an agent's output must not drive the terminal. */
208
+ function firstLine(text) {
209
+ return stripAnsi(String(text ?? ''))
210
+ // eslint-disable-next-line no-control-regex
211
+ .split('\n').map((part) => part.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').trim()).find(Boolean) ?? '';
212
+ }
213
+
214
+ // --- the rain ------------------------------------------------------------------
215
+
216
+ /**
217
+ * How each status rains: its colour, how fast a drop falls (rows per tick),
218
+ * how many columns carry one, and how long its trail is.
219
+ */
220
+ export const RAIN = {
221
+ running: { speed: 1, density: 0.5, trail: 6 },
222
+ waiting: { speed: 0.5, density: 0.45, trail: 4 },
223
+ queued: { speed: 0.15, density: 0.2, trail: 2 },
224
+ done: { speed: 0.08, density: 0.18, trail: 2 },
225
+ idle: { speed: 0.1, density: 0.22, trail: 2 },
226
+ stopped: { speed: 0.12, density: 0.3, trail: 3 },
227
+ ended: { speed: 0, density: 0.12, trail: 1 },
228
+ };
229
+
230
+ /** Half-width katakana and digits: one column each, which is what the rain is measured in. */
231
+ const GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789';
232
+
233
+ /** A small integer hash — the same inputs, the same drop. */
234
+ function mix(...values) {
235
+ let h = 0x811c9dc5;
236
+ for (const value of values) {
237
+ h ^= value | 0;
238
+ h = Math.imul(h, 0x01000193);
239
+ h ^= h >>> 15;
240
+ }
241
+ return h >>> 0;
242
+ }
243
+
244
+ /** A string's seed: a session's id, so its rain is its own. */
245
+ export function seedOf(text) {
246
+ let h = 0;
247
+ for (const ch of String(text ?? '')) h = Math.imul(h ^ ch.codePointAt(0), 0x5bd1e995) >>> 0;
248
+ return h;
249
+ }
250
+
251
+ /**
252
+ * `rows` lines of rain, each exactly `width` columns, at `tick`.
253
+ *
254
+ * Each column either carries a drop or not (`density`, fixed per column). A
255
+ * drop falls at the status's speed times its column's own (0.6–1.4, so the
256
+ * columns do not march together), wraps after its trail and a gap, and its
257
+ * glyphs change as it moves. The head is bright, the trail fades into dim.
258
+ *
259
+ * @param mood a key of `RAIN` (a card's `cardStatus(...).key`)
260
+ */
261
+ export function rainLines(width, rows, { seed = 0, tick = 0, mood = 'running' } = {}, ink) {
262
+ const rain = RAIN[mood] ?? RAIN.ended;
263
+ const paint = statusPaint(mood, ink);
264
+ const grid = Array.from({ length: rows }, () => new Array(width).fill(null));
265
+ for (let x = 0; x < width; x++) {
266
+ const h = mix(seed, x);
267
+ if ((h % 1000) / 1000 >= rain.density) continue;
268
+ const trail = rain.trail + (h >>> 10) % 3;
269
+ const period = rows + trail + 2 + ((h >>> 14) % Math.max(1, rows));
270
+ const pace = 0.6 + ((h >>> 20) % 9) / 10;
271
+ const fallen = Math.floor(tick * rain.speed * pace);
272
+ const head = ((h >>> 4) % period + fallen) % period;
273
+ for (let y = 0; y < rows; y++) {
274
+ const behind = head - y;
275
+ if (behind < 0 || behind > trail) continue;
276
+ const glyph = GLYPHS[mix(seed, x, y, fallen) % GLYPHS.length];
277
+ grid[y][x] = { glyph, tone: behind === 0 ? 'head' : behind <= trail / 2 ? 'body' : 'tail' };
278
+ }
279
+ }
280
+ const styles = {
281
+ head: (text) => ink.bold(mood === 'running' ? ink.text(text) : paint(text)),
282
+ body: paint,
283
+ tail: (text) => ink.dim(paint(text)),
284
+ };
285
+ // Runs of one style are painted once: a cell each would be ten bytes of
286
+ // escape per glyph, eight times a second, for every card on the screen.
287
+ return grid.map((cells) => {
288
+ let out = '';
289
+ let run = '';
290
+ let style = null;
291
+ const close = () => {
292
+ if (run) out += style ? styles[style](run) : run;
293
+ run = '';
294
+ };
295
+ for (const cell of cells) {
296
+ const next = cell?.tone ?? null;
297
+ if (next !== style) {
298
+ close();
299
+ style = next;
300
+ }
301
+ run += cell ? cell.glyph : ' ';
302
+ }
303
+ close();
304
+ return out;
305
+ });
306
+ }
307
+
308
+ /** Cut to `width` with `…` where it was cut. */
309
+ function ellipsed(text, width) {
310
+ const plain = String(text);
311
+ return plain.length <= width ? plain : `${plain.slice(0, Math.max(0, width - 1)).trimEnd()}…`;
312
+ }
313
+
314
+ // --- the robot ------------------------------------------------------------------
315
+
316
+ /** What the robot holds up: the plan is done, and the card is ready to develop. */
317
+ export const ROBOT_SIGN = '✓ plan done';
318
+
319
+ /**
320
+ * A finished plan's card, in place of its rain: a small robot pacing along
321
+ * the bottom of the card, waving and blinking, with `ROBOT_SIGN` beside its
322
+ * head. The rain says a session is working; the robot says it worked, and
323
+ * that what it made, a plan, is waiting for somebody to build it.
324
+ *
325
+ * [o_o] ✓ plan done three rows and more: head, arms, legs,
326
+ * \[_]/ standing on the card's last spare row
327
+ * / \
328
+ *
329
+ * Two rows are a head and legs, one a head and the sign. It walks a column
330
+ * every four ticks and turns at either side; `still` stands it at the left,
331
+ * for `/matrix off`. Pure: the same tick is the same frame.
332
+ */
333
+ export function robotLines(width, rows, { tick = 0, still = false } = {}, ink) {
334
+ const paint = (text) => ink.bold(ink.success(text));
335
+ const sign = ` ${ROBOT_SIGN}`;
336
+ const body = 5;
337
+ const room = Math.max(0, width - body - sign.length - 2);
338
+ const step = still ? 0 : Math.floor(tick / 4);
339
+ const x = 1 + (room ? (Math.floor(step / room) % 2 ? room - (step % room) : step % room) : 0);
340
+ const blink = !still && tick % 23 === 22;
341
+ const head = blink ? '[-_-]' : '[o_o]';
342
+ const arms = still || Math.floor(tick / 3) % 2 ? '\\[_]/' : '/[_]\\';
343
+ const legs = still || step % 2 ? ' / \\ ' : ' | | ';
344
+ const at = (text, extra = '') => {
345
+ const line = `${' '.repeat(x)}${paint(text)}${extra}`;
346
+ return padVisible(clip(line, width), width);
347
+ };
348
+ const withSign = (text) => at(text, width >= body + sign.length + 1 ? ink.success(sign) : '');
349
+ const drawn = rows >= 3 ? [withSign(head), at(arms), at(legs)]
350
+ : rows === 2 ? [withSign(head), at(legs)]
351
+ : [withSign(head)];
352
+ return [...new Array(Math.max(0, rows - drawn.length)).fill(' '.repeat(width)), ...drawn].slice(-rows);
353
+ }
354
+
355
+ /**
356
+ * A card being merged, in place of its rain: the robot, looking ahead
357
+ * (`[>_>]`), carries the branch (`[■]`) to the base on the right (`▶ main`),
358
+ * sets it down and walks back for the next load — a loop, because a merge has
359
+ * no progress to show, only that it is under way. The card around it is grey:
360
+ * nothing can be done on it until it lands, and then it closes.
361
+ *
362
+ * [>_>] three rows and more
363
+ * /[_]=[■] ▶ main
364
+ * / \
365
+ *
366
+ * `still` (`/matrix off`) stands it at the left with its load.
367
+ */
368
+ export function mergingRobotLines(width, rows, { tick = 0, still = false, base = 'main' } = {}, ink) {
369
+ const paint = (text) => ink.bold(ink.accent(text));
370
+ const dock = ` ▶ ${base}`;
371
+ const body = 8;
372
+ const room = Math.max(0, width - body - dock.length - 2);
373
+ const x = 1 + (still || !room ? 0 : Math.floor(tick / 2) % (room + 1));
374
+ const legs = still || Math.floor(tick / 2) % 2 ? ' / \\ ' : ' | | ';
375
+ const at = (text, extra = '') => padVisible(clip(`${' '.repeat(x)}${paint(text)}${extra}`, width), width);
376
+ // The base stays where it is while the robot walks to it.
377
+ const toDock = (text) => {
378
+ const gap = width - x - visibleWidth(text) - dock.length;
379
+ return gap >= 1 ? at(text, `${' '.repeat(gap)}${ink.muted(dock)}`) : at(text);
380
+ };
381
+ const drawn = rows >= 3 ? [at('[>_>]'), toDock('/[_]=[■]'), at(legs)]
382
+ : rows === 2 ? [at('[>_>]'), toDock('/[_]=[■]')]
383
+ : [toDock('[>_>]=[■]')];
384
+ return [...new Array(Math.max(0, rows - drawn.length)).fill(' '.repeat(width)), ...drawn].slice(-rows);
385
+ }
386
+
387
+ // --- a card --------------------------------------------------------------------
388
+
389
+ /**
390
+ * One card, as exactly `rect.height` strings of exactly `rect.width` columns.
391
+ *
392
+ * ┌─ 1 DEV R42 Payments refund flow ──── ● running · 12m ─┐
393
+ * │ Claude · Opus 5.5 ▸ saasng · …/saasng-auto-2 │
394
+ * │ plan ✓ ─ implement ● ─ test ○ │
395
+ * │ now: Bash npm test │
396
+ * │ ア 7 サ │ ← rain
397
+ * │ /pr opens the pull request · /close │
398
+ * └─────────────────────────────────────────────────────────┘
399
+ *
400
+ * The frame is the status's colour; the focused card is drawn heavy, which
401
+ * survives `NO_COLOR`. The title is clipped before the status is: "● running"
402
+ * is what a glance is for.
403
+ *
404
+ * @param card `{ n, profile, title, status, lines, footer, focused, seed, tick, rain }`
405
+ * — `lines` the card's words, top down; `rain` false draws none.
406
+ */
407
+ export function sessionCardLines(rect, card, ink) {
408
+ const { width, height } = rect;
409
+ const inner = Math.max(0, width - 2);
410
+ const [h, v, tl, tr, bl, br] = card.focused
411
+ ? ['━', '┃', '┏', '┓', '┗', '┛']
412
+ : ['─', '│', '┌', '┐', '└', '┘'];
413
+ const paint = statusPaint(card.status.key, ink);
414
+
415
+ const status = ` ${card.status.mark} ${card.status.word} `;
416
+ const left = ` ${card.n ? `${card.n} ` : ''}`;
417
+ const badge = badgeOf(card.profile, ink);
418
+ const titleRoom = Math.max(0, inner - 1 - visibleWidth(left) - visibleWidth(badge) - 1
419
+ - visibleWidth(status) - 2);
420
+ const title = titleRoom >= 4 ? ` ${ellipsed(card.title ?? '', titleRoom - 1)} ` : ' ';
421
+ const label = `${left}${badge}${title}`;
422
+ const fill = Math.max(0, inner - 1 - visibleWidth(label) - visibleWidth(status) - 1);
423
+ const shownStatus = visibleWidth(label) + visibleWidth(status) + 2 <= inner ? paint(status) : '';
424
+ const top = paint(tl + h) + (card.focused ? ink.bold(label) : label)
425
+ + paint(h.repeat(shownStatus ? fill : Math.max(0, inner - 1 - visibleWidth(label)))) + shownStatus
426
+ + paint((shownStatus ? h : '') + tr);
427
+
428
+ // A column of margin inside the border, for the words; the rain runs to it.
429
+ const row = (text) => paint(v) + padVisible(clip(text, inner), inner) + RESET + paint(v);
430
+ const said = (text) => row(` ${clip(text, Math.max(0, inner - 2))}`);
431
+ const footer = card.footer ? 1 : 0;
432
+ const room = Math.max(0, height - 2 - footer);
433
+ const words = (card.lines ?? []).filter((line) => line !== null && line !== undefined).slice(0, room);
434
+ const spare = room - words.length;
435
+ const rain = spare <= 0 ? []
436
+ // A planned card has its robot whatever `/matrix` says; off, it stands still.
437
+ : card.status.key === 'planned'
438
+ ? robotLines(inner, spare, { tick: card.tick, still: card.rain === false }, ink)
439
+ : card.status.key === 'merging'
440
+ ? mergingRobotLines(inner, spare, { tick: card.tick, still: card.rain === false, base: card.base }, ink)
441
+ : card.rain === false
442
+ ? new Array(spare).fill('')
443
+ : rainLines(inner, spare, { seed: card.seed, tick: card.tick, mood: card.status.key }, ink);
444
+ const lines = [top, ...words.map(said), ...rain.map(row)];
445
+ if (footer) lines.push(said(ink.muted(card.footer)));
446
+ lines.push(paint(bl + h.repeat(inner) + br));
447
+ return lines.slice(0, height);
448
+ }
@@ -0,0 +1,214 @@
1
+ // R605 — the screenshots a SHOW stage took, on this machine: where a copy is
2
+ // kept, how it is opened, and how a person's comments become a decision.
3
+ //
4
+ // Zero dependencies, and pure wherever it can be. The one thing here that
5
+ // touches the disk is `localCopy`, and it is the ONLY writer of the cache:
6
+ // the daemon never writes under `~/.cawdev/shots`, because a session could
7
+ // plant `<runId>` there as a symlink and the daemon would then write files
8
+ // with names the session chose wherever the link points. The terminal
9
+ // writes names it derives itself — the checksum and an extension taken from
10
+ // the platform's content type — and refuses a link at any level.
11
+
12
+ import { createHash, randomUUID } from 'node:crypto';
13
+ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { stripAnsi } from '../lib/ansi.mjs';
17
+
18
+ const EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg' };
19
+
20
+ /** Where copies are kept. */
21
+ export function shotsDir(env = process.env) {
22
+ return env.CAWDEV_SHOTS_DIR ?? join(homedir(), '.cawdev', 'shots');
23
+ }
24
+
25
+ /** A picture the OS viewer may be handed. HTML is not: it opens on the platform. */
26
+ export function isImage(view) {
27
+ return Object.hasOwn(EXTENSIONS, view?.contentType ?? '');
28
+ }
29
+
30
+ /**
31
+ * Where a copy of this screenshot lives, or null for HTML. The name is the
32
+ * checksum and the extension comes from the content type — never from `name`,
33
+ * which a session chose.
34
+ */
35
+ export function cachedPath(dir, view) {
36
+ if (!isImage(view) || !/^[0-9a-f]{64}$/i.test(view.sha256 ?? '')) return null;
37
+ // Both are path parts: nothing but a plain id and a whole number may become one.
38
+ if (!/^[A-Za-z0-9_-]+$/.test(String(view.runId ?? '')) || !Number.isInteger(view.round) || view.round < 0) return null;
39
+ return join(dir, String(view.runId ?? ''), String(view.round), `${view.sha256.toLowerCase()}.${EXTENSIONS[view.contentType]}`);
40
+ }
41
+
42
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
43
+
44
+ async function requireNotALink(path) {
45
+ const found = await lstat(path).catch(() => null);
46
+ if (found?.isSymbolicLink()) {
47
+ throw new Error(`${path} is a link; remove it`);
48
+ }
49
+ return found;
50
+ }
51
+
52
+ /**
53
+ * A local copy of an image, downloaded the first time and never again while the
54
+ * bytes on disk still match their checksum.
55
+ *
56
+ * @param view the attachment as the platform lists it, with `runId` added
57
+ * @param fetchBytes called only when no good copy exists
58
+ */
59
+ export async function localCopy(view, { dir, fetchBytes }) {
60
+ const path = cachedPath(dir, view);
61
+ if (path === null) throw new Error(`${printable(view.name)} is not an image to keep — it opens on cawdev`);
62
+ const wanted = view.sha256.toLowerCase();
63
+ const parts = [String(view.runId ?? ''), String(view.round)];
64
+
65
+ await mkdir(dir, { recursive: true, mode: 0o700 });
66
+ let level = dir;
67
+ await requireNotALink(level);
68
+ for (const part of parts) {
69
+ level = join(level, part);
70
+ await requireNotALink(level);
71
+ await mkdir(level, { mode: 0o700 }).catch((failure) => {
72
+ if (failure.code !== 'EEXIST') throw failure;
73
+ });
74
+ await requireNotALink(level);
75
+ }
76
+
77
+ const there = await lstat(path).catch(() => null);
78
+ if (there) {
79
+ if (!there.isFile()) throw new Error(`${path} is not a regular file; remove it`);
80
+ if (sha256(await readFile(path)) === wanted) return { path, downloaded: false };
81
+ }
82
+
83
+ const bytes = await fetchBytes();
84
+ if (sha256(bytes) !== wanted) {
85
+ throw new Error(`the platform's copy of ${printable(view.name)} does not match its own checksum`);
86
+ }
87
+ // 'wx' is O_EXCL: it fails on anything already at the name — a link planted
88
+ // there included — instead of writing through it. The name is unguessable.
89
+ const partial = `${path}.tmp-${randomUUID()}`;
90
+ try {
91
+ await writeFile(partial, bytes, { mode: 0o600, flag: 'wx' });
92
+ await rename(partial, path);
93
+ } catch (failure) {
94
+ await rm(partial, { force: true });
95
+ throw failure;
96
+ }
97
+ return { path, downloaded: true };
98
+ }
99
+
100
+ /**
101
+ * How to hand files to the OS viewer: a list of [command, args] to spawn, each
102
+ * with an argument array and no shell. macOS gets one `open` with every path, so
103
+ * Preview pages through them in one window.
104
+ */
105
+ export function openerFor(platform) {
106
+ if (platform === 'darwin') return (paths) => (paths.length ? [['open', paths]] : []);
107
+ if (platform === 'win32') return (paths) => paths.map((path) => ['explorer', [path]]);
108
+ return (paths) => paths.map((path) => ['xdg-open', [path]]);
109
+ }
110
+
111
+ /**
112
+ * A name or a caption comes from the session: nothing in it may move the cursor,
113
+ * start a line of its own, or reorder what follows it. `lines` keeps newlines,
114
+ * for a text that is several lines on purpose.
115
+ */
116
+ export function printable(text, { lines = false } = {}) {
117
+ const plain = stripAnsi(String(text ?? ''))
118
+ .replace(/[\u2028\u2029]/g, lines ? '\n' : ' ')
119
+ .replace(lines ? /[\t]/g : /[\t\n\r]/g, ' ')
120
+ .replace(lines ? /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g : /[\x00-\x1f\x7f-\x9f]/g, '');
121
+ // Bidirectional controls: a name that reads one way and means another.
122
+ return plain.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '');
123
+ }
124
+
125
+ /**
126
+ * A person's comments → the decision's `reason` and `notes`.
127
+ *
128
+ * A line starting `#2` is a note on the second screenshot of the round (numbered
129
+ * from 1, in list order); anything else is the round's note. Several lines for
130
+ * one screenshot are joined, because the platform takes one note per shot.
131
+ *
132
+ * @returns {{reason: string, notes: Record<string,string>, error: string|null}}
133
+ */
134
+ export function parseComments(text, shots) {
135
+ const reasons = [];
136
+ const perShot = new Map();
137
+ for (const raw of String(text ?? '').split('\n')) {
138
+ const line = raw.trim();
139
+ if (!line) continue;
140
+ const mine = /^#(\d+)\s*[:—-]?\s*(.*)$/.exec(line);
141
+ if (!mine) {
142
+ reasons.push(line);
143
+ continue;
144
+ }
145
+ const at = Number(mine[1]);
146
+ if (at < 1 || at > shots.length) {
147
+ return { reason: '', notes: {}, error: `there is no screenshot ${at} — this round has ${shots.length}` };
148
+ }
149
+ if (!mine[2]) {
150
+ return { reason: '', notes: {}, error: `write the note after #${at}` };
151
+ }
152
+ perShot.set(at, [...(perShot.get(at) ?? []), mine[2]]);
153
+ }
154
+ const notes = {};
155
+ for (const [at, lines] of perShot) notes[shots[at - 1].id] = lines.join('\n');
156
+ if (reasons.length === 0 && perShot.size === 0) {
157
+ return { reason: '', notes: {}, error: 'write a note on the round or on a screenshot (#2 …)' };
158
+ }
159
+ return { reason: reasons.join('\n'), notes, error: null };
160
+ }
161
+
162
+ /**
163
+ * `{round, ids}` from a `stage:SHOW` approval's `toolInput`, else null. What a
164
+ * gate NAMES is the session's claim until the platform's sealed list agrees.
165
+ */
166
+ export function showGate(approval) {
167
+ if (approval?.toolName !== 'stage:SHOW') return null;
168
+ try {
169
+ const input = typeof approval.toolInput === 'string'
170
+ ? JSON.parse(approval.toolInput || '{}')
171
+ : approval.toolInput ?? {};
172
+ const ids = Array.isArray(input.attachments) ? input.attachments.map(String) : [];
173
+ return { round: Number(input.round ?? 0), ids };
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /** The numbered lines of a round, for the scrollback or a window. */
180
+ export function roundListing(shots, ink = { muted: (text) => text }) {
181
+ return shots.map((shot, at) => {
182
+ const caption = shot.caption ? ` — ${printable(shot.caption)}` : '';
183
+ const html = isImage(shot) ? '' : ink.muted(' (html — opens on cawdev)');
184
+ return ` ${at + 1} ${printable(shot.name)}${caption}${html}`;
185
+ });
186
+ }
187
+
188
+ // --- thumbnails ---------------------------------------------------------------
189
+
190
+ /** Which inline-image escape this terminal speaks, or null: no other gets any. */
191
+ export function thumbnailProtocol(env = process.env) {
192
+ if (env.TERM_PROGRAM === 'iTerm.app' || env.TERM_PROGRAM === 'WezTerm') return 'iterm';
193
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') return 'kitty';
194
+ return null;
195
+ }
196
+
197
+ /** The bytes of an image as that terminal's inline-image escape, `cols` wide. */
198
+ export function thumbnailEscape(bytes, protocol, { cols = 24 } = {}) {
199
+ const base64 = Buffer.from(bytes).toString('base64');
200
+ if (protocol === 'iterm') {
201
+ return `\x1b]1337;File=inline=1;width=${cols};preserveAspectRatio=1:${base64}\x07`;
202
+ }
203
+ if (protocol === 'kitty') {
204
+ let out = '';
205
+ for (let at = 0; at < base64.length; at += 4096) {
206
+ const chunk = base64.slice(at, at + 4096);
207
+ const more = at + 4096 < base64.length ? 1 : 0;
208
+ const head = at === 0 ? `a=T,f=100,c=${cols},m=${more}` : `m=${more}`;
209
+ out += `\x1b_G${head};${chunk}\x1b\\`;
210
+ }
211
+ return out;
212
+ }
213
+ return '';
214
+ }