cawdev-cli 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/finish-check.mjs +9 -1
- package/lib/gate-condition.mjs +11 -0
- package/lib/stage-tools.mjs +33 -2
- package/lib/version-files.mjs +99 -0
- package/mcp/server.mjs +40 -0
- package/package.json +1 -1
- package/runner/README.md +260 -32
- package/runner/attach.mjs +1692 -110
- 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 +415 -29
- package/runner/session-cards.mjs +448 -0
- package/runner/shots.mjs +214 -0
- package/runner/sign-in.mjs +29 -2
- package/runner/split.mjs +272 -64
package/runner/sign-in.mjs
CHANGED
|
@@ -50,7 +50,12 @@ export class Session {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The one place a request leaves: the cookie, the XSRF header, the cookies
|
|
55
|
+
* that come back, and the 401 rule. `request` reads JSON from it and
|
|
56
|
+
* `requestBytes` reads bytes; neither may grow a copy of this.
|
|
57
|
+
*/
|
|
58
|
+
async #send(path, { method = 'GET', body, redirect } = {}) {
|
|
54
59
|
const response = await fetch(`${this.url}${path}`, {
|
|
55
60
|
method,
|
|
56
61
|
headers: {
|
|
@@ -63,9 +68,9 @@ export class Session {
|
|
|
63
68
|
: {}),
|
|
64
69
|
},
|
|
65
70
|
body: body ? JSON.stringify(body) : undefined,
|
|
71
|
+
...(redirect ? { redirect } : {}),
|
|
66
72
|
});
|
|
67
73
|
this.#remember(response);
|
|
68
|
-
const text = await response.text();
|
|
69
74
|
if (response.status === 401 && this.email !== null) {
|
|
70
75
|
// The cookie stopped meaning anything: expired, signed out elsewhere, or
|
|
71
76
|
// the account revoked. Until this every action failed one at a time with
|
|
@@ -74,6 +79,12 @@ export class Session {
|
|
|
74
79
|
this.email = null;
|
|
75
80
|
this.onExpired?.();
|
|
76
81
|
}
|
|
82
|
+
return response;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async request(path, { method = 'GET', body } = {}) {
|
|
86
|
+
const response = await this.#send(path, { method, body });
|
|
87
|
+
const text = await response.text();
|
|
77
88
|
if (!response.ok) {
|
|
78
89
|
const failure = new Error(messageIn(text) ?? `${method} ${path} failed: HTTP ${response.status}`);
|
|
79
90
|
failure.status = response.status;
|
|
@@ -82,6 +93,22 @@ export class Session {
|
|
|
82
93
|
return text ? JSON.parse(text) : null;
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
/**
|
|
97
|
+
* A GET whose answer is bytes — a screenshot (R605). Redirects are refused:
|
|
98
|
+
* these are the platform's own bytes, and a redirect is somebody else's.
|
|
99
|
+
*/
|
|
100
|
+
async requestBytes(path) {
|
|
101
|
+
const response = await this.#send(path, { redirect: 'error' });
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const failure = new Error(
|
|
104
|
+
messageIn(await response.text()) ?? `GET ${path} failed: HTTP ${response.status}`,
|
|
105
|
+
);
|
|
106
|
+
failure.status = response.status;
|
|
107
|
+
throw failure;
|
|
108
|
+
}
|
|
109
|
+
return Buffer.from(await response.arrayBuffer());
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
/** One open GET, purely to be handed the CSRF cookie before the first POST. */
|
|
86
113
|
async prime() {
|
|
87
114
|
await this.request('/api/health').catch(() => undefined);
|
package/runner/split.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// which is how scrollback.mjs's own layout bugs were found. The class at the
|
|
17
17
|
// bottom only enters, leaves, and writes the rows that changed.
|
|
18
18
|
|
|
19
|
-
import { clip, padVisible, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
19
|
+
import { clip, keyList, padVisible, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
20
|
+
import { staleLockOf } from './git-lock.mjs';
|
|
20
21
|
|
|
21
22
|
const ESC = '\x1b';
|
|
22
23
|
const ALT_ON = `${ESC}[?1049h`;
|
|
@@ -406,6 +407,14 @@ export function subtitleFor(meta, width) {
|
|
|
406
407
|
return `${project} · ${shown}`;
|
|
407
408
|
}
|
|
408
409
|
|
|
410
|
+
/**
|
|
411
|
+
* What a window says while its run is stopped at SHOW — R605. Pure, so the
|
|
412
|
+
* singular is pinned once.
|
|
413
|
+
*/
|
|
414
|
+
export function showWaitingLine(gate, count) {
|
|
415
|
+
return `waiting for you — ${count} screenshot${count === 1 ? '' : 's'}, round ${gate.round} · a to look and decide`;
|
|
416
|
+
}
|
|
417
|
+
|
|
409
418
|
/**
|
|
410
419
|
* A window frame's colour, from where its session stands: `live` (red) while
|
|
411
420
|
* it runs or waits, `done` (green) once it FINISHED — a plan planned, a dev
|
|
@@ -429,6 +438,9 @@ export function toneFor(meta) {
|
|
|
429
438
|
* What can be done about a session now — the window's last line. Empty while
|
|
430
439
|
* it runs: the prompt, the answer and the permission keys are the prompt's.
|
|
431
440
|
*
|
|
441
|
+
* A session that stopped on a git lock a crash left behind offers `/clean`
|
|
442
|
+
* beside `/continue` (git-lock.mjs).
|
|
443
|
+
*
|
|
432
444
|
* A finished PLAN on a card offers its development — `d`, in its place. A finished session that
|
|
433
445
|
* wrote a branch offers the pull request until one is known, then shows it.
|
|
434
446
|
* Every ended window can be closed.
|
|
@@ -440,6 +452,10 @@ export function actionsFor(meta) {
|
|
|
440
452
|
const view = meta.view;
|
|
441
453
|
const parts = [];
|
|
442
454
|
if (view?.canCarryOn && !meta.carryAsked) {
|
|
455
|
+
// A lock a crash left behind fails /continue the same way again.
|
|
456
|
+
if (!meta.lockCleaned && staleLockOf(view.exitSummary, view.workspace ?? meta.workspace)) {
|
|
457
|
+
parts.push('/clean removes the stale git lock');
|
|
458
|
+
}
|
|
443
459
|
parts.push('/continue picks it back up here');
|
|
444
460
|
} else if (meta.carryAsked) {
|
|
445
461
|
parts.push('continue asked for — back when this machine takes it');
|
|
@@ -459,10 +475,16 @@ export function actionsFor(meta) {
|
|
|
459
475
|
} else if (view?.branch && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED') {
|
|
460
476
|
parts.push('/pr opens the pull request');
|
|
461
477
|
}
|
|
478
|
+
// A finished development on a card has a branch to read and decide on here.
|
|
479
|
+
if (view?.entryRef && view.branch && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED'
|
|
480
|
+
&& !builtNothing(meta)) {
|
|
481
|
+
parts.push('/review');
|
|
482
|
+
}
|
|
483
|
+
// The command alone: a card's last line is short, and /help says what it does.
|
|
462
484
|
if (meta.dropped) {
|
|
463
485
|
parts.push('checkout deleted');
|
|
464
486
|
} else if (isAutoCheckout(view?.workspace) && view?.state !== 'RUNNING' && !view?.live) {
|
|
465
|
-
parts.push('/drop
|
|
487
|
+
parts.push('/drop');
|
|
466
488
|
}
|
|
467
489
|
parts.push('/close');
|
|
468
490
|
return parts.join(' · ');
|
|
@@ -492,22 +514,6 @@ export function isAutoCheckout(path) {
|
|
|
492
514
|
return typeof path === 'string' && /-auto-\d+$/.test(path.replace(/\/+$/, ''));
|
|
493
515
|
}
|
|
494
516
|
|
|
495
|
-
/**
|
|
496
|
-
* `/new plan R3`, `/new dev R3`, `/new ask what does the importer do?` — and a
|
|
497
|
-
* plan or a dev from a sentence, with no card: `/new plan add a CSV export`,
|
|
498
|
-
* `/new dev fix the sort on the client list`. `@slug` anywhere names the
|
|
499
|
-
* project; without it the caller asks which.
|
|
500
|
-
*
|
|
501
|
-
* One card reference and nothing else is that card; anything else is the task
|
|
502
|
-
* in words. A plan from words writes its card first and plans what it wrote
|
|
503
|
-
* (R150); a dev from words is a manual session, on a branch of its own.
|
|
504
|
-
*
|
|
505
|
-
* @param words what followed `/new`, split on spaces
|
|
506
|
-
* @param projects the slugs this machine serves
|
|
507
|
-
* @returns `{ kind, slug, ref }` for a card, `{ kind, slug, text }` for words,
|
|
508
|
-
* `{ needsProject, projects }` when no `@project` was written — the project
|
|
509
|
-
* is ALWAYS asked, never guessed — or `{ error }` in words.
|
|
510
|
-
*/
|
|
511
517
|
/**
|
|
512
518
|
* A command line as words — on spaces and tabs, with every line break kept as
|
|
513
519
|
* a word of its own (`'\n'`). Splitting on all whitespace turned a pasted list
|
|
@@ -522,16 +528,47 @@ export function wordsText(words) {
|
|
|
522
528
|
return words.join(' ').replace(/ *\n */g, '\n').trim();
|
|
523
529
|
}
|
|
524
530
|
|
|
531
|
+
/**
|
|
532
|
+
* `/new plan R3`, `/new dev R3`, `/new ask what does the importer do?` — and a
|
|
533
|
+
* plan or a dev from a sentence, with no card: `/new plan add a CSV export`,
|
|
534
|
+
* `/new dev fix the sort on the client list`. `@slug` anywhere names the
|
|
535
|
+
* project; without it the caller asks which.
|
|
536
|
+
*
|
|
537
|
+
* One card reference and nothing else is that card; anything else is the task
|
|
538
|
+
* in words. A plan from words writes its card first and plans what it wrote
|
|
539
|
+
* (R150); a dev from words is a manual session, on a branch of its own.
|
|
540
|
+
*
|
|
541
|
+
* R606: several references and nothing else are several cards, in the order
|
|
542
|
+
* written (`R3 R5 R7` or `R3,R5,R7`), started as one batch on this machine.
|
|
543
|
+
* `plan+dev` plans each card and builds it before the next is planned, so it
|
|
544
|
+
* takes cards only — one is a batch of one. `--in-order` puts dev cards on one
|
|
545
|
+
* branch, each run from the last one's commits, and `--branch <name>` names it.
|
|
546
|
+
*
|
|
547
|
+
* @param words what followed `/new`, split on spaces
|
|
548
|
+
* @param projects the slugs this machine serves
|
|
549
|
+
* @returns `{ kind, slug, ref }` for a card, `{ kind, slug, refs }` for
|
|
550
|
+
* several (with `inOrder` and `branch` when asked for), `{ kind, slug, text }`
|
|
551
|
+
* for words, `{ needsProject, projects }` when no `@project` was written —
|
|
552
|
+
* the project is ALWAYS asked, never guessed — or `{ error }` in words.
|
|
553
|
+
*/
|
|
525
554
|
export function parseNew(words, { projects = [] } = {}) {
|
|
526
555
|
const named = words.find((word) => word.startsWith('@'));
|
|
527
|
-
const
|
|
556
|
+
const at = words.indexOf('--branch');
|
|
557
|
+
const branch = at >= 0 ? words[at + 1] : undefined;
|
|
558
|
+
const inOrder = words.includes('--in-order');
|
|
559
|
+
const rest = words.filter((word, index) => word !== named && word !== '' && word !== '--in-order'
|
|
560
|
+
&& (at < 0 || (index !== at && index !== at + 1)));
|
|
528
561
|
const [kindWord, ...args] = rest;
|
|
529
|
-
const kind = { plan: 'plan', dev: 'dev', code: 'dev', ask: 'ask'
|
|
530
|
-
|
|
531
|
-
|
|
562
|
+
const kind = { plan: 'plan', dev: 'dev', code: 'dev', ask: 'ask', 'plan+dev': 'plan+dev', 'plan+code': 'plan+dev' }[
|
|
563
|
+
String(kindWord ?? '').toLowerCase()];
|
|
564
|
+
const usage = 'use /new plan R3, /new dev R3, /new plan|dev <the task in words>, /new ask <question>'
|
|
565
|
+
+ ' or several cards: /new plan|dev|plan+dev R3 R5 R7 (and @project to pick one)';
|
|
532
566
|
if (!kind) {
|
|
533
567
|
return { error: kindWord ? `no such session kind: ${kindWord} — ${usage}` : usage };
|
|
534
568
|
}
|
|
569
|
+
if (at >= 0 && (!branch || branch.startsWith('-') || branch.startsWith('@'))) {
|
|
570
|
+
return { error: '--branch what? /new dev R3 R5 --in-order --branch <name>' };
|
|
571
|
+
}
|
|
535
572
|
const slug = named ? named.slice(1) : null;
|
|
536
573
|
if (slug && projects.length && !projects.includes(slug)) {
|
|
537
574
|
return { error: `this machine does not serve ${slug} — it serves ${projects.join(', ')}` };
|
|
@@ -539,14 +576,38 @@ export function parseNew(words, { projects = [] } = {}) {
|
|
|
539
576
|
const text = wordsText(args);
|
|
540
577
|
if (!text) {
|
|
541
578
|
return { error: kind === 'ask' ? 'ask what? /new ask <question>'
|
|
542
|
-
: `${kind} what? /new ${kind} R3
|
|
579
|
+
: `${kind} what? /new ${kind} R3${kind === 'plan+dev' ? ' R5' : `, or /new ${kind} <the task in words>`}` };
|
|
580
|
+
}
|
|
581
|
+
// A card is written R3 and an issue i3 — EntryRef's spelling; a bare number
|
|
582
|
+
// is a roadmap card, as it is everywhere else. Commas only part references:
|
|
583
|
+
// a sentence with a comma in it stays the sentence it was.
|
|
584
|
+
const pieces = args.flatMap((word) => word.split(','));
|
|
585
|
+
const refs = kind !== 'ask' && pieces.filter(Boolean).length
|
|
586
|
+
&& pieces.every((word) => word === '' || /^[RrIi]?\d+$/.test(word))
|
|
587
|
+
? pieces.filter(Boolean).map((word) => {
|
|
588
|
+
const digits = word.replace(/^[RrIi]/, '');
|
|
589
|
+
return /^[Ii]/.test(word) ? `i${digits}` : `R${digits}`;
|
|
590
|
+
})
|
|
591
|
+
: null;
|
|
592
|
+
const twice = refs?.find((ref, index) => refs.indexOf(ref) !== index);
|
|
593
|
+
if (twice) {
|
|
594
|
+
return { error: `${twice} is on the line twice — a card is planned or built once per batch` };
|
|
595
|
+
}
|
|
596
|
+
if (kind === 'plan+dev' && !refs) {
|
|
597
|
+
return { error: 'plan+dev takes cards: /new plan+dev R3 R5 R7 — /card writes one first' };
|
|
598
|
+
}
|
|
599
|
+
const several = refs && (refs.length > 1 || kind === 'plan+dev');
|
|
600
|
+
if ((inOrder || at >= 0) && !(several && kind === 'dev')) {
|
|
601
|
+
return { error: '--in-order and --branch are for several dev cards: /new dev R3 R5 --in-order' };
|
|
602
|
+
}
|
|
603
|
+
if (at >= 0 && !inOrder) {
|
|
604
|
+
return { error: '--branch names the one branch of --in-order; without it each card has its own' };
|
|
543
605
|
}
|
|
544
606
|
let what;
|
|
545
|
-
if (
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
what = { kind, ref: /^[Ii]/.test(args[0]) ? `i${digits}` : `R${digits}` };
|
|
607
|
+
if (several) {
|
|
608
|
+
what = { kind, refs, ...(inOrder ? { inOrder } : {}), ...(branch ? { branch } : {}) };
|
|
609
|
+
} else if (refs) {
|
|
610
|
+
what = { kind, ref: refs[0] };
|
|
550
611
|
} else {
|
|
551
612
|
what = { kind, text };
|
|
552
613
|
}
|
|
@@ -561,6 +622,37 @@ export function parseNew(words, { projects = [] } = {}) {
|
|
|
561
622
|
return { ...what, slug };
|
|
562
623
|
}
|
|
563
624
|
|
|
625
|
+
/**
|
|
626
|
+
* What a batch started from `/new` did, in lines — R606. Both halves, as the
|
|
627
|
+
* console's dialog says them: what went, in the order it will run, and each
|
|
628
|
+
* card the platform skipped with its reason. A batch that started nothing
|
|
629
|
+
* says so first, so the skipped lines read as the reason.
|
|
630
|
+
*
|
|
631
|
+
* @param asked what `parseNew` returned (`kind`, `inOrder`)
|
|
632
|
+
* @param outcome the API's `StartedBatch` (`started` runs, `refused` cards)
|
|
633
|
+
*/
|
|
634
|
+
export function batchStartedLines(asked, outcome, slug, agent = null) {
|
|
635
|
+
const started = outcome?.started ?? [];
|
|
636
|
+
const refs = started.map((run) => run.entryRef ?? `#${run.entryNumber}`);
|
|
637
|
+
const where = agent ? `on ${slug} with ${agent}` : `on ${slug}`;
|
|
638
|
+
const lines = [];
|
|
639
|
+
if (!started.length) {
|
|
640
|
+
lines.push(`nothing started on ${slug}`);
|
|
641
|
+
} else if (asked.kind === 'plan+dev') {
|
|
642
|
+
lines.push(`planning then building ${refs.join(' → ')} ${where}, one card at a time`);
|
|
643
|
+
} else if (asked.kind === 'plan') {
|
|
644
|
+
lines.push(`planning ${refs.join(' → ')} ${where}, in order`);
|
|
645
|
+
} else if (asked.inOrder) {
|
|
646
|
+
lines.push(`building ${refs.join(' → ')} ${where}, in order on ${started[0].branch}`);
|
|
647
|
+
} else {
|
|
648
|
+
lines.push(`building ${refs.join(', ')} ${where}, a branch each, queued on this machine`);
|
|
649
|
+
}
|
|
650
|
+
for (const card of outcome?.refused ?? []) {
|
|
651
|
+
lines.push(`skipped ${card.ref ?? `#${card.entryNumber}`}: ${card.reason}`);
|
|
652
|
+
}
|
|
653
|
+
return lines;
|
|
654
|
+
}
|
|
655
|
+
|
|
564
656
|
/**
|
|
565
657
|
* The branch a dev from words works on: `task-` and the first words of the
|
|
566
658
|
* task, accents folded, and a short suffix so two tasks worded alike do not
|
|
@@ -701,36 +793,14 @@ export const MIN_COLUMN = 18;
|
|
|
701
793
|
const STRIP_HEAD = 2;
|
|
702
794
|
|
|
703
795
|
/**
|
|
704
|
-
* The
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
* THE ROOM RUNS OUT TWO WAYS, and each is said rather than cut:
|
|
709
|
-
* - **Too many cards for the rows** — a column ends in `+N more` on its last
|
|
710
|
-
* row, never a card cut in half, and the header keeps the true count.
|
|
711
|
-
* - **Too many columns for the width** — empty columns go first (the header
|
|
712
|
-
* line of their project still names nothing lost), then whole columns in
|
|
713
|
-
* `GIVES_WAY` order, each named in the project's header as `merged 3
|
|
714
|
-
* hidden`; then whole projects, counted as `+N projects`. A project always
|
|
715
|
-
* keeps one column.
|
|
716
|
-
*
|
|
717
|
-
* Between `STRIP_HEAD + 1` and `maxRows` lines — as many as the fullest
|
|
718
|
-
* column needs, no more — of exactly `width` columns. One line when there is
|
|
719
|
-
* no project at all.
|
|
720
|
-
*
|
|
721
|
-
* ISSUES SHARE THE COLUMNS (`columnOf`) and go first in each, most severe
|
|
722
|
-
* first: a column cut to `+N more` should not hide a critical defect under
|
|
723
|
-
* ideas. Their ref (`i12`) is in the colour of their severity.
|
|
724
|
-
*
|
|
725
|
-
* @param cards `[{ ref, title, slug, status, live?, severity? }]` — a card
|
|
726
|
-
* with a `severity` is an issue
|
|
727
|
-
* @param projects the slugs to show, in order; defaults to those the cards
|
|
728
|
-
* come from
|
|
796
|
+
* The boards and columns the strip fits into `width`, in left-to-right order.
|
|
797
|
+
* Shared by {@link roadmapStrip} and {@link stripColumns} so keyboard focus
|
|
798
|
+
* walks the exact columns drawn on screen.
|
|
729
799
|
*/
|
|
730
|
-
|
|
800
|
+
function stripBoards(width, cards, projects = null) {
|
|
731
801
|
const slugs = projects?.length ? [...projects] : [...new Set(cards.map((card) => card.slug))];
|
|
732
802
|
if (!slugs.length) {
|
|
733
|
-
return
|
|
803
|
+
return { slugs: [], shown: [], hiddenProjects: 0 };
|
|
734
804
|
}
|
|
735
805
|
const boards = slugs.map((slug) => {
|
|
736
806
|
const mine = cards.filter((card) => card.slug === slug);
|
|
@@ -783,6 +853,84 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
|
783
853
|
}
|
|
784
854
|
board.width = board.columns.reduce((sum, column) => sum + column.width, 0) + board.columns.length - 1;
|
|
785
855
|
}
|
|
856
|
+
return { slugs, shown, hiddenProjects };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* The non-empty columns drawn on the roadmap strip at `width`, left to right:
|
|
861
|
+
* `[{ slug, status, label, cards }]`. Empty columns (`—`) are skipped so
|
|
862
|
+
* left/right navigation always lands on a card.
|
|
863
|
+
*/
|
|
864
|
+
export function stripColumns(width, cards, projects = null) {
|
|
865
|
+
const { shown } = stripBoards(width, cards, projects);
|
|
866
|
+
return shown.flatMap((board) => board.columns
|
|
867
|
+
.filter((column) => column.cards.length > 0)
|
|
868
|
+
.map((column) => ({ slug: board.slug, status: column.status, label: column.label, cards: column.cards })));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Step the board focus by `(dCol, dRow)` across `columns` (`stripColumns`).
|
|
873
|
+
* Moving horizontally keeps the row index clamped to the destination column's
|
|
874
|
+
* cards; moving vertically wraps within the current column.
|
|
875
|
+
*/
|
|
876
|
+
export function moveInStrip(columns, focus, dCol = 0, dRow = 0) {
|
|
877
|
+
if (!columns.length) return null;
|
|
878
|
+
let colAt = focus ? columns.findIndex((col) => col.slug === focus.slug
|
|
879
|
+
&& col.cards.some((card) => card.ref === focus.ref)) : -1;
|
|
880
|
+
let rowAt = colAt >= 0 ? columns[colAt].cards.findIndex((card) => card.ref === focus.ref) : 0;
|
|
881
|
+
if (colAt < 0) {
|
|
882
|
+
colAt = 0;
|
|
883
|
+
rowAt = 0;
|
|
884
|
+
if (dCol === 0 && dRow === 0) {
|
|
885
|
+
return { slug: columns[0].slug, ref: columns[0].cards[0].ref };
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (dCol !== 0) {
|
|
889
|
+
colAt = (colAt + dCol + columns.length) % columns.length;
|
|
890
|
+
rowAt = Math.min(rowAt, columns[colAt].cards.length - 1);
|
|
891
|
+
} else if (dRow !== 0) {
|
|
892
|
+
const count = columns[colAt].cards.length;
|
|
893
|
+
rowAt = (rowAt + dRow + count) % count;
|
|
894
|
+
}
|
|
895
|
+
return { slug: columns[colAt].slug, ref: columns[colAt].cards[rowAt].ref };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* The roadmap across the top of advanced mode: every project this machine
|
|
900
|
+
* serves, each as a small board — its columns side by side, one line per
|
|
901
|
+
* card, a `●` on a card with a session running now, and `❯` on the card
|
|
902
|
+
* focused when browsing the strip (`focus`).
|
|
903
|
+
*
|
|
904
|
+
* THE ROOM RUNS OUT TWO WAYS, and each is said rather than cut:
|
|
905
|
+
* - **Too many cards for the rows** — a column ends in `+N more` on its last
|
|
906
|
+
* row, never a card cut in half, and the header keeps the true count. When
|
|
907
|
+
* browsing a column past the fold, its window walks with `focus` so every
|
|
908
|
+
* card in the column can be reached.
|
|
909
|
+
* - **Too many columns for the width** — empty columns go first (the header
|
|
910
|
+
* line of their project still names nothing lost), then whole columns in
|
|
911
|
+
* `GIVES_WAY` order, each named in the project's header as `merged 3
|
|
912
|
+
* hidden`; then whole projects, counted as `+N projects`. A project always
|
|
913
|
+
* keeps one column.
|
|
914
|
+
*
|
|
915
|
+
* Between `STRIP_HEAD + 1` and `maxRows` lines — as many as the fullest
|
|
916
|
+
* column needs, no more — of exactly `width` columns. One line when there is
|
|
917
|
+
* no project at all.
|
|
918
|
+
*
|
|
919
|
+
* ISSUES SHARE THE COLUMNS (`columnOf`) and go first in each, most severe
|
|
920
|
+
* first: a column cut to `+N more` should not hide a critical defect under
|
|
921
|
+
* ideas. Their ref (`i12`) is in the colour of their severity.
|
|
922
|
+
*
|
|
923
|
+
* @param cards `[{ ref, title, slug, status, live?, severity? }]` — a card
|
|
924
|
+
* with a `severity` is an issue
|
|
925
|
+
* @param projects the slugs to show, in order; defaults to those the cards
|
|
926
|
+
* come from
|
|
927
|
+
* @param focus `{ slug, ref }` when browsing the strip, else null
|
|
928
|
+
*/
|
|
929
|
+
export function roadmapStrip(width, maxRows, cards, ink, projects = null, focus = null) {
|
|
930
|
+
const { slugs, shown, hiddenProjects } = stripBoards(width, cards, projects);
|
|
931
|
+
if (!slugs.length) {
|
|
932
|
+
return [padVisible(ink.muted(' no project here yet — the roadmap shows here'), width)];
|
|
933
|
+
}
|
|
786
934
|
|
|
787
935
|
// Rows: as many as the fullest column needs, within what the screen gave.
|
|
788
936
|
const fullest = Math.max(1, ...shown.flatMap((board) => board.columns.map((column) => column.cards.length)));
|
|
@@ -805,20 +953,35 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
|
805
953
|
const tail = [...lost, ...more, ...counts].join(' · ');
|
|
806
954
|
return `${ink.bold(board.slug)}${tail ? ink.muted(` · ${tail}`) : ''}`;
|
|
807
955
|
}));
|
|
808
|
-
lines.push(lineOf((board) => board.columns.map((column) =>
|
|
809
|
-
|
|
810
|
-
column.
|
|
956
|
+
lines.push(lineOf((board) => board.columns.map((column) => {
|
|
957
|
+
const active = Boolean(focus && board.slug === focus.slug
|
|
958
|
+
&& column.cards.some((card) => card.ref === focus.ref));
|
|
959
|
+
const paintLabel = hueOf(column.status, ink);
|
|
960
|
+
const head = active ? ink.strong(paintLabel(column.label)) : paintLabel(column.label);
|
|
961
|
+
return padVisible(clip(`${head} ${ink.muted(String(column.cards.length))}`,
|
|
962
|
+
column.width), column.width);
|
|
963
|
+
}).join(' ')));
|
|
811
964
|
for (let row = 0; row < cardRows; row++) {
|
|
812
965
|
lines.push(lineOf((board) => board.columns.map((column) => {
|
|
966
|
+
const focusedAt = focus && board.slug === focus.slug
|
|
967
|
+
? column.cards.findIndex((card) => card.ref === focus.ref)
|
|
968
|
+
: -1;
|
|
813
969
|
const overflow = column.cards.length > cardRows;
|
|
970
|
+
const atEnd = overflow && focusedAt === column.cards.length - 1 && cardRows > 1;
|
|
971
|
+
const start = !overflow ? 0
|
|
972
|
+
: cardRows === 1 ? Math.max(0, focusedAt)
|
|
973
|
+
: atEnd ? column.cards.length - cardRows
|
|
974
|
+
: focusedAt >= 0 ? Math.max(0, focusedAt - (cardRows - 2)) : 0;
|
|
814
975
|
const last = row === cardRows - 1;
|
|
815
976
|
let cell = '';
|
|
816
|
-
if (overflow && last) {
|
|
817
|
-
cell = ink.muted(`+${column.cards.length - (cardRows - 1)} more`);
|
|
818
|
-
} else if (column.cards[row]) {
|
|
819
|
-
const card = column.cards[row];
|
|
820
|
-
const
|
|
821
|
-
|
|
977
|
+
if (overflow && last && !atEnd && !(cardRows === 1 && focusedAt >= 0)) {
|
|
978
|
+
cell = ink.muted(`+${column.cards.length - (start + cardRows - 1)} more`);
|
|
979
|
+
} else if (column.cards[start + row]) {
|
|
980
|
+
const card = column.cards[start + row];
|
|
981
|
+
const chosen = focusedAt >= 0 && card.ref === focus.ref;
|
|
982
|
+
const mark = chosen ? ink.bold(ink.accent('❯')) : card.live ? ink.accent('●') : ' ';
|
|
983
|
+
const title = chosen ? ink.bold(ink.text(card.title ?? '')) : (card.title ?? '');
|
|
984
|
+
cell = `${mark}${refInk(card, ink)(card.ref)} ${title}`;
|
|
822
985
|
} else if (row === 0 && !column.cards.length) {
|
|
823
986
|
cell = ink.muted(' —');
|
|
824
987
|
}
|
|
@@ -828,6 +991,51 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
|
828
991
|
return lines.map((line) => padVisible(clip(line, width), width));
|
|
829
992
|
}
|
|
830
993
|
|
|
994
|
+
/**
|
|
995
|
+
* The preview panel directly below the roadmap strip while a card in the strip
|
|
996
|
+
* has focus (`b` / `/board`): its full untruncated title and its description,
|
|
997
|
+
* wrapped to `width`.
|
|
998
|
+
*
|
|
999
|
+
* @param card `{ ref, title, slug, status, statusDisplay?, severity?, section?, body? }`
|
|
1000
|
+
* @returns `{ lines, top, max, room }`
|
|
1001
|
+
*/
|
|
1002
|
+
export function boardPreviewLines(width, maxRows, card, ink, { top = 0, loading = false } = {}) {
|
|
1003
|
+
const inner = Math.max(1, width - 2);
|
|
1004
|
+
const colLabel = BOARD_COLUMNS.find(([s]) => s === columnOf(card.status))?.[1]
|
|
1005
|
+
?? card.statusDisplay ?? card.status ?? '';
|
|
1006
|
+
const sev = card.severity ? String(card.severity).toLowerCase() : null;
|
|
1007
|
+
const headParts = [card.ref, colLabel, sev, card.section, card.slug].filter(Boolean);
|
|
1008
|
+
const fullName = `${card.ref ?? ''} ${card.title ?? ''}`.trim();
|
|
1009
|
+
const titleRows = wordWrap(fullName, inner).map((line) => ` ${ink.bold(ink.text(line))}`);
|
|
1010
|
+
const rawBody = card.body !== undefined && card.body !== null ? String(card.body).trim() : null;
|
|
1011
|
+
const descRows = rawBody
|
|
1012
|
+
? wordWrap(rawBody, inner).map((line) => (line ? ` ${line}` : ''))
|
|
1013
|
+
: [ink.muted(loading ? ' reading description…' : ' no description')];
|
|
1014
|
+
const allRows = [...titleRows, ...descRows];
|
|
1015
|
+
const maxBody = Math.max(2, maxRows - 3);
|
|
1016
|
+
const room = Math.max(2, Math.min(maxBody, allRows.length));
|
|
1017
|
+
const max = Math.max(0, allRows.length - room);
|
|
1018
|
+
const at = Math.min(Math.max(0, top), max);
|
|
1019
|
+
const shown = allRows.slice(at, at + room);
|
|
1020
|
+
const where = max > 0 ? ` · ${at + 1}–${at + shown.length} of ${allRows.length}` : '';
|
|
1021
|
+
const footer = keyList(
|
|
1022
|
+
['←→↑↓ move', ...(max > 0 ? ['space/PgDn scroll'] : []), 'p plan', 'd develop', 'v full card', 'esc sessions'],
|
|
1023
|
+
'',
|
|
1024
|
+
inner,
|
|
1025
|
+
ink,
|
|
1026
|
+
);
|
|
1027
|
+
return {
|
|
1028
|
+
lines: windowLines(
|
|
1029
|
+
{ width, height: room + 3 },
|
|
1030
|
+
{ title: `${headParts.join(' · ')}${where}`, body: shown, footer, focused: true },
|
|
1031
|
+
ink,
|
|
1032
|
+
),
|
|
1033
|
+
top: at,
|
|
1034
|
+
max,
|
|
1035
|
+
room,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
831
1039
|
/** A column's cards: its issues first, most severe first, then its roadmap cards, each in the order given. */
|
|
832
1040
|
function inColumn(cards, status) {
|
|
833
1041
|
const here = cards.filter((card) => columnOf(card.status) === status);
|