cawdev-cli 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/finish-check.mjs +9 -1
- package/lib/gate-condition.mjs +11 -0
- package/lib/sonar-analysis.mjs +55 -9
- package/lib/stage-tools.mjs +33 -2
- package/mcp/server.mjs +44 -12
- package/package.json +1 -1
- package/runner/README.md +294 -33
- package/runner/attach.mjs +1927 -123
- package/runner/card-import.mjs +115 -0
- package/runner/cawdev.mjs +70 -1
- package/runner/diff-view.mjs +252 -0
- package/runner/git-lock.mjs +73 -0
- package/runner/runner.mjs +508 -39
- package/runner/session-cards.mjs +660 -0
- package/runner/shots.mjs +214 -0
- package/runner/sign-in.mjs +29 -2
- package/runner/split.mjs +307 -74
package/runner/split.mjs
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
// Advanced mode — the attached terminal split into one window per session,
|
|
2
2
|
// emacs-style, with the client's own prompt at the bottom.
|
|
3
3
|
//
|
|
4
|
-
// **R81
|
|
5
|
-
// reason
|
|
6
|
-
// search and copy stop working and the transcript above the fold is gone.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// **R81 chose the scrollback as the default, and R624 changes the default.**
|
|
5
|
+
// R81's reason 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. With
|
|
7
|
+
// several sessions and a roadmap on one machine that reason no longer wins, so
|
|
8
|
+
// `cawdev` opens HERE wherever the terminal can draw it (`advancedBlocker`: a
|
|
9
|
+
// TTY, not TERM=dumb, 80×24 at least); `cawdev --simple` and `/simple` are the
|
|
10
|
+
// scrollback terminal (`scrollback.mjs`, untouched), one command away, and the
|
|
11
|
+
// choice is remembered in prefs.mjs. It is drawn on the terminal's ALTERNATE
|
|
9
12
|
// screen — the buffer vim and emacs use — which leaves the normal screen and
|
|
10
13
|
// its scrollback exactly as they were. Leaving the mode puts them back, and
|
|
11
14
|
// what was said on the watched session meanwhile is laid into the scrollback
|
|
12
|
-
// then, so nothing is lost by having looked at it
|
|
15
|
+
// then (`/export` does it on demand), so nothing is lost by having looked at it
|
|
16
|
+
// this way.
|
|
13
17
|
//
|
|
14
18
|
// Everything that decides a picture is a pure function — the layout, one
|
|
15
19
|
// window, the whole frame — so it can be rendered at four sizes and READ,
|
|
16
20
|
// which is how scrollback.mjs's own layout bugs were found. The class at the
|
|
17
21
|
// bottom only enters, leaves, and writes the rows that changed.
|
|
18
22
|
|
|
19
|
-
import { clip, padVisible, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
23
|
+
import { clip, keyList, padVisible, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
24
|
+
import { staleLockOf } from './git-lock.mjs';
|
|
20
25
|
|
|
21
26
|
const ESC = '\x1b';
|
|
22
27
|
const ALT_ON = `${ESC}[?1049h`;
|
|
@@ -132,7 +137,10 @@ export function windowLines(rect, {
|
|
|
132
137
|
const label = title ? ` ${clip(title, Math.max(0, inner - 3))} ` : '';
|
|
133
138
|
const top = paint(tl + h) + label + paint(h.repeat(Math.max(0, inner - 1 - visibleWidth(label))) + tr);
|
|
134
139
|
|
|
135
|
-
|
|
140
|
+
// A footer is a row, or several: keys that do not fit one wrap onto the next
|
|
141
|
+
// rather than being cut off.
|
|
142
|
+
const footers = (Array.isArray(footer) ? footer : [footer]).filter(Boolean);
|
|
143
|
+
const footerRows = footers.length;
|
|
136
144
|
const subtitleRows = subtitle ? 1 : 0;
|
|
137
145
|
const rows = Math.max(0, height - 2 - footerRows - subtitleRows);
|
|
138
146
|
const tail = tailOf(body, inner, rows);
|
|
@@ -145,8 +153,8 @@ export function windowLines(rect, {
|
|
|
145
153
|
for (let i = 0; i < rows; i++) {
|
|
146
154
|
lines.push(paint(v) + padVisible(clip(tail[i] ?? '', inner), inner) + RESET + paint(v));
|
|
147
155
|
}
|
|
148
|
-
|
|
149
|
-
lines.push(paint(v) + padVisible(clip(
|
|
156
|
+
for (const row of footers) {
|
|
157
|
+
lines.push(paint(v) + padVisible(clip(row, inner), inner) + RESET + paint(v));
|
|
150
158
|
}
|
|
151
159
|
lines.push(paint(bl + h.repeat(inner) + br));
|
|
152
160
|
return lines.slice(0, height);
|
|
@@ -406,6 +414,14 @@ export function subtitleFor(meta, width) {
|
|
|
406
414
|
return `${project} · ${shown}`;
|
|
407
415
|
}
|
|
408
416
|
|
|
417
|
+
/**
|
|
418
|
+
* What a window says while its run is stopped at SHOW — R605. Pure, so the
|
|
419
|
+
* singular is pinned once.
|
|
420
|
+
*/
|
|
421
|
+
export function showWaitingLine(gate, count) {
|
|
422
|
+
return `waiting for you — ${count} screenshot${count === 1 ? '' : 's'}, round ${gate.round} · a to look and decide`;
|
|
423
|
+
}
|
|
424
|
+
|
|
409
425
|
/**
|
|
410
426
|
* A window frame's colour, from where its session stands: `live` (red) while
|
|
411
427
|
* it runs or waits, `done` (green) once it FINISHED — a plan planned, a dev
|
|
@@ -429,6 +445,9 @@ export function toneFor(meta) {
|
|
|
429
445
|
* What can be done about a session now — the window's last line. Empty while
|
|
430
446
|
* it runs: the prompt, the answer and the permission keys are the prompt's.
|
|
431
447
|
*
|
|
448
|
+
* A session that stopped on a git lock a crash left behind offers `/clean`
|
|
449
|
+
* beside `/continue` (git-lock.mjs).
|
|
450
|
+
*
|
|
432
451
|
* A finished PLAN on a card offers its development — `d`, in its place. A finished session that
|
|
433
452
|
* wrote a branch offers the pull request until one is known, then shows it.
|
|
434
453
|
* Every ended window can be closed.
|
|
@@ -440,6 +459,10 @@ export function actionsFor(meta) {
|
|
|
440
459
|
const view = meta.view;
|
|
441
460
|
const parts = [];
|
|
442
461
|
if (view?.canCarryOn && !meta.carryAsked) {
|
|
462
|
+
// A lock a crash left behind fails /continue the same way again.
|
|
463
|
+
if (!meta.lockCleaned && staleLockOf(view.exitSummary, view.workspace ?? meta.workspace)) {
|
|
464
|
+
parts.push('/clean removes the stale git lock');
|
|
465
|
+
}
|
|
443
466
|
parts.push('/continue picks it back up here');
|
|
444
467
|
} else if (meta.carryAsked) {
|
|
445
468
|
parts.push('continue asked for — back when this machine takes it');
|
|
@@ -459,12 +482,18 @@ export function actionsFor(meta) {
|
|
|
459
482
|
} else if (view?.branch && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED') {
|
|
460
483
|
parts.push('/pr opens the pull request');
|
|
461
484
|
}
|
|
485
|
+
// A finished development on a card has a branch to read and decide on here.
|
|
486
|
+
if (view?.entryRef && view.branch && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED'
|
|
487
|
+
&& !builtNothing(meta)) {
|
|
488
|
+
parts.push('/review');
|
|
489
|
+
}
|
|
490
|
+
// The command alone: a card's last line is short, and /help says what it does.
|
|
462
491
|
if (meta.dropped) {
|
|
463
492
|
parts.push('checkout deleted');
|
|
464
493
|
} else if (isAutoCheckout(view?.workspace) && view?.state !== 'RUNNING' && !view?.live) {
|
|
465
|
-
parts.push('/drop
|
|
494
|
+
parts.push('/drop');
|
|
466
495
|
}
|
|
467
|
-
parts.push('
|
|
496
|
+
parts.push('w closes');
|
|
468
497
|
return parts.join(' · ');
|
|
469
498
|
}
|
|
470
499
|
|
|
@@ -492,22 +521,6 @@ export function isAutoCheckout(path) {
|
|
|
492
521
|
return typeof path === 'string' && /-auto-\d+$/.test(path.replace(/\/+$/, ''));
|
|
493
522
|
}
|
|
494
523
|
|
|
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
524
|
/**
|
|
512
525
|
* A command line as words — on spaces and tabs, with every line break kept as
|
|
513
526
|
* a word of its own (`'\n'`). Splitting on all whitespace turned a pasted list
|
|
@@ -522,16 +535,47 @@ export function wordsText(words) {
|
|
|
522
535
|
return words.join(' ').replace(/ *\n */g, '\n').trim();
|
|
523
536
|
}
|
|
524
537
|
|
|
538
|
+
/**
|
|
539
|
+
* `/new plan R3`, `/new dev R3`, `/new ask what does the importer do?` — and a
|
|
540
|
+
* plan or a dev from a sentence, with no card: `/new plan add a CSV export`,
|
|
541
|
+
* `/new dev fix the sort on the client list`. `@slug` anywhere names the
|
|
542
|
+
* project; without it the caller asks which.
|
|
543
|
+
*
|
|
544
|
+
* One card reference and nothing else is that card; anything else is the task
|
|
545
|
+
* in words. A plan from words writes its card first and plans what it wrote
|
|
546
|
+
* (R150); a dev from words is a manual session, on a branch of its own.
|
|
547
|
+
*
|
|
548
|
+
* R606: several references and nothing else are several cards, in the order
|
|
549
|
+
* written (`R3 R5 R7` or `R3,R5,R7`), started as one batch on this machine.
|
|
550
|
+
* `plan+dev` plans each card and builds it before the next is planned, so it
|
|
551
|
+
* takes cards only — one is a batch of one. `--in-order` puts dev cards on one
|
|
552
|
+
* branch, each run from the last one's commits, and `--branch <name>` names it.
|
|
553
|
+
*
|
|
554
|
+
* @param words what followed `/new`, split on spaces
|
|
555
|
+
* @param projects the slugs this machine serves
|
|
556
|
+
* @returns `{ kind, slug, ref }` for a card, `{ kind, slug, refs }` for
|
|
557
|
+
* several (with `inOrder` and `branch` when asked for), `{ kind, slug, text }`
|
|
558
|
+
* for words, `{ needsProject, projects }` when no `@project` was written —
|
|
559
|
+
* the project is ALWAYS asked, never guessed — or `{ error }` in words.
|
|
560
|
+
*/
|
|
525
561
|
export function parseNew(words, { projects = [] } = {}) {
|
|
526
562
|
const named = words.find((word) => word.startsWith('@'));
|
|
527
|
-
const
|
|
563
|
+
const at = words.indexOf('--branch');
|
|
564
|
+
const branch = at >= 0 ? words[at + 1] : undefined;
|
|
565
|
+
const inOrder = words.includes('--in-order');
|
|
566
|
+
const rest = words.filter((word, index) => word !== named && word !== '' && word !== '--in-order'
|
|
567
|
+
&& (at < 0 || (index !== at && index !== at + 1)));
|
|
528
568
|
const [kindWord, ...args] = rest;
|
|
529
|
-
const kind = { plan: 'plan', dev: 'dev', code: 'dev', ask: 'ask'
|
|
530
|
-
|
|
531
|
-
|
|
569
|
+
const kind = { plan: 'plan', dev: 'dev', code: 'dev', ask: 'ask', 'plan+dev': 'plan+dev', 'plan+code': 'plan+dev' }[
|
|
570
|
+
String(kindWord ?? '').toLowerCase()];
|
|
571
|
+
const usage = 'use /new plan R3, /new dev R3, /new plan|dev <the task in words>, /new ask <question>'
|
|
572
|
+
+ ' or several cards: /new plan|dev|plan+dev R3 R5 R7 (and @project to pick one)';
|
|
532
573
|
if (!kind) {
|
|
533
574
|
return { error: kindWord ? `no such session kind: ${kindWord} — ${usage}` : usage };
|
|
534
575
|
}
|
|
576
|
+
if (at >= 0 && (!branch || branch.startsWith('-') || branch.startsWith('@'))) {
|
|
577
|
+
return { error: '--branch what? /new dev R3 R5 --in-order --branch <name>' };
|
|
578
|
+
}
|
|
535
579
|
const slug = named ? named.slice(1) : null;
|
|
536
580
|
if (slug && projects.length && !projects.includes(slug)) {
|
|
537
581
|
return { error: `this machine does not serve ${slug} — it serves ${projects.join(', ')}` };
|
|
@@ -539,14 +583,38 @@ export function parseNew(words, { projects = [] } = {}) {
|
|
|
539
583
|
const text = wordsText(args);
|
|
540
584
|
if (!text) {
|
|
541
585
|
return { error: kind === 'ask' ? 'ask what? /new ask <question>'
|
|
542
|
-
: `${kind} what? /new ${kind} R3
|
|
586
|
+
: `${kind} what? /new ${kind} R3${kind === 'plan+dev' ? ' R5' : `, or /new ${kind} <the task in words>`}` };
|
|
587
|
+
}
|
|
588
|
+
// A card is written R3 and an issue i3 — EntryRef's spelling; a bare number
|
|
589
|
+
// is a roadmap card, as it is everywhere else. Commas only part references:
|
|
590
|
+
// a sentence with a comma in it stays the sentence it was.
|
|
591
|
+
const pieces = args.flatMap((word) => word.split(','));
|
|
592
|
+
const refs = kind !== 'ask' && pieces.filter(Boolean).length
|
|
593
|
+
&& pieces.every((word) => word === '' || /^[RrIi]?\d+$/.test(word))
|
|
594
|
+
? pieces.filter(Boolean).map((word) => {
|
|
595
|
+
const digits = word.replace(/^[RrIi]/, '');
|
|
596
|
+
return /^[Ii]/.test(word) ? `i${digits}` : `R${digits}`;
|
|
597
|
+
})
|
|
598
|
+
: null;
|
|
599
|
+
const twice = refs?.find((ref, index) => refs.indexOf(ref) !== index);
|
|
600
|
+
if (twice) {
|
|
601
|
+
return { error: `${twice} is on the line twice — a card is planned or built once per batch` };
|
|
602
|
+
}
|
|
603
|
+
if (kind === 'plan+dev' && !refs) {
|
|
604
|
+
return { error: 'plan+dev takes cards: /new plan+dev R3 R5 R7 — /card writes one first' };
|
|
605
|
+
}
|
|
606
|
+
const several = refs && (refs.length > 1 || kind === 'plan+dev');
|
|
607
|
+
if ((inOrder || at >= 0) && !(several && kind === 'dev')) {
|
|
608
|
+
return { error: '--in-order and --branch are for several dev cards: /new dev R3 R5 --in-order' };
|
|
609
|
+
}
|
|
610
|
+
if (at >= 0 && !inOrder) {
|
|
611
|
+
return { error: '--branch names the one branch of --in-order; without it each card has its own' };
|
|
543
612
|
}
|
|
544
613
|
let what;
|
|
545
|
-
if (
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
what = { kind, ref: /^[Ii]/.test(args[0]) ? `i${digits}` : `R${digits}` };
|
|
614
|
+
if (several) {
|
|
615
|
+
what = { kind, refs, ...(inOrder ? { inOrder } : {}), ...(branch ? { branch } : {}) };
|
|
616
|
+
} else if (refs) {
|
|
617
|
+
what = { kind, ref: refs[0] };
|
|
550
618
|
} else {
|
|
551
619
|
what = { kind, text };
|
|
552
620
|
}
|
|
@@ -561,6 +629,37 @@ export function parseNew(words, { projects = [] } = {}) {
|
|
|
561
629
|
return { ...what, slug };
|
|
562
630
|
}
|
|
563
631
|
|
|
632
|
+
/**
|
|
633
|
+
* What a batch started from `/new` did, in lines — R606. Both halves, as the
|
|
634
|
+
* console's dialog says them: what went, in the order it will run, and each
|
|
635
|
+
* card the platform skipped with its reason. A batch that started nothing
|
|
636
|
+
* says so first, so the skipped lines read as the reason.
|
|
637
|
+
*
|
|
638
|
+
* @param asked what `parseNew` returned (`kind`, `inOrder`)
|
|
639
|
+
* @param outcome the API's `StartedBatch` (`started` runs, `refused` cards)
|
|
640
|
+
*/
|
|
641
|
+
export function batchStartedLines(asked, outcome, slug, agent = null) {
|
|
642
|
+
const started = outcome?.started ?? [];
|
|
643
|
+
const refs = started.map((run) => run.entryRef ?? `#${run.entryNumber}`);
|
|
644
|
+
const where = agent ? `on ${slug} with ${agent}` : `on ${slug}`;
|
|
645
|
+
const lines = [];
|
|
646
|
+
if (!started.length) {
|
|
647
|
+
lines.push(`nothing started on ${slug}`);
|
|
648
|
+
} else if (asked.kind === 'plan+dev') {
|
|
649
|
+
lines.push(`planning then building ${refs.join(' → ')} ${where}, one card at a time`);
|
|
650
|
+
} else if (asked.kind === 'plan') {
|
|
651
|
+
lines.push(`planning ${refs.join(' → ')} ${where}, in order`);
|
|
652
|
+
} else if (asked.inOrder) {
|
|
653
|
+
lines.push(`building ${refs.join(' → ')} ${where}, in order on ${started[0].branch}`);
|
|
654
|
+
} else {
|
|
655
|
+
lines.push(`building ${refs.join(', ')} ${where}, a branch each, queued on this machine`);
|
|
656
|
+
}
|
|
657
|
+
for (const card of outcome?.refused ?? []) {
|
|
658
|
+
lines.push(`skipped ${card.ref ?? `#${card.entryNumber}`}: ${card.reason}`);
|
|
659
|
+
}
|
|
660
|
+
return lines;
|
|
661
|
+
}
|
|
662
|
+
|
|
564
663
|
/**
|
|
565
664
|
* The branch a dev from words works on: `task-` and the first words of the
|
|
566
665
|
* task, accents folded, and a short suffix so two tasks worded alike do not
|
|
@@ -701,36 +800,14 @@ export const MIN_COLUMN = 18;
|
|
|
701
800
|
const STRIP_HEAD = 2;
|
|
702
801
|
|
|
703
802
|
/**
|
|
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
|
|
803
|
+
* The boards and columns the strip fits into `width`, in left-to-right order.
|
|
804
|
+
* Shared by {@link roadmapStrip} and {@link stripColumns} so keyboard focus
|
|
805
|
+
* walks the exact columns drawn on screen.
|
|
729
806
|
*/
|
|
730
|
-
|
|
807
|
+
function stripBoards(width, cards, projects = null) {
|
|
731
808
|
const slugs = projects?.length ? [...projects] : [...new Set(cards.map((card) => card.slug))];
|
|
732
809
|
if (!slugs.length) {
|
|
733
|
-
return
|
|
810
|
+
return { slugs: [], shown: [], hiddenProjects: 0 };
|
|
734
811
|
}
|
|
735
812
|
const boards = slugs.map((slug) => {
|
|
736
813
|
const mine = cards.filter((card) => card.slug === slug);
|
|
@@ -783,6 +860,84 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
|
783
860
|
}
|
|
784
861
|
board.width = board.columns.reduce((sum, column) => sum + column.width, 0) + board.columns.length - 1;
|
|
785
862
|
}
|
|
863
|
+
return { slugs, shown, hiddenProjects };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The non-empty columns drawn on the roadmap strip at `width`, left to right:
|
|
868
|
+
* `[{ slug, status, label, cards }]`. Empty columns (`—`) are skipped so
|
|
869
|
+
* left/right navigation always lands on a card.
|
|
870
|
+
*/
|
|
871
|
+
export function stripColumns(width, cards, projects = null) {
|
|
872
|
+
const { shown } = stripBoards(width, cards, projects);
|
|
873
|
+
return shown.flatMap((board) => board.columns
|
|
874
|
+
.filter((column) => column.cards.length > 0)
|
|
875
|
+
.map((column) => ({ slug: board.slug, status: column.status, label: column.label, cards: column.cards })));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Step the board focus by `(dCol, dRow)` across `columns` (`stripColumns`).
|
|
880
|
+
* Moving horizontally keeps the row index clamped to the destination column's
|
|
881
|
+
* cards; moving vertically wraps within the current column.
|
|
882
|
+
*/
|
|
883
|
+
export function moveInStrip(columns, focus, dCol = 0, dRow = 0) {
|
|
884
|
+
if (!columns.length) return null;
|
|
885
|
+
let colAt = focus ? columns.findIndex((col) => col.slug === focus.slug
|
|
886
|
+
&& col.cards.some((card) => card.ref === focus.ref)) : -1;
|
|
887
|
+
let rowAt = colAt >= 0 ? columns[colAt].cards.findIndex((card) => card.ref === focus.ref) : 0;
|
|
888
|
+
if (colAt < 0) {
|
|
889
|
+
colAt = 0;
|
|
890
|
+
rowAt = 0;
|
|
891
|
+
if (dCol === 0 && dRow === 0) {
|
|
892
|
+
return { slug: columns[0].slug, ref: columns[0].cards[0].ref };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (dCol !== 0) {
|
|
896
|
+
colAt = (colAt + dCol + columns.length) % columns.length;
|
|
897
|
+
rowAt = Math.min(rowAt, columns[colAt].cards.length - 1);
|
|
898
|
+
} else if (dRow !== 0) {
|
|
899
|
+
const count = columns[colAt].cards.length;
|
|
900
|
+
rowAt = (rowAt + dRow + count) % count;
|
|
901
|
+
}
|
|
902
|
+
return { slug: columns[colAt].slug, ref: columns[colAt].cards[rowAt].ref };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* The roadmap across the top of advanced mode: every project this machine
|
|
907
|
+
* serves, each as a small board — its columns side by side, one line per
|
|
908
|
+
* card, a `●` on a card with a session running now, and `❯` on the card
|
|
909
|
+
* focused when browsing the strip (`focus`).
|
|
910
|
+
*
|
|
911
|
+
* THE ROOM RUNS OUT TWO WAYS, and each is said rather than cut:
|
|
912
|
+
* - **Too many cards for the rows** — a column ends in `+N more` on its last
|
|
913
|
+
* row, never a card cut in half, and the header keeps the true count. When
|
|
914
|
+
* browsing a column past the fold, its window walks with `focus` so every
|
|
915
|
+
* card in the column can be reached.
|
|
916
|
+
* - **Too many columns for the width** — empty columns go first (the header
|
|
917
|
+
* line of their project still names nothing lost), then whole columns in
|
|
918
|
+
* `GIVES_WAY` order, each named in the project's header as `merged 3
|
|
919
|
+
* hidden`; then whole projects, counted as `+N projects`. A project always
|
|
920
|
+
* keeps one column.
|
|
921
|
+
*
|
|
922
|
+
* Between `STRIP_HEAD + 1` and `maxRows` lines — as many as the fullest
|
|
923
|
+
* column needs, no more — of exactly `width` columns. One line when there is
|
|
924
|
+
* no project at all.
|
|
925
|
+
*
|
|
926
|
+
* ISSUES SHARE THE COLUMNS (`columnOf`) and go first in each, most severe
|
|
927
|
+
* first: a column cut to `+N more` should not hide a critical defect under
|
|
928
|
+
* ideas. Their ref (`i12`) is in the colour of their severity.
|
|
929
|
+
*
|
|
930
|
+
* @param cards `[{ ref, title, slug, status, live?, severity? }]` — a card
|
|
931
|
+
* with a `severity` is an issue
|
|
932
|
+
* @param projects the slugs to show, in order; defaults to those the cards
|
|
933
|
+
* come from
|
|
934
|
+
* @param focus `{ slug, ref }` when browsing the strip, else null
|
|
935
|
+
*/
|
|
936
|
+
export function roadmapStrip(width, maxRows, cards, ink, projects = null, focus = null) {
|
|
937
|
+
const { slugs, shown, hiddenProjects } = stripBoards(width, cards, projects);
|
|
938
|
+
if (!slugs.length) {
|
|
939
|
+
return [padVisible(ink.muted(' no project here yet — the roadmap shows here'), width)];
|
|
940
|
+
}
|
|
786
941
|
|
|
787
942
|
// Rows: as many as the fullest column needs, within what the screen gave.
|
|
788
943
|
const fullest = Math.max(1, ...shown.flatMap((board) => board.columns.map((column) => column.cards.length)));
|
|
@@ -805,20 +960,35 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
|
805
960
|
const tail = [...lost, ...more, ...counts].join(' · ');
|
|
806
961
|
return `${ink.bold(board.slug)}${tail ? ink.muted(` · ${tail}`) : ''}`;
|
|
807
962
|
}));
|
|
808
|
-
lines.push(lineOf((board) => board.columns.map((column) =>
|
|
809
|
-
|
|
810
|
-
column.
|
|
963
|
+
lines.push(lineOf((board) => board.columns.map((column) => {
|
|
964
|
+
const active = Boolean(focus && board.slug === focus.slug
|
|
965
|
+
&& column.cards.some((card) => card.ref === focus.ref));
|
|
966
|
+
const paintLabel = hueOf(column.status, ink);
|
|
967
|
+
const head = active ? ink.strong(paintLabel(column.label)) : paintLabel(column.label);
|
|
968
|
+
return padVisible(clip(`${head} ${ink.muted(String(column.cards.length))}`,
|
|
969
|
+
column.width), column.width);
|
|
970
|
+
}).join(' ')));
|
|
811
971
|
for (let row = 0; row < cardRows; row++) {
|
|
812
972
|
lines.push(lineOf((board) => board.columns.map((column) => {
|
|
973
|
+
const focusedAt = focus && board.slug === focus.slug
|
|
974
|
+
? column.cards.findIndex((card) => card.ref === focus.ref)
|
|
975
|
+
: -1;
|
|
813
976
|
const overflow = column.cards.length > cardRows;
|
|
977
|
+
const atEnd = overflow && focusedAt === column.cards.length - 1 && cardRows > 1;
|
|
978
|
+
const start = !overflow ? 0
|
|
979
|
+
: cardRows === 1 ? Math.max(0, focusedAt)
|
|
980
|
+
: atEnd ? column.cards.length - cardRows
|
|
981
|
+
: focusedAt >= 0 ? Math.max(0, focusedAt - (cardRows - 2)) : 0;
|
|
814
982
|
const last = row === cardRows - 1;
|
|
815
983
|
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
|
-
|
|
984
|
+
if (overflow && last && !atEnd && !(cardRows === 1 && focusedAt >= 0)) {
|
|
985
|
+
cell = ink.muted(`+${column.cards.length - (start + cardRows - 1)} more`);
|
|
986
|
+
} else if (column.cards[start + row]) {
|
|
987
|
+
const card = column.cards[start + row];
|
|
988
|
+
const chosen = focusedAt >= 0 && card.ref === focus.ref;
|
|
989
|
+
const mark = chosen ? ink.bold(ink.accent('❯')) : card.live ? ink.accent('●') : ' ';
|
|
990
|
+
const title = chosen ? ink.bold(ink.text(card.title ?? '')) : (card.title ?? '');
|
|
991
|
+
cell = `${mark}${refInk(card, ink)(card.ref)} ${title}`;
|
|
822
992
|
} else if (row === 0 && !column.cards.length) {
|
|
823
993
|
cell = ink.muted(' —');
|
|
824
994
|
}
|
|
@@ -828,6 +998,51 @@ export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
|
828
998
|
return lines.map((line) => padVisible(clip(line, width), width));
|
|
829
999
|
}
|
|
830
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* The preview panel directly below the roadmap strip while a card in the strip
|
|
1003
|
+
* has focus (`b` / `/board`): its full untruncated title and its description,
|
|
1004
|
+
* wrapped to `width`.
|
|
1005
|
+
*
|
|
1006
|
+
* @param card `{ ref, title, slug, status, statusDisplay?, severity?, section?, body? }`
|
|
1007
|
+
* @returns `{ lines, top, max, room }`
|
|
1008
|
+
*/
|
|
1009
|
+
export function boardPreviewLines(width, maxRows, card, ink, { top = 0, loading = false } = {}) {
|
|
1010
|
+
const inner = Math.max(1, width - 2);
|
|
1011
|
+
const colLabel = BOARD_COLUMNS.find(([s]) => s === columnOf(card.status))?.[1]
|
|
1012
|
+
?? card.statusDisplay ?? card.status ?? '';
|
|
1013
|
+
const sev = card.severity ? String(card.severity).toLowerCase() : null;
|
|
1014
|
+
const headParts = [card.ref, colLabel, sev, card.section, card.slug].filter(Boolean);
|
|
1015
|
+
const fullName = `${card.ref ?? ''} ${card.title ?? ''}`.trim();
|
|
1016
|
+
const titleRows = wordWrap(fullName, inner).map((line) => ` ${ink.bold(ink.text(line))}`);
|
|
1017
|
+
const rawBody = card.body !== undefined && card.body !== null ? String(card.body).trim() : null;
|
|
1018
|
+
const descRows = rawBody
|
|
1019
|
+
? wordWrap(rawBody, inner).map((line) => (line ? ` ${line}` : ''))
|
|
1020
|
+
: [ink.muted(loading ? ' reading description…' : ' no description')];
|
|
1021
|
+
const allRows = [...titleRows, ...descRows];
|
|
1022
|
+
const maxBody = Math.max(2, maxRows - 3);
|
|
1023
|
+
const room = Math.max(2, Math.min(maxBody, allRows.length));
|
|
1024
|
+
const max = Math.max(0, allRows.length - room);
|
|
1025
|
+
const at = Math.min(Math.max(0, top), max);
|
|
1026
|
+
const shown = allRows.slice(at, at + room);
|
|
1027
|
+
const where = max > 0 ? ` · ${at + 1}–${at + shown.length} of ${allRows.length}` : '';
|
|
1028
|
+
const footer = keyList(
|
|
1029
|
+
['←→↑↓ move', ...(max > 0 ? ['space/PgDn scroll'] : []), 'p plan', 'd develop', 'v full card', 'esc sessions'],
|
|
1030
|
+
'',
|
|
1031
|
+
inner,
|
|
1032
|
+
ink,
|
|
1033
|
+
);
|
|
1034
|
+
return {
|
|
1035
|
+
lines: windowLines(
|
|
1036
|
+
{ width, height: room + 3 },
|
|
1037
|
+
{ title: `${headParts.join(' · ')}${where}`, body: shown, footer, focused: true },
|
|
1038
|
+
ink,
|
|
1039
|
+
),
|
|
1040
|
+
top: at,
|
|
1041
|
+
max,
|
|
1042
|
+
room,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
831
1046
|
/** A column's cards: its issues first, most severe first, then its roadmap cards, each in the order given. */
|
|
832
1047
|
function inColumn(cards, status) {
|
|
833
1048
|
const here = cards.filter((card) => columnOf(card.status) === status);
|
|
@@ -993,3 +1208,21 @@ export function cardLines(card, ink, url = null) {
|
|
|
993
1208
|
}
|
|
994
1209
|
return lines;
|
|
995
1210
|
}
|
|
1211
|
+
|
|
1212
|
+
/** Advanced mode's smallest terminal. */
|
|
1213
|
+
export const ADVANCED_MIN_COLUMNS = 80;
|
|
1214
|
+
export const ADVANCED_MIN_ROWS = 24;
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Why a terminal cannot draw advanced mode, in a few words — or null when it
|
|
1218
|
+
* can: a TTY, not `TERM=dumb`, at least 80×24. Pure, so the default is a
|
|
1219
|
+
* test and not a guess.
|
|
1220
|
+
*/
|
|
1221
|
+
export function advancedBlocker({ tty, term, columns, rows }) {
|
|
1222
|
+
if (!tty) return 'no terminal to draw on (a pipe) · /advanced-mode needs a TTY';
|
|
1223
|
+
if (term === 'dumb') return 'TERM=dumb cannot draw it';
|
|
1224
|
+
if ((columns ?? 0) < ADVANCED_MIN_COLUMNS || (rows ?? 0) < ADVANCED_MIN_ROWS) {
|
|
1225
|
+
return `the terminal is ${columns ?? '?'}×${rows ?? '?'}, advanced mode needs ${ADVANCED_MIN_COLUMNS}×${ADVANCED_MIN_ROWS}`;
|
|
1226
|
+
}
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|