cawdev-cli 1.0.4-beta → 1.0.6-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,923 @@
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
+ /**
286
+ * A dev session that ended FINISHED having committed nothing — R24: IMPLEMENT
287
+ * stopped on a plan it would not follow, TEST and MEMORY ran on an untouched
288
+ * branch, and the run finished. "finished" in green with `/pr` offered read
289
+ * as work done, and `/pr` then said there was nothing to propose. Known only
290
+ * once the terminal has read the run's commits (`meta.commits`); until then
291
+ * nothing is claimed either way.
292
+ */
293
+ export function builtNothing(meta) {
294
+ const view = meta.view;
295
+ return Boolean(meta.ended && view?.state === 'FINISHED'
296
+ && (view.profile ?? 'CODE') === 'CODE' && meta.commits === 0);
297
+ }
298
+
299
+ export function titleFor(meta, waiting = null) {
300
+ const view = meta.view ?? {};
301
+ const profile = view.profile ?? meta.profile ?? null;
302
+ const type = TYPE_WORDS[profile] ?? (profile ? String(profile).toLowerCase() : 'session');
303
+ const what = view.entryRef
304
+ ? `${view.entryRef} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim()
305
+ : (view.label ?? meta.label ?? 'a session');
306
+ const state = builtNothing(meta) ? 'finished · nothing built'
307
+ : meta.ended
308
+ ? String(view.state ?? 'ended').toLowerCase().replace(/_/g, ' ')
309
+ : meta.state ?? 'running';
310
+ // What it is and where it stands FIRST: a narrow window clips from the
311
+ // right, and "plan · finished" is what decides what to do next. The project
312
+ // is the window's subtitle, a line of its own, and not in here.
313
+ return [type, waiting ?? state, what].filter(Boolean).join(' · ');
314
+ }
315
+
316
+ /**
317
+ * What a person calls a model: `claude-opus-5-5` → `Opus 5.5`, `sonnet` →
318
+ * `Sonnet`, `claude-haiku-4-5-20251001` → `Haiku 4.5`, `gpt-5-codex` →
319
+ * `GPT-5-codex`. Anything else is shown as it came — a label must never hide
320
+ * which model actually ran.
321
+ */
322
+ export function modelLabel(model) {
323
+ if (!model) return null;
324
+ const raw = String(model).trim().replace(/\[.*\]$/, '');
325
+ const claude = /^(?:claude-)?(opus|sonnet|haiku|fable)(?:-(\d+))?(?:-(\d{1,2}))?(?:-\d{8})?$/i.exec(raw);
326
+ if (claude) {
327
+ const [, family, major, minor] = claude;
328
+ const version = major ? ` ${major}${minor ? `.${minor}` : ''}` : '';
329
+ return `${family[0].toUpperCase()}${family.slice(1).toLowerCase()}${version}`;
330
+ }
331
+ if (/^gpt/i.test(raw)) return `GPT${raw.slice(3)}`;
332
+ return raw;
333
+ }
334
+
335
+ /** Which CLI, as a person says it. */
336
+ const AGENT_NAMES = { claude: 'Claude', codex: 'Codex', agy: 'Antigravity' };
337
+
338
+ /**
339
+ * `Claude · Opus 5.5` — the agent a session runs on and its model, for the
340
+ * window's second line. The daemon's word for a live session (what it
341
+ * actually spawned, per stage), the platform's for an ended one. With no
342
+ * model named, the CLI's own default is what ran, and it says so.
343
+ */
344
+ export function agentModelFor(meta) {
345
+ const agent = meta.agent ?? meta.view?.agent ?? null;
346
+ const model = meta.model ?? meta.view?.model ?? null;
347
+ if (!agent && !model) return '';
348
+ const who = AGENT_NAMES[agent] ?? (agent ? String(agent) : 'Claude');
349
+ return `${who} · ${modelLabel(model) ?? 'default model'}`;
350
+ }
351
+
352
+ /**
353
+ * A window's second line: the project, and for a dev session the ABSOLUTE
354
+ * path of the checkout its changes are being made in — on a machine with
355
+ * `saasng`, `saasng-auto-1` and `saasng-auto-2`, the label alone does not say
356
+ * which directory to open. The daemon's list says where a live session is;
357
+ * the platform's view says where an ended one was.
358
+ *
359
+ * Too long for `width`, the path gives way FROM THE LEFT: `…/saasng-auto-2`
360
+ * tells two checkouts apart, `/Users/ben/dev/…` tells nothing.
361
+ */
362
+ export function subtitleFor(meta, width) {
363
+ const project = `▸ ${meta.projectSlug ?? ''}`;
364
+ const profile = meta.view?.profile ?? meta.profile ?? null;
365
+ const path = meta.workspace ?? meta.view?.workspace ?? null;
366
+ if (profile !== 'CODE' || !path) {
367
+ return project;
368
+ }
369
+ const room = width - project.length - 3;
370
+ if (room < 8) {
371
+ return project;
372
+ }
373
+ const shown = path.length <= room ? path : `…${path.slice(path.length - room + 1)}`;
374
+ return `${project} · ${shown}`;
375
+ }
376
+
377
+ /**
378
+ * A window frame's colour, from where its session stands: `live` (red) while
379
+ * it runs or waits, `done` (green) once it FINISHED — a plan planned, a dev
380
+ * developed — and `stopped` (amber) for any other ending. Null until an ended
381
+ * session's outcome has been read, so a frame never claims a success it has
382
+ * not seen.
383
+ */
384
+ export function toneFor(meta) {
385
+ if (!meta.ended) {
386
+ return 'live';
387
+ }
388
+ const state = meta.view?.state;
389
+ if (!state || meta.view?.live) {
390
+ return null;
391
+ }
392
+ // Finished having built nothing is not done: amber, not green.
393
+ return state === 'FINISHED' && !builtNothing(meta) ? 'done' : 'stopped';
394
+ }
395
+
396
+ /**
397
+ * What can be done about a session now — the window's last line. Empty while
398
+ * it runs: the prompt, the answer and the permission keys are the prompt's.
399
+ *
400
+ * A finished PLAN on a card offers its development — `d`, in its place. A finished session that
401
+ * wrote a branch offers the pull request until one is known, then shows it.
402
+ * Every ended window can be closed.
403
+ */
404
+ export function actionsFor(meta) {
405
+ if (!meta.ended) {
406
+ return '';
407
+ }
408
+ const view = meta.view;
409
+ const parts = [];
410
+ if (view?.canCarryOn && !meta.carryAsked) {
411
+ parts.push('/continue picks it back up here');
412
+ } else if (meta.carryAsked) {
413
+ parts.push('continue asked for — back when this machine takes it');
414
+ }
415
+ if (view?.state === 'FINISHED' && view.profile === 'PLAN' && view.entryRef) {
416
+ // `d` (or /develop) develops it with the agent that planned it, and the
417
+ // development's window takes this one's place.
418
+ parts.push(`d develops ${view.entryRef} here, in place of this plan`);
419
+ parts.push('/open reads the plan on cawdev');
420
+ }
421
+ if (isPullRequest(view?.prUrl)) {
422
+ parts.push(`PR ${view.prUrl} · /open`);
423
+ } else if (meta.prAsked) {
424
+ parts.push(meta.prAsked === true ? 'pull request asked for…' : meta.prAsked);
425
+ } else if (builtNothing(meta)) {
426
+ parts.push(`nothing was committed${view.entryRef ? ` — /new plan ${view.entryRef} to re-plan it` : ''}`);
427
+ } else if (view?.branch && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED') {
428
+ parts.push('/pr opens the pull request');
429
+ }
430
+ if (meta.dropped) {
431
+ parts.push('checkout deleted');
432
+ } else if (isAutoCheckout(view?.workspace) && view?.state !== 'RUNNING' && !view?.live) {
433
+ parts.push('/drop deletes its checkout');
434
+ }
435
+ parts.push('/close');
436
+ return parts.join(' · ');
437
+ }
438
+
439
+ /**
440
+ * Whether a run the platform listed is one THIS terminal can carry on, on
441
+ * THIS machine — the console's Carry on, from here. Three conditions, each
442
+ * the API's own refusal read in advance: the platform says it can be carried
443
+ * on at all (`canCarryOn`: stopped or failed, a session or a hand-off to go
444
+ * on from, not discarded); it is the signed-in person's, because carry-on is
445
+ * the starter's alone; and its checkout is here or nowhere, because a run
446
+ * holding one resumes on the machine that holds it.
447
+ */
448
+ export function carriesOnHere(view, runnerId, email) {
449
+ return Boolean(view?.canCarryOn)
450
+ && Boolean(email) && view.startedByEmail === email
451
+ && (!view.runnerId || view.runnerId === runnerId);
452
+ }
453
+
454
+ /**
455
+ * Whether a checkout is one the daemon made on demand — `<first>-auto-<n>`
456
+ * (auto-workspaces.mjs). Only those are ever offered to `/drop`: the
457
+ * checkouts in the config are somebody's, and the project always keeps them.
458
+ */
459
+ export function isAutoCheckout(path) {
460
+ return typeof path === 'string' && /-auto-\d+$/.test(path.replace(/\/+$/, ''));
461
+ }
462
+
463
+ /**
464
+ * `/new plan R3`, `/new dev R3`, `/new ask what does the importer do?` — and a
465
+ * plan or a dev from a sentence, with no card: `/new plan add a CSV export`,
466
+ * `/new dev fix the sort on the client list`. `@slug` anywhere names the
467
+ * project; without it the caller asks which.
468
+ *
469
+ * One card reference and nothing else is that card; anything else is the task
470
+ * in words. A plan from words writes its card first and plans what it wrote
471
+ * (R150); a dev from words is a manual session, on a branch of its own.
472
+ *
473
+ * @param words what followed `/new`, split on spaces
474
+ * @param projects the slugs this machine serves
475
+ * @returns `{ kind, slug, ref }` for a card, `{ kind, slug, text }` for words,
476
+ * `{ needsProject, projects }` when no `@project` was written — the project
477
+ * is ALWAYS asked, never guessed — or `{ error }` in words.
478
+ */
479
+ /**
480
+ * A command line as words — on spaces and tabs, with every line break kept as
481
+ * a word of its own (`'\n'`). Splitting on all whitespace turned a pasted list
482
+ * of requirements into one long line; kept here, `wordsText` puts it back.
483
+ */
484
+ export function commandWords(text) {
485
+ return String(text).split(/[ \t]+/).flatMap((token) => token.split(/(\n)/)).filter(Boolean);
486
+ }
487
+
488
+ /** The words of a task back into its text, line breaks where they were. */
489
+ export function wordsText(words) {
490
+ return words.join(' ').replace(/ *\n */g, '\n').trim();
491
+ }
492
+
493
+ export function parseNew(words, { projects = [] } = {}) {
494
+ const named = words.find((word) => word.startsWith('@'));
495
+ const rest = words.filter((word) => word !== named && word !== '');
496
+ const [kindWord, ...args] = rest;
497
+ const kind = { plan: 'plan', dev: 'dev', code: 'dev', ask: 'ask' }[String(kindWord ?? '').toLowerCase()];
498
+ const usage = 'use /new plan R3, /new dev R3, /new plan|dev <the task in words> or /new ask <question>'
499
+ + ' (and @project to pick one)';
500
+ if (!kind) {
501
+ return { error: kindWord ? `no such session kind: ${kindWord} — ${usage}` : usage };
502
+ }
503
+ const slug = named ? named.slice(1) : null;
504
+ if (slug && projects.length && !projects.includes(slug)) {
505
+ return { error: `this machine does not serve ${slug} — it serves ${projects.join(', ')}` };
506
+ }
507
+ const text = wordsText(args);
508
+ if (!text) {
509
+ return { error: kind === 'ask' ? 'ask what? /new ask <question>'
510
+ : `${kind} what? /new ${kind} R3, or /new ${kind} <the task in words>` };
511
+ }
512
+ let what;
513
+ if (kind !== 'ask' && args.length === 1 && /^[RrIi]?\d+$/.test(args[0])) {
514
+ // A card is written R3 and an issue i3 — EntryRef's spelling; a bare
515
+ // number is a roadmap card, as it is everywhere else.
516
+ const digits = args[0].replace(/^[RrIi]/, '');
517
+ what = { kind, ref: /^[Ii]/.test(args[0]) ? `i${digits}` : `R${digits}` };
518
+ } else {
519
+ what = { kind, text };
520
+ }
521
+ // Never guessed — not from the focused window, not from a machine serving
522
+ // one project: a session started on the wrong project is a card planned or
523
+ // a branch cut in the wrong repository, and asking costs one key.
524
+ if (!slug) {
525
+ return projects.length
526
+ ? { needsProject: true, projects: [...projects], error: `which project? ${projects.join(', ')}` }
527
+ : { error: 'this machine serves no project yet — c adds one' };
528
+ }
529
+ return { ...what, slug };
530
+ }
531
+
532
+ /**
533
+ * The branch a dev from words works on: `task-` and the first words of the
534
+ * task, accents folded, and a short suffix so two tasks worded alike do not
535
+ * share one. Never the default branch — a manual session given no branch
536
+ * works on the project's default, and a task typed into a prompt must not
537
+ * commit straight onto `main`.
538
+ */
539
+ /**
540
+ * The title of the card `/new dev <the task in words>` writes before it
541
+ * starts: the sentence itself, its first line, cut between words near a
542
+ * hundred characters with `…` — the whole text goes in the card's body, so
543
+ * nothing typed is lost to the cut.
544
+ */
545
+ export function cardTitleFor(text, max = 100) {
546
+ const first = String(text).trim().split('\n')[0].trim();
547
+ if (first.length <= max) return first;
548
+ const cut = first.slice(0, max - 1);
549
+ const at = cut.lastIndexOf(' ');
550
+ return `${(at > max / 2 ? cut.slice(0, at) : cut).trimEnd()}…`;
551
+ }
552
+
553
+ export function branchForTask(text, suffix = Date.now().toString(36).slice(-4)) {
554
+ const words = String(text).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
555
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
556
+ let cut = words.slice(0, 40);
557
+ if (words.length > 40 && cut.includes('-')) {
558
+ cut = cut.slice(0, cut.lastIndexOf('-'));
559
+ }
560
+ return ['task', cut || 'session', suffix].filter(Boolean).join('-');
561
+ }
562
+
563
+ // --- the roadmap, across the top ------------------------------------------------
564
+
565
+ /**
566
+ * A card's title in `rows` lines of at most `width`, broken between words — a
567
+ * card read at a glance should not say "mob / ile" — with `…` where it had to
568
+ * stop. A word longer than a line is cut, since it cannot be broken anywhere
569
+ * better.
570
+ */
571
+ export function wordsIn(text, width, rows) {
572
+ const lines = [];
573
+ let line = '';
574
+ const words = text.split(/\s+/).filter(Boolean);
575
+ for (let i = 0; i < words.length; i++) {
576
+ const word = words[i].length > width ? `${words[i].slice(0, width - 1)}…` : words[i];
577
+ const next = line ? `${line} ${word}` : word;
578
+ if (next.length <= width) {
579
+ line = next;
580
+ continue;
581
+ }
582
+ lines.push(line);
583
+ line = word;
584
+ if (lines.length === rows) {
585
+ line = '';
586
+ const last = lines[rows - 1];
587
+ lines[rows - 1] = last.length < width ? `${last}…` : `${last.slice(0, width - 1)}…`;
588
+ return lines;
589
+ }
590
+ }
591
+ if (line) lines.push(line);
592
+ return lines.slice(0, rows);
593
+ }
594
+
595
+ /**
596
+ * Plain text in lines of at most `width`, broken between words; a word longer
597
+ * than a line is cut, since it cannot be broken anywhere better. What a
598
+ * question needs in a narrow prompt: `wordsIn` stops after N rows, and
599
+ * ansi.mjs's `wrap` breaks mid-word ("fiscal ye" / "ar?").
600
+ */
601
+ export function wordWrap(text, width) {
602
+ const w = Math.max(1, width);
603
+ const lines = [];
604
+ for (const paragraph of String(text).split('\n')) {
605
+ let line = '';
606
+ for (let word of paragraph.split(/\s+/).filter(Boolean)) {
607
+ while (word.length > w) {
608
+ if (line) { lines.push(line); line = ''; }
609
+ lines.push(word.slice(0, w));
610
+ word = word.slice(w);
611
+ }
612
+ if (!word) continue;
613
+ const next = line ? `${line} ${word}` : word;
614
+ if (next.length <= w) {
615
+ line = next;
616
+ } else {
617
+ lines.push(line);
618
+ line = word;
619
+ }
620
+ }
621
+ lines.push(line);
622
+ }
623
+ return lines;
624
+ }
625
+
626
+ /**
627
+ * The board's columns, in the board's order, and the words the console uses
628
+ * for them — the work still to be done, which is what somebody at this
629
+ * terminal acts on. MERGED, SHIPPED and DECLINED are not columns: the work
630
+ * is over, the three only grow, and the console's own board folds the last
631
+ * two to a count (R68) for the same reason — so here all three are counts
632
+ * beside the project's name.
633
+ */
634
+ export const BOARD_COLUMNS = [
635
+ ['CONSIDERING', 'Considering'],
636
+ ['PLANNED', 'Planned'],
637
+ ['IN_DEVELOPMENT', 'In development'],
638
+ ];
639
+ const FOLDED = [['MERGED', 'merged'], ['SHIPPED', 'shipped'], ['DECLINED', 'declined']];
640
+
641
+ /**
642
+ * Which column gives way first when the width runs out: the ideas, then what
643
+ * is ready, and what is being built last of all — the order of how much a
644
+ * person at this terminal is about to act on it.
645
+ */
646
+ const GIVES_WAY = ['CONSIDERING', 'PLANNED', 'IN_DEVELOPMENT'];
647
+
648
+ /** Narrower than this, a column's card lines say nothing. */
649
+ export const MIN_COLUMN = 18;
650
+
651
+ /** Header rows above the cards: the projects, then the columns. */
652
+ const STRIP_HEAD = 2;
653
+
654
+ /**
655
+ * The roadmap across the top of advanced mode: every project this machine
656
+ * serves, each as a small board — its columns side by side, one line per
657
+ * card, a `●` on a card with a session running now.
658
+ *
659
+ * THE ROOM RUNS OUT TWO WAYS, and each is said rather than cut:
660
+ * - **Too many cards for the rows** — a column ends in `+N more` on its last
661
+ * row, never a card cut in half, and the header keeps the true count.
662
+ * - **Too many columns for the width** — empty columns go first (the header
663
+ * line of their project still names nothing lost), then whole columns in
664
+ * `GIVES_WAY` order, each named in the project's header as `merged 3
665
+ * hidden`; then whole projects, counted as `+N projects`. A project always
666
+ * keeps one column.
667
+ *
668
+ * Between `STRIP_HEAD + 1` and `maxRows` lines — as many as the fullest
669
+ * column needs, no more — of exactly `width` columns. One line when there is
670
+ * no project at all.
671
+ *
672
+ * @param cards `[{ ref, title, slug, status, live? }]`
673
+ * @param projects the slugs to show, in order; defaults to those the cards
674
+ * come from
675
+ */
676
+ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
677
+ const slugs = projects?.length ? [...projects] : [...new Set(cards.map((card) => card.slug))];
678
+ if (!slugs.length) {
679
+ return [padVisible(ink.muted(' no project here yet — the roadmap shows here'), width)];
680
+ }
681
+ const boards = slugs.map((slug) => {
682
+ const mine = cards.filter((card) => card.slug === slug);
683
+ return {
684
+ slug,
685
+ folded: FOLDED.map(([status, word]) => [word, mine.filter((c) => c.status === status).length]),
686
+ columns: BOARD_COLUMNS.map(([status, label]) => ({
687
+ status, label, cards: mine.filter((card) => card.status === status),
688
+ })),
689
+ hidden: [],
690
+ };
691
+ });
692
+
693
+ // What fits: every column at MIN_COLUMN, a space between two columns and a
694
+ // rule between two projects.
695
+ const need = (list) => list.reduce((sum, board) => sum + board.columns.length * (MIN_COLUMN + 1), 0)
696
+ - 1 + (list.length - 1) * 2;
697
+ const drop = (board, column) => {
698
+ board.columns = board.columns.filter((each) => each !== column);
699
+ if (column.cards.length) board.hidden.push(column);
700
+ };
701
+ if (need(boards) > width) {
702
+ for (const board of boards) {
703
+ for (const column of [...board.columns]) {
704
+ if (!column.cards.length && board.columns.length > 1) drop(board, column);
705
+ }
706
+ }
707
+ }
708
+ for (const status of GIVES_WAY) {
709
+ if (need(boards) <= width) break;
710
+ for (const board of boards) {
711
+ const column = board.columns.find((each) => each.status === status);
712
+ if (column && board.columns.length > 1) drop(board, column);
713
+ }
714
+ }
715
+ const shown = [...boards];
716
+ while (shown.length > 1 && need(shown) > width) shown.pop();
717
+ const hiddenProjects = boards.length - shown.length;
718
+
719
+ // Widths: what is left after the gaps, shared by column, the remainder
720
+ // going to the first columns so the rule lines never move by one.
721
+ const columnCount = shown.reduce((sum, board) => sum + board.columns.length, 0);
722
+ const gaps = shown.reduce((sum, board) => sum + board.columns.length - 1, 0) + (shown.length - 1) * 3;
723
+ const base = Math.max(1, Math.floor((width - gaps) / columnCount));
724
+ let extra = Math.max(0, width - gaps - base * columnCount);
725
+ for (const board of shown) {
726
+ for (const column of board.columns) {
727
+ column.width = base + (extra > 0 ? 1 : 0);
728
+ if (extra > 0) extra -= 1;
729
+ }
730
+ board.width = board.columns.reduce((sum, column) => sum + column.width, 0) + board.columns.length - 1;
731
+ }
732
+
733
+ // Rows: as many as the fullest column needs, within what the screen gave.
734
+ const fullest = Math.max(1, ...shown.flatMap((board) => board.columns.map((column) => column.cards.length)));
735
+ const rows = Math.max(STRIP_HEAD + 1, Math.min(maxRows, STRIP_HEAD + fullest));
736
+ const cardRows = rows - STRIP_HEAD;
737
+
738
+ const lineOf = (pick) => shown.map((board, b) => {
739
+ const segment = pick(board, b);
740
+ return padVisible(ellipsed(segment, board.width), board.width);
741
+ }).join(ink.muted(' │ '));
742
+
743
+ const lines = [];
744
+ lines.push(lineOf((board, b) => {
745
+ const counts = board.folded.filter(([, n]) => n).map(([word, n]) => `${n} ${word}`);
746
+ const lost = board.hidden.map((column) => `${column.label.toLowerCase()} ${column.cards.length} hidden`);
747
+ const more = b === shown.length - 1 && hiddenProjects
748
+ ? [`+${hiddenProjects} project${hiddenProjects === 1 ? '' : 's'}`] : [];
749
+ // What is NOT drawn first: a clipped header should lose the tally of
750
+ // shipped work before it loses the fact that a column is missing.
751
+ const tail = [...lost, ...more, ...counts].join(' · ');
752
+ return `${ink.bold(board.slug)}${tail ? ink.muted(` · ${tail}`) : ''}`;
753
+ }));
754
+ lines.push(lineOf((board) => board.columns.map((column) =>
755
+ padVisible(clip(`${hueOf(column.status, ink)(column.label)} ${ink.muted(String(column.cards.length))}`,
756
+ column.width), column.width)).join(' ')));
757
+ for (let row = 0; row < cardRows; row++) {
758
+ lines.push(lineOf((board) => board.columns.map((column) => {
759
+ const overflow = column.cards.length > cardRows;
760
+ const last = row === cardRows - 1;
761
+ let cell = '';
762
+ if (overflow && last) {
763
+ cell = ink.muted(`+${column.cards.length - (cardRows - 1)} more`);
764
+ } else if (column.cards[row]) {
765
+ const card = column.cards[row];
766
+ const mark = card.live ? ink.accent('●') : ' ';
767
+ cell = `${mark}${ink.bold(card.ref)} ${card.title ?? ''}`;
768
+ } else if (row === 0 && !column.cards.length) {
769
+ cell = ink.muted(' —');
770
+ }
771
+ return padVisible(ellipsed(cell, column.width), column.width);
772
+ }).join(' ')));
773
+ }
774
+ return lines.map((line) => padVisible(clip(line, width), width));
775
+ }
776
+
777
+ /** Clipped to `width`, with `…` where it had to stop — a title cut dead reads as the whole title. */
778
+ function ellipsed(text, width) {
779
+ return visibleWidth(text) <= width ? text : `${clip(text, width - 1)}…`;
780
+ }
781
+
782
+ /** A column's name in the colour of how close to done it is. */
783
+ function hueOf(status, ink) {
784
+ return status === 'IN_DEVELOPMENT' ? ink.accent
785
+ : status === 'MERGED' ? ink.success
786
+ : status === 'PLANNED' ? ink.warn : ink.muted;
787
+ }
788
+
789
+ // --- more sessions than windows ------------------------------------------------
790
+
791
+ /** No more windows than this: past four, a window is too small to read. */
792
+ export const MAX_WINDOWS = 4;
793
+
794
+ /**
795
+ * Which sessions get a window when there are more than `max`, in the order
796
+ * they are listed (so a window does not jump about as the choice changes).
797
+ *
798
+ * The focused one always — Tab onto a session in the tab line brings it up.
799
+ * Then sessions still running, then ended ones, each most recently focused
800
+ * first: the work in progress is what the screen is for.
801
+ *
802
+ * @param metas every session with a window, listed in their order
803
+ * @param recent session ids, most recently focused first
804
+ */
805
+ export function visibleSessions(metas, recent, focus, max = MAX_WINDOWS) {
806
+ if (metas.length <= max) return metas;
807
+ const rank = (meta) => {
808
+ const at = recent.indexOf(meta.id);
809
+ return [meta.id === focus ? 0 : 1, meta.ended ? 1 : 0, at === -1 ? Infinity : at];
810
+ };
811
+ const order = [...metas].sort((a, b) => {
812
+ const [x, y] = [rank(a), rank(b)];
813
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2] || metas.indexOf(a) - metas.indexOf(b);
814
+ });
815
+ const keep = new Set(order.slice(0, max).map((meta) => meta.id));
816
+ return metas.filter((meta) => keep.has(meta.id));
817
+ }
818
+
819
+ /**
820
+ * The one line naming the sessions with no window: each one's number (what
821
+ * `1`–`9` and the window titles use), its kind, its card and where it stands,
822
+ * in its state's colour. Clipped to the width, the count of what did not fit
823
+ * at the end.
824
+ *
825
+ * @param hidden `[{ n, meta }]`
826
+ */
827
+ export function tabsLine(hidden, width, ink) {
828
+ const byTone = { live: ink.danger, done: ink.success, stopped: ink.warn };
829
+ const parts = hidden.map(({ n, meta }) => {
830
+ const view = meta.view ?? {};
831
+ const type = TYPE_WORDS[view.profile ?? meta.profile] ?? 'session';
832
+ const state = meta.ended ? String(view.state ?? 'ended').toLowerCase() : meta.state ?? 'running';
833
+ const what = clip(String(view.entryRef ?? view.label ?? meta.label ?? ''), 24);
834
+ const text = `${n} ${type} · ${state}${what ? ` · ${what}` : ''}`;
835
+ return (byTone[toneFor(meta)] ?? ink.muted)(text);
836
+ });
837
+ // Room is kept for the "+N more" a line that does not hold them all ends in.
838
+ const reserve = 24;
839
+ let line = ink.muted(' also ▸ ');
840
+ for (let i = 0; i < parts.length; i++) {
841
+ const next = line + (i ? ink.muted(' ') : '') + parts[i];
842
+ const last = i === parts.length - 1;
843
+ if (!last && visibleWidth(next) > width - reserve && i > 0) {
844
+ line += ink.muted(` +${parts.length - i} more — tab reaches them`);
845
+ break;
846
+ }
847
+ line = next;
848
+ }
849
+ return padVisible(clip(line, width), width);
850
+ }
851
+
852
+ /**
853
+ * A session named so a person can find its window: its number, its kind, its
854
+ * card (or label) and its project — "2 · plan · R3 Offline mode — saasng".
855
+ * What a question or a permission request in the prompt says it came from.
856
+ */
857
+ export function sessionTag(n, meta) {
858
+ const view = meta?.view ?? {};
859
+ const profile = view.profile ?? meta?.profile ?? null;
860
+ const type = TYPE_WORDS[profile] ?? (profile ? String(profile).toLowerCase() : 'session');
861
+ // Cut, so the tag leaves room for what it is attached to — the question.
862
+ const what = clip(view.entryRef
863
+ ? `${view.entryRef} ${view.entryTitle ?? ''}`.trim()
864
+ : (view.label ?? meta?.label ?? 'a session'), 32);
865
+ return `${n ? `${n} · ` : ''}${type} · ${what}${meta?.projectSlug ? ` — ${meta.projectSlug}` : ''}`;
866
+ }
867
+
868
+ // --- a card, in the terminal ---------------------------------------------------
869
+
870
+ /** How many lines of a card's body `/view` prints before pointing at the web. */
871
+ export const CARD_BODY_LINES = 30;
872
+
873
+ /**
874
+ * `/view R29`: a card as lines for the transcript — what it is, where it
875
+ * stands, who has it, its branch and pull request, then its body. Pure, so it
876
+ * is read at every combination rather than guessed from a live platform.
877
+ * Only what the card SAYS: an empty field draws no line, because a row of
878
+ * "none"s is read instead of scanned.
879
+ *
880
+ * @param card the platform's RoadmapEntryView (`GET …/roadmap/{ref}`).
881
+ * @param url the card's page on the console, or null.
882
+ */
883
+ export function cardLines(card, ink, url = null) {
884
+ const lines = [];
885
+ const field = (label, value) => {
886
+ if (value) lines.push(` ${ink.muted(label.padEnd(9))} ${value}`);
887
+ };
888
+ lines.push(` ${ink.bold(`${card.ref ?? ''} ${card.title ?? ''}`.trim())}`);
889
+ const kind = card.kind === 'ISSUE'
890
+ ? `issue${card.severity ? ` · ${String(card.severity).toLowerCase()}` : ''}` : null;
891
+ field('status', [card.statusDisplay ?? card.status, kind].filter(Boolean).join(' · '));
892
+ field('section', card.section);
893
+ field('tags', card.tags?.length ? card.tags.join(', ') : null);
894
+ field('sprint', card.sprint ? `${card.sprint.ref ?? card.sprint.number} ${card.sprint.name ?? ''}`.trim() : null);
895
+ const item = card.workItem;
896
+ field('branch', card.branch ?? item?.branch);
897
+ field('work', item ? [item.statusDisplay ?? item.status, item.runnerName && `on ${item.runnerName}`]
898
+ .filter(Boolean).join(' · ') : null);
899
+ const pr = item?.prUrl ?? card.review?.prUrl;
900
+ field('pr', isPullRequest(pr) ? pr : null);
901
+ const run = card.activeRun;
902
+ field('session', run ? [String(run.state ?? '').toLowerCase().replace(/_/g, ' '),
903
+ run.runnerName && `on ${run.runnerName}`].filter(Boolean).join(' · ') : null);
904
+ field('coding', card.codingBy?.email);
905
+ field('review', card.reviewingBy?.email);
906
+ field('rejected', card.rejection?.note ? `${card.rejection.note}${card.rejection.decidedByEmail ? ` — ${card.rejection.decidedByEmail}` : ''}` : null);
907
+ field('merged', card.merge);
908
+ field('version', card.version);
909
+ field('declined', card.declinedReason);
910
+ field('related', card.relatedRefs?.length ? card.relatedRefs.join(', ') : null);
911
+ field('after', card.after?.length ? card.after.map((each) => each.ref).join(', ') : null);
912
+ field('web', url);
913
+ const body = String(card.body ?? '').replace(/\s+$/, '');
914
+ if (body) {
915
+ const all = body.split('\n');
916
+ lines.push('');
917
+ for (const line of all.slice(0, CARD_BODY_LINES)) lines.push(` ${line}`);
918
+ if (all.length > CARD_BODY_LINES) {
919
+ lines.push(ink.muted(` … ${all.length - CARD_BODY_LINES} more line(s)${url ? ' — the rest on the web' : ''}`));
920
+ }
921
+ }
922
+ return lines;
923
+ }