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.
- package/CHANGELOG.md +53 -0
- package/README.md +80 -37
- package/backend.ts +55 -42
- package/bro.ts +166 -1359
- package/config-ui.ts +325 -0
- package/docs/README.md +2 -0
- package/docs/guided-review.md +131 -0
- package/docs/pig-compatibility.md +1 -0
- package/package.json +13 -4
- package/prompt.ts +19 -4
- package/review-modal.ts +1272 -0
- package/review-source.ts +296 -0
- package/review-ui.ts +519 -0
- package/review.ts +586 -0
- package/settings.ts +505 -0
- package/sources.ts +367 -0
- package/util.ts +26 -0
package/review-modal.ts
ADDED
|
@@ -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
|
+
}
|