pi-bro 0.21.0 → 0.22.0

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.
@@ -0,0 +1,1272 @@
1
+ import {
2
+ Input,
3
+ Markdown,
4
+ matchesKey,
5
+ truncateToWidth,
6
+ visibleWidth,
7
+ wrapTextWithAnsi,
8
+ } from "@earendil-works/pi-tui";
9
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ findingText,
12
+ guideEvidence,
13
+ type Evidence,
14
+ type Finding,
15
+ type ReviewRecord,
16
+ } from "./review.ts";
17
+
18
+ export type ReviewView = {
19
+ selected: string;
20
+ screen: "reading" | "file" | "discussion" | "finding";
21
+ file: string;
22
+ target: string;
23
+ pane: "contents" | "detail";
24
+ offsets: Record<string, number>;
25
+ drafts: Record<string, string>;
26
+ row: number;
27
+ scope?: string;
28
+ returnTo?: {
29
+ selected: string;
30
+ screen: "reading" | "file" | "finding";
31
+ file: string;
32
+ target: string;
33
+ row: number;
34
+ };
35
+ };
36
+ export const newReviewView = (): ReviewView => ({
37
+ selected: "overview",
38
+ screen: "reading",
39
+ file: "",
40
+ target: "whole review",
41
+ pane: "contents",
42
+ offsets: {},
43
+ drafts: {},
44
+ row: 0,
45
+ });
46
+ export type ReviewControls = {
47
+ ask(target: string, question: string): void;
48
+ prepare(): void;
49
+ regenerate(): void;
50
+ retrySave(): void;
51
+ stop(): void;
52
+ close(): void;
53
+ changed(): void;
54
+ copy(text: string): void;
55
+ };
56
+ type Tui = { terminal: { rows: number; columns?: number }; requestRender(): void };
57
+ // Structural subset keeps normalized mouse support compatible with the pinned pre-mouse SDK.
58
+ type ReviewMouseEvent = { type: string; x: number; y: number; wheelDelta?: number };
59
+ type Theme = { fg: (name: any, text: string) => string; bold: (text: string) => string };
60
+ export const safeReviewText = (text: string): string =>
61
+ text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
62
+ const label = (text: string) => safeReviewText(text).replace(/[\n\r\t]/g, " ");
63
+
64
+ /** The controller owns requests and saving; this component only owns interaction. */
65
+ export class ReviewModal {
66
+ focused = true;
67
+ busy = false;
68
+ startedAt = 0;
69
+ stage:
70
+ | { step: "start" | "investigate" | "receive" | "check" | "save"; detail: string }
71
+ | undefined;
72
+ operation: "guide" | "answer" | "regenerate" = "guide";
73
+ activeTurn: ReviewRecord["turns"][number] | undefined;
74
+ notice = "";
75
+ saveError = "";
76
+ evidence = new Map<string, string>();
77
+ evidenceErrors = new Map<string, string>();
78
+ private input = new Input();
79
+ private markdown = new Markdown("", 0, 0, getMarkdownTheme());
80
+ private focus: "contents" | "body" | "actions" | "input";
81
+ private action = 0;
82
+ private maxOffset = 0;
83
+ private tiny = false;
84
+ private width = 100;
85
+ private followAnswer = false;
86
+ private fileDiffs: Map<string, string> | undefined;
87
+ private contentsOffset: number | undefined;
88
+ private manualListKey: string | undefined;
89
+ private layout = {
90
+ height: 0,
91
+ bodyRows: 0,
92
+ leftWidth: 0,
93
+ navigationOnly: false,
94
+ contentsStart: 0,
95
+ bodyStart: 4,
96
+ };
97
+ readonly view: ReviewView;
98
+
99
+ constructor(
100
+ public record: ReviewRecord,
101
+ public tui: Tui,
102
+ private theme: Theme,
103
+ private controls: ReviewControls,
104
+ ) {
105
+ this.view = record.ui ??= newReviewView();
106
+ this.focus =
107
+ this.view.pane === "contents"
108
+ ? "contents"
109
+ : this.view.screen === "discussion"
110
+ ? "input"
111
+ : "body";
112
+ this.syncGuideView();
113
+ }
114
+ private entries(): { id: string; title: string }[] {
115
+ return [
116
+ { id: "overview", title: "Overview" },
117
+ ...(this.record.guide?.topics ?? []).map((topic, i) => ({
118
+ id: topic.id,
119
+ title: `${i + 1}. ${topic.title}`,
120
+ })),
121
+ ...(this.record.guide?.assessment
122
+ ? [
123
+ { id: "findings", title: `Findings (${this.record.guide.assessment.findings.length})` },
124
+ ...(this.record.guide.assessment.findings.some((finding) => !finding.topicId)
125
+ ? [{ id: "across", title: "Across the change" }]
126
+ : []),
127
+ { id: "coverage", title: "Coverage and limitations" },
128
+ ]
129
+ : []),
130
+ { id: "files", title: `Changed files (${this.record.snapshot.files.length})` },
131
+ { id: "questions", title: `Your questions (${this.discussions.length})` },
132
+ { id: "close", title: this.busy ? "Stop and close" : "Close" },
133
+ ];
134
+ }
135
+ private get topic() {
136
+ return this.record.guide?.topics.find((topic) => topic.id === this.view.selected);
137
+ }
138
+ private get finding() {
139
+ return this.record.guide?.assessment?.findings.find(
140
+ (finding) => finding.id === this.view.target,
141
+ );
142
+ }
143
+ private get latestTurn() {
144
+ return this.record.turns.filter((turn) => turn.target === this.view.target).at(-1);
145
+ }
146
+ private get stopLabel() {
147
+ return this.operation === "answer"
148
+ ? "Stop response"
149
+ : this.operation === "regenerate"
150
+ ? "Stop regeneration"
151
+ : "Stop preparation";
152
+ }
153
+ private get findings() {
154
+ const rank = { high: 0, medium: 1, low: 2 };
155
+ return [...(this.record.guide?.assessment?.findings ?? [])]
156
+ .filter(
157
+ (finding) =>
158
+ (this.view.selected !== "across" || !finding.topicId) &&
159
+ (!this.view.scope || finding.topicId === this.view.scope),
160
+ )
161
+ .sort(
162
+ (a, b) =>
163
+ Number(a.kind === "question") - Number(b.kind === "question") ||
164
+ rank[a.severity] - rank[b.severity],
165
+ );
166
+ }
167
+ private get isList() {
168
+ return (
169
+ this.view.screen === "reading" &&
170
+ ["files", "questions", "findings", "across"].includes(this.view.selected)
171
+ );
172
+ }
173
+ private get discussions() {
174
+ return [
175
+ ...this.record.turns.map((turn) => ({
176
+ target: turn.target,
177
+ text: turn.question,
178
+ draft: false,
179
+ })),
180
+ ...Object.entries(this.view.drafts)
181
+ .filter(([, text]) => text)
182
+ .map(([target, text]) => ({ target, text, draft: true })),
183
+ ];
184
+ }
185
+ private get listLength() {
186
+ return this.view.selected === "files"
187
+ ? this.record.snapshot.files.length
188
+ : this.view.selected === "questions"
189
+ ? this.discussions.length
190
+ : this.findings.length;
191
+ }
192
+ private get key() {
193
+ return `${this.view.selected}:${this.view.screen}:${["discussion", "finding"].includes(this.view.screen) ? this.view.target : this.view.screen === "file" ? this.view.file : ""}${this.view.scope ? `:topic:${this.view.scope}` : ""}`;
194
+ }
195
+ private targetLabel(target: string): string {
196
+ if (target === "whole review") return "whole review";
197
+ if (target.startsWith("file:")) return target.slice(5);
198
+ return (
199
+ this.record.guide?.topics.find((topic) => topic.id === target)?.title ??
200
+ this.record.guide?.assessment?.findings.find((finding) => finding.id === target)?.title ??
201
+ `Earlier · ${this.record.targetContexts?.[target]?.title ?? target}`
202
+ );
203
+ }
204
+ syncGuideView(): void {
205
+ if (!this.record.guide?.topics.some((topic) => topic.id === this.view.scope))
206
+ delete this.view.scope;
207
+ const origin = this.view.returnTo;
208
+ if (
209
+ origin &&
210
+ (typeof origin !== "object" ||
211
+ !["reading", "file", "finding"].includes(origin.screen) ||
212
+ typeof origin.file !== "string" ||
213
+ typeof origin.target !== "string" ||
214
+ !Number.isInteger(origin.row) ||
215
+ origin.row < 0 ||
216
+ !this.entries().some((entry) => entry.id === origin.selected) ||
217
+ (origin.screen === "finding" &&
218
+ !this.record.guide?.assessment?.findings.some(
219
+ (finding) => finding.id === origin.target,
220
+ )) ||
221
+ (origin.screen === "file" &&
222
+ !this.record.snapshot.files.some((file) => file.path === origin.file)))
223
+ )
224
+ delete this.view.returnTo;
225
+ this.focus =
226
+ this.view.pane === "contents"
227
+ ? "contents"
228
+ : this.view.screen === "discussion"
229
+ ? "input"
230
+ : "body";
231
+ this.input.setValue(this.view.drafts[this.view.target] ?? "");
232
+ this.contentsOffset = undefined;
233
+ this.manualListKey = undefined;
234
+ this.followAnswer = false;
235
+ this.action = 0;
236
+ }
237
+ private actions(): string[] {
238
+ const recovery = this.saveError && this.view.selected !== "close" ? ["Retry saving"] : [];
239
+ if (this.view.selected === "close") return [];
240
+ if (this.view.screen === "discussion")
241
+ return [
242
+ ...recovery,
243
+ this.busy ? this.stopLabel : "Send question",
244
+ ...(!this.busy && this.latestTurn && this.latestTurn.status !== "complete"
245
+ ? ["Edit and resend"]
246
+ : []),
247
+ "Back",
248
+ ];
249
+ const stop = this.busy ? [this.stopLabel] : [];
250
+ const ask = this.busy
251
+ ? []
252
+ : [
253
+ this.view.screen === "finding"
254
+ ? "Ask about this finding"
255
+ : this.view.screen === "file"
256
+ ? "Ask about this file"
257
+ : this.topic
258
+ ? "Ask a question"
259
+ : "Ask about this review",
260
+ ];
261
+ if (this.view.screen === "finding")
262
+ return [...recovery, ...ask, "Copy finding", "Back", ...stop];
263
+ if (this.view.screen === "file") return [...recovery, ...ask, "Back to files", ...stop];
264
+ if (this.isList || this.view.selected === "coverage") return [...recovery, ...stop];
265
+ if (this.topic)
266
+ return [
267
+ ...recovery,
268
+ ...(this.record.guide?.assessment?.findings.some(
269
+ (finding) => finding.topicId === this.topic!.id,
270
+ )
271
+ ? ["View topic findings"]
272
+ : []),
273
+ ...ask,
274
+ ...stop,
275
+ ];
276
+ return [
277
+ ...recovery,
278
+ ...(!this.record.guide && !this.busy ? ["Prepare guide"] : []),
279
+ ...ask,
280
+ ...(this.busy ? stop : this.record.guide ? ["Regenerate guide"] : []),
281
+ ];
282
+ }
283
+ private changed() {
284
+ this.view.pane = this.focus === "contents" ? "contents" : "detail";
285
+ this.controls.changed();
286
+ this.tui.requestRender();
287
+ }
288
+ private select(id: string) {
289
+ this.contentsOffset = undefined;
290
+ this.manualListKey = undefined;
291
+ if (id !== this.view.selected) this.view.row = 0;
292
+ delete this.view.scope;
293
+ delete this.view.returnTo;
294
+ this.notice = "";
295
+ this.view.selected = id;
296
+ this.view.screen = "reading";
297
+ this.action = 0;
298
+ this.maxOffset = 0;
299
+ }
300
+ openDiscussion(target: string) {
301
+ if (this.view.screen !== "discussion")
302
+ this.view.returnTo = {
303
+ selected: this.view.selected,
304
+ screen: this.view.screen,
305
+ file: this.view.file,
306
+ target: this.view.target,
307
+ row: this.view.row,
308
+ };
309
+ this.notice = "";
310
+ this.view.target = target;
311
+ this.followAnswer = false;
312
+ this.view.screen = "discussion";
313
+ this.focus = "input";
314
+ this.input.setValue(this.view.drafts[target] ?? "");
315
+ this.action = 0;
316
+ }
317
+ private back() {
318
+ this.notice = "";
319
+ if (this.view.screen === "discussion" && this.view.returnTo) {
320
+ Object.assign(this.view, this.view.returnTo);
321
+ delete this.view.returnTo;
322
+ this.focus = "body";
323
+ } else if (this.view.screen === "discussion") {
324
+ this.view.screen = this.finding
325
+ ? "finding"
326
+ : this.view.target.startsWith("file:")
327
+ ? "file"
328
+ : "reading";
329
+ if (this.view.screen === "file") {
330
+ this.view.file = this.view.target.slice(5);
331
+ this.view.selected = "files";
332
+ } else
333
+ this.view.selected = this.finding
334
+ ? "findings"
335
+ : this.view.target === "whole review"
336
+ ? "overview"
337
+ : this.record.guide?.topics.some((topic) => topic.id === this.view.target)
338
+ ? this.view.target
339
+ : "questions";
340
+ if (this.finding)
341
+ this.view.row = Math.max(
342
+ 0,
343
+ this.findings.findIndex((finding) => finding.id === this.view.target),
344
+ );
345
+ this.focus = "body";
346
+ } else if (this.view.screen === "file" || this.view.screen === "finding") {
347
+ this.view.screen = "reading";
348
+ this.focus = "body";
349
+ } else if (this.view.scope) {
350
+ const topic = this.view.scope;
351
+ this.select(topic);
352
+ this.focus = "body";
353
+ } else {
354
+ this.focus = "contents";
355
+ }
356
+ this.action = 0;
357
+ }
358
+ private ask() {
359
+ if (this.busy) {
360
+ this.notice = `${this.operation === "answer" ? "An answer" : "Preparation"} is running. Your draft is kept; stop it before sending.`;
361
+ return;
362
+ }
363
+ const question = this.input.getValue().trim();
364
+ if (!question) {
365
+ this.focus = "input";
366
+ return;
367
+ }
368
+ this.input.setValue("");
369
+ this.view.drafts[this.view.target] = "";
370
+ this.followAnswer = true;
371
+ this.controls.ask(this.view.target, question);
372
+ }
373
+ private activate() {
374
+ const action = this.actions()[this.action];
375
+ if (action?.startsWith("Ask"))
376
+ this.openDiscussion(
377
+ this.view.screen === "file"
378
+ ? `file:${this.view.file}`
379
+ : this.view.screen === "finding"
380
+ ? this.view.target
381
+ : (this.topic?.id ?? "whole review"),
382
+ );
383
+ else if (action === "Copy finding" && this.finding)
384
+ this.controls.copy(findingText(this.finding));
385
+ else if (action === "View topic findings") {
386
+ const id = this.topic!.id;
387
+ this.select("findings");
388
+ this.view.scope = id;
389
+ this.view.row = 0;
390
+ this.focus = "body";
391
+ } else if (action === "Send question") this.ask();
392
+ else if (action === "Edit and resend") {
393
+ if (this.view.drafts[this.view.target])
394
+ this.notice =
395
+ "Your current draft is kept. Edit it or clear it before restoring the earlier question.";
396
+ else {
397
+ const question = this.latestTurn?.question ?? "";
398
+ this.input.setValue(question);
399
+ this.view.drafts[this.view.target] = question;
400
+ }
401
+ this.focus = "input";
402
+ } else if (action === this.stopLabel) this.controls.stop();
403
+ else if (action === "Prepare guide") this.controls.prepare();
404
+ else if (action === "Regenerate guide") this.controls.regenerate();
405
+ else if (action === "Retry saving") this.controls.retrySave();
406
+ else if (action === "Back" || action === "Back to files") this.back();
407
+ this.action = 0;
408
+ }
409
+ handleMouse(event: ReviewMouseEvent): { handled: boolean; render: boolean } | undefined {
410
+ if (event.type !== "wheel") return;
411
+ if (
412
+ this.tiny ||
413
+ event.x < 0 ||
414
+ event.x >= this.width ||
415
+ event.y < 0 ||
416
+ event.y >= this.layout.height
417
+ )
418
+ return { handled: true, render: false };
419
+ const { bodyRows, leftWidth, navigationOnly, contentsStart, bodyStart } = this.layout;
420
+ const start = 4;
421
+ const rows = bodyRows + bodyStart - 4;
422
+ if (event.y < start || event.y >= start + rows || event.x === 0 || event.x === this.width - 1)
423
+ return { handled: true, render: false };
424
+ const delta = Math.trunc(event.wheelDelta ?? 0);
425
+ if (!delta) return { handled: true, render: false };
426
+ if (navigationOnly || (leftWidth && event.x <= leftWidth)) {
427
+ this.contentsOffset = Math.max(
428
+ 0,
429
+ Math.min(
430
+ Math.max(0, this.entries().length - bodyRows),
431
+ (this.contentsOffset ?? contentsStart) + delta,
432
+ ),
433
+ );
434
+ } else {
435
+ if (leftWidth && event.x < leftWidth + 4) return { handled: true, render: false };
436
+ this.scroll(delta);
437
+ this.manualListKey = this.key;
438
+ this.controls.changed();
439
+ }
440
+ this.tui.requestRender();
441
+ return { handled: true, render: true };
442
+ }
443
+ private scroll(delta: number) {
444
+ const offset = Math.max(
445
+ 0,
446
+ Math.min(this.maxOffset, (this.view.offsets[this.key] ?? 0) + delta),
447
+ );
448
+ this.view.offsets[this.key] = offset;
449
+ this.followAnswer =
450
+ this.busy &&
451
+ this.activeTurn?.target === this.view.target &&
452
+ this.view.screen === "discussion" &&
453
+ delta > 0 &&
454
+ offset === this.maxOffset;
455
+ }
456
+ handleInput(data: string): void {
457
+ // Pi 0.84 forwards raw wheel input to focused overlays; newer hosts use handleMouse.
458
+ const mouse = /^\u001b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
459
+ if (mouse) {
460
+ const button = Number(mouse[1]);
461
+ if (button & 64 && (button & 3) < 2 && mouse[4] === "M") {
462
+ const columns = this.tui.terminal.columns;
463
+ if (columns !== undefined)
464
+ this.handleMouse({
465
+ type: "wheel",
466
+ x: Number(mouse[2]) - 1 - Math.floor((columns - this.width) / 2),
467
+ y: Number(mouse[3]) - 1 - Math.floor((this.tui.terminal.rows - this.layout.height) / 2),
468
+ wheelDelta: button & 1 ? 3 : -3,
469
+ });
470
+ }
471
+ return;
472
+ }
473
+ if (this.tiny) {
474
+ if (matchesKey(data, "escape")) this.controls.close();
475
+ return;
476
+ }
477
+ if (matchesKey(data, "escape")) {
478
+ if (this.focus === "contents") {
479
+ this.controls.close();
480
+ return;
481
+ }
482
+ this.back();
483
+ this.changed();
484
+ return;
485
+ }
486
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
487
+ const order: (typeof this.focus)[] = [
488
+ "contents",
489
+ "body",
490
+ ...(this.view.screen === "discussion" ? ["input" as const] : []),
491
+ ...(this.actions().length ? ["actions" as const] : []),
492
+ ];
493
+ this.focus =
494
+ order[
495
+ (order.indexOf(this.focus) + (matchesKey(data, "tab") ? 1 : order.length - 1)) %
496
+ order.length
497
+ ]!;
498
+ } else if (matchesKey(data, "pageDown") || matchesKey(data, "pageUp")) {
499
+ this.render(this.width);
500
+ const delta = (matchesKey(data, "pageDown") ? 1 : -1) * Math.max(1, this.layout.bodyRows - 1);
501
+ if (this.focus === "contents") {
502
+ const entries = this.entries(),
503
+ index = entries.findIndex((entry) => entry.id === this.view.selected);
504
+ this.select(entries[Math.max(0, Math.min(entries.length - 1, index + delta))]!.id);
505
+ } else if (this.isList && this.focus === "body") {
506
+ this.manualListKey = undefined;
507
+ this.view.row = Math.max(
508
+ 0,
509
+ Math.min(Math.max(0, this.listLength - 1), this.view.row + delta),
510
+ );
511
+ } else this.scroll(delta);
512
+ } else if (this.focus === "input") {
513
+ if (matchesKey(data, "return")) this.ask();
514
+ else {
515
+ this.input.handleInput(data);
516
+ this.view.drafts[this.view.target] = this.input.getValue();
517
+ }
518
+ } else if (matchesKey(data, "up") || matchesKey(data, "down")) {
519
+ const delta = matchesKey(data, "down") ? 1 : -1;
520
+ if (this.focus === "contents") {
521
+ const entries = this.entries();
522
+ const index = entries.findIndex((entry) => entry.id === this.view.selected);
523
+ this.select(entries[Math.max(0, Math.min(entries.length - 1, index + delta))]!.id);
524
+ } else if (this.focus === "actions")
525
+ this.action = Math.max(0, Math.min(this.actions().length - 1, this.action + delta));
526
+ else if (this.isList) {
527
+ this.manualListKey = undefined;
528
+ this.view.row = Math.max(
529
+ 0,
530
+ Math.min(Math.max(0, this.listLength - 1), this.view.row + delta),
531
+ );
532
+ } else {
533
+ this.render(this.width);
534
+ this.scroll(delta);
535
+ }
536
+ } else if (matchesKey(data, "return")) {
537
+ if (this.focus === "contents") {
538
+ if (this.view.selected === "close") {
539
+ this.controls.close();
540
+ return;
541
+ }
542
+ this.focus = "body";
543
+ } else if (this.view.selected === "close") {
544
+ this.controls.close();
545
+ return;
546
+ } else if (this.focus === "actions") this.activate();
547
+ else if (this.isList) {
548
+ if (this.view.selected === "files") {
549
+ const file = this.record.snapshot.files[this.view.row];
550
+ if (file) {
551
+ this.view.file = file.path;
552
+ this.view.screen = "file";
553
+ }
554
+ } else if (this.view.selected === "findings" || this.view.selected === "across") {
555
+ const finding = this.findings[this.view.row];
556
+ if (finding) {
557
+ this.view.target = finding.id;
558
+ this.view.screen = "finding";
559
+ }
560
+ } else {
561
+ const discussion = this.discussions[this.view.row];
562
+ if (discussion) this.openDiscussion(discussion.target);
563
+ }
564
+ } else if (this.actions().length) this.focus = "actions";
565
+ }
566
+ this.changed();
567
+ }
568
+ private section(title: string, width: number, color = "accent"): string {
569
+ const text = `── ${label(title)} `;
570
+ return this.theme.fg(
571
+ color,
572
+ truncateToWidth(text + "─".repeat(Math.max(0, width - visibleWidth(text))), width, "…"),
573
+ );
574
+ }
575
+ private codeLine(text: string, gutter: string, width: number, color = "text"): string[] {
576
+ const prefix = `${gutter} │ `;
577
+ const available = Math.max(1, width - visibleWidth(prefix));
578
+ return wrapTextWithAnsi(safeReviewText(text).replace(/\t/g, " "), available).map(
579
+ (part, index) =>
580
+ this.theme.fg("dim", index ? " ".repeat(visibleWidth(gutter)) + " │ " : prefix) +
581
+ this.theme.fg(color, part),
582
+ );
583
+ }
584
+ private diffLines(section: string, width: number): string[] {
585
+ const source = section.split("\n");
586
+ const digits = Math.max(
587
+ 1,
588
+ ...Array.from(section.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm), (match) =>
589
+ Math.max(
590
+ String(Number(match[1]) + Number(match[2] ?? 1)).length,
591
+ String(Number(match[3]) + Number(match[4] ?? 1)).length,
592
+ ),
593
+ ),
594
+ );
595
+ let old = 0,
596
+ next = 0,
597
+ inHunk = false;
598
+ const output: string[] = [];
599
+ for (const line of source) {
600
+ if (line.startsWith("diff --git ")) {
601
+ inHunk = false;
602
+ continue;
603
+ }
604
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
605
+ if (hunk) {
606
+ old = Number(hunk[1]);
607
+ next = Number(hunk[2]);
608
+ inHunk = true;
609
+ output.push(this.section(line, width));
610
+ continue;
611
+ }
612
+ if (!inHunk) {
613
+ if (!/^(diff --git |index |--- |\+\+\+ )/.test(line) && line)
614
+ output.push(...wrapTextWithAnsi(safeReviewText(line), width));
615
+ continue;
616
+ }
617
+ if (!/^[ +\-]/.test(line)) {
618
+ if (line) output.push(...wrapTextWithAnsi(safeReviewText(line), width));
619
+ continue;
620
+ }
621
+ const added = line.startsWith("+"),
622
+ removed = line.startsWith("-");
623
+ const gutter = `${added ? " ".repeat(digits) : String(old++).padStart(digits)} ${removed ? " ".repeat(digits) : String(next++).padStart(digits)}`;
624
+ output.push(
625
+ ...this.codeLine(line, gutter, width, added ? "success" : removed ? "error" : "text"),
626
+ );
627
+ }
628
+ return output;
629
+ }
630
+ private findingBadge(finding: Finding): string {
631
+ return this.theme.bold(
632
+ this.theme.fg(
633
+ finding.kind === "question"
634
+ ? "accent"
635
+ : finding.severity === "high"
636
+ ? "error"
637
+ : finding.severity === "medium"
638
+ ? "warning"
639
+ : "accent",
640
+ `▌ ${finding.severity.toUpperCase()} · ${finding.kind}`,
641
+ ),
642
+ );
643
+ }
644
+ private findingCard(finding: Finding, width: number): string[] {
645
+ const rail = this.theme.fg("border", "│ ");
646
+ return [
647
+ this.findingBadge(finding),
648
+ ...wrapTextWithAnsi(
649
+ this.theme.bold(label(finding.title)),
650
+ Math.max(1, Math.min(width, 72) - 2),
651
+ ).map((line) => rail + line),
652
+ "",
653
+ ];
654
+ }
655
+ private middle(text: string, width: number): string {
656
+ text = label(text);
657
+ if (visibleWidth(text) <= width) return text;
658
+ const tail = Array.from(text);
659
+ let suffix = "";
660
+ while (tail.length && visibleWidth(suffix + tail.at(-1)!) <= Math.max(1, width - 4))
661
+ suffix = tail.pop()! + suffix;
662
+ return truncateToWidth(text, Math.max(1, width - visibleWidth(suffix) - 1), "") + "…" + suffix;
663
+ }
664
+ private listRow(text: string, index: number, width: number): string[] {
665
+ const selected = index === this.view.row;
666
+ const prefix = selected ? "› " : " ";
667
+ const lines = selected
668
+ ? wrapTextWithAnsi(text, Math.max(1, width - 2)).slice(0, 3)
669
+ : [truncateToWidth(text, width - 2, "…")];
670
+ return lines.map(
671
+ (line, i) =>
672
+ (i ? " " : prefix) +
673
+ (selected && this.focus === "body" ? this.theme.fg("accent", line) : line),
674
+ );
675
+ }
676
+ private content(width: number): string[] {
677
+ const prose = (text: string) =>
678
+ wrapTextWithAnsi(safeReviewText(text), Math.max(1, Math.min(width, 72)));
679
+ const quiet = (text: string) => prose(text).map((line) => this.theme.fg("dim", line));
680
+ const markdown = (text: string, size = Math.min(width, 72)) => {
681
+ this.markdown.setText(safeReviewText(text));
682
+ const lines = [...this.markdown.render(size)];
683
+ while (lines.length && !visibleWidth(lines[0]!)) lines.shift();
684
+ while (lines.length && !visibleWidth(lines.at(-1)!)) lines.pop();
685
+ return lines;
686
+ };
687
+ if (this.view.screen === "discussion")
688
+ return [
689
+ ...prose(`Your question · ${this.targetLabel(this.view.target)}`),
690
+ ...(this.record.targetContexts?.[this.view.target]
691
+ ? [
692
+ this.section("ORIGINAL DISCUSSION CONTEXT", width),
693
+ ...markdown(this.record.targetContexts[this.view.target]!.text),
694
+ "",
695
+ ]
696
+ : []),
697
+ ...this.record.turns
698
+ .filter((turn) => turn.target === this.view.target)
699
+ .flatMap((turn) => [
700
+ this.section("YOU", width),
701
+ ...prose(turn.question),
702
+ this.section("BRO · ANSWER", width),
703
+ ...markdown(
704
+ turn.answer ||
705
+ (this.busy && turn === this.activeTurn ? "Working…" : "No answer received."),
706
+ ),
707
+ ...(turn.error
708
+ ? [this.theme.fg("warning", "Request failed"), ...prose(turn.error)]
709
+ : []),
710
+ ...(turn.status !== "complete"
711
+ ? [
712
+ this.busy && turn === this.activeTurn
713
+ ? "Answering…"
714
+ : turn.status === "failed"
715
+ ? "Failed · partial answer kept"
716
+ : "Stopped · partial answer kept",
717
+ ]
718
+ : []),
719
+ "",
720
+ ]),
721
+ ...(this.record.turns.some((turn) => turn.target === this.view.target)
722
+ ? []
723
+ : ["Ask a question below. Nothing is posted to GitHub."]),
724
+ ];
725
+ if (this.view.screen === "file") {
726
+ if (!this.fileDiffs) {
727
+ // Capture disables renames; type changes can have two adjacent sections with the same header.
728
+ const sections: string[] = [];
729
+ let previousHeader = "";
730
+ for (const section of this.record.snapshot.diff
731
+ .split(/(?=^diff --git )/m)
732
+ .filter(Boolean)) {
733
+ if (!section.startsWith("diff --git ")) continue;
734
+ const header = section.split("\n", 1)[0]!;
735
+ if (header === previousHeader) sections[sections.length - 1] += section;
736
+ else {
737
+ sections.push(section);
738
+ previousHeader = header;
739
+ }
740
+ }
741
+ this.fileDiffs = new Map(
742
+ sections.length === this.record.snapshot.files.length
743
+ ? this.record.snapshot.files.map((file, index) => [file.path, sections[index]!])
744
+ : [],
745
+ );
746
+ }
747
+ const section = this.fileDiffs.get(this.view.file);
748
+ return [
749
+ this.section("CHANGES", width),
750
+ ...prose(this.view.file),
751
+ this.theme.fg("dim", "old / new line numbers"),
752
+ ...(section
753
+ ? this.diffLines(section, width)
754
+ : ["Diff inventory mismatch; captured changes cannot be mapped safely."]),
755
+ ];
756
+ }
757
+ if (this.view.screen === "finding") {
758
+ const finding = this.finding;
759
+ if (!finding) return ["Finding no longer available in this guide."];
760
+ const block = (title: string, text: string, color = "accent") => [
761
+ this.theme.bold(this.theme.fg(color, title)),
762
+ "",
763
+ ...markdown(text, Math.max(1, Math.min(width, 64) - 2)).map((line) => " " + line),
764
+ "",
765
+ ];
766
+ return [
767
+ ...wrapTextWithAnsi(
768
+ `${this.findingBadge(finding)} ${this.theme.bold(label(finding.title))}`,
769
+ width,
770
+ ),
771
+ "",
772
+ ...block("What happens", finding.scenario),
773
+ ...block("Why it matters", finding.impact),
774
+ ...block("Suggested fix", finding.fix, "success"),
775
+ ...block("How to check", finding.check, "success"),
776
+ ...block("Reasoning", finding.reasoning),
777
+ ...(finding.uncertainty
778
+ ? block("Uncertainty", finding.uncertainty)
779
+ : quiet("Review note: source assessment only; not independently verified.")),
780
+ "",
781
+ ...(finding.evidence.length
782
+ ? this.evidenceLines(finding.evidence, width)
783
+ : quiet("No resolved source references cited.")),
784
+ "",
785
+ ...quiet(
786
+ "Suggestions are not applied or independently verified. Suggested checks have not been executed by this review.",
787
+ ),
788
+ ...(!finding.evidence.some((ref) => ref.valid)
789
+ ? quiet("No resolved source evidence; treat this finding as unverified.")
790
+ : []),
791
+ ];
792
+ }
793
+ if (this.view.selected === "findings" || this.view.selected === "across")
794
+ return [
795
+ this.section(
796
+ this.view.scope
797
+ ? `Findings · ${this.targetLabel(this.view.scope)}`
798
+ : this.view.selected === "across"
799
+ ? "ACROSS THE CHANGE"
800
+ : "FINDINGS TO EXAMINE",
801
+ width,
802
+ "warning",
803
+ ),
804
+ ...(this.findings.length
805
+ ? this.findings.flatMap((finding, index) =>
806
+ this.listRow(
807
+ `${this.findingBadge(finding)} ${this.theme.bold(label(finding.title))}`,
808
+ index,
809
+ width,
810
+ ),
811
+ )
812
+ : ["No issues reported in covered material. This is not approval."]),
813
+ ];
814
+ if (this.view.selected === "coverage") {
815
+ const assessment = this.record.guide?.assessment;
816
+ if (!assessment) return ["Quality assessment not performed."];
817
+ const cited = new Set(
818
+ guideEvidence(this.record.guide)
819
+ .filter((ref) => ref.valid)
820
+ .map((ref) => ref.path),
821
+ );
822
+ const list = (title: string, items: string[], empty: string) => [
823
+ this.theme.bold(this.theme.fg("accent", title)),
824
+ "",
825
+ ...(items.length
826
+ ? items.flatMap((item) =>
827
+ wrapTextWithAnsi(safeReviewText(item), Math.max(1, Math.min(width, 64) - 2)).map(
828
+ (line, index) => (index ? " " : "• ") + line,
829
+ ),
830
+ )
831
+ : quiet(empty)),
832
+ "",
833
+ ];
834
+ return [
835
+ this.section("COVERAGE", width),
836
+ "",
837
+ ...prose(
838
+ this.record.snapshot.diff.length > 100_000
839
+ ? "Diff: first 100,000 characters supplied. Full captured diff remains available."
840
+ : "Diff: complete captured diff supplied.",
841
+ ),
842
+ ...quiet("Execution: no project tests independently verified."),
843
+ "",
844
+ ...list("Inspected · model-reported", assessment.inspected, "No inspected paths reported."),
845
+ ...list(
846
+ "Not checked · model-reported",
847
+ assessment.notExamined,
848
+ "No gaps reported; this does not prove complete coverage.",
849
+ ),
850
+ ...list(
851
+ "Changed files without citations",
852
+ this.record.snapshot.files
853
+ .filter((file) => !cited.has(file.path))
854
+ .map((file) => file.path),
855
+ "All changed files have cited evidence.",
856
+ ),
857
+ ...(assessment.warnings.length ? list("Assessment gaps", assessment.warnings, "") : []),
858
+ this.theme.fg("dim", "Reading these results"),
859
+ "",
860
+ ...quiet(
861
+ "Citations show source locations, not proof of full inspection or a finding’s conclusion.",
862
+ ),
863
+ "",
864
+ ...quiet(
865
+ "Automatic preparation is instructed not to execute repository code. This is not an enforced sandbox. The assessment is not independently verified.",
866
+ ),
867
+ ];
868
+ }
869
+ if (this.topic) {
870
+ const findings =
871
+ this.record.guide?.assessment?.findings.filter(
872
+ (finding) => finding.topicId === this.topic!.id,
873
+ ) ?? [];
874
+ return [
875
+ ...prose(this.topic.title).map((line) => this.theme.bold(line)),
876
+ "",
877
+ this.section("EXPLANATION", width),
878
+ ...markdown(this.topic.explanation),
879
+ "",
880
+ ...(findings.length
881
+ ? [
882
+ this.section("FINDINGS TO EXAMINE", width, "warning"),
883
+ "",
884
+ ...findings.flatMap((finding) => this.findingCard(finding, width)),
885
+ ...quiet("View topic findings for evidence, fixes and checks."),
886
+ "",
887
+ ]
888
+ : []),
889
+ ...this.evidenceLines(this.topic.evidence, width),
890
+ ];
891
+ }
892
+ if (this.view.selected === "files")
893
+ return [
894
+ "Changed files",
895
+ ...this.record.snapshot.files.flatMap((file, index) =>
896
+ this.listRow(
897
+ `${({ M: "Modified", A: "Added", D: "Deleted", T: "Type changed" } as Record<string, string>)[file.status] ?? file.status} · ${this.middle(file.path, Math.max(8, width - 14))}`,
898
+ index,
899
+ width,
900
+ ),
901
+ ),
902
+ ];
903
+ if (this.view.selected === "questions")
904
+ return [
905
+ "Your questions and drafts",
906
+ ...(this.discussions.length
907
+ ? this.discussions.flatMap((discussion, index) =>
908
+ this.listRow(
909
+ `${discussion.draft ? "Draft · " : ""}${this.middle(this.targetLabel(discussion.target), Math.max(8, Math.floor(width / 2)))}: ${label(discussion.text)}`,
910
+ index,
911
+ width,
912
+ ),
913
+ )
914
+ : ["No questions yet. Ask from the overview, a topic, finding or file."]),
915
+ ];
916
+ if (this.view.selected === "close")
917
+ return [
918
+ this.busy ? "Stop and close" : "Close",
919
+ "",
920
+ ...prose(
921
+ this.busy
922
+ ? `The active ${this.operation === "answer" ? "answer" : this.operation === "regenerate" ? "regeneration" : "preparation"} will stop. Partial work, drafts and reading position will be saved privately.`
923
+ : "Your questions, drafts and reading position are saved privately. Nothing is posted to GitHub.",
924
+ ),
925
+ "",
926
+ "Enter to close.",
927
+ ];
928
+ return [
929
+ this.section("PURPOSE OF THIS CHANGE", width),
930
+ "",
931
+ ...markdown(
932
+ this.record.guide?.summary ??
933
+ (this.busy
934
+ ? "Bro is reading the captured changes. You can browse files while it works."
935
+ : "No guide yet. Prepare it below, or inspect the changed files."),
936
+ ),
937
+ ...(this.record.guide
938
+ ? this.record.guide.assessment
939
+ ? [
940
+ "",
941
+ this.section("FINDINGS TO EXAMINE", width, "warning"),
942
+ "",
943
+ ...this.findings.slice(0, 3).flatMap((finding) => this.findingCard(finding, width)),
944
+ ...quiet(
945
+ this.record.guide.assessment.warnings.length
946
+ ? "Assessment incomplete · See Coverage and limitations."
947
+ : this.findings.length
948
+ ? `Open Findings for all ${this.findings.length} concerns, evidence, fixes and checks.`
949
+ : "No issues reported in covered material. This is not approval.",
950
+ ),
951
+ "",
952
+ this.section("QUALITY ASSESSMENT", width),
953
+ "",
954
+ ...markdown(this.record.guide.assessment.overview),
955
+ "",
956
+ ...quiet(
957
+ "Source assessment · execution not independently verified. See Coverage and limitations.",
958
+ ),
959
+ ]
960
+ : [
961
+ ...prose(
962
+ "Quality assessment not performed. Regenerate guide to assess this captured revision.",
963
+ ),
964
+ ]
965
+ : []),
966
+ ...(!this.busy && this.record.guideAttempt
967
+ ? [
968
+ "",
969
+ ...prose(
970
+ this.record.guideAttempt.error ??
971
+ (this.record.guide
972
+ ? "Regeneration was interrupted. Your current guide is unchanged; choose Regenerate guide to retry."
973
+ : "Preparation was interrupted. Choose Prepare guide to try again."),
974
+ ),
975
+ ]
976
+ : []),
977
+ "",
978
+ ...quiet(
979
+ `${this.record.snapshot.files.length} changed files · captured ${this.record.snapshot.target.head.slice(0, 8)}`,
980
+ ),
981
+ ];
982
+ }
983
+ private evidenceLines(evidence: Evidence[], width: number): string[] {
984
+ const prose = (text: string) => wrapTextWithAnsi(safeReviewText(text), Math.max(1, width));
985
+ return evidence.flatMap((ref) => {
986
+ const key = `${ref.path}:${ref.side}:${ref.start}:${ref.end}`;
987
+ const code = this.evidence.get(key),
988
+ digits = String(ref.end).length;
989
+ return [
990
+ this.section("CAPTURED CODE", width),
991
+ ...prose(`${ref.path} · ${ref.side} lines ${ref.start}–${ref.end}`),
992
+ ...(!ref.valid
993
+ ? ["Evidence unavailable — unresolved reference"]
994
+ : this.evidenceErrors.has(key)
995
+ ? prose(this.evidenceErrors.get(key)!)
996
+ : code === undefined
997
+ ? ["Loading captured code…"]
998
+ : code
999
+ .split("\n")
1000
+ .flatMap((line, index) =>
1001
+ this.codeLine(line, String(ref.start + index).padStart(digits), width),
1002
+ )),
1003
+ ];
1004
+ });
1005
+ }
1006
+ private runningStatus(narrow: boolean): string {
1007
+ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][
1008
+ Math.floor(Date.now() / 200) % 10
1009
+ ];
1010
+ const elapsed = `${Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000))}s`;
1011
+ if (this.stage) {
1012
+ if (this.operation === "answer") return `${spinner} ${this.stage.detail} · ${elapsed}`;
1013
+ const step = this.stage.step === "check" ? 1 : this.stage.step === "save" ? 2 : 0;
1014
+ const trail = [0, 1, 2]
1015
+ .map((index) => (index < step ? "●" : index === step ? spinner : "○"))
1016
+ .join(" ");
1017
+ return narrow
1018
+ ? `${spinner} ${elapsed} · ${this.stage.detail}`
1019
+ : `${trail} ${this.operation === "regenerate" ? "↻ Regenerating" : "Preparing"} · ${this.stage.detail} · ${elapsed}`;
1020
+ }
1021
+ return `${spinner} ${this.operation === "guide" ? "Preparing review guide" : this.operation === "regenerate" ? "Regenerating guide" : "Answering question"} · ${elapsed}`;
1022
+ }
1023
+ render(width: number): string[] {
1024
+ this.width = width;
1025
+ const height = Math.max(1, Math.min(36, this.tui.terminal.rows - 2));
1026
+ const inner = Math.max(1, width - 2);
1027
+ const fit = (text: string, size: number) => {
1028
+ const line = truncateToWidth(text, size, "…");
1029
+ return line + " ".repeat(Math.max(0, size - visibleWidth(line)));
1030
+ };
1031
+ const border = (left: string, right: string) =>
1032
+ this.theme.fg("border", left + "─".repeat(Math.max(0, width - 2)) + right);
1033
+ const row = (text: string) =>
1034
+ this.theme.fg("border", "│") + fit(text, inner) + this.theme.fg("border", "│");
1035
+ this.tiny = width < 36 || height < 16;
1036
+ this.layout.height = height;
1037
+ if (this.tiny)
1038
+ return [
1039
+ border("╭", "╮"),
1040
+ row("Resize to at least 36×18"),
1041
+ row(this.busy ? "Esc: stop and close" : "Esc: close"),
1042
+ border("╰", "╯"),
1043
+ ]
1044
+ .slice(0, height)
1045
+ .map((line) => truncateToWidth(line, width, ""));
1046
+ const narrow = width < 90;
1047
+ const navigationOnly = narrow && this.focus === "contents";
1048
+ const leftWidth = narrow ? 0 : Math.min(38, Math.floor(inner * 0.32));
1049
+ const detailWidth = inner - leftWidth - (leftWidth ? 3 : 0);
1050
+ const detailRow = (text: string) =>
1051
+ row((leftWidth ? " ".repeat(leftWidth) + this.theme.fg("border", " │ ") : "") + text);
1052
+ const composer = !navigationOnly && this.view.screen === "discussion";
1053
+ const actions = navigationOnly ? [] : this.actions();
1054
+ this.action = Math.max(0, Math.min(this.action, actions.length - 1));
1055
+ const short: Record<string, string> = {
1056
+ "Send question": "Send",
1057
+ "Ask about this finding": "Ask",
1058
+ "Ask about this file": "Ask",
1059
+ "Ask about this review": "Ask",
1060
+ "Ask a question": "Ask",
1061
+ "Copy finding": "Copy",
1062
+ "Back to files": "Back",
1063
+ "View topic findings": "Topic findings",
1064
+ "Edit and resend": "Edit and resend",
1065
+ };
1066
+ const buttons = actions.map((action, index) => {
1067
+ const focused = this.focus === "actions" && index === this.action;
1068
+ const text = `${focused ? "›" : ""}[${narrow ? (short[action] ?? action) : action}]`;
1069
+ return focused ? this.theme.bold(this.theme.fg("accent", text)) : text;
1070
+ });
1071
+ const actionRows = !buttons.length
1072
+ ? []
1073
+ : narrow
1074
+ ? wrapTextWithAnsi(buttons.join(" "), detailWidth)
1075
+ : buttons.map((button) => " " + button);
1076
+ const availableRows = height - (narrow ? 7 : 8) - actionRows.length - (composer ? 1 : 0);
1077
+ const sticky =
1078
+ !navigationOnly &&
1079
+ this.view.screen === "finding" &&
1080
+ this.finding &&
1081
+ availableRows >= 4 &&
1082
+ (this.view.offsets[this.key] ?? 0) > 0
1083
+ ? truncateToWidth(
1084
+ `${this.findingBadge(this.finding)} ${this.theme.bold(label(this.finding.title))}`,
1085
+ detailWidth,
1086
+ "…",
1087
+ )
1088
+ : undefined;
1089
+ const actionGap = actionRows.length && availableRows >= 8 ? 1 : 0;
1090
+ const bodyRows = availableRows - (sticky ? 1 : 0) - actionGap;
1091
+ const bodyStart = 4 + (sticky ? 1 : 0);
1092
+ if (this.isList)
1093
+ this.view.row = Math.max(0, Math.min(Math.max(0, this.listLength - 1), this.view.row));
1094
+ const body = [
1095
+ ...(this.saveError
1096
+ ? [
1097
+ this.theme.fg("warning", "Not saved · Retry saving"),
1098
+ ...wrapTextWithAnsi(label(this.saveError), detailWidth),
1099
+ "",
1100
+ ]
1101
+ : []),
1102
+ ...(this.notice && this.notice !== "Saved" && !this.busy
1103
+ ? [
1104
+ ...wrapTextWithAnsi(label(this.notice), detailWidth).map((line) =>
1105
+ this.theme.fg("accent", line),
1106
+ ),
1107
+ "",
1108
+ ]
1109
+ : []),
1110
+ ...this.content(detailWidth),
1111
+ ];
1112
+ this.maxOffset = Math.max(0, body.length - bodyRows);
1113
+ const listCursor = this.isList ? body.findIndex((line) => line.startsWith("› ")) : 0;
1114
+ const offset =
1115
+ this.view.screen === "discussion" && this.followAnswer
1116
+ ? this.maxOffset
1117
+ : this.isList && this.manualListKey !== this.key
1118
+ ? Math.max(0, Math.min(this.maxOffset, Math.max(0, listCursor) - bodyRows + 3))
1119
+ : Math.min(this.maxOffset, this.view.offsets[this.key] ?? 0);
1120
+ this.view.offsets[this.key] = offset;
1121
+ const entries = this.entries();
1122
+ const selected = entries.findIndex((entry) => entry.id === this.view.selected);
1123
+ const start = Math.max(
1124
+ 0,
1125
+ Math.min(
1126
+ this.contentsOffset ?? selected - Math.floor(bodyRows / 2),
1127
+ Math.max(0, entries.length - bodyRows),
1128
+ ),
1129
+ );
1130
+ this.layout = { height, bodyRows, leftWidth, navigationOnly, contentsStart: start, bodyStart };
1131
+ const target = this.record.snapshot.target;
1132
+ const paneRule = (title: string, size: number, active: boolean) =>
1133
+ this.theme.fg(
1134
+ active ? "accent" : "border",
1135
+ truncateToWidth(`─ ${title} ` + "─".repeat(size), size, ""),
1136
+ );
1137
+ const divider = leftWidth
1138
+ ? row(
1139
+ paneRule("Contents", leftWidth, this.focus === "contents") +
1140
+ this.theme.fg("border", " │ ") +
1141
+ paneRule(
1142
+ this.focus === "input"
1143
+ ? "Your draft"
1144
+ : this.focus === "actions"
1145
+ ? "Actions"
1146
+ : "Reading",
1147
+ detailWidth,
1148
+ this.focus !== "contents",
1149
+ ),
1150
+ )
1151
+ : border("├", "┤");
1152
+ const lines = [
1153
+ border("╭", "╮"),
1154
+ row(this.theme.fg("accent", label(`Bro · Review · ${target.repository} #${target.number}`))),
1155
+ row(
1156
+ this.theme.bold(
1157
+ this.theme.fg(
1158
+ this.busy
1159
+ ? "accent"
1160
+ : this.saveError
1161
+ ? "warning"
1162
+ : this.record.guideAttempt
1163
+ ? "warning"
1164
+ : this.record.guide
1165
+ ? "success"
1166
+ : "muted",
1167
+ this.busy
1168
+ ? this.runningStatus(narrow)
1169
+ : this.saveError
1170
+ ? "Review not saved · retry saving"
1171
+ : this.record.guide && this.record.guideAttempt
1172
+ ? `Regeneration ${this.record.guideAttempt.status === "failed" ? "failed" : "stopped"} · current guide kept`
1173
+ : this.record.guide
1174
+ ? this.record.guide.assessment
1175
+ ? this.record.guide.assessment.warnings.length
1176
+ ? "Review ready · assessment incomplete"
1177
+ : `✓ Review ready · ${this.record.guide.assessment.findings.length} findings`
1178
+ : `✓ Guide ready · ${this.record.guide.topics.length} topic${this.record.guide.topics.length === 1 ? "" : "s"}`
1179
+ : this.record.guideAttempt
1180
+ ? `Preparation ${this.record.guideAttempt.status === "failed" ? "failed" : "stopped"} · Prepare guide to retry`
1181
+ : "Captured changes ready",
1182
+ ),
1183
+ ),
1184
+ ),
1185
+ divider,
1186
+ ];
1187
+ for (let i = 0; i < bodyRows + (sticky ? 1 : 0); i++) {
1188
+ const entry = entries[start + i];
1189
+ let nav = entry ? `${entry.id === this.view.selected ? "›" : " "} ${label(entry.title)}` : "";
1190
+ if (entry?.id === this.view.selected)
1191
+ nav =
1192
+ this.focus === "contents"
1193
+ ? this.theme.bold(this.theme.fg("accent", nav))
1194
+ : this.theme.fg("dim", nav);
1195
+ if (i === 0 && start > 0) nav = "▲ " + nav.slice(2);
1196
+ if (i === bodyRows + (sticky ? 1 : 0) - 1 && start + i + 1 < entries.length)
1197
+ nav = "▼ " + nav.slice(2);
1198
+ lines.push(
1199
+ row(
1200
+ navigationOnly
1201
+ ? nav
1202
+ : leftWidth
1203
+ ? fit(nav, leftWidth) +
1204
+ this.theme.fg("border", " │ ") +
1205
+ fit(
1206
+ sticky && i === 0 ? sticky : (body[offset + i - (sticky ? 1 : 0)] ?? ""),
1207
+ detailWidth,
1208
+ )
1209
+ : sticky && i === 0
1210
+ ? sticky
1211
+ : (body[offset + i - (sticky ? 1 : 0)] ?? ""),
1212
+ ),
1213
+ );
1214
+ }
1215
+ if (actionGap) lines.push(detailRow(this.theme.fg("dim", "─".repeat(detailWidth))));
1216
+ if (composer) {
1217
+ this.input.focused = this.focus === "input" && this.focused;
1218
+ lines.push(detailRow(this.input.render(detailWidth)[0] ?? ""));
1219
+ }
1220
+ for (const action of actionRows) lines.push(detailRow(action));
1221
+ const exit =
1222
+ this.focus === "contents" ? (this.busy ? "Esc stop/close" : "Esc close") : "Esc back";
1223
+ const primary =
1224
+ this.focus === "contents"
1225
+ ? this.view.selected === "close"
1226
+ ? "Enter close"
1227
+ : "Enter read"
1228
+ : this.focus === "input"
1229
+ ? this.busy
1230
+ ? "Typing draft"
1231
+ : "Enter send"
1232
+ : this.focus === "actions"
1233
+ ? "Enter select"
1234
+ : this.view.selected === "close"
1235
+ ? "Enter close"
1236
+ : this.isList && this.listLength
1237
+ ? "Enter open"
1238
+ : actions.length
1239
+ ? "Enter actions"
1240
+ : "↑↓ read";
1241
+ const hint = `${exit} · ${primary} · Tab${narrow ? "" : this.focus === "input" ? " actions" : " focus"}`;
1242
+ const more = navigationOnly ? start + bodyRows < entries.length : offset < this.maxOffset;
1243
+ const paused =
1244
+ this.busy &&
1245
+ this.view.screen === "discussion" &&
1246
+ !this.followAnswer &&
1247
+ this.activeTurn?.target === this.view.target &&
1248
+ offset < this.maxOffset;
1249
+ const status = this.saveError
1250
+ ? "Not saved · Retry saving"
1251
+ : paused
1252
+ ? "New output below"
1253
+ : this.notice || `Private · ${target.head.slice(0, 8)}`;
1254
+ lines.push(border("├", "┤"));
1255
+ if (!narrow)
1256
+ lines.push(
1257
+ row(
1258
+ `${this.focus === "contents" ? "Contents" : this.focus === "body" ? "Reading" : this.focus === "input" ? "Your draft" : "Actions"} · ${label(status)}${more ? " · ▼ more below" : ""}`,
1259
+ ),
1260
+ );
1261
+ lines.push(
1262
+ row(hint + (narrow && more ? (paused ? " · ↓ new" : " · ▼") : "")),
1263
+ border("╰", "╯"),
1264
+ );
1265
+ return lines;
1266
+ }
1267
+ invalidate() {
1268
+ this.input.invalidate();
1269
+ this.markdown.invalidate();
1270
+ }
1271
+ dispose() {}
1272
+ }