@humanbased/crosscheck 1.5.0 → 1.6.0-beta.13

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.
Files changed (53) hide show
  1. package/dist/__tests__/board.test.js +527 -20
  2. package/dist/__tests__/board.test.js.map +1 -1
  3. package/dist/__tests__/lock-ownership.test.d.ts +2 -0
  4. package/dist/__tests__/lock-ownership.test.d.ts.map +1 -0
  5. package/dist/__tests__/lock-ownership.test.js +167 -0
  6. package/dist/__tests__/lock-ownership.test.js.map +1 -0
  7. package/dist/__tests__/vendor-error-summary.test.d.ts +2 -0
  8. package/dist/__tests__/vendor-error-summary.test.d.ts.map +1 -0
  9. package/dist/__tests__/vendor-error-summary.test.js +74 -0
  10. package/dist/__tests__/vendor-error-summary.test.js.map +1 -0
  11. package/dist/cli.js +2 -1
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands/review.d.ts +3 -1
  14. package/dist/commands/review.d.ts.map +1 -1
  15. package/dist/commands/review.js +88 -2
  16. package/dist/commands/review.js.map +1 -1
  17. package/dist/commands/run.d.ts.map +1 -1
  18. package/dist/commands/run.js +22 -5
  19. package/dist/commands/run.js.map +1 -1
  20. package/dist/commands/watch.d.ts.map +1 -1
  21. package/dist/commands/watch.js +27 -23
  22. package/dist/commands/watch.js.map +1 -1
  23. package/dist/github/review-status.d.ts +33 -0
  24. package/dist/github/review-status.d.ts.map +1 -1
  25. package/dist/github/review-status.js +94 -9
  26. package/dist/github/review-status.js.map +1 -1
  27. package/dist/lib/board.d.ts +59 -4
  28. package/dist/lib/board.d.ts.map +1 -1
  29. package/dist/lib/board.js +376 -64
  30. package/dist/lib/board.js.map +1 -1
  31. package/dist/lib/pr-lock.d.ts +37 -1
  32. package/dist/lib/pr-lock.d.ts.map +1 -1
  33. package/dist/lib/pr-lock.js +176 -29
  34. package/dist/lib/pr-lock.js.map +1 -1
  35. package/dist/lib/pr-workflow-state.d.ts +2 -0
  36. package/dist/lib/pr-workflow-state.d.ts.map +1 -1
  37. package/dist/lib/pr-workflow-state.js +15 -0
  38. package/dist/lib/pr-workflow-state.js.map +1 -1
  39. package/dist/lib/tips.d.ts.map +1 -1
  40. package/dist/lib/tips.js +1 -0
  41. package/dist/lib/tips.js.map +1 -1
  42. package/dist/lib/vendor-error-summary.d.ts +19 -0
  43. package/dist/lib/vendor-error-summary.d.ts.map +1 -0
  44. package/dist/lib/vendor-error-summary.js +95 -0
  45. package/dist/lib/vendor-error-summary.js.map +1 -0
  46. package/dist/reviewers/claude.d.ts.map +1 -1
  47. package/dist/reviewers/claude.js +2 -1
  48. package/dist/reviewers/claude.js.map +1 -1
  49. package/dist/reviewers/codex.d.ts.map +1 -1
  50. package/dist/reviewers/codex.js +2 -1
  51. package/dist/reviewers/codex.js.map +1 -1
  52. package/get-started.md +18 -0
  53. package/package.json +1 -1
package/dist/lib/board.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { selectTip } from './tips.js';
3
+ import { oneLine } from './vendor-error-summary.js';
4
+ const OUTCOME_ORDER = ['APPROVE', 'NEEDS WORK', 'BLOCK', 'no verdict', 'skipped', 'error'];
3
5
  // ── Constants ─────────────────────────────────────────────────────────────────
4
6
  const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
7
  const BAR_FILLED = '█';
