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/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 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
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 this way.
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
- const footerRows = footer ? 1 : 0;
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
- if (footer) {
149
- lines.push(paint(v) + padVisible(clip(footer, inner), inner) + RESET + paint(v));
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 deletes its checkout');
494
+ parts.push('/drop');
466
495
  }
467
- parts.push('/close');
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 rest = words.filter((word) => word !== named && word !== '');
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' }[String(kindWord ?? '').toLowerCase()];
530
- const usage = 'use /new plan R3, /new dev R3, /new plan|dev <the task in words> or /new ask <question>'
531
- + ' (and @project to pick one)';
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, or /new ${kind} <the task in words>` };
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 (kind !== 'ask' && args.length === 1 && /^[RrIi]?\d+$/.test(args[0])) {
546
- // A card is written R3 and an issue i3 — EntryRef's spelling; a bare
547
- // number is a roadmap card, as it is everywhere else.
548
- const digits = args[0].replace(/^[RrIi]/, '');
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 roadmap across the top of advanced mode: every project this machine
705
- * serves, each as a small board — its columns side by side, one line per
706
- * card, a `●` on a card with a session running now.
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
- export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
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 [padVisible(ink.muted(' no project here yet — the roadmap shows here'), width)];
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
- padVisible(clip(`${hueOf(column.status, ink)(column.label)} ${ink.muted(String(column.cards.length))}`,
810
- column.width), column.width)).join(' ')));
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 mark = card.live ? ink.accent('●') : ' ';
821
- cell = `${mark}${refInk(card, ink)(card.ref)} ${card.title ?? ''}`;
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
+ }