@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.
- package/dist/__tests__/board.test.js +527 -20
- package/dist/__tests__/board.test.js.map +1 -1
- package/dist/__tests__/lock-ownership.test.d.ts +2 -0
- package/dist/__tests__/lock-ownership.test.d.ts.map +1 -0
- package/dist/__tests__/lock-ownership.test.js +167 -0
- package/dist/__tests__/lock-ownership.test.js.map +1 -0
- package/dist/__tests__/vendor-error-summary.test.d.ts +2 -0
- package/dist/__tests__/vendor-error-summary.test.d.ts.map +1 -0
- package/dist/__tests__/vendor-error-summary.test.js +74 -0
- package/dist/__tests__/vendor-error-summary.test.js.map +1 -0
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/review.d.ts +3 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +88 -2
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +22 -5
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +27 -23
- package/dist/commands/watch.js.map +1 -1
- package/dist/github/review-status.d.ts +33 -0
- package/dist/github/review-status.d.ts.map +1 -1
- package/dist/github/review-status.js +94 -9
- package/dist/github/review-status.js.map +1 -1
- package/dist/lib/board.d.ts +59 -4
- package/dist/lib/board.d.ts.map +1 -1
- package/dist/lib/board.js +376 -64
- package/dist/lib/board.js.map +1 -1
- package/dist/lib/pr-lock.d.ts +37 -1
- package/dist/lib/pr-lock.d.ts.map +1 -1
- package/dist/lib/pr-lock.js +176 -29
- package/dist/lib/pr-lock.js.map +1 -1
- package/dist/lib/pr-workflow-state.d.ts +2 -0
- package/dist/lib/pr-workflow-state.d.ts.map +1 -1
- package/dist/lib/pr-workflow-state.js +15 -0
- package/dist/lib/pr-workflow-state.js.map +1 -1
- package/dist/lib/tips.d.ts.map +1 -1
- package/dist/lib/tips.js +1 -0
- package/dist/lib/tips.js.map +1 -1
- package/dist/lib/vendor-error-summary.d.ts +19 -0
- package/dist/lib/vendor-error-summary.d.ts.map +1 -0
- package/dist/lib/vendor-error-summary.js +95 -0
- package/dist/lib/vendor-error-summary.js.map +1 -0
- package/dist/reviewers/claude.d.ts.map +1 -1
- package/dist/reviewers/claude.js +2 -1
- package/dist/reviewers/claude.js.map +1 -1
- package/dist/reviewers/codex.d.ts.map +1 -1
- package/dist/reviewers/codex.js +2 -1
- package/dist/reviewers/codex.js.map +1 -1
- package/get-started.md +18 -0
- 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
|
|
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,
|
|
213
|
-
//
|
|
214
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
563
|
-
|
|
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
|
-
|
|
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()}
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|