@@ -27,6 +29,44 @@ function fmtDuration(ms) {
27
29
  function fmtStartTime(epochMs) {
28
30
  return new Date(epochMs).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
29
31
  }
32
+ // Session age in hours and minutes: "3h32m", or "12m" under the hour. Deliberately
33
+ // not fmtDuration: that renders MM:SS shaped output under an hour, which reads as
34
+ // hours:minutes on a long-running watch and understates the session by 60x.
35
+ export function fmtUptime(ms) {
36
+ const totalMin = Math.max(0, Math.floor(ms / 60_000));
37
+ const h = Math.floor(totalMin / 60);
38
+ const m = totalMin % 60;
39
+ return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`;
40
+ }
41
+ /**
42
+ * Whole percentages summing to exactly 100, by the largest-remainder method.
43
+ * Plain rounding drifts — three equal shares render as 33/33/33 — and a
44
+ * distribution that visibly fails to add up reads as a bug in the numbers.
45
+ *
46
+ * A non-zero count too small to earn a point comes back as 0; the caller
47
+ * renders those as "<1%" rather than claiming the outcome never happened.
48
+ */
49
+ export function distribute(counts) {
50
+ const total = counts.reduce((a, b) => a + b, 0);
51
+ if (total === 0)
52
+ return counts.map(() => 0);
53
+ const exact = counts.map(c => (c * 100) / total);
54
+ const out = exact.map(Math.floor);
55
+ // Exact shares sum to 100, so the floors leave fewer whole points than there
56
+ // are entries: one pass in remainder order always places every last one.
57
+ let remaining = 100 - out.reduce((a, b) => a + b, 0);
58
+ const byRemainder = exact
59
+ .map((e, i) => ({ i, rem: e - Math.floor(e) }))
60
+ .filter(({ i }) => counts[i] > 0)
61
+ .sort((a, b) => b.rem - a.rem);
62
+ for (const { i } of byRemainder) {
63
+ if (remaining <= 0)
64
+ break;
65
+ out[i]++;
66
+ remaining--;
67
+ }
68
+ return out;
69
+ }
30
70
  // Format token count as a compact suffix: "(900)", "(1.2K)", "(1.5M)". Returns '' when undefined.
31
71
  export function fmtTokens(n) {
32
72
  if (n == null)
@@ -55,6 +95,116 @@ function stripAnsi(s) {
55
95
  function truncate(s, max) {
56
96
  return s.length <= max ? s : s.slice(0, max - 1) + '…';
57
97
  }
98
+ // Cut a rendered line to `max` visible columns, keeping the ANSI sequences it
99
+ // passes over (they have no width) and closing with a reset so a cut mid-colour
100
+ // cannot bleed into the next line. Folded rows are clamped with this so they
101
+ // always occupy exactly one terminal row — history pagination counts on it.
102
+ function truncateVisible(s, max) {
103
+ if (max <= 0)
104
+ return '';
105
+ if (stripAnsi(s).length <= max)
106
+ return s;
107
+ let out = '';
108
+ let width = 0;
109
+ for (let i = 0; i < s.length; i++) {
110
+ const ch = s[i];
111
+ if (ch === '\x1B') {
112
+ const end = s.indexOf('m', i);
113
+ if (end === -1)
114
+ break;
115
+ out += s.slice(i, end + 1);
116
+ i = end;
117
+ continue;
118
+ }
119
+ if (width >= max - 1)
120
+ break;
121
+ out += ch;
122
+ width++;
123
+ }
124
+ return out + '…\x1B[0m';
125
+ }
126
+ // Lines the runner hands to its log callback are prefixed ⚠ or ✗ when they
127
+ // report something an operator must act on. Everything else is progress
128
+ // narration that the board's own PR rows already carry, and a multi-line
129
+ // message is a dump (an unposted review, a dry-run comment) that always
130
+ // follows one of those notices. Only these earn a line of terminal scrollback
131
+ // beside the board; the rest goes to the file log.
132
+ // eslint-disable-next-line no-control-regex -- matching the ESC byte is the point
133
+ const NOTICE_PREFIX = /^(?:\x1B\[[0-9;]*m)*\s*[⚠✗]/;
134
+ export function isNoticeLine(msg) {
135
+ return NOTICE_PREFIX.test(msg) || msg.includes('\n');
136
+ }
137
+ // Bare `←`/`→` and `<`/`>` (with their unshifted `,`/`.`) always work.
138
+ // Terminals cannot agree on how to report ctrl/cmd with a punctuation key, so
139
+ // every encoding they do emit is accepted: meta-prefixed, CSI-u (kitty / xterm
140
+ // modifyOtherKeys) and modified arrows.
141
+ //
142
+ // macOS needs the extra encodings on the end. It never forwards cmd, and fn+←/→
143
+ // scrolls the terminal's own scrollback rather than reaching the process, so the
144
+ // arrows are the shortcut that works there — including option+←/→, which
145
+ // Terminal.app sends as the readline words `ESC b` / `ESC f` and iTerm2 sends as
146
+ // a doubled escape. `ESC O D/C` is the application-cursor form of a bare arrow,
147
+ // which is what Terminal.app emits while an alternate-screen app has the tty.
148
+ const PAGE_OLDER_KEYS = new Set([
149
+ '<', ',', '\u001b<', '\u001b,', '\u001b[D', '\u001b[5~',
150
+ '\u001bb', '\u001b\u001b[D', '\u001bOD',
151
+ ]);
152
+ const PAGE_NEWER_KEYS = new Set([
153
+ '>', '.', '\u001b>', '\u001b.', '\u001b[C', '\u001b[6~',
154
+ '\u001bf', '\u001b\u001b[C', '\u001bOC',
155
+ ]);
156
+ // eslint-disable-next-line no-control-regex -- the ESC byte is the sequence
157
+ const CSI_U = /^\u001b\[(\d+);\d+u$/; // ESC [ <codepoint> ; <modifiers> u
158
+ // eslint-disable-next-line no-control-regex -- the ESC byte is the sequence
159
+ const CSI_ARROW = /^\u001b\[1;\d+([DC])$/; // ESC [ 1 ; <modifiers> D|C
160
+ /** Map a raw stdin key sequence to a page direction, or null when it is not a page key. */
161
+ export function pageKeyAction(seq) {
162
+ if (PAGE_OLDER_KEYS.has(seq))
163
+ return 'older';
164
+ if (PAGE_NEWER_KEYS.has(seq))
165
+ return 'newer';
166
+ const csiU = CSI_U.exec(seq);
167
+ if (csiU) {
168
+ const codepoint = Number(csiU[1]);
169
+ if (codepoint === 44 || codepoint === 60)
170
+ return 'older'; // , <
171
+ if (codepoint === 46 || codepoint === 62)
172
+ return 'newer'; // . >
173
+ return null;
174
+ }
175
+ const arrow = CSI_ARROW.exec(seq);
176
+ if (arrow)
177
+ return arrow[1] === 'D' ? 'older' : 'newer';
178
+ return null;
179
+ }
180
+ // Keep every active slot plus the newest `budget` settled ones. Anything older
181
+ // cannot fit the live page (one row minimum per slot), so the fitting loop need
182
+ // never consider it.
183
+ function trimToBudget(pool, budget) {
184
+ const settled = pool.filter(s => s.completedAt !== undefined);
185
+ if (settled.length <= budget)
186
+ return pool.slice();
187
+ const keep = new Set(settled.slice(settled.length - budget));
188
+ return pool.filter(s => s.completedAt === undefined || keep.has(s));
189
+ }
190
+ /**
191
+ * The one outcome a settled slot counts toward. A recheck verdict supersedes
192
+ * the review verdict — it is the later word on the same PR — and `null` from
193
+ * either step means a reviewer ran but returned nothing parseable, which is a
194
+ * different event from never having run at all.
195
+ */
196
+ function outcomeOf(slot) {
197
+ if (slot.error !== undefined)
198
+ return 'error';
199
+ const final = typeof slot.recheckVerdict === 'string'
200
+ ? slot.recheckVerdict
201
+ : typeof slot.verdict === 'string' ? slot.verdict : null;
202
+ if (final === 'APPROVE' || final === 'NEEDS WORK' || final === 'BLOCK')
203
+ return final;
204
+ if (slot.recheckVerdict === null || slot.verdict === null)
205
+ return 'no verdict';
206
+ return 'skipped';
207
+ }
58
208
  function makeBar(filled, total, fillFn, emptyFn) {
59
209
  const f = Math.max(0, Math.min(total, Math.round(filled)));
60
210
  return fillFn(BAR_FILLED.repeat(f)) + emptyFn(BAR_EMPTY.repeat(total - f));
@@ -154,7 +304,7 @@ function buildTheme(cfg) {
154
304
  const CONN_LOG_MAX = 6; // max connectivity log lines kept in memory
155
305
  const COMPACT_CONN_LOG_LINES = 2; // conn log lines shown when the live block must shrink to fit the viewport
156
306
  const FOLD_THRESHOLD = 3; // when completed count exceeds this, fold all completed PRs to 1 line
157
- const WORKSPACE_MAX = 25; // when total slots exceed this, evict oldest completed to scrollback
307
+ const HISTORY_MAX = 2000; // hard cap on retained slots oldest settled rows drop out beyond it
158
308
  const LAYOUT_NORMAL = { foldAll: false, connLogLines: CONN_LOG_MAX, showTip: true };
159
309
  const LAYOUT_COMPACT = { foldAll: true, connLogLines: COMPACT_CONN_LOG_LINES, showTip: false };
160
310
  export class PRBoard {
@@ -165,6 +315,10 @@ export class PRBoard {
165
315
  liveContent = '';
166
316
  isTTY = Boolean(process.stdout.isTTY);
167
317
  connLog = [];
318
+ page = 0; // 0 = live page (newest); higher = further back in history
319
+ pageCount = 1; // recomputed every render; page keys clamp against it
320
+ keyHandler = null;
321
+ stdinWasRaw = false;
168
322
  stats = {
169
323
  prsReceived: 0,
170
324
  crsCompleted: 0,
@@ -172,6 +326,7 @@ export class PRBoard {
172
326
  errorsOccurred: 0,
173
327
  crTotalMs: 0,
174
328
  sessionStart: Date.now(),
329
+ outcomes: { 'APPROVE': 0, 'NEEDS WORK': 0, 'BLOCK': 0, 'no verdict': 0, 'skipped': 0, 'error': 0 },
175
330
  };
176
331
  tunnel = {
177
332
  type: 'none', url: null, alive: false,
@@ -195,6 +350,7 @@ export class PRBoard {
195
350
  start() {
196
351
  if (!this.isTTY)
197
352
  return;
353
+ this.attachKeys();
198
354
  this.timer = setInterval(() => {
199
355
  this.frameIdx = (this.frameIdx + 1) % FRAMES.length;
200
356
  this.redraw();
@@ -205,18 +361,34 @@ export class PRBoard {
205
361
  clearInterval(this.timer);
206
362
  this.timer = null;
207
363
  }
364
+ this.detachKeys();
208
365
  this.eraseLive();
209
366
  }
367
+ /** Flip one page toward older history. No-op when already at the oldest page. */
368
+ pageOlder() {
369
+ const next = Math.min(this.page + 1, Math.max(0, this.pageCount - 1));
370
+ if (next === this.page)
371
+ return;
372
+ this.page = next;
373
+ this.redraw();
374
+ }
375
+ /** Flip one page toward the live page. No-op when already on it. */
376
+ pageNewer() {
377
+ if (this.page === 0)
378
+ return;
379
+ this.page--;
380
+ this.redraw();
381
+ }
210
382
  addPR(key, prNumber, repo, branch, round) {
211
383
  // When a new round starts for a PR that already has a completed slot in the
212
- // workspace, evict the prior-round slot to scrollback so only the current
213
- // round is shown. Prior-round slots always have a different key (different
214
- // SHA suffix) but the same prNumber + repo combination.
384
+ // workspace, mark the prior-round slot superseded so only the current round
385
+ // shows on the live page. It stays in the map: history pages still carry it.
386
+ // Prior-round slots always have a different key (different SHA suffix) but
387
+ // the same prNumber + repo combination.
215
388
  if ((round ?? 1) >= 2) {
216
389
  for (const [existingKey, slot] of this.slots) {
217
390
  if (existingKey !== key && slot.prNumber === prNumber && slot.repo === repo && slot.completedAt !== undefined) {
218
- this.printStatic(this.renderPRSlotFolded(slot));
219
- this.slots.delete(existingKey);
391
+ slot.superseded = true;
220
392
  }
221
393
  }
222
394
  }
@@ -271,19 +443,37 @@ export class PRBoard {
271
443
  }
272
444
  if (fixCount !== undefined && fixCount > 0)
273
445
  this.stats.fixesApplied++;
446
+ this.stats.outcomes[outcomeOf(slot)]++;
274
447
  // Non-TTY has no live block to re-render — emit the folded line to scrollback and drop the slot.
275
448
  if (!this.isTTY) {
276
449
  process.stdout.write(this.renderPRSlotFolded(slot) + '\n');
277
450
  this.slots.delete(key);
278
451
  }
279
452
  }
453
+ /**
454
+ * Settle a slot that ended in an error. The slot stays in the workspace as a
455
+ * settled row, the same as a completed one: a failed run is a session record,
456
+ * and deleting it here was what pushed reviewer timeouts out of the table and
457
+ * into raw scrollback, where a long watch session showed 43 errors above an
458
+ * empty workspace reading "no PRs yet".
459
+ */
280
460
  failPR(key, error) {
281
461
  const slot = this.slots.get(key);
282
- this.slots.delete(key);
283
462
  this.stats.errorsOccurred++;
284
- if (slot) {
285
- const ts = fmtTime();
286
- this.printStatic(`${chalk.dim(ts)} PR #${slot.prNumber} ${chalk.red('✗')} ${error}`);
463
+ if (!slot || slot.completedAt !== undefined)
464
+ return;
465
+ slot.completedAt = Date.now();
466
+ // A failure message is whatever the thrower had — a subprocess dump can
467
+ // arrive with embedded newlines and hundreds of columns. The folded row is
468
+ // clamped to one terminal row and history pagination counts on that, so the
469
+ // text is flattened here rather than at render time. The untouched error is
470
+ // in the file log.
471
+ slot.error = oneLine(error, 160);
472
+ slot.label = 'failed';
473
+ this.stats.outcomes.error++;
474
+ if (!this.isTTY) {
475
+ process.stdout.write(this.renderPRSlotFolded(slot) + '\n');
476
+ this.slots.delete(key);
287
477
  }
288
478
  }
289
479
  /** Print 1–2 static lines to scrollback (above the live block). */
@@ -353,14 +543,43 @@ export class PRBoard {
353
543
  return t.barCRNeedsWork;
354
544
  }
355
545
  // ── Private: render ────────────────────────────────────────────────────────
356
- uptime() {
357
- const totalSec = Math.floor((Date.now() - this.stats.sessionStart) / 1000);
358
- const h = Math.floor(totalSec / 3600);
359
- const m = Math.floor((totalSec % 3600) / 60);
360
- const s = totalSec % 60;
361
- if (h > 0)
362
- return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
363
- return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
546
+ /** "since 08:25 PM · up 3h32m" — when watch started, and how long it has run. */
547
+ sessionRow() {
548
+ const t = this.theme;
549
+ const started = fmtStartTime(this.stats.sessionStart);
550
+ const age = fmtUptime(Date.now() - this.stats.sessionStart);
551
+ return `${t.dim('since')} ${started} ${t.dim('·')} ${t.dim('up')} ${age}`;
552
+ }
553
+ /**
554
+ * Outcome shares across every PR that settled this session, e.g.
555
+ * "APPROVE 45% · BLOCK 30% · error 25%". Empty when nothing has settled yet.
556
+ */
557
+ outcomeRow() {
558
+ const t = this.theme;
559
+ const counts = OUTCOME_ORDER.map(o => this.stats.outcomes[o]);
560
+ const total = counts.reduce((a, b) => a + b, 0);
561
+ if (total === 0)
562
+ return '';
563
+ const pcts = distribute(counts);
564
+ const mixed = counts.filter(c => c > 0).length > 1;
565
+ const parts = OUTCOME_ORDER.map((outcome, i) => {
566
+ if (counts[i] === 0)
567
+ return null;
568
+ // A share too small to earn a whole point still happened; "<1%" says so
569
+ // where "0%" would read as never. Its complement is then not a whole 100
570
+ // either — "error 100%" beside "APPROVE <1%" contradicts itself — so the
571
+ // rounded-up bucket reads ">99%" whenever some other outcome is non-zero.
572
+ const pct = pcts[i] === 0 ? '<1%'
573
+ : pcts[i] === 100 && mixed ? '>99%'
574
+ : `${pcts[i]}%`;
575
+ const paint = outcome === 'APPROVE' ? t.barCRApprove
576
+ : outcome === 'BLOCK' ? t.barCRBlock
577
+ : outcome === 'NEEDS WORK' ? t.barCRNeedsWork
578
+ : outcome === 'error' ? t.error
579
+ : t.dim;
580
+ return `${paint(outcome)} ${t.dim(pct)}`;
581
+ }).filter((p) => p !== null);
582
+ return parts.join(t.dim(' · '));
364
583
  }
365
584
  statsRow() {
366
585
  const { prsReceived, crsCompleted, fixesApplied, errorsOccurred, crTotalMs } = this.stats;
@@ -523,29 +742,62 @@ export class PRBoard {
523
742
  render() {
524
743
  if (!this.config)
525
744
  return '';
526
- // When the workspace overflows, evict oldest completed slots to scrollback.
527
- this.evictOverflow();
745
+ this.trimHistory();
528
746
  const w = process.stdout.columns || 80;
529
747
  // The live block must never be taller than the viewport: lines that scroll
530
748
  // out the top cannot be erased on the next frame (cursor-up clamps at the
531
749
  // viewport top), leaving permanent duplicates in scrollback. Reserve one
532
750
  // row for the trailing newline writeLive appends.
533
751
  const budget = (process.stdout.rows || 24) - 1;
534
- const normal = this.renderLayout(w, LAYOUT_NORMAL);
535
- if (this.countRenderedLines(normal, w) <= budget)
536
- return normal;
537
- let compact = this.renderLayout(w, LAYOUT_COMPACT);
538
- // Still too tall: flush oldest completed slots to scrollback until it fits.
539
- while (this.countRenderedLines(compact, w) > budget && this.evictOldestCompleted()) {
540
- compact = this.renderLayout(w, LAYOUT_COMPACT);
752
+ const entries = [...this.slots.values()];
753
+ // The live page carries every active slot plus the newest settled ones that
754
+ // fit. What it cannot show is not dropped — it moves into the history pages.
755
+ const live = this.fitLivePage(entries.filter(s => s.superseded !== true), w, budget);
756
+ const onLivePage = new Set(live.visible);
757
+ const history = entries.filter(s => !onLivePage.has(s)); // oldest → newest
758
+ // History rows are always folded, and a folded row is clamped to one
759
+ // terminal row, so a history page holds exactly the rows the panels leave.
760
+ const perPage = Math.max(1, budget - this.chromeRows(w, LAYOUT_COMPACT));
761
+ this.pageCount = 1 + Math.ceil(history.length / perPage);
762
+ this.page = Math.max(0, Math.min(this.page, this.pageCount - 1));
763
+ if (this.page === 0)
764
+ return this.fitToBudget(this.renderLayout(w, live.opts, live.visible, entries.length), w, budget);
765
+ // Page 1 is the newest history page, so it ends where the live page begins.
766
+ const end = history.length - (this.page - 1) * perPage;
767
+ const slice = history.slice(Math.max(0, end - perPage), end);
768
+ return this.fitToBudget(this.renderLayout(w, LAYOUT_COMPACT, slice, entries.length), w, budget);
769
+ }
770
+ /**
771
+ * Pick the slots the live page shows: the expanded layout when it fits, then
772
+ * the compact one, then compact with the oldest settled slots handed over to
773
+ * history until the block fits the viewport. Active slots are never handed
774
+ * over — a running review must stay on screen.
775
+ */
776
+ fitLivePage(pool, w, budget) {
777
+ // Bound the work: a slot costs at least one row, so a settled slot older
778
+ // than the last `budget` of them can never fit on the live page anyway.
779
+ const visible = trimToBudget(pool, budget);
780
+ const fits = (opts) => this.countRenderedLines(this.renderLayout(w, opts, visible, pool.length), w) <= budget;
781
+ if (fits(LAYOUT_NORMAL))
782
+ return { visible, opts: LAYOUT_NORMAL };
783
+ while (!fits(LAYOUT_COMPACT)) {
784
+ const oldestSettled = visible.findIndex(s => s.completedAt !== undefined);
785
+ if (oldestSettled === -1)
786
+ break; // only active slots left — truncateTop takes it from here
787
+ visible.splice(oldestSettled, 1);
541
788
  }
542
- if (this.countRenderedLines(compact, w) <= budget)
543
- return compact;
544
- // Only active slots remain and they still overflow (tiny terminal):
545
- // drop rows from the top, keeping the most recent activity visible.
546
- return this.truncateTop(compact, w, budget);
789
+ return { visible, opts: LAYOUT_COMPACT };
547
790
  }
548
- renderLayout(w, opts) {
791
+ /** Rows the panels and footer cost, i.e. everything but the PR rows. */
792
+ chromeRows(w, opts) {
793
+ return this.countRenderedLines(this.renderLayout(w, opts, [], this.slots.size), w);
794
+ }
795
+ fitToBudget(content, w, budget) {
796
+ return this.countRenderedLines(content, w) <= budget
797
+ ? content
798
+ : this.truncateTop(content, w, budget);
799
+ }
800
+ renderLayout(w, opts, visible, total) {
549
801
  const t = this.theme;
550
802
  // Use w-1 to prevent the exact-terminal-width cursor wrap ambiguity that
551
803
  // causes the first char of the next line to appear at the end of the separator.
@@ -555,20 +807,25 @@ export class PRBoard {
555
807
  sep,
556
808
  ...this.renderStatsPanel(opts.connLogLines, opts.showTip),
557
809
  sep,
558
- ...this.renderPRWorkspace(opts.foldAll),
810
+ ...this.renderPRWorkspace(visible, opts.foldAll, w),
559
811
  sep,
812
+ // Clamped: the footer is counted as exactly one row when sizing a page.
813
+ truncateVisible(this.renderFooter(visible.length, total), w - 1),
560
814
  ].join('\n');
561
815
  }
562
- /** Evict the oldest completed slot to scrollback. Returns false when none left. */
563
- evictOldestCompleted() {
816
+ /** Drop the oldest settled slots once the session history outgrows the cap. */
817
+ trimHistory() {
818
+ if (this.slots.size <= HISTORY_MAX)
819
+ return;
820
+ let excess = this.slots.size - HISTORY_MAX;
564
821
  for (const [key, slot] of this.slots) {
822
+ if (excess <= 0)
823
+ break;
565
824
  if (slot.completedAt === undefined)
566
- continue;
567
- this.printStatic(this.renderPRSlotFolded(slot));
825
+ continue; // never drop an active slot
568
826
  this.slots.delete(key);
569
- return true;
827
+ excess--;
570
828
  }
571
- return false;
572
829
  }
573
830
  /** Drop rows from the top until the content (plus an indicator line) fits the budget. */
574
831
  truncateTop(content, w, budget) {
@@ -601,7 +858,11 @@ export class PRBoard {
601
858
  renderStatsPanel(connLogLines, showTip) {
602
859
  const t = this.theme;
603
860
  const lines = [];
604
- lines.push(` ${this.statsRow()} ${t.dim('│')} ${t.dim('↑')} ${this.uptime()}`);
861
+ lines.push(` ${this.statsRow()}`);
862
+ // Session line: start time + age, and the outcome split when anything has
863
+ // settled. One row, because every row here costs a PR row on the live page.
864
+ const outcomes = this.outcomeRow();
865
+ lines.push(` ${this.sessionRow()}${outcomes ? ` ${t.dim('│')} ${outcomes}` : ''}`);
605
866
  const { type: tunnelType, url, alive } = this.tunnel;
606
867
  if (tunnelType !== 'none') {
607
868
  const tunnelLabel = tunnelType === 'serve' ? 'endpoint:' : 'tunnel: ';
@@ -630,14 +891,14 @@ export class PRBoard {
630
891
  const formatted = parts.map(p => p.startsWith('`') ? t.accent(p) : t.dim(p)).join('');
631
892
  return ` ${badge}${formatted}`;
632
893
  }
633
- renderPRWorkspace(foldAll) {
894
+ renderPRWorkspace(visible, foldAll, w) {
634
895
  const t = this.theme;
635
896
  const frame = FRAMES[this.frameIdx];
636
- if (this.slots.size === 0) {
637
- return [t.dim(' waiting for PRs...')];
897
+ if (visible.length === 0) {
898
+ return this.slots.size === 0 ? [t.dim(' waiting for PRs...')] : [];
638
899
  }
639
900
  let completedCount = 0;
640
- for (const slot of this.slots.values()) {
901
+ for (const slot of visible) {
641
902
  if (slot.completedAt !== undefined)
642
903
  completedCount++;
643
904
  }
@@ -645,7 +906,7 @@ export class PRBoard {
645
906
  const lines = [];
646
907
  let prevWasExpanded = false;
647
908
  let first = true;
648
- for (const slot of this.slots.values()) {
909
+ for (const slot of visible) {
649
910
  const isCompleted = slot.completedAt !== undefined;
650
911
  // Sticky fold: once a completed slot folds because the count crossed
651
912
  // FOLD_THRESHOLD, keep it folded. Otherwise a later recheck round drops
@@ -655,9 +916,14 @@ export class PRBoard {
655
916
  // should re-expand.
656
917
  if (isCompleted && foldByCount)
657
918
  slot.stickyFolded = true;
658
- const useFolded = isCompleted && (foldAll || slot.stickyFolded === true);
919
+ // A failed slot is always folded: its pipeline bars are frozen wherever the
920
+ // run died ("CR queued", "Fix queued"), which describes work that will
921
+ // never happen. The folded row carries the error instead.
922
+ const useFolded = isCompleted && (foldAll || slot.stickyFolded === true || slot.error !== undefined);
659
923
  if (useFolded) {
660
- lines.push(this.renderPRSlotFolded(slot));
924
+ // Clamped so the row never wraps: history pagination sizes a page by
925
+ // counting one terminal row per folded slot.
926
+ lines.push(truncateVisible(this.renderPRSlotFolded(slot), w - 1));
661
927
  prevWasExpanded = false;
662
928
  }
663
929
  else {
@@ -670,6 +936,24 @@ export class PRBoard {
670
936
  }
671
937
  return lines;
672
938
  }
939
+ /** Footer: where in the retained history this page sits, and how to move. */
940
+ renderFooter(shown, total) {
941
+ const t = this.theme;
942
+ if (total === 0)
943
+ return t.dim(' no PRs yet');
944
+ const position = this.page === 0
945
+ ? `${t.success('live')}${this.pageCount > 1 ? t.dim(` · page 1/${this.pageCount}`) : ''}`
946
+ : t.dim(`history · page ${this.page + 1}/${this.pageCount}`);
947
+ // "shown of retained", not of stats.prsReceived: rounds add rows, and the
948
+ // history cap eventually drops the oldest, so the two counts diverge.
949
+ const counts = t.dim(`showing ${shown} of ${total}`);
950
+ // Arrows first: they are the one binding every terminal forwards, and on
951
+ // macOS the ctrl+punctuation form never arrives at all.
952
+ const keys = this.pageCount > 1
953
+ ? ` ${t.dim('│')} ${t.accent('←')} ${t.dim('older')} ${t.accent('→')} ${t.dim('newer')}`
954
+ : '';
955
+ return ` ${position} ${t.dim('│')} ${counts}${keys}`;
956
+ }
673
957
  // ── Folded PR slot ─────────────────────────────────────────────────────────
674
958
  renderPRSlotFolded(slot) {
675
959
  const t = this.theme;
@@ -697,29 +981,57 @@ export class PRBoard {
697
981
  const rFn = this.crLabelFn(slot.recheckVerdict);
698
982
  parts.push(`recheck ${rFn(slot.recheckVerdict)}`);
699
983
  }
984
+ // A failed run reports the error in place of the verdict trail: whatever the
985
+ // pipeline had reached is what it never got to finish, and the reason it
986
+ // stopped is the only thing worth the row.
987
+ if (slot.error !== undefined) {
988
+ parts.push(t.error(slot.error));
989
+ }
700
990
  const urlPart = slot.url ? ` ${t.dim('→')} ${t.accent(slot.url)}` : '';
701
991
  const partsStr = parts.length > 0 ? parts.join(t.dim(' · ')) : t.dim('—');
702
- return ` ${t.success('')} ${t.dim(`#${slot.prNumber}`)} ${t.dim(slot.repo)} ${t.dim(branch)} ${partsStr} ${t.dim(`(${elapsed})`)}${urlPart}`;
703
- }
704
- // ── Overflow eviction ──────────────────────────────────────────────────────
705
- evictOverflow() {
706
- if (this.slots.size <= WORKSPACE_MAX)
707
- return;
708
- let toEvict = this.slots.size - WORKSPACE_MAX;
709
- for (const [key, slot] of this.slots) {
710
- if (toEvict <= 0)
711
- break;
712
- if (slot.completedAt === undefined)
713
- continue; // never evict active
714
- this.printStatic(this.renderPRSlotFolded(slot));
715
- this.slots.delete(key);
716
- toEvict--;
717
- }
992
+ const icon = slot.error !== undefined ? t.error('') : t.success('✓');
993
+ return ` ${icon} ${t.dim(`#${slot.prNumber}`)} ${t.dim(slot.repo)} ${t.dim(branch)} ${partsStr} ${t.dim(`(${elapsed})`)}${urlPart}`;
718
994
  }
719
995
  redraw() {
720
996
  const content = this.render();
721
997
  if (content)
722
998
  this.writeLive(content);
723
999
  }
1000
+ // ── Private: key input ─────────────────────────────────────────────────────
1001
+ attachKeys() {
1002
+ const stdin = process.stdin;
1003
+ if (this.keyHandler || !stdin.isTTY)
1004
+ return;
1005
+ this.stdinWasRaw = stdin.isRaw === true;
1006
+ stdin.setRawMode(true);
1007
+ stdin.resume();
1008
+ const handler = (data) => {
1009
+ const seq = data.toString('utf8');
1010
+ // Raw mode suppresses the terminal's own ctrl-c → SIGINT translation, so
1011
+ // the board has to raise it itself or the daemon becomes unkillable.
1012
+ if (seq === '\u0003') {
1013
+ process.kill(process.pid, 'SIGINT');
1014
+ return;
1015
+ }
1016
+ const action = pageKeyAction(seq);
1017
+ if (action === 'older')
1018
+ this.pageOlder();
1019
+ else if (action === 'newer')
1020
+ this.pageNewer();
1021
+ };
1022
+ stdin.on('data', handler);
1023
+ this.keyHandler = handler;
1024
+ }
1025
+ detachKeys() {
1026
+ if (!this.keyHandler)
1027
+ return;
1028
+ process.stdin.off('data', this.keyHandler);
1029
+ this.keyHandler = null;
1030
+ // Hand the terminal back the way it was found: the idle-issue flow and the
1031
+ // interactive prompts read stdin themselves while the board is stopped.
1032
+ if (process.stdin.isTTY && !this.stdinWasRaw)
1033
+ process.stdin.setRawMode(false);
1034
+ process.stdin.pause();
1035
+ }
724
1036
  }
725
1037
  //# sourceMappingURL=board.js.map