cawdev-cli 1.2.0 → 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.
@@ -50,7 +50,12 @@ export class Session {
50
50
  }
51
51
  }
52
52
 
53
- async request(path, { method = 'GET', body } = {}) {
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 deletes its checkout');
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 rest = words.filter((word) => word !== named && word !== '');
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' }[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)';
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, or /new ${kind} <the task in words>` };
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 (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}` };
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 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
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
- export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
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 [padVisible(ink.muted(' no project here yet — the roadmap shows here'), width)];
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
- padVisible(clip(`${hueOf(column.status, ink)(column.label)} ${ink.muted(String(column.cards.length))}`,
810
- column.width), column.width)).join(' ')));
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 mark = card.live ? ink.accent('●') : ' ';
821
- cell = `${mark}${refInk(card, ink)(card.ref)} ${card.title ?? ''}`;
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);