cawdev-cli 1.0.4-beta → 1.0.5-beta

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,830 @@
1
+ // Advanced mode — the attached terminal split into one window per session,
2
+ // emacs-style, with the client's own prompt at the bottom.
3
+ //
4
+ // **R81 decided against a pane manager, and this does not reopen that.** The
5
+ // reason there was that a full-screen repaint EATS THE SCROLLBACK: the wheel,
6
+ // search and copy stop working and the transcript above the fold is gone. So
7
+ // the default is still `scrollback.mjs`, untouched. This is a mode somebody
8
+ // ASKS for with `/advanced-mode`, and it is drawn on the terminal's ALTERNATE
9
+ // screen — the buffer vim and emacs use — which leaves the normal screen and
10
+ // its scrollback exactly as they were. Leaving the mode puts them back, and
11
+ // what was said on the watched session meanwhile is laid into the scrollback
12
+ // then, so nothing is lost by having looked at it this way.
13
+ //
14
+ // Everything that decides a picture is a pure function — the layout, one
15
+ // window, the whole frame — so it can be rendered at four sizes and READ,
16
+ // which is how scrollback.mjs's own layout bugs were found. The class at the
17
+ // bottom only enters, leaves, and writes the rows that changed.
18
+
19
+ import { clip, padVisible, visibleWidth, wrap } from '../lib/ansi.mjs';
20
+
21
+ const ESC = '\x1b';
22
+ const ALT_ON = `${ESC}[?1049h`;
23
+ const ALT_OFF = `${ESC}[?1049l`;
24
+ // What nano and emacs set as well as the alternate screen, and what makes the
25
+ // screen hold still: no AUTO-WRAP, so a row a little wider than we measured it
26
+ // (an emoji in an agent's output is two columns and one character) is cut at
27
+ // the edge instead of wrapping — a wrap on the last row scrolls the whole
28
+ // screen, and iTerm keeps what scrolled off where the wheel reaches it; and
29
+ // ALTERNATE SCROLL, so the wheel sends arrow keys to the program instead of
30
+ // scrolling the terminal. Both are put back on the way out.
31
+ const HOLD_STILL = `${ESC}[?7l${ESC}[?1007h`;
32
+ const LET_GO = `${ESC}[?1007l${ESC}[?7h`;
33
+ const HIDE_CURSOR = `${ESC}[?25l`;
34
+ const SHOW_CURSOR = `${ESC}[?25h`;
35
+ const RESET = `${ESC}[0m`;
36
+
37
+ /** Narrower than this a window cannot show a title and a line of output. */
38
+ export const MIN_ZONE_WIDTH = 28;
39
+ /** Border, title, two lines of output, an action line. */
40
+ export const MIN_ZONE_HEIGHT = 5;
41
+ /** The prompt's band when nothing asks for more: border, messages, input, keys. */
42
+ export const PROMPT_ROWS = 8;
43
+
44
+ /**
45
+ * Where everything goes, for `count` sessions on a `width`×`height` screen.
46
+ *
47
+ * The prompt is ALWAYS the band at the bottom, the way a shell's is — the
48
+ * sessions fill the screen above it, in one row or, when there is room, two.
49
+ * Sessions that do not fit at `MIN_ZONE_WIDTH` are not drawn and are counted
50
+ * in `hidden`, so the prompt can say how many are off screen rather than
51
+ * squeezing every window into something unreadable.
52
+ *
53
+ * @param promptRows how tall the prompt wants to be — taller while a picker
54
+ * is open, so the list is not cut off.
55
+ * @returns `{ zones: [{ top, left, width, height }], prompt: {…}, hidden }`,
56
+ * rows and columns 0-based.
57
+ */
58
+ export function splitLayout(width, height, count, promptRows = PROMPT_ROWS) {
59
+ const promptHeight = Math.max(4, Math.min(height, promptRows));
60
+ const room = height - promptHeight;
61
+ if (count <= 0 || room < MIN_ZONE_HEIGHT) {
62
+ return { zones: [], prompt: { top: 0, left: 0, width, height }, hidden: Math.max(0, count) };
63
+ }
64
+ const perRow = Math.max(1, Math.floor(width / MIN_ZONE_WIDTH));
65
+ const twoRows = count > perRow || (count > 2 && Math.floor(room / 2) >= MIN_ZONE_HEIGHT * 2);
66
+ const rows = twoRows && Math.floor(room / 2) >= MIN_ZONE_HEIGHT ? 2 : 1;
67
+ const shown = Math.min(count, perRow * rows);
68
+ const topCount = rows === 2 ? Math.ceil(shown / 2) : shown;
69
+ const bottomCount = shown - topCount;
70
+ const topHeight = rows === 2 ? Math.ceil(room / 2) : room;
71
+
72
+ const row = (n, top, rowHeight) => spread(width, n).map(([left, w]) => ({
73
+ top, left, width: w, height: rowHeight,
74
+ }));
75
+ return {
76
+ zones: [
77
+ ...row(topCount, 0, topHeight),
78
+ ...(bottomCount ? row(bottomCount, topHeight, room - topHeight) : []),
79
+ ],
80
+ prompt: { top: room, left: 0, width, height: promptHeight },
81
+ hidden: count - shown,
82
+ };
83
+ }
84
+
85
+ /** `n` columns across `width`, the remainder given to the leftmost ones. */
86
+ function spread(width, n) {
87
+ const base = Math.floor(width / n);
88
+ const extra = width - base * n;
89
+ const out = [];
90
+ let left = 0;
91
+ for (let i = 0; i < n; i++) {
92
+ const w = base + (i < extra ? 1 : 0);
93
+ out.push([left, w]);
94
+ left += w;
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * One window, as exactly `rect.height` strings of exactly `rect.width` columns.
101
+ *
102
+ * The SUBTITLE, when there is one, is a fixed first line inside it.
103
+ * The TITLE is in the top border, clipped from the right so the start of it —
104
+ * the number and the card — survives a narrow window. The BODY is the tail of
105
+ * whatever lines it is handed, wrapped to the inside width: a window shows the
106
+ * latest output, the way a terminal does. The FOOTER, when there is one, is the
107
+ * last line inside the border — what can be done about this session now.
108
+ *
109
+ * The focused window is drawn in heavy lines, so which one the prompt is
110
+ * talking to survives `NO_COLOR`; `tone` colours the frame by the session's
111
+ * state — red while it runs, green once it finished, amber if it stopped any
112
+ * other way.
113
+ */
114
+ export function windowLines(rect, {
115
+ title = '', subtitle = '', body = [], footer = '', focused = false, tone = null, dotted = false,
116
+ }, ink) {
117
+ const { width, height } = rect;
118
+ const inner = Math.max(0, width - 2);
119
+ // Heavy and solid is the window the prompt talks to; a session window that
120
+ // is NOT focused is dotted, so which one is active reads at a glance — the
121
+ // heavy line alone was too close to the thin one on most terminal fonts.
122
+ const [h, v, tl, tr, bl, br] = focused
123
+ ? ['━', '┃', '┏', '┓', '┗', '┛']
124
+ : dotted
125
+ ? ['┄', '┆', '┌', '┐', '└', '┘']
126
+ : ['─', '│', '┌', '┐', '└', '┘'];
127
+ // The frame's COLOUR is the session's state (see `toneFor`); which window is
128
+ // focused is the heavy line, so the two never compete for one channel.
129
+ const byTone = { live: ink.danger, done: ink.success, stopped: ink.warn };
130
+ const paint = byTone[tone] ?? ((text) => (focused ? ink.accent(text) : ink.muted(text)));
131
+
132
+ const label = title ? ` ${clip(title, Math.max(0, inner - 3))} ` : '';
133
+ const top = paint(tl + h) + label + paint(h.repeat(Math.max(0, inner - 1 - visibleWidth(label))) + tr);
134
+
135
+ const footerRows = footer ? 1 : 0;
136
+ const subtitleRows = subtitle ? 1 : 0;
137
+ const rows = Math.max(0, height - 2 - footerRows - subtitleRows);
138
+ const tail = tailOf(body, inner, rows);
139
+ const lines = [top];
140
+ if (subtitle) {
141
+ // A fixed line under the title — the project — so a long title never
142
+ // pushes it out of sight and the output scrolling never takes it.
143
+ lines.push(paint(v) + padVisible(clip(subtitle, inner), inner) + RESET + paint(v));
144
+ }
145
+ for (let i = 0; i < rows; i++) {
146
+ lines.push(paint(v) + padVisible(clip(tail[i] ?? '', inner), inner) + RESET + paint(v));
147
+ }
148
+ if (footer) {
149
+ lines.push(paint(v) + padVisible(clip(footer, inner), inner) + RESET + paint(v));
150
+ }
151
+ lines.push(paint(bl + h.repeat(inner) + br));
152
+ return lines.slice(0, height);
153
+ }
154
+
155
+ /**
156
+ * The last `rows` screen lines of `lines`, each wrapped to `width` — walked
157
+ * from the end, so a session with four thousand lines of history costs what
158
+ * the window shows and not what the session said.
159
+ */
160
+ export function tailOf(lines, width, rows) {
161
+ const out = [];
162
+ for (let i = lines.length - 1; i >= 0 && out.length < rows; i--) {
163
+ const wrapped = wrap(String(lines[i] ?? ''), Math.max(1, width));
164
+ for (let j = wrapped.length - 1; j >= 0 && out.length < rows; j--) {
165
+ out.push(wrapped[j]);
166
+ }
167
+ }
168
+ return out.reverse();
169
+ }
170
+
171
+ /**
172
+ * The whole screen, from placed pieces: `height` rows, each the pieces that
173
+ * cross it laid left to right, and blank where nothing is.
174
+ *
175
+ * @param pieces `[{ rect, lines }]`, `lines` being what `windowLines` returned.
176
+ */
177
+ export function composeFrame(width, height, pieces) {
178
+ const rows = [];
179
+ for (let y = 0; y < height; y++) {
180
+ const crossing = pieces
181
+ .filter(({ rect }) => y >= rect.top && y < rect.top + rect.height)
182
+ .sort((a, b) => a.rect.left - b.rect.left);
183
+ let row = '';
184
+ let at = 0;
185
+ for (const { rect, lines } of crossing) {
186
+ if (rect.left > at) {
187
+ row += ' '.repeat(rect.left - at);
188
+ at = rect.left;
189
+ }
190
+ row += padVisible(lines[y - rect.top] ?? '', rect.width) + RESET;
191
+ at += rect.width;
192
+ }
193
+ rows.push(at < width ? row + ' '.repeat(width - at) : row);
194
+ }
195
+ return rows;
196
+ }
197
+
198
+ /**
199
+ * The alternate screen, and the rows on it.
200
+ *
201
+ * Writes only the rows that changed since the last frame: a busy session
202
+ * prints hundreds of lines a second into one window, and repainting the four
203
+ * others and the prompt for each of them is how a split screen flickers.
204
+ */
205
+ export class SplitScreen {
206
+ constructor(out = process.stdout) {
207
+ this.out = out;
208
+ this.last = [];
209
+ this.on = false;
210
+ }
211
+
212
+ get width() {
213
+ return this.out.columns ?? 80;
214
+ }
215
+
216
+ get height() {
217
+ return this.out.rows ?? 24;
218
+ }
219
+
220
+ enter() {
221
+ if (this.on) return;
222
+ this.on = true;
223
+ this.last = [];
224
+ this.out.write(`${ALT_ON}${HOLD_STILL}${HIDE_CURSOR}${ESC}[2J${ESC}[H`);
225
+ }
226
+
227
+ /** Back to the normal screen, its scrollback exactly as it was left. */
228
+ leave() {
229
+ if (!this.on) return;
230
+ this.on = false;
231
+ this.last = [];
232
+ this.out.write(`${RESET}${LET_GO}${ALT_OFF}${HIDE_CURSOR}`);
233
+ }
234
+
235
+ /** The window changed shape: every row is repainted on the next draw. */
236
+ resize() {
237
+ this.last = [];
238
+ if (this.on) this.out.write(`${ESC}[2J`);
239
+ }
240
+
241
+ draw(rows) {
242
+ if (!this.on) return;
243
+ let out = '';
244
+ for (let y = 0; y < rows.length; y++) {
245
+ if (rows[y] !== this.last[y]) {
246
+ out += `${ESC}[${y + 1};1H${rows[y]}${RESET}${ESC}[K`;
247
+ }
248
+ }
249
+ this.last = rows.slice();
250
+ if (out) this.out.write(out);
251
+ }
252
+
253
+ /** Leaving for good — a crash or a quit must not strand somebody on the alternate screen. */
254
+ release() {
255
+ if (this.on) {
256
+ this.out.write(`${RESET}${LET_GO}${ALT_OFF}${SHOW_CURSOR}`);
257
+ this.on = false;
258
+ }
259
+ }
260
+ }
261
+
262
+ // --- what a window says about its session -------------------------------------
263
+
264
+ /** A profile as the word a person uses for it — "dev", not CODE. */
265
+ export const TYPE_WORDS = {
266
+ PLAN: 'plan', CODE: 'dev', REVIEW: 'review', ASK: 'ask', AUDIT: 'audit', SCOPE: 'scope',
267
+ INTERVIEW: 'interview', MERGE: 'merge', RELEASE: 'release', ROADMAP: 'roadmap',
268
+ };
269
+
270
+ /** A pull request URL, and not the host's offer to open one (`/compare/`). */
271
+ export function isPullRequest(url) {
272
+ return typeof url === 'string' && /^https?:\/\//.test(url) && !url.includes('/compare/');
273
+ }
274
+
275
+ /**
276
+ * The window's title: what kind of session it is, where it stands, the card
277
+ * and its title. The project is on a line of its own (`windowLines`'s
278
+ * subtitle).
279
+ *
280
+ * @param meta what this terminal knows of a session — the daemon's snapshot
281
+ * (`projectSlug`, `label`, `profile`, `state`) and, once read, the platform's
282
+ * `view` of the run, which is where the card and the final state come from.
283
+ * @param waiting a word when the session is stopped on a person, else null.
284
+ */
285
+ export function titleFor(meta, waiting = null) {
286
+ const view = meta.view ?? {};
287
+ const profile = view.profile ?? meta.profile ?? null;
288
+ const type = TYPE_WORDS[profile] ?? (profile ? String(profile).toLowerCase() : 'session');
289
+ const what = view.entryRef
290
+ ? `${view.entryRef} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim()
291
+ : (view.label ?? meta.label ?? 'a session');
292
+ const state = meta.ended
293
+ ? String(view.state ?? 'ended').toLowerCase().replace(/_/g, ' ')
294
+ : meta.state ?? 'running';
295
+ // What it is and where it stands FIRST: a narrow window clips from the
296
+ // right, and "plan · finished" is what decides what to do next. The project
297
+ // is the window's subtitle, a line of its own, and not in here.
298
+ return [type, waiting ?? state, what].filter(Boolean).join(' · ');
299
+ }
300
+
301
+ /**
302
+ * What a person calls a model: `claude-opus-5-5` → `Opus 5.5`, `sonnet` →
303
+ * `Sonnet`, `claude-haiku-4-5-20251001` → `Haiku 4.5`, `gpt-5-codex` →
304
+ * `GPT-5-codex`. Anything else is shown as it came — a label must never hide
305
+ * which model actually ran.
306
+ */
307
+ export function modelLabel(model) {
308
+ if (!model) return null;
309
+ const raw = String(model).trim().replace(/\[.*\]$/, '');
310
+ const claude = /^(?:claude-)?(opus|sonnet|haiku|fable)(?:-(\d+))?(?:-(\d{1,2}))?(?:-\d{8})?$/i.exec(raw);
311
+ if (claude) {
312
+ const [, family, major, minor] = claude;
313
+ const version = major ? ` ${major}${minor ? `.${minor}` : ''}` : '';
314
+ return `${family[0].toUpperCase()}${family.slice(1).toLowerCase()}${version}`;
315
+ }
316
+ if (/^gpt/i.test(raw)) return `GPT${raw.slice(3)}`;
317
+ return raw;
318
+ }
319
+
320
+ /** Which CLI, as a person says it. */
321
+ const AGENT_NAMES = { claude: 'Claude', codex: 'Codex', agy: 'Antigravity' };
322
+
323
+ /**
324
+ * `Claude · Opus 5.5` — the agent a session runs on and its model, for the
325
+ * window's second line. The daemon's word for a live session (what it
326
+ * actually spawned, per stage), the platform's for an ended one. With no
327
+ * model named, the CLI's own default is what ran, and it says so.
328
+ */
329
+ export function agentModelFor(meta) {
330
+ const agent = meta.agent ?? meta.view?.agent ?? null;
331
+ const model = meta.model ?? meta.view?.model ?? null;
332
+ if (!agent && !model) return '';
333
+ const who = AGENT_NAMES[agent] ?? (agent ? String(agent) : 'Claude');
334
+ return `${who} · ${modelLabel(model) ?? 'default model'}`;
335
+ }
336
+
337
+ /**
338
+ * A window's second line: the project, and for a dev session the ABSOLUTE
339
+ * path of the checkout its changes are being made in — on a machine with
340
+ * `saasng`, `saasng-auto-1` and `saasng-auto-2`, the label alone does not say
341
+ * which directory to open. The daemon's list says where a live session is;
342
+ * the platform's view says where an ended one was.
343
+ *
344
+ * Too long for `width`, the path gives way FROM THE LEFT: `…/saasng-auto-2`
345
+ * tells two checkouts apart, `/Users/ben/dev/…` tells nothing.
346
+ */
347
+ export function subtitleFor(meta, width) {
348
+ const project = `▸ ${meta.projectSlug ?? ''}`;
349
+ const profile = meta.view?.profile ?? meta.profile ?? null;
350
+ const path = meta.workspace ?? meta.view?.workspace ?? null;
351
+ if (profile !== 'CODE' || !path) {
352
+ return project;
353
+ }
354
+ const room = width - project.length - 3;
355
+ if (room < 8) {
356
+ return project;
357
+ }
358
+ const shown = path.length <= room ? path : `…${path.slice(path.length - room + 1)}`;
359
+ return `${project} · ${shown}`;
360
+ }
361
+
362
+ /**
363
+ * A window frame's colour, from where its session stands: `live` (red) while
364
+ * it runs or waits, `done` (green) once it FINISHED — a plan planned, a dev
365
+ * developed — and `stopped` (amber) for any other ending. Null until an ended
366
+ * session's outcome has been read, so a frame never claims a success it has
367
+ * not seen.
368
+ */
369
+ export function toneFor(meta) {
370
+ if (!meta.ended) {
371
+ return 'live';
372
+ }
373
+ const state = meta.view?.state;
374
+ if (!state || meta.view?.live) {
375
+ return null;
376
+ }
377
+ return state === 'FINISHED' ? 'done' : 'stopped';
378
+ }
379
+
380
+ /**
381
+ * What can be done about a session now — the window's last line. Empty while
382
+ * it runs: the prompt, the answer and the permission keys are the prompt's.
383
+ *
384
+ * A finished PLAN on a card offers its development. A finished session that
385
+ * wrote a branch offers the pull request until one is known, then shows it.
386
+ * Every ended window can be closed.
387
+ */
388
+ export function actionsFor(meta) {
389
+ if (!meta.ended) {
390
+ return '';
391
+ }
392
+ const view = meta.view;
393
+ const parts = [];
394
+ if (view?.canCarryOn && !meta.carryAsked) {
395
+ parts.push('/continue picks it back up here');
396
+ } else if (meta.carryAsked) {
397
+ parts.push('continue asked for — back when this machine takes it');
398
+ }
399
+ if (view?.state === 'FINISHED' && view.profile === 'PLAN' && view.entryRef) {
400
+ parts.push(`/develop starts ${view.entryRef}`);
401
+ }
402
+ if (isPullRequest(view?.prUrl)) {
403
+ parts.push(`PR ${view.prUrl} · /open`);
404
+ } else if (meta.prAsked) {
405
+ parts.push(meta.prAsked === true ? 'pull request asked for…' : meta.prAsked);
406
+ } else if (view?.branch && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED') {
407
+ parts.push('/pr opens the pull request');
408
+ }
409
+ if (meta.dropped) {
410
+ parts.push('checkout deleted');
411
+ } else if (isAutoCheckout(view?.workspace) && view?.state !== 'RUNNING' && !view?.live) {
412
+ parts.push('/drop deletes its checkout');
413
+ }
414
+ parts.push('/close');
415
+ return parts.join(' · ');
416
+ }
417
+
418
+ /**
419
+ * Whether a run the platform listed is one THIS terminal can carry on, on
420
+ * THIS machine — the console's Carry on, from here. Three conditions, each
421
+ * the API's own refusal read in advance: the platform says it can be carried
422
+ * on at all (`canCarryOn`: stopped or failed, a session or a hand-off to go
423
+ * on from, not discarded); it is the signed-in person's, because carry-on is
424
+ * the starter's alone; and its checkout is here or nowhere, because a run
425
+ * holding one resumes on the machine that holds it.
426
+ */
427
+ export function carriesOnHere(view, runnerId, email) {
428
+ return Boolean(view?.canCarryOn)
429
+ && Boolean(email) && view.startedByEmail === email
430
+ && (!view.runnerId || view.runnerId === runnerId);
431
+ }
432
+
433
+ /**
434
+ * Whether a checkout is one the daemon made on demand — `<first>-auto-<n>`
435
+ * (auto-workspaces.mjs). Only those are ever offered to `/drop`: the
436
+ * checkouts in the config are somebody's, and the project always keeps them.
437
+ */
438
+ export function isAutoCheckout(path) {
439
+ return typeof path === 'string' && /-auto-\d+$/.test(path.replace(/\/+$/, ''));
440
+ }
441
+
442
+ /**
443
+ * `/new plan R3`, `/new dev R3`, `/new ask what does the importer do?` — and a
444
+ * plan or a dev from a sentence, with no card: `/new plan add a CSV export`,
445
+ * `/new dev fix the sort on the client list`. `@slug` anywhere names the
446
+ * project; without it the caller asks which.
447
+ *
448
+ * One card reference and nothing else is that card; anything else is the task
449
+ * in words. A plan from words writes its card first and plans what it wrote
450
+ * (R150); a dev from words is a manual session, on a branch of its own.
451
+ *
452
+ * @param words what followed `/new`, split on spaces
453
+ * @param projects the slugs this machine serves
454
+ * @returns `{ kind, slug, ref }` for a card, `{ kind, slug, text }` for words,
455
+ * `{ needsProject, projects }` when no `@project` was written — the project
456
+ * is ALWAYS asked, never guessed — or `{ error }` in words.
457
+ */
458
+ export function parseNew(words, { projects = [] } = {}) {
459
+ const named = words.find((word) => word.startsWith('@'));
460
+ const rest = words.filter((word) => word !== named && word !== '');
461
+ const [kindWord, ...args] = rest;
462
+ const kind = { plan: 'plan', dev: 'dev', code: 'dev', ask: 'ask' }[String(kindWord ?? '').toLowerCase()];
463
+ const usage = 'use /new plan R3, /new dev R3, /new plan|dev <the task in words> or /new ask <question>'
464
+ + ' (and @project to pick one)';
465
+ if (!kind) {
466
+ return { error: kindWord ? `no such session kind: ${kindWord} — ${usage}` : usage };
467
+ }
468
+ const slug = named ? named.slice(1) : null;
469
+ if (slug && projects.length && !projects.includes(slug)) {
470
+ return { error: `this machine does not serve ${slug} — it serves ${projects.join(', ')}` };
471
+ }
472
+ const text = args.join(' ').trim();
473
+ if (!text) {
474
+ return { error: kind === 'ask' ? 'ask what? /new ask <question>'
475
+ : `${kind} what? /new ${kind} R3, or /new ${kind} <the task in words>` };
476
+ }
477
+ let what;
478
+ if (kind !== 'ask' && args.length === 1 && /^[RrIi]?\d+$/.test(args[0])) {
479
+ // A card is written R3 and an issue i3 — EntryRef's spelling; a bare
480
+ // number is a roadmap card, as it is everywhere else.
481
+ const digits = args[0].replace(/^[RrIi]/, '');
482
+ what = { kind, ref: /^[Ii]/.test(args[0]) ? `i${digits}` : `R${digits}` };
483
+ } else {
484
+ what = { kind, text };
485
+ }
486
+ // Never guessed — not from the focused window, not from a machine serving
487
+ // one project: a session started on the wrong project is a card planned or
488
+ // a branch cut in the wrong repository, and asking costs one key.
489
+ if (!slug) {
490
+ return projects.length
491
+ ? { needsProject: true, projects: [...projects], error: `which project? ${projects.join(', ')}` }
492
+ : { error: 'this machine serves no project yet — c adds one' };
493
+ }
494
+ return { ...what, slug };
495
+ }
496
+
497
+ /**
498
+ * The branch a dev from words works on: `task-` and the first words of the
499
+ * task, accents folded, and a short suffix so two tasks worded alike do not
500
+ * share one. Never the default branch — a manual session given no branch
501
+ * works on the project's default, and a task typed into a prompt must not
502
+ * commit straight onto `main`.
503
+ */
504
+ /**
505
+ * The title of the card `/new dev <the task in words>` writes before it
506
+ * starts: the sentence itself, its first line, cut between words near a
507
+ * hundred characters with `…` — the whole text goes in the card's body, so
508
+ * nothing typed is lost to the cut.
509
+ */
510
+ export function cardTitleFor(text, max = 100) {
511
+ const first = String(text).trim().split('\n')[0].trim();
512
+ if (first.length <= max) return first;
513
+ const cut = first.slice(0, max - 1);
514
+ const at = cut.lastIndexOf(' ');
515
+ return `${(at > max / 2 ? cut.slice(0, at) : cut).trimEnd()}…`;
516
+ }
517
+
518
+ export function branchForTask(text, suffix = Date.now().toString(36).slice(-4)) {
519
+ const words = String(text).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
520
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
521
+ let cut = words.slice(0, 40);
522
+ if (words.length > 40 && cut.includes('-')) {
523
+ cut = cut.slice(0, cut.lastIndexOf('-'));
524
+ }
525
+ return ['task', cut || 'session', suffix].filter(Boolean).join('-');
526
+ }
527
+
528
+ // --- the roadmap, across the top ------------------------------------------------
529
+
530
+ /**
531
+ * A card's title in `rows` lines of at most `width`, broken between words — a
532
+ * card read at a glance should not say "mob / ile" — with `…` where it had to
533
+ * stop. A word longer than a line is cut, since it cannot be broken anywhere
534
+ * better.
535
+ */
536
+ export function wordsIn(text, width, rows) {
537
+ const lines = [];
538
+ let line = '';
539
+ const words = text.split(/\s+/).filter(Boolean);
540
+ for (let i = 0; i < words.length; i++) {
541
+ const word = words[i].length > width ? `${words[i].slice(0, width - 1)}…` : words[i];
542
+ const next = line ? `${line} ${word}` : word;
543
+ if (next.length <= width) {
544
+ line = next;
545
+ continue;
546
+ }
547
+ lines.push(line);
548
+ line = word;
549
+ if (lines.length === rows) {
550
+ line = '';
551
+ const last = lines[rows - 1];
552
+ lines[rows - 1] = last.length < width ? `${last}…` : `${last.slice(0, width - 1)}…`;
553
+ return lines;
554
+ }
555
+ }
556
+ if (line) lines.push(line);
557
+ return lines.slice(0, rows);
558
+ }
559
+
560
+ /**
561
+ * Plain text in lines of at most `width`, broken between words; a word longer
562
+ * than a line is cut, since it cannot be broken anywhere better. What a
563
+ * question needs in a narrow prompt: `wordsIn` stops after N rows, and
564
+ * ansi.mjs's `wrap` breaks mid-word ("fiscal ye" / "ar?").
565
+ */
566
+ export function wordWrap(text, width) {
567
+ const w = Math.max(1, width);
568
+ const lines = [];
569
+ for (const paragraph of String(text).split('\n')) {
570
+ let line = '';
571
+ for (let word of paragraph.split(/\s+/).filter(Boolean)) {
572
+ while (word.length > w) {
573
+ if (line) { lines.push(line); line = ''; }
574
+ lines.push(word.slice(0, w));
575
+ word = word.slice(w);
576
+ }
577
+ if (!word) continue;
578
+ const next = line ? `${line} ${word}` : word;
579
+ if (next.length <= w) {
580
+ line = next;
581
+ } else {
582
+ lines.push(line);
583
+ line = word;
584
+ }
585
+ }
586
+ lines.push(line);
587
+ }
588
+ return lines;
589
+ }
590
+
591
+ /**
592
+ * The board's columns, in the board's order, and the words the console uses
593
+ * for them. SHIPPED and DECLINED are not columns: they only grow, and the
594
+ * console's own board folds them to a count (R68) for the same reason — so
595
+ * here they are counts beside the project's name.
596
+ */
597
+ export const BOARD_COLUMNS = [
598
+ ['CONSIDERING', 'Considering'],
599
+ ['PLANNED', 'Planned'],
600
+ ['IN_DEVELOPMENT', 'In development'],
601
+ ['MERGED', 'Merged'],
602
+ ];
603
+ const FOLDED = [['SHIPPED', 'shipped'], ['DECLINED', 'declined']];
604
+
605
+ /**
606
+ * Which column gives way first when the width runs out: the finished work,
607
+ * then the ideas, then what is ready, and what is being built last of all —
608
+ * the order of how much a person at this terminal is about to act on it.
609
+ */
610
+ const GIVES_WAY = ['MERGED', 'CONSIDERING', 'PLANNED', 'IN_DEVELOPMENT'];
611
+
612
+ /** Narrower than this, a column's card lines say nothing. */
613
+ export const MIN_COLUMN = 18;
614
+
615
+ /** Header rows above the cards: the projects, then the columns. */
616
+ const STRIP_HEAD = 2;
617
+
618
+ /**
619
+ * The roadmap across the top of advanced mode: every project this machine
620
+ * serves, each as a small board — its columns side by side, one line per
621
+ * card, a `●` on a card with a session running now.
622
+ *
623
+ * THE ROOM RUNS OUT TWO WAYS, and each is said rather than cut:
624
+ * - **Too many cards for the rows** — a column ends in `+N more` on its last
625
+ * row, never a card cut in half, and the header keeps the true count.
626
+ * - **Too many columns for the width** — empty columns go first (the header
627
+ * line of their project still names nothing lost), then whole columns in
628
+ * `GIVES_WAY` order, each named in the project's header as `merged 3
629
+ * hidden`; then whole projects, counted as `+N projects`. A project always
630
+ * keeps one column.
631
+ *
632
+ * Between `STRIP_HEAD + 1` and `maxRows` lines — as many as the fullest
633
+ * column needs, no more — of exactly `width` columns. One line when there is
634
+ * no project at all.
635
+ *
636
+ * @param cards `[{ ref, title, slug, status, live? }]`
637
+ * @param projects the slugs to show, in order; defaults to those the cards
638
+ * come from
639
+ */
640
+ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
641
+ const slugs = projects?.length ? [...projects] : [...new Set(cards.map((card) => card.slug))];
642
+ if (!slugs.length) {
643
+ return [padVisible(ink.muted(' no project here yet — the roadmap shows here'), width)];
644
+ }
645
+ const boards = slugs.map((slug) => {
646
+ const mine = cards.filter((card) => card.slug === slug);
647
+ return {
648
+ slug,
649
+ folded: FOLDED.map(([status, word]) => [word, mine.filter((c) => c.status === status).length]),
650
+ columns: BOARD_COLUMNS.map(([status, label]) => ({
651
+ status, label, cards: mine.filter((card) => card.status === status),
652
+ })),
653
+ hidden: [],
654
+ };
655
+ });
656
+
657
+ // What fits: every column at MIN_COLUMN, a space between two columns and a
658
+ // rule between two projects.
659
+ const need = (list) => list.reduce((sum, board) => sum + board.columns.length * (MIN_COLUMN + 1), 0)
660
+ - 1 + (list.length - 1) * 2;
661
+ const drop = (board, column) => {
662
+ board.columns = board.columns.filter((each) => each !== column);
663
+ if (column.cards.length) board.hidden.push(column);
664
+ };
665
+ if (need(boards) > width) {
666
+ for (const board of boards) {
667
+ for (const column of [...board.columns]) {
668
+ if (!column.cards.length && board.columns.length > 1) drop(board, column);
669
+ }
670
+ }
671
+ }
672
+ for (const status of GIVES_WAY) {
673
+ if (need(boards) <= width) break;
674
+ for (const board of boards) {
675
+ const column = board.columns.find((each) => each.status === status);
676
+ if (column && board.columns.length > 1) drop(board, column);
677
+ }
678
+ }
679
+ const shown = [...boards];
680
+ while (shown.length > 1 && need(shown) > width) shown.pop();
681
+ const hiddenProjects = boards.length - shown.length;
682
+
683
+ // Widths: what is left after the gaps, shared by column, the remainder
684
+ // going to the first columns so the rule lines never move by one.
685
+ const columnCount = shown.reduce((sum, board) => sum + board.columns.length, 0);
686
+ const gaps = shown.reduce((sum, board) => sum + board.columns.length - 1, 0) + (shown.length - 1) * 3;
687
+ const base = Math.max(1, Math.floor((width - gaps) / columnCount));
688
+ let extra = Math.max(0, width - gaps - base * columnCount);
689
+ for (const board of shown) {
690
+ for (const column of board.columns) {
691
+ column.width = base + (extra > 0 ? 1 : 0);
692
+ if (extra > 0) extra -= 1;
693
+ }
694
+ board.width = board.columns.reduce((sum, column) => sum + column.width, 0) + board.columns.length - 1;
695
+ }
696
+
697
+ // Rows: as many as the fullest column needs, within what the screen gave.
698
+ const fullest = Math.max(1, ...shown.flatMap((board) => board.columns.map((column) => column.cards.length)));
699
+ const rows = Math.max(STRIP_HEAD + 1, Math.min(maxRows, STRIP_HEAD + fullest));
700
+ const cardRows = rows - STRIP_HEAD;
701
+
702
+ const lineOf = (pick) => shown.map((board, b) => {
703
+ const segment = pick(board, b);
704
+ return padVisible(ellipsed(segment, board.width), board.width);
705
+ }).join(ink.muted(' │ '));
706
+
707
+ const lines = [];
708
+ lines.push(lineOf((board, b) => {
709
+ const counts = board.folded.filter(([, n]) => n).map(([word, n]) => `${n} ${word}`);
710
+ const lost = board.hidden.map((column) => `${column.label.toLowerCase()} ${column.cards.length} hidden`);
711
+ const more = b === shown.length - 1 && hiddenProjects
712
+ ? [`+${hiddenProjects} project${hiddenProjects === 1 ? '' : 's'}`] : [];
713
+ // What is NOT drawn first: a clipped header should lose the tally of
714
+ // shipped work before it loses the fact that a column is missing.
715
+ const tail = [...lost, ...more, ...counts].join(' · ');
716
+ return `${ink.bold(board.slug)}${tail ? ink.muted(` · ${tail}`) : ''}`;
717
+ }));
718
+ lines.push(lineOf((board) => board.columns.map((column) =>
719
+ padVisible(clip(`${hueOf(column.status, ink)(column.label)} ${ink.muted(String(column.cards.length))}`,
720
+ column.width), column.width)).join(' ')));
721
+ for (let row = 0; row < cardRows; row++) {
722
+ lines.push(lineOf((board) => board.columns.map((column) => {
723
+ const overflow = column.cards.length > cardRows;
724
+ const last = row === cardRows - 1;
725
+ let cell = '';
726
+ if (overflow && last) {
727
+ cell = ink.muted(`+${column.cards.length - (cardRows - 1)} more`);
728
+ } else if (column.cards[row]) {
729
+ const card = column.cards[row];
730
+ const mark = card.live ? ink.accent('●') : ' ';
731
+ cell = `${mark}${ink.bold(card.ref)} ${card.title ?? ''}`;
732
+ } else if (row === 0 && !column.cards.length) {
733
+ cell = ink.muted(' —');
734
+ }
735
+ return padVisible(ellipsed(cell, column.width), column.width);
736
+ }).join(' ')));
737
+ }
738
+ return lines.map((line) => padVisible(clip(line, width), width));
739
+ }
740
+
741
+ /** Clipped to `width`, with `…` where it had to stop — a title cut dead reads as the whole title. */
742
+ function ellipsed(text, width) {
743
+ return visibleWidth(text) <= width ? text : `${clip(text, width - 1)}…`;
744
+ }
745
+
746
+ /** A column's name in the colour of how close to done it is. */
747
+ function hueOf(status, ink) {
748
+ return status === 'IN_DEVELOPMENT' ? ink.accent
749
+ : status === 'MERGED' ? ink.success
750
+ : status === 'PLANNED' ? ink.warn : ink.muted;
751
+ }
752
+
753
+ // --- more sessions than windows ------------------------------------------------
754
+
755
+ /** No more windows than this: past four, a window is too small to read. */
756
+ export const MAX_WINDOWS = 4;
757
+
758
+ /**
759
+ * Which sessions get a window when there are more than `max`, in the order
760
+ * they are listed (so a window does not jump about as the choice changes).
761
+ *
762
+ * The focused one always — Tab onto a session in the tab line brings it up.
763
+ * Then sessions still running, then ended ones, each most recently focused
764
+ * first: the work in progress is what the screen is for.
765
+ *
766
+ * @param metas every session with a window, listed in their order
767
+ * @param recent session ids, most recently focused first
768
+ */
769
+ export function visibleSessions(metas, recent, focus, max = MAX_WINDOWS) {
770
+ if (metas.length <= max) return metas;
771
+ const rank = (meta) => {
772
+ const at = recent.indexOf(meta.id);
773
+ return [meta.id === focus ? 0 : 1, meta.ended ? 1 : 0, at === -1 ? Infinity : at];
774
+ };
775
+ const order = [...metas].sort((a, b) => {
776
+ const [x, y] = [rank(a), rank(b)];
777
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2] || metas.indexOf(a) - metas.indexOf(b);
778
+ });
779
+ const keep = new Set(order.slice(0, max).map((meta) => meta.id));
780
+ return metas.filter((meta) => keep.has(meta.id));
781
+ }
782
+
783
+ /**
784
+ * The one line naming the sessions with no window: each one's number (what
785
+ * `1`–`9` and the window titles use), its kind, its card and where it stands,
786
+ * in its state's colour. Clipped to the width, the count of what did not fit
787
+ * at the end.
788
+ *
789
+ * @param hidden `[{ n, meta }]`
790
+ */
791
+ export function tabsLine(hidden, width, ink) {
792
+ const byTone = { live: ink.danger, done: ink.success, stopped: ink.warn };
793
+ const parts = hidden.map(({ n, meta }) => {
794
+ const view = meta.view ?? {};
795
+ const type = TYPE_WORDS[view.profile ?? meta.profile] ?? 'session';
796
+ const state = meta.ended ? String(view.state ?? 'ended').toLowerCase() : meta.state ?? 'running';
797
+ const what = clip(String(view.entryRef ?? view.label ?? meta.label ?? ''), 24);
798
+ const text = `${n} ${type} · ${state}${what ? ` · ${what}` : ''}`;
799
+ return (byTone[toneFor(meta)] ?? ink.muted)(text);
800
+ });
801
+ // Room is kept for the "+N more" a line that does not hold them all ends in.
802
+ const reserve = 24;
803
+ let line = ink.muted(' also ▸ ');
804
+ for (let i = 0; i < parts.length; i++) {
805
+ const next = line + (i ? ink.muted(' ') : '') + parts[i];
806
+ const last = i === parts.length - 1;
807
+ if (!last && visibleWidth(next) > width - reserve && i > 0) {
808
+ line += ink.muted(` +${parts.length - i} more — tab reaches them`);
809
+ break;
810
+ }
811
+ line = next;
812
+ }
813
+ return padVisible(clip(line, width), width);
814
+ }
815
+
816
+ /**
817
+ * A session named so a person can find its window: its number, its kind, its
818
+ * card (or label) and its project — "2 · plan · R3 Offline mode — saasng".
819
+ * What a question or a permission request in the prompt says it came from.
820
+ */
821
+ export function sessionTag(n, meta) {
822
+ const view = meta?.view ?? {};
823
+ const profile = view.profile ?? meta?.profile ?? null;
824
+ const type = TYPE_WORDS[profile] ?? (profile ? String(profile).toLowerCase() : 'session');
825
+ // Cut, so the tag leaves room for what it is attached to — the question.
826
+ const what = clip(view.entryRef
827
+ ? `${view.entryRef} ${view.entryTitle ?? ''}`.trim()
828
+ : (view.label ?? meta?.label ?? 'a session'), 32);
829
+ return `${n ? `${n} · ` : ''}${type} · ${what}${meta?.projectSlug ? ` — ${meta.projectSlug}` : ''}`;
830
+ }