cawdev-cli 1.2.0 → 1.2.2
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/lib/finish-check.mjs +9 -1
- package/lib/gate-condition.mjs +11 -0
- package/lib/sonar-analysis.mjs +55 -9
- package/lib/stage-tools.mjs +33 -2
- package/mcp/server.mjs +44 -12
- package/package.json +1 -1
- package/runner/README.md +294 -33
- package/runner/attach.mjs +1927 -123
- package/runner/card-import.mjs +115 -0
- package/runner/cawdev.mjs +70 -1
- package/runner/diff-view.mjs +252 -0
- package/runner/git-lock.mjs +73 -0
- package/runner/runner.mjs +508 -39
- package/runner/session-cards.mjs +660 -0
- package/runner/shots.mjs +214 -0
- package/runner/sign-in.mjs +29 -2
- package/runner/split.mjs +307 -74
|
@@ -0,0 +1,660 @@
|
|
|
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
|
+
TEST: ['TEST', '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(), tick = 0) {
|
|
113
|
+
if (waiting) {
|
|
114
|
+
return { key: 'waiting', mark: '◆', word: waiting };
|
|
115
|
+
}
|
|
116
|
+
if (!meta.ended) {
|
|
117
|
+
const shape = spinnerFor(meta.view?.profile ?? meta.profile, meta.stage);
|
|
118
|
+
if (meta.state === 'queued' || meta.state === 'claiming') {
|
|
119
|
+
// Nothing moves while queued: the spinner rests on its first frame, grey.
|
|
120
|
+
return { key: 'queued', mark: shape[0], word: meta.state };
|
|
121
|
+
}
|
|
122
|
+
const since = Date.parse(meta.startedAt ?? '');
|
|
123
|
+
const ran = Number.isFinite(since) ? ` · ${elapsed(now - since)}` : '';
|
|
124
|
+
return { key: 'running', mark: spinnerFrame(shape, tick), word: `${meta.state ?? 'running'}${ran}` };
|
|
125
|
+
}
|
|
126
|
+
const state = meta.view?.state;
|
|
127
|
+
if (!state || meta.view?.live) {
|
|
128
|
+
return { key: 'ended', mark: '…', word: 'ended' };
|
|
129
|
+
}
|
|
130
|
+
if (state === 'FINISHED') {
|
|
131
|
+
// A plan that finished is a card planned: the robot's, not the rain's.
|
|
132
|
+
if (meta.view.profile === 'PLAN') {
|
|
133
|
+
return { key: 'planned', mark: '✓', word: 'planned' };
|
|
134
|
+
}
|
|
135
|
+
return builtNothing(meta)
|
|
136
|
+
? { key: 'idle', mark: '✓', word: 'finished · nothing built' }
|
|
137
|
+
: { key: 'done', mark: '✓', word: 'finished' };
|
|
138
|
+
}
|
|
139
|
+
return { key: 'stopped', mark: '✕', word: String(state).toLowerCase().replace(/_/g, ' ') };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- the spinner ---------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** A stage that writes turns the solid quadrant counter-clockwise: the rule `cawdev-actions.js` draws. */
|
|
145
|
+
export const SPINNER_WRITES = ['◴', '◵', '◶', '◷'];
|
|
146
|
+
/** A stage that only reads turns the thin arc clockwise. */
|
|
147
|
+
export const SPINNER_READS = ['◜', '◝', '◞', '◟'];
|
|
148
|
+
/** Stages that only read; every other action — plan, code, merge, release, roadmap, interview — writes. */
|
|
149
|
+
const READING_STAGES = new Set(['test', 'ask', 'audit', 'scope', 'show']);
|
|
150
|
+
const READING_PROFILES = new Set(['TEST', 'ASK', 'AUDIT', 'SCOPE']);
|
|
151
|
+
|
|
152
|
+
/** The frames a session turns through: by its stage when it walks stages, else by its profile. */
|
|
153
|
+
export function spinnerFor(profile, stage = null) {
|
|
154
|
+
const word = String(stage ?? '').toLowerCase();
|
|
155
|
+
if (word) return READING_STAGES.has(word) ? SPINNER_READS : SPINNER_WRITES;
|
|
156
|
+
return READING_PROFILES.has(String(profile ?? '').toUpperCase()) ? SPINNER_READS : SPINNER_WRITES;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One frame every two ticks, so every spinner on the screen is in phase. */
|
|
160
|
+
export function spinnerFrame(shape, tick = 0) {
|
|
161
|
+
return shape[Math.floor(Math.max(0, tick) / 2) % shape.length];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** How many ticks a changed value is drawn bright before it settles. */
|
|
165
|
+
export const BEAT_TICKS = 4;
|
|
166
|
+
|
|
167
|
+
/** Whether a value that changed at `changedAt` is still in its beat at `tick`. */
|
|
168
|
+
export function beating(changedAt, tick) {
|
|
169
|
+
return Number.isFinite(changedAt) && tick - changedAt >= 0 && tick - changedAt < BEAT_TICKS;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `42s`, `12m`, `2h05`. */
|
|
173
|
+
export function elapsed(ms) {
|
|
174
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
175
|
+
if (seconds < 60) return `${seconds}s`;
|
|
176
|
+
const minutes = Math.floor(seconds / 60);
|
|
177
|
+
if (minutes < 60) return `${minutes}m`;
|
|
178
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The colour of each status — the frame's, the mark's and the rain's. */
|
|
182
|
+
const STATUS_PAINT = {
|
|
183
|
+
running: 'success', waiting: 'warn', queued: 'muted', done: 'success', planned: 'success', merging: 'muted',
|
|
184
|
+
idle: 'warn', stopped: 'danger', ended: 'muted', landed: 'success',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export function statusPaint(key, ink) {
|
|
188
|
+
return ink[STATUS_PAINT[key]] ?? ink.muted;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* `plan ✓ ─ implement ● ─ test ○` — the stages a live session walks and the
|
|
193
|
+
* one it is on. Too long for `width`, it says the count and the stage instead,
|
|
194
|
+
* which still answers "how far has it got".
|
|
195
|
+
*/
|
|
196
|
+
export function stageWalk(stages, current, width, ink, tick = null) {
|
|
197
|
+
if (!Array.isArray(stages) || !stages.length || !current) return '';
|
|
198
|
+
const at = stages.indexOf(current);
|
|
199
|
+
// The mark on the current stage turns when a tick is given, as the status's does.
|
|
200
|
+
const mark = tick === null ? '●' : spinnerFrame(spinnerFor(null, current), tick);
|
|
201
|
+
const words = stages.map((name, i) => {
|
|
202
|
+
const word = String(name).toLowerCase();
|
|
203
|
+
if (at < 0 || i > at) return ink.muted(`${word} ○`);
|
|
204
|
+
return i === at ? ink.bold(`${word} ${mark}`) : ink.muted(`${word} ✓`);
|
|
205
|
+
});
|
|
206
|
+
const line = words.join(ink.muted(' ─ '));
|
|
207
|
+
if (visibleWidth(line) <= width) return line;
|
|
208
|
+
return at < 0
|
|
209
|
+
? ink.bold(String(current).toLowerCase())
|
|
210
|
+
: `${ink.muted(`stage ${at + 1} of ${stages.length} ·`)} ${ink.bold(String(current).toLowerCase())}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The one line of what a session is doing — never its log. The last tool call
|
|
215
|
+
* (`describeCall`'s words: `Edit pay.js`, `Bash npm test`) while it works,
|
|
216
|
+
* else the last thing it said; for an ended session that failed or built
|
|
217
|
+
* nothing, the first line of why.
|
|
218
|
+
*
|
|
219
|
+
* @param lines the session's transcript lines, `{ kind, body }`
|
|
220
|
+
*/
|
|
221
|
+
export function nowLine(meta, lines = []) {
|
|
222
|
+
const view = meta.view ?? {};
|
|
223
|
+
if (meta.ended && view.exitSummary && view.state !== 'FINISHED') {
|
|
224
|
+
return `why: ${firstLine(view.exitSummary)}`;
|
|
225
|
+
}
|
|
226
|
+
if (builtNothing(meta) && view.exitSummary) {
|
|
227
|
+
return `why: ${firstLine(view.exitSummary)}`;
|
|
228
|
+
}
|
|
229
|
+
if (!meta.ended && (meta.state === 'queued' || meta.state === 'claiming')) {
|
|
230
|
+
return meta.why ? `waiting — ${meta.why}` : '';
|
|
231
|
+
}
|
|
232
|
+
// The last ACT, even with words said since: what it last did is what it is
|
|
233
|
+
// doing, and a sentence is the log's. Before its first tool, what it said.
|
|
234
|
+
const tool = lines.findLast((line) => line?.kind === 'TOOL');
|
|
235
|
+
if (tool) return `now: ${firstLine(tool.body)}`;
|
|
236
|
+
const spoke = lines.findLast((line) => line?.kind === 'ASSISTANT' && firstLine(line.body));
|
|
237
|
+
if (spoke) return `said: ${firstLine(spoke.body)}`;
|
|
238
|
+
return meta.ended ? '' : 'starting…';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The first line with words in it, escapes and control bytes gone: an agent's output must not drive the terminal. */
|
|
242
|
+
function firstLine(text) {
|
|
243
|
+
return stripAnsi(String(text ?? ''))
|
|
244
|
+
// eslint-disable-next-line no-control-regex
|
|
245
|
+
.split('\n').map((part) => part.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').trim()).find(Boolean) ?? '';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- the rain ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* How each status rains: its colour, how fast a drop falls (rows per tick),
|
|
252
|
+
* how many columns carry one, and how long its trail is.
|
|
253
|
+
*/
|
|
254
|
+
export const RAIN = {
|
|
255
|
+
running: { speed: 1, density: 0.5, trail: 6 },
|
|
256
|
+
waiting: { speed: 0.5, density: 0.45, trail: 4 },
|
|
257
|
+
queued: { speed: 0.15, density: 0.2, trail: 2 },
|
|
258
|
+
done: { speed: 0.08, density: 0.18, trail: 2 },
|
|
259
|
+
idle: { speed: 0.1, density: 0.22, trail: 2 },
|
|
260
|
+
stopped: { speed: 0.12, density: 0.3, trail: 3 },
|
|
261
|
+
ended: { speed: 0, density: 0.12, trail: 1 },
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Plain ASCII: one column in every terminal and every font. Half-width
|
|
266
|
+
* katakana are East Asian "halfwidth" — a terminal or font that draws them two
|
|
267
|
+
* columns wide pushed every rain row past the card's right border.
|
|
268
|
+
*/
|
|
269
|
+
export const GLYPHS = '0123456789=+*<>:ZXJ';
|
|
270
|
+
|
|
271
|
+
/** A small integer hash — the same inputs, the same drop. */
|
|
272
|
+
function mix(...values) {
|
|
273
|
+
let h = 0x811c9dc5;
|
|
274
|
+
for (const value of values) {
|
|
275
|
+
h ^= value | 0;
|
|
276
|
+
h = Math.imul(h, 0x01000193);
|
|
277
|
+
h ^= h >>> 15;
|
|
278
|
+
}
|
|
279
|
+
return h >>> 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A string's seed: a session's id, so its rain is its own. */
|
|
283
|
+
export function seedOf(text) {
|
|
284
|
+
let h = 0;
|
|
285
|
+
for (const ch of String(text ?? '')) h = Math.imul(h ^ ch.codePointAt(0), 0x5bd1e995) >>> 0;
|
|
286
|
+
return h;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `rows` lines of rain, each exactly `width` columns, at `tick`.
|
|
291
|
+
*
|
|
292
|
+
* Each column either carries a drop or not (`density`, fixed per column). A
|
|
293
|
+
* drop falls at the status's speed times its column's own (0.6–1.4, so the
|
|
294
|
+
* columns do not march together), wraps after its trail and a gap, and its
|
|
295
|
+
* glyphs change as it moves. The head is bright, the trail fades into dim.
|
|
296
|
+
*
|
|
297
|
+
* @param mood a key of `RAIN` (a card's `cardStatus(...).key`)
|
|
298
|
+
*/
|
|
299
|
+
export function rainLines(width, rows, { seed = 0, tick = 0, mood = 'running' } = {}, ink) {
|
|
300
|
+
const rain = RAIN[mood] ?? RAIN.ended;
|
|
301
|
+
const paint = statusPaint(mood, ink);
|
|
302
|
+
const grid = Array.from({ length: rows }, () => new Array(width).fill(null));
|
|
303
|
+
for (let x = 0; x < width; x++) {
|
|
304
|
+
const h = mix(seed, x);
|
|
305
|
+
if ((h % 1000) / 1000 >= rain.density) continue;
|
|
306
|
+
const trail = rain.trail + (h >>> 10) % 3;
|
|
307
|
+
const period = rows + trail + 2 + ((h >>> 14) % Math.max(1, rows));
|
|
308
|
+
const pace = 0.6 + ((h >>> 20) % 9) / 10;
|
|
309
|
+
const fallen = Math.floor(tick * rain.speed * pace);
|
|
310
|
+
const head = ((h >>> 4) % period + fallen) % period;
|
|
311
|
+
for (let y = 0; y < rows; y++) {
|
|
312
|
+
const behind = head - y;
|
|
313
|
+
if (behind < 0 || behind > trail) continue;
|
|
314
|
+
const glyph = GLYPHS[mix(seed, x, y, fallen) % GLYPHS.length];
|
|
315
|
+
grid[y][x] = { glyph, tone: behind === 0 ? 'head' : behind <= trail / 2 ? 'body' : 'tail' };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const styles = {
|
|
319
|
+
head: (text) => ink.bold(mood === 'running' ? ink.text(text) : paint(text)),
|
|
320
|
+
body: paint,
|
|
321
|
+
tail: (text) => ink.dim(paint(text)),
|
|
322
|
+
};
|
|
323
|
+
// Runs of one style are painted once: a cell each would be ten bytes of
|
|
324
|
+
// escape per glyph, eight times a second, for every card on the screen.
|
|
325
|
+
return grid.map((cells) => {
|
|
326
|
+
let out = '';
|
|
327
|
+
let run = '';
|
|
328
|
+
let style = null;
|
|
329
|
+
const close = () => {
|
|
330
|
+
if (run) out += style ? styles[style](run) : run;
|
|
331
|
+
run = '';
|
|
332
|
+
};
|
|
333
|
+
for (const cell of cells) {
|
|
334
|
+
const next = cell?.tone ?? null;
|
|
335
|
+
if (next !== style) {
|
|
336
|
+
close();
|
|
337
|
+
style = next;
|
|
338
|
+
}
|
|
339
|
+
run += cell ? cell.glyph : ' ';
|
|
340
|
+
}
|
|
341
|
+
close();
|
|
342
|
+
return out;
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Cut to `width` with `…` where it was cut. */
|
|
347
|
+
function ellipsed(text, width) {
|
|
348
|
+
const plain = String(text);
|
|
349
|
+
return plain.length <= width ? plain : `${plain.slice(0, Math.max(0, width - 1)).trimEnd()}…`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- the robot ------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
/** What the robot holds up: the plan is done, and the card is ready to develop. */
|
|
355
|
+
export const ROBOT_SIGN = '✓ plan done';
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A finished plan's card, in place of its rain: a small robot pacing along
|
|
359
|
+
* the bottom of the card, waving and blinking, with `ROBOT_SIGN` beside its
|
|
360
|
+
* head. The rain says a session is working; the robot says it worked, and
|
|
361
|
+
* that what it made, a plan, is waiting for somebody to build it.
|
|
362
|
+
*
|
|
363
|
+
* [o_o] ✓ plan done three rows and more: head, arms, legs,
|
|
364
|
+
* \[_]/ standing on the card's last spare row
|
|
365
|
+
* / \
|
|
366
|
+
*
|
|
367
|
+
* Two rows are a head and legs, one a head and the sign. It walks a column
|
|
368
|
+
* every four ticks and turns at either side; `still` stands it at the left,
|
|
369
|
+
* for `/matrix off`. Pure: the same tick is the same frame.
|
|
370
|
+
*/
|
|
371
|
+
export function robotLines(width, rows, { tick = 0, still = false } = {}, ink) {
|
|
372
|
+
const paint = (text) => ink.bold(ink.success(text));
|
|
373
|
+
const sign = ` ${ROBOT_SIGN}`;
|
|
374
|
+
const body = 5;
|
|
375
|
+
const room = Math.max(0, width - body - sign.length - 2);
|
|
376
|
+
const step = still ? 0 : Math.floor(tick / 4);
|
|
377
|
+
const x = 1 + (room ? (Math.floor(step / room) % 2 ? room - (step % room) : step % room) : 0);
|
|
378
|
+
const blink = !still && tick % 23 === 22;
|
|
379
|
+
const head = blink ? '[-_-]' : '[o_o]';
|
|
380
|
+
const arms = still || Math.floor(tick / 3) % 2 ? '\\[_]/' : '/[_]\\';
|
|
381
|
+
const legs = still || step % 2 ? ' / \\ ' : ' | | ';
|
|
382
|
+
const at = (text, extra = '') => {
|
|
383
|
+
const line = `${' '.repeat(x)}${paint(text)}${extra}`;
|
|
384
|
+
return padVisible(clip(line, width), width);
|
|
385
|
+
};
|
|
386
|
+
const withSign = (text) => at(text, width >= body + sign.length + 1 ? ink.success(sign) : '');
|
|
387
|
+
const drawn = rows >= 3 ? [withSign(head), at(arms), at(legs)]
|
|
388
|
+
: rows === 2 ? [withSign(head), at(legs)]
|
|
389
|
+
: [withSign(head)];
|
|
390
|
+
return [...new Array(Math.max(0, rows - drawn.length)).fill(' '.repeat(width)), ...drawn].slice(-rows);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A card being merged, in place of its rain: the robot, looking ahead
|
|
395
|
+
* (`[>_>]`), carries the branch (`[■]`) to the base on the right (`▶ main`),
|
|
396
|
+
* sets it down and walks back for the next load — a loop, because a merge has
|
|
397
|
+
* no progress to show, only that it is under way. The card around it is grey:
|
|
398
|
+
* nothing can be done on it until it lands, and then it closes.
|
|
399
|
+
*
|
|
400
|
+
* [>_>] three rows and more
|
|
401
|
+
* /[_]=[■] ▶ main
|
|
402
|
+
* / \
|
|
403
|
+
*
|
|
404
|
+
* `still` (`/matrix off`) stands it at the left with its load.
|
|
405
|
+
*/
|
|
406
|
+
export function mergingRobotLines(width, rows, { tick = 0, still = false, base = 'main' } = {}, ink) {
|
|
407
|
+
const paint = (text) => ink.bold(ink.accent(text));
|
|
408
|
+
const dock = ` ▶ ${base}`;
|
|
409
|
+
const body = 8;
|
|
410
|
+
const room = Math.max(0, width - body - dock.length - 2);
|
|
411
|
+
const x = 1 + (still || !room ? 0 : Math.floor(tick / 2) % (room + 1));
|
|
412
|
+
const legs = still || Math.floor(tick / 2) % 2 ? ' / \\ ' : ' | | ';
|
|
413
|
+
const at = (text, extra = '') => padVisible(clip(`${' '.repeat(x)}${paint(text)}${extra}`, width), width);
|
|
414
|
+
// The base stays where it is while the robot walks to it.
|
|
415
|
+
const toDock = (text) => {
|
|
416
|
+
const gap = width - x - visibleWidth(text) - dock.length;
|
|
417
|
+
return gap >= 1 ? at(text, `${' '.repeat(gap)}${ink.muted(dock)}`) : at(text);
|
|
418
|
+
};
|
|
419
|
+
const drawn = rows >= 3 ? [at('[>_>]'), toDock('/[_]=[■]'), at(legs)]
|
|
420
|
+
: rows === 2 ? [at('[>_>]'), toDock('/[_]=[■]')]
|
|
421
|
+
: [toDock('[>_>]=[■]')];
|
|
422
|
+
return [...new Array(Math.max(0, rows - drawn.length)).fill(' '.repeat(width)), ...drawn].slice(-rows);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --- the robots' moments ----------------------------------------------------------
|
|
426
|
+
//
|
|
427
|
+
// Each is a pure function of `since`, the ticks since the card's status changed
|
|
428
|
+
// (attach.mjs records the tick when it marks a session ended), so a test can
|
|
429
|
+
// name the frame it wants. The pacing and merging robots above are unchanged.
|
|
430
|
+
|
|
431
|
+
/** The tick the walk-in of a finished plan takes: from x = -6, a column every two ticks, in by x = 0. */
|
|
432
|
+
export const WALK_IN_TICKS = 14;
|
|
433
|
+
|
|
434
|
+
/** `text` placed at column `x` of a row `width` wide; a negative `x` cuts the text's front off. */
|
|
435
|
+
function placed(text, x, width) {
|
|
436
|
+
const cut = x < 0 ? text.slice(Math.min(text.length, -x)) : text;
|
|
437
|
+
const left = Math.max(0, x);
|
|
438
|
+
return (' '.repeat(left) + cut).slice(0, width).padEnd(width);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Overlays plain `text` on the plain row `base` at `x`; spaces in `text` leave `base` alone. */
|
|
442
|
+
function overlay(base, text, x) {
|
|
443
|
+
const chars = [...base];
|
|
444
|
+
[...text].forEach((ch, i) => {
|
|
445
|
+
const at = x + i;
|
|
446
|
+
if (at >= 0 && at < chars.length && ch !== ' ') chars[at] = ch;
|
|
447
|
+
});
|
|
448
|
+
return chars.join('');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Three plain rows → exactly `rows` painted ones, bottom-aligned, as the other robots are. */
|
|
452
|
+
function stand(rowsOf, width, rows, paintRow) {
|
|
453
|
+
const drawn = (rows >= 3 ? rowsOf : rows === 2 ? [rowsOf[0], rowsOf[2]] : [rowsOf[0]])
|
|
454
|
+
.map((row, i, all) => paintRow(row, i, all.length));
|
|
455
|
+
return [...new Array(Math.max(0, rows - drawn.length)).fill(' '.repeat(width)), ...drawn].slice(-rows);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** A finished plan, the moment it finishes: the robot walks in from the left carrying `✓ plan done`, then paces. */
|
|
459
|
+
export function planWalkInLines(width, rows, { since = 0, tick = 0, still = false } = {}, ink) {
|
|
460
|
+
if (still || since >= WALK_IN_TICKS) {
|
|
461
|
+
return robotLines(width, rows, { tick: still ? 0 : Math.max(0, since - WALK_IN_TICKS), still }, ink);
|
|
462
|
+
}
|
|
463
|
+
const x = -6 + Math.floor(since / 2);
|
|
464
|
+
const sign = ` ${ROBOT_SIGN}`;
|
|
465
|
+
const legs = since % 2 ? ' / \\ ' : ' | | ';
|
|
466
|
+
const rowsOf = [`[o_o]${sign}`, '\\[_]/', legs];
|
|
467
|
+
return stand(rowsOf, width, rows, (row, i) => {
|
|
468
|
+
const plain = placed(row, x, width);
|
|
469
|
+
return i === 0 && row.length > 5
|
|
470
|
+
? ink.bold(ink.success(plain.slice(0, Math.max(0, x + 5)))) + ink.success(plain.slice(Math.max(0, x + 5)))
|
|
471
|
+
: ink.bold(ink.success(plain));
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Where a development's robot sets the branch down: just left of the dock's sign. */
|
|
476
|
+
function dockOf(width, word) {
|
|
477
|
+
const dock = ` ▶ ${word}`;
|
|
478
|
+
return { dock, dockAt: Math.max(0, width - dock.length), branchX: Math.max(8, width - dock.length - 4) };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** A plain row painted in `[from, to, paint]` segments, the rest in `base`. */
|
|
482
|
+
function segments(plain, spans, base) {
|
|
483
|
+
let out = '';
|
|
484
|
+
let at = 0;
|
|
485
|
+
for (const [from, to, paint] of [...spans].sort((a, b) => a[0] - b[0])) {
|
|
486
|
+
if (from > at) out += base(plain.slice(at, from));
|
|
487
|
+
out += paint(plain.slice(from, to));
|
|
488
|
+
at = to;
|
|
489
|
+
}
|
|
490
|
+
return out + (at < plain.length ? base(plain.slice(at)) : '');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The dock's sign set on the row the branch sits on (the middle one, or the last when there is no room for three). */
|
|
494
|
+
function withDock(rowsOf, rows, dock, width) {
|
|
495
|
+
const at = rows >= 3 ? 1 : rowsOf.length - 1;
|
|
496
|
+
return rowsOf.map((row, i) => (i === at && width > dock.length
|
|
497
|
+
? row.slice(0, width - dock.length) + dock : row));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* A finished development, the moment it finishes: the robot walks in carrying
|
|
502
|
+
* the branch (`[>_>]`, `/[_]=[■]`) to `▶ review`, sets it down (the `[■]` stays
|
|
503
|
+
* by the dock), steps back three columns and waits beside it — `[o_o]`, a blink
|
|
504
|
+
* every 23 ticks, a look at the branch (`[>_>]`) three ticks in every forty —
|
|
505
|
+
* under the sign `✓ built · /pr`.
|
|
506
|
+
*/
|
|
507
|
+
export function devDoneRobotLines(width, rows, { since = 0, still = false } = {}, ink) {
|
|
508
|
+
const { dock, dockAt, branchX } = dockOf(width, 'review');
|
|
509
|
+
const endX = Math.max(0, branchX - 5);
|
|
510
|
+
const walk = 2 * (endX + 8);
|
|
511
|
+
const t = still ? walk + 4 : since;
|
|
512
|
+
const carrying = t < walk + 1;
|
|
513
|
+
const back = carrying ? 0 : Math.min(3, t - walk - 1);
|
|
514
|
+
const x = carrying ? Math.min(endX, -8 + Math.floor(t / 2)) : Math.max(0, endX - back);
|
|
515
|
+
const waited = t - walk - 4;
|
|
516
|
+
const blink = !still && waited >= 0 && waited % 23 === 22;
|
|
517
|
+
const looking = !still && waited >= 0 && waited % 40 >= 37;
|
|
518
|
+
const head = carrying || looking ? '[>_>]' : blink ? '[-_-]' : '[o_o]';
|
|
519
|
+
const legs = !carrying || still ? ' | | ' : Math.floor(t / 2) % 2 ? ' / \\ ' : ' | | ';
|
|
520
|
+
const sign = '✓ built · /pr';
|
|
521
|
+
const signed = !carrying && x >= sign.length + 2;
|
|
522
|
+
let top = placed(head, x, width);
|
|
523
|
+
if (signed) top = overlay(top, sign, 1);
|
|
524
|
+
const plain = withDock([
|
|
525
|
+
top,
|
|
526
|
+
carrying ? placed('/[_]=[■]', x, width) : overlay(placed('/[_]\\', x, width), '[■]', branchX),
|
|
527
|
+
placed(legs, x, width),
|
|
528
|
+
], rows, dock, width);
|
|
529
|
+
return stand(plain, width, rows, (row, i, n) => {
|
|
530
|
+
const onDock = (n >= 3 ? i === 1 : i === n - 1);
|
|
531
|
+
const spans = [];
|
|
532
|
+
if (onDock) spans.push([dockAt, width, ink.muted]);
|
|
533
|
+
if (signed && (n >= 3 ? i === 0 : i === 0)) spans.push([1, 1 + sign.length, ink.success]);
|
|
534
|
+
return segments(row, spans, (text) => ink.bold(ink.accent(text)));
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Nothing built: the robot walks in empty-handed, amber, stands at the left and shrugs. */
|
|
539
|
+
export function nothingBuiltRobotLines(width, rows, { since = 0, still = false, ref = null } = {}, ink) {
|
|
540
|
+
const paint = (text) => ink.bold(ink.warn(text));
|
|
541
|
+
const x = still ? 1 : Math.min(1, -6 + Math.floor(since / 2));
|
|
542
|
+
const waited = still ? 0 : since - 14;
|
|
543
|
+
const shrug = waited >= 0 && waited % 30 >= 24;
|
|
544
|
+
const blink = waited >= 0 && waited % 23 === 22;
|
|
545
|
+
const head = blink ? '[-_-]' : '[o_o]';
|
|
546
|
+
const arms = shrug ? '¯[_]¯' : '/[_]\\';
|
|
547
|
+
const sign = ` ✗ nothing built · /new plan ${ref ?? '<card>'}`;
|
|
548
|
+
const shown = width - 6 - 1 >= sign.length ? sign : ' ✗ nothing built';
|
|
549
|
+
const rowsOf = [head + (x >= 0 ? shown : ''), arms, ' | | '];
|
|
550
|
+
return stand(rowsOf, width, rows, (row, i) => {
|
|
551
|
+
const plain = placed(row, x, width);
|
|
552
|
+
return i === 0 && x >= 0
|
|
553
|
+
? paint(plain.slice(0, x + 5)) + ink.warn(plain.slice(x + 5))
|
|
554
|
+
: paint(plain);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Ticks the robot waves for after a landing, and ticks it then takes to walk off a card `width` wide. */
|
|
559
|
+
export const WAVE_TICKS = 12;
|
|
560
|
+
/** A landed card closes itself this many ticks after it lands (3 s at 120 ms). */
|
|
561
|
+
export const CLOSE_AFTER_TICKS = 25;
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The landing: the branch is set down at `▶ main`, the robot waves (`[^_^]`,
|
|
565
|
+
* arms alternating) for twelve ticks and walks off the right edge.
|
|
566
|
+
*/
|
|
567
|
+
export function landingRobotLines(width, rows, { since = 0, still = false, base = 'main' } = {}, ink) {
|
|
568
|
+
const { dock, dockAt, branchX } = dockOf(width, base);
|
|
569
|
+
const stands = Math.max(0, branchX - 8);
|
|
570
|
+
const t = still ? 0 : since;
|
|
571
|
+
const waving = t < WAVE_TICKS;
|
|
572
|
+
// Off the edge before the card closes itself, however wide the card is.
|
|
573
|
+
const pace = Math.max(1, Math.ceil((width - stands) / (CLOSE_AFTER_TICKS - WAVE_TICKS - 1)));
|
|
574
|
+
const x = stands + Math.max(0, t - WAVE_TICKS) * pace;
|
|
575
|
+
const arms = !waving ? '/[_]\\' : Math.floor(t / 2) % 2 ? '\\[_]/' : '/[_]\\';
|
|
576
|
+
const head = waving ? '[^_^]' : '[>_>]';
|
|
577
|
+
const legs = !waving && Math.floor(t / 2) % 2 ? ' / \\ ' : ' | | ';
|
|
578
|
+
const plain = withDock([
|
|
579
|
+
placed(head, x, width),
|
|
580
|
+
overlay(placed(arms, x, width), '[■]', branchX),
|
|
581
|
+
placed(legs, x, width),
|
|
582
|
+
], rows, dock, width);
|
|
583
|
+
return stand(plain, width, rows, (row, i, n) => segments(
|
|
584
|
+
row, (n >= 3 ? i === 1 : i === n - 1) ? [[dockAt, width, ink.muted]] : [], (text) => ink.bold(ink.accent(text))));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// --- a card --------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* One card, as exactly `rect.height` strings of exactly `rect.width` columns.
|
|
591
|
+
*
|
|
592
|
+
* ┌─ 1 DEV R42 Payments refund flow ──── ● running · 12m ─┐
|
|
593
|
+
* │ Claude · Opus 5.5 ▸ saasng · …/saasng-auto-2 │
|
|
594
|
+
* │ plan ✓ ─ implement ● ─ test ○ │
|
|
595
|
+
* │ now: Bash npm test │
|
|
596
|
+
* │ ア 7 サ │ ← rain
|
|
597
|
+
* │ /pr opens the pull request · /close │
|
|
598
|
+
* └─────────────────────────────────────────────────────────┘
|
|
599
|
+
*
|
|
600
|
+
* The frame is the status's colour; the focused card is drawn heavy, which
|
|
601
|
+
* survives `NO_COLOR`. The title is clipped before the status is: "● running"
|
|
602
|
+
* is what a glance is for.
|
|
603
|
+
*
|
|
604
|
+
* @param card `{ n, profile, title, status, lines, footer, focused, seed, tick, rain }`
|
|
605
|
+
* — `lines` the card's words, top down; `rain` false draws none.
|
|
606
|
+
*/
|
|
607
|
+
export function sessionCardLines(rect, card, ink) {
|
|
608
|
+
const { width, height } = rect;
|
|
609
|
+
const inner = Math.max(0, width - 2);
|
|
610
|
+
const [h, v, tl, tr, bl, br] = card.focused
|
|
611
|
+
? ['━', '┃', '┏', '┓', '┗', '┛']
|
|
612
|
+
: ['─', '│', '┌', '┐', '└', '┘'];
|
|
613
|
+
const paint = statusPaint(card.status.key, ink);
|
|
614
|
+
|
|
615
|
+
const status = ` ${card.status.mark} ${card.status.word} `;
|
|
616
|
+
const left = ` ${card.n ? `${card.n} ` : ''}`;
|
|
617
|
+
const badge = badgeOf(card.profile, ink);
|
|
618
|
+
const titleRoom = Math.max(0, inner - 1 - visibleWidth(left) - visibleWidth(badge) - 1
|
|
619
|
+
- visibleWidth(status) - 2);
|
|
620
|
+
const title = titleRoom >= 4 ? ` ${ellipsed(card.title ?? '', titleRoom - 1)} ` : ' ';
|
|
621
|
+
const label = `${left}${badge}${title}`;
|
|
622
|
+
const fill = Math.max(0, inner - 1 - visibleWidth(label) - visibleWidth(status) - 1);
|
|
623
|
+
// A status that changed is drawn reversed for a few ticks, then settles.
|
|
624
|
+
const beat = card.changedAt !== undefined && beating(card.changedAt, card.tick ?? 0);
|
|
625
|
+
const shownStatus = visibleWidth(label) + visibleWidth(status) + 2 <= inner
|
|
626
|
+
? (beat ? ink.reverse(paint(status)) : paint(status)) : '';
|
|
627
|
+
const top = paint(tl + h) + (card.focused ? ink.bold(label) : label)
|
|
628
|
+
+ paint(h.repeat(shownStatus ? fill : Math.max(0, inner - 1 - visibleWidth(label)))) + shownStatus
|
|
629
|
+
+ paint((shownStatus ? h : '') + tr);
|
|
630
|
+
|
|
631
|
+
// A column of margin inside the border, for the words; the rain runs to it.
|
|
632
|
+
const row = (text) => paint(v) + padVisible(clip(text, inner), inner) + RESET + paint(v);
|
|
633
|
+
const said = (text) => row(` ${clip(text, Math.max(0, inner - 2))}`);
|
|
634
|
+
const footer = card.footer ? 1 : 0;
|
|
635
|
+
const room = Math.max(0, height - 2 - footer);
|
|
636
|
+
const words = (card.lines ?? []).filter((line) => line !== null && line !== undefined).slice(0, room);
|
|
637
|
+
const spare = room - words.length;
|
|
638
|
+
// The robots move whether the rain is on or off: they are not the matrix.
|
|
639
|
+
const since = Number.isFinite(card.since) ? card.since : null;
|
|
640
|
+
const rain = spare <= 0 ? []
|
|
641
|
+
: card.status.key === 'planned'
|
|
642
|
+
? (since === null ? robotLines(inner, spare, { tick: card.tick }, ink)
|
|
643
|
+
: planWalkInLines(inner, spare, { since }, ink))
|
|
644
|
+
: card.status.key === 'merging'
|
|
645
|
+
? mergingRobotLines(inner, spare, { tick: card.tick, base: card.base }, ink)
|
|
646
|
+
: card.status.key === 'done'
|
|
647
|
+
? devDoneRobotLines(inner, spare, { since: since ?? 1e6 }, ink)
|
|
648
|
+
: card.status.key === 'idle'
|
|
649
|
+
? nothingBuiltRobotLines(inner, spare, { since: since ?? 1e6, ref: card.ref }, ink)
|
|
650
|
+
: card.status.key === 'landed'
|
|
651
|
+
? landingRobotLines(inner, spare, { since: since ?? 1e6, base: card.base }, ink)
|
|
652
|
+
// Failed, running, waiting, queued, stopped: no robot. The rain only when asked for.
|
|
653
|
+
: card.rain !== true || card.status.key === 'failed'
|
|
654
|
+
? new Array(spare).fill('')
|
|
655
|
+
: rainLines(inner, spare, { seed: card.seed, tick: card.tick, mood: card.status.key }, ink);
|
|
656
|
+
const lines = [top, ...words.map(said), ...rain.map(row)];
|
|
657
|
+
if (footer) lines.push(said(ink.muted(card.footer)));
|
|
658
|
+
lines.push(paint(bl + h.repeat(inner) + br));
|
|
659
|
+
return lines.slice(0, height);
|
|
660
|
+
}
|