pi-bro 0.20.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 +71 -0
- package/README.md +140 -64
- package/backend.ts +158 -232
- package/bro.ts +411 -1421
- 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 +65 -11
- 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-ui.ts
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import {
|
|
2
|
+
matchesKey,
|
|
3
|
+
truncateToWidth,
|
|
4
|
+
visibleWidth,
|
|
5
|
+
wrapTextWithAnsi,
|
|
6
|
+
} from "@earendil-works/pi-tui";
|
|
7
|
+
import {
|
|
8
|
+
copyToClipboard,
|
|
9
|
+
getAgentDir,
|
|
10
|
+
type ExtensionCommandContext,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { execute, reviewBackendError } from "./backend.ts";
|
|
14
|
+
import { readSettings, selectionForCapability } from "./settings.ts";
|
|
15
|
+
import {
|
|
16
|
+
replaceGuide,
|
|
17
|
+
guideEvidence,
|
|
18
|
+
buildReviewPrompt,
|
|
19
|
+
listReviews,
|
|
20
|
+
loadReview,
|
|
21
|
+
parseGuide,
|
|
22
|
+
reviewKey,
|
|
23
|
+
saveReview,
|
|
24
|
+
type ReviewRecord,
|
|
25
|
+
} from "./review.ts";
|
|
26
|
+
import { captureReview, readEvidence, resolveReviewTarget } from "./review-source.ts";
|
|
27
|
+
import { ReviewModal, newReviewView, safeReviewText } from "./review-modal.ts";
|
|
28
|
+
|
|
29
|
+
const activeReviews = new Map<string, AbortController | undefined>();
|
|
30
|
+
export function stopGuidedReviews(): void {
|
|
31
|
+
for (const controller of activeReviews.values()) controller?.abort();
|
|
32
|
+
}
|
|
33
|
+
const overlayOptions = {
|
|
34
|
+
anchor: "center" as const,
|
|
35
|
+
width: "100%" as const,
|
|
36
|
+
maxHeight: "100%" as const,
|
|
37
|
+
margin: { top: 1, bottom: 1 },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export async function openGuidedReview(
|
|
41
|
+
ctx: ExtensionCommandContext,
|
|
42
|
+
value: string,
|
|
43
|
+
preferences: string,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
if (ctx.mode !== "tui") {
|
|
46
|
+
ctx.ui.notify("Guided Review requires local Pi interactive mode.", "warning");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const root = join(getAgentDir(), "bro-reviews");
|
|
50
|
+
let record: ReviewRecord | undefined;
|
|
51
|
+
let fresh = false;
|
|
52
|
+
if (value === "resume" || !value) {
|
|
53
|
+
const records = await listReviews(root, (file, error) =>
|
|
54
|
+
ctx.ui.notify(
|
|
55
|
+
`Cannot load ${file}: ${String(error)}. Original file left untouched.`,
|
|
56
|
+
"warning",
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
if (!records.length) {
|
|
60
|
+
ctx.ui.notify("Start with /bro guided-review <PR number or URL>.", "info");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
records.sort(
|
|
64
|
+
(a, b) =>
|
|
65
|
+
a.snapshot.target.repository.localeCompare(b.snapshot.target.repository) ||
|
|
66
|
+
b.snapshot.target.number - a.snapshot.target.number,
|
|
67
|
+
);
|
|
68
|
+
const labels = records.map(
|
|
69
|
+
(item) =>
|
|
70
|
+
`${item.snapshot.target.repository} #${item.snapshot.target.number} · ${safeReviewText(item.snapshot.target.title ?? "").replace(/[\r\n\t]/g, " ")} · ${item.guide ? (item.guide.assessment ? "review ready" : "guide ready") : "not prepared"} · ${item.snapshot.target.head.slice(0, 8)}`,
|
|
71
|
+
);
|
|
72
|
+
const picked = await ctx.ui.select("Resume guided review", labels);
|
|
73
|
+
if (!picked) return;
|
|
74
|
+
record = records[labels.indexOf(picked)];
|
|
75
|
+
} else {
|
|
76
|
+
record = await ctx.ui.custom<ReviewRecord | undefined>(
|
|
77
|
+
(tui, theme, _keys, done) => {
|
|
78
|
+
let controller = new AbortController();
|
|
79
|
+
let message = "Resolving PR identity…";
|
|
80
|
+
let failed = false;
|
|
81
|
+
let retryable = true;
|
|
82
|
+
let startedAt = Date.now();
|
|
83
|
+
let disposed = false;
|
|
84
|
+
const heartbeat = setInterval(() => {
|
|
85
|
+
if (!disposed && !failed) tui.requestRender();
|
|
86
|
+
}, 200);
|
|
87
|
+
let locked: string | undefined;
|
|
88
|
+
const release = () => {
|
|
89
|
+
if (locked) {
|
|
90
|
+
activeReviews.delete(locked);
|
|
91
|
+
locked = undefined;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const acquire = async () => {
|
|
95
|
+
failed = false;
|
|
96
|
+
retryable = true;
|
|
97
|
+
startedAt = Date.now();
|
|
98
|
+
controller = new AbortController();
|
|
99
|
+
message = "Resolving PR identity…";
|
|
100
|
+
tui.requestRender();
|
|
101
|
+
try {
|
|
102
|
+
const target = await resolveReviewTarget(value, ctx.cwd, controller.signal);
|
|
103
|
+
if (disposed) return;
|
|
104
|
+
const id = reviewKey(target);
|
|
105
|
+
if (activeReviews.has(id)) {
|
|
106
|
+
retryable = false;
|
|
107
|
+
throw Error(
|
|
108
|
+
"This review is already open in this Pi session. Close its existing workspace first.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
locked = id;
|
|
112
|
+
activeReviews.set(id, controller);
|
|
113
|
+
const existing = await loadReview(root, id);
|
|
114
|
+
if (existing) {
|
|
115
|
+
if (
|
|
116
|
+
existing.snapshot.target.head !== target.head ||
|
|
117
|
+
existing.snapshot.target.base !== target.base
|
|
118
|
+
)
|
|
119
|
+
ctx.ui.notify(
|
|
120
|
+
"PR has newer source. Continuing your saved captured revision.",
|
|
121
|
+
"info",
|
|
122
|
+
);
|
|
123
|
+
release();
|
|
124
|
+
if (!disposed) done(existing);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const selection = selectionForCapability(await readSettings(), "review");
|
|
128
|
+
const backendError = reviewBackendError(selection.backend ?? "agy");
|
|
129
|
+
if (backendError) {
|
|
130
|
+
retryable = false;
|
|
131
|
+
throw Error(backendError);
|
|
132
|
+
}
|
|
133
|
+
message = `${target.repository} #${target.number} · ${target.head.slice(0, 8)}\nFetching captured source…`;
|
|
134
|
+
tui.requestRender();
|
|
135
|
+
const snapshot = await captureReview(target, root, controller.signal);
|
|
136
|
+
if (disposed) return;
|
|
137
|
+
const next: ReviewRecord = { version: 1, id, snapshot, turns: [], ui: newReviewView() };
|
|
138
|
+
await saveReview(root, next);
|
|
139
|
+
fresh = true;
|
|
140
|
+
release();
|
|
141
|
+
if (!disposed) done(next);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (!disposed) {
|
|
144
|
+
failed = true;
|
|
145
|
+
message = error instanceof Error ? error.message : String(error);
|
|
146
|
+
tui.requestRender();
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
release();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
void acquire();
|
|
153
|
+
return {
|
|
154
|
+
render(width: number) {
|
|
155
|
+
const inner = Math.max(1, width - 2);
|
|
156
|
+
const height = Math.max(1, tui.terminal.rows - 2);
|
|
157
|
+
const border = "─".repeat(inner);
|
|
158
|
+
const row = (text: string) => {
|
|
159
|
+
const line = truncateToWidth(text, inner, "…");
|
|
160
|
+
return `│${line}${" ".repeat(Math.max(0, inner - visibleWidth(line)))}│`;
|
|
161
|
+
};
|
|
162
|
+
const body = wrapTextWithAnsi(safeReviewText(message), inner).slice(
|
|
163
|
+
0,
|
|
164
|
+
Math.max(1, height - 4),
|
|
165
|
+
);
|
|
166
|
+
const lines = [
|
|
167
|
+
`╭${border}╮`,
|
|
168
|
+
row(
|
|
169
|
+
`Bro · Review${failed ? "" : ` · ${Math.floor((Date.now() - startedAt) / 1000)}s`}`,
|
|
170
|
+
),
|
|
171
|
+
...body.map(row),
|
|
172
|
+
row(failed ? (retryable ? "Esc close · Enter retry" : "Esc close") : "Esc cancel"),
|
|
173
|
+
`╰${border}╯`,
|
|
174
|
+
];
|
|
175
|
+
return lines
|
|
176
|
+
.slice(0, height)
|
|
177
|
+
.map((line) => theme.fg("accent", truncateToWidth(line, width, "")));
|
|
178
|
+
},
|
|
179
|
+
invalidate() {},
|
|
180
|
+
handleInput(data: string) {
|
|
181
|
+
if (matchesKey(data, "escape")) {
|
|
182
|
+
disposed = true;
|
|
183
|
+
controller.abort();
|
|
184
|
+
release();
|
|
185
|
+
done(undefined);
|
|
186
|
+
} else if (failed && retryable && matchesKey(data, "return")) void acquire();
|
|
187
|
+
},
|
|
188
|
+
dispose() {
|
|
189
|
+
disposed = true;
|
|
190
|
+
clearInterval(heartbeat);
|
|
191
|
+
controller.abort();
|
|
192
|
+
release();
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
{ overlay: true, overlayOptions },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (!record) return;
|
|
200
|
+
const state = record;
|
|
201
|
+
if (activeReviews.has(state.id)) {
|
|
202
|
+
ctx.ui.notify("This review is already open in this Pi session.", "info");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
activeReviews.set(state.id, undefined);
|
|
206
|
+
let overlayHandle: { setHidden(hidden: boolean): void } | undefined;
|
|
207
|
+
try {
|
|
208
|
+
await ctx.ui.custom<void>(
|
|
209
|
+
(tui, theme, _keys, done) => {
|
|
210
|
+
let controller: AbortController | undefined;
|
|
211
|
+
let running: Promise<void> | undefined;
|
|
212
|
+
let closed = false;
|
|
213
|
+
let closing = false;
|
|
214
|
+
let confirming = false;
|
|
215
|
+
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
216
|
+
let queue = Promise.resolve();
|
|
217
|
+
const evidenceAbort = new AbortController();
|
|
218
|
+
const persist = () => {
|
|
219
|
+
if (saveTimer) {
|
|
220
|
+
clearTimeout(saveTimer);
|
|
221
|
+
saveTimer = undefined;
|
|
222
|
+
}
|
|
223
|
+
const snapshot = structuredClone(state);
|
|
224
|
+
queue = queue.catch(() => {}).then(() => saveReview(root, snapshot));
|
|
225
|
+
return queue.then(
|
|
226
|
+
() => {
|
|
227
|
+
panel.saveError = "";
|
|
228
|
+
if (!closed) tui.requestRender();
|
|
229
|
+
},
|
|
230
|
+
(error) => {
|
|
231
|
+
panel.saveError = `Not saved: ${error instanceof Error ? error.message : String(error)}. Use Retry saving, or Close to retry.`;
|
|
232
|
+
tui.requestRender();
|
|
233
|
+
throw error;
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
const changed = () => {
|
|
238
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
239
|
+
saveTimer = setTimeout(() => {
|
|
240
|
+
void persist().catch(() => {});
|
|
241
|
+
}, 250);
|
|
242
|
+
};
|
|
243
|
+
const evidenceReads = new Map<string, Promise<void>>();
|
|
244
|
+
const hydrateEvidence = async () => {
|
|
245
|
+
for (const ref of guideEvidence(state.guide)) {
|
|
246
|
+
if (!ref.valid || closed) continue;
|
|
247
|
+
const key = `${ref.path}:${ref.side}:${ref.start}:${ref.end}`;
|
|
248
|
+
if (panel.evidence.has(key)) continue;
|
|
249
|
+
let pending = evidenceReads.get(key);
|
|
250
|
+
if (!pending) {
|
|
251
|
+
pending = (async () => {
|
|
252
|
+
try {
|
|
253
|
+
const source = await readEvidence(
|
|
254
|
+
state.snapshot,
|
|
255
|
+
ref.path,
|
|
256
|
+
ref.side,
|
|
257
|
+
evidenceAbort.signal,
|
|
258
|
+
);
|
|
259
|
+
panel.evidence.set(
|
|
260
|
+
key,
|
|
261
|
+
source
|
|
262
|
+
.split("\n")
|
|
263
|
+
.slice(ref.start - 1, ref.end)
|
|
264
|
+
.join("\n"),
|
|
265
|
+
);
|
|
266
|
+
panel.evidenceErrors.delete(key);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
panel.evidenceErrors.set(key, `Captured code unavailable: ${String(error)}`);
|
|
269
|
+
} finally {
|
|
270
|
+
evidenceReads.delete(key);
|
|
271
|
+
}
|
|
272
|
+
if (!closed) tui.requestRender();
|
|
273
|
+
})();
|
|
274
|
+
evidenceReads.set(key, pending);
|
|
275
|
+
}
|
|
276
|
+
await pending;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
const run = async (target: string, question?: string, regenerate = false) => {
|
|
280
|
+
if (controller || closed || closing) return;
|
|
281
|
+
if (!question && state.guide && !regenerate) return;
|
|
282
|
+
let generated = false;
|
|
283
|
+
const abort = new AbortController();
|
|
284
|
+
controller = abort;
|
|
285
|
+
activeReviews.set(state.id, abort);
|
|
286
|
+
panel.busy = true;
|
|
287
|
+
panel.operation = question ? "answer" : regenerate ? "regenerate" : "guide";
|
|
288
|
+
panel.startedAt = Date.now();
|
|
289
|
+
panel.notice = question
|
|
290
|
+
? "Answering…"
|
|
291
|
+
: regenerate
|
|
292
|
+
? "Regenerating guide…"
|
|
293
|
+
: "Preparing guide…";
|
|
294
|
+
panel.stage = { step: "start", detail: "Starting review" };
|
|
295
|
+
const heartbeat = setInterval(() => {
|
|
296
|
+
if (!closed) tui.requestRender();
|
|
297
|
+
}, 200);
|
|
298
|
+
const prompt = buildReviewPrompt(state, question, target, preferences);
|
|
299
|
+
const turn: ReviewRecord["turns"][number] | undefined = question
|
|
300
|
+
? { question, answer: "", target, status: "partial" }
|
|
301
|
+
: undefined;
|
|
302
|
+
panel.activeTurn = turn;
|
|
303
|
+
if (turn) state.turns.push(turn);
|
|
304
|
+
else state.guideAttempt = { text: "", status: "partial" };
|
|
305
|
+
tui.requestRender();
|
|
306
|
+
try {
|
|
307
|
+
await persist();
|
|
308
|
+
const settings = await readSettings();
|
|
309
|
+
const selection = selectionForCapability(settings, "review");
|
|
310
|
+
panel.notice = `${question ? "Answering" : regenerate ? "Regenerating guide" : "Preparing guide"} · ${selection.backend ?? "agy"}`;
|
|
311
|
+
panel.stage = {
|
|
312
|
+
step: "investigate",
|
|
313
|
+
detail: question ? "Considering your question" : "Inspecting captured changes",
|
|
314
|
+
};
|
|
315
|
+
tui.requestRender();
|
|
316
|
+
const outcome = await execute(
|
|
317
|
+
{ feature: "review", access: "restricted", cwd: state.snapshot.checkout, prompt },
|
|
318
|
+
selection,
|
|
319
|
+
abort.signal,
|
|
320
|
+
(progress) => {
|
|
321
|
+
if (closed || abort.signal.aborted) return;
|
|
322
|
+
if (progress.kind === "text") {
|
|
323
|
+
if (turn) turn.answer = progress.text;
|
|
324
|
+
else state.guideAttempt = { text: progress.text, status: "partial" };
|
|
325
|
+
panel.stage = {
|
|
326
|
+
step: "receive",
|
|
327
|
+
detail: `Receiving response · ${progress.text.length.toLocaleString()} chars`,
|
|
328
|
+
};
|
|
329
|
+
} else panel.notice = progress.label;
|
|
330
|
+
changed();
|
|
331
|
+
tui.requestRender();
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
if (outcome.status !== "success") {
|
|
335
|
+
if (outcome.partialText) {
|
|
336
|
+
if (turn) turn.answer = outcome.partialText;
|
|
337
|
+
else state.guideAttempt = { text: outcome.partialText, status: "partial" };
|
|
338
|
+
}
|
|
339
|
+
throw Error(outcome.message);
|
|
340
|
+
}
|
|
341
|
+
if (abort.signal.aborted) throw Error("Cancelled");
|
|
342
|
+
if (turn) {
|
|
343
|
+
turn.answer = outcome.text;
|
|
344
|
+
turn.status = "complete";
|
|
345
|
+
} else {
|
|
346
|
+
state.guideAttempt = { text: outcome.text, status: "partial" };
|
|
347
|
+
let checked = 0;
|
|
348
|
+
panel.stage = { step: "check", detail: "Checking references" };
|
|
349
|
+
tui.requestRender();
|
|
350
|
+
const guide = await parseGuide(
|
|
351
|
+
outcome.text,
|
|
352
|
+
async (path, side) => {
|
|
353
|
+
try {
|
|
354
|
+
return await readEvidence(state.snapshot, path, side, abort.signal);
|
|
355
|
+
} finally {
|
|
356
|
+
panel.stage = {
|
|
357
|
+
step: "check",
|
|
358
|
+
detail: `Checking references · ${++checked} checked`,
|
|
359
|
+
};
|
|
360
|
+
if (!closed) tui.requestRender();
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
true,
|
|
364
|
+
);
|
|
365
|
+
if (abort.signal.aborted) throw Error("Cancelled");
|
|
366
|
+
replaceGuide(state, guide);
|
|
367
|
+
panel.syncGuideView();
|
|
368
|
+
generated = true;
|
|
369
|
+
delete state.guideAttempt;
|
|
370
|
+
void hydrateEvidence();
|
|
371
|
+
}
|
|
372
|
+
panel.stage = { step: "save", detail: "Saving review" };
|
|
373
|
+
tui.requestRender();
|
|
374
|
+
await persist();
|
|
375
|
+
panel.notice = "Saved";
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (turn && !abort.signal.aborted) {
|
|
378
|
+
turn.status = "failed";
|
|
379
|
+
turn.error = error instanceof Error ? error.message : String(error);
|
|
380
|
+
}
|
|
381
|
+
if (!turn && !generated && !abort.signal.aborted)
|
|
382
|
+
state.guideAttempt = {
|
|
383
|
+
text: state.guideAttempt?.text ?? "",
|
|
384
|
+
status: "failed",
|
|
385
|
+
error: String(error),
|
|
386
|
+
};
|
|
387
|
+
panel.notice = abort.signal.aborted ? "Stopped; partial work retained." : String(error);
|
|
388
|
+
try {
|
|
389
|
+
await persist();
|
|
390
|
+
} catch {
|
|
391
|
+
/* saveError stays visible independently of request status. */
|
|
392
|
+
}
|
|
393
|
+
} finally {
|
|
394
|
+
clearInterval(heartbeat);
|
|
395
|
+
controller = undefined;
|
|
396
|
+
if (activeReviews.has(state.id)) activeReviews.set(state.id, undefined);
|
|
397
|
+
panel.activeTurn = undefined;
|
|
398
|
+
panel.stage = undefined;
|
|
399
|
+
panel.busy = false;
|
|
400
|
+
if (!closed) tui.requestRender();
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const close = async () => {
|
|
404
|
+
if (closing || closed) return;
|
|
405
|
+
closing = true;
|
|
406
|
+
controller?.abort();
|
|
407
|
+
try {
|
|
408
|
+
await running;
|
|
409
|
+
await persist();
|
|
410
|
+
closed = true;
|
|
411
|
+
ctx.ui.notify(
|
|
412
|
+
`Review saved privately. Reopen with /bro guided-review ${state.snapshot.target.url ?? `https://${state.snapshot.target.host ?? "github.com"}/${state.snapshot.target.repository}/pull/${state.snapshot.target.number}`}. Use /bro guided-review to list saved reviews.`,
|
|
413
|
+
"info",
|
|
414
|
+
);
|
|
415
|
+
done();
|
|
416
|
+
} catch {
|
|
417
|
+
closing = false;
|
|
418
|
+
tui.requestRender();
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
const panel = new ReviewModal(state, tui, theme, {
|
|
422
|
+
ask(target, question) {
|
|
423
|
+
running = run(target, question);
|
|
424
|
+
},
|
|
425
|
+
prepare() {
|
|
426
|
+
running = run("whole review");
|
|
427
|
+
},
|
|
428
|
+
retrySave() {
|
|
429
|
+
void persist().catch(() => {});
|
|
430
|
+
},
|
|
431
|
+
regenerate() {
|
|
432
|
+
if (confirming || controller || closing || closed) return;
|
|
433
|
+
confirming = true;
|
|
434
|
+
overlayHandle?.setHidden(true);
|
|
435
|
+
void ctx.ui
|
|
436
|
+
.confirm(
|
|
437
|
+
"Regenerate guide?",
|
|
438
|
+
`Generate a new guide for captured revision ${state.snapshot.target.head.slice(0, 8)}? The new guide replaces your current guide when generation succeeds. Questions and drafts are kept with their original context. This makes a model call.`,
|
|
439
|
+
)
|
|
440
|
+
.then((approved) => {
|
|
441
|
+
if (approved && !closed && !closing) running = run("whole review", undefined, true);
|
|
442
|
+
})
|
|
443
|
+
.catch((error) => {
|
|
444
|
+
panel.notice = String(error);
|
|
445
|
+
})
|
|
446
|
+
.finally(() => {
|
|
447
|
+
confirming = false;
|
|
448
|
+
if (!closed) overlayHandle?.setHidden(false);
|
|
449
|
+
tui.requestRender();
|
|
450
|
+
});
|
|
451
|
+
},
|
|
452
|
+
stop() {
|
|
453
|
+
controller?.abort();
|
|
454
|
+
panel.notice = "Stopping…";
|
|
455
|
+
tui.requestRender();
|
|
456
|
+
},
|
|
457
|
+
copy(text) {
|
|
458
|
+
void copyToClipboard(text)
|
|
459
|
+
.then(() => {
|
|
460
|
+
panel.notice = "Finding copied to clipboard. Nothing submitted.";
|
|
461
|
+
})
|
|
462
|
+
.catch((error) => {
|
|
463
|
+
panel.notice = `Copy failed: ${String(error)}`;
|
|
464
|
+
})
|
|
465
|
+
.finally(() => {
|
|
466
|
+
if (!closed) tui.requestRender();
|
|
467
|
+
});
|
|
468
|
+
},
|
|
469
|
+
close() {
|
|
470
|
+
void close();
|
|
471
|
+
},
|
|
472
|
+
changed,
|
|
473
|
+
});
|
|
474
|
+
void hydrateEvidence();
|
|
475
|
+
if (fresh)
|
|
476
|
+
queueMicrotask(() => {
|
|
477
|
+
if (!closed && !closing) running = run("whole review");
|
|
478
|
+
});
|
|
479
|
+
return {
|
|
480
|
+
render: (width: number) => panel.render(width),
|
|
481
|
+
invalidate: () => panel.invalidate(),
|
|
482
|
+
handleInput: (data: string) => {
|
|
483
|
+
if (!closing) {
|
|
484
|
+
panel.handleInput(data);
|
|
485
|
+
if (matchesKey(data, "return") && ["reading", "finding"].includes(panel.view.screen))
|
|
486
|
+
void hydrateEvidence();
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
handleMouse: (event: Parameters<ReviewModal["handleMouse"]>[0]) =>
|
|
490
|
+
closing ? { handled: true, render: false } : panel.handleMouse(event),
|
|
491
|
+
get focused() {
|
|
492
|
+
return panel.focused;
|
|
493
|
+
},
|
|
494
|
+
set focused(value: boolean) {
|
|
495
|
+
panel.focused = value;
|
|
496
|
+
},
|
|
497
|
+
dispose() {
|
|
498
|
+
closed = true;
|
|
499
|
+
panel.dispose();
|
|
500
|
+
evidenceAbort.abort();
|
|
501
|
+
controller?.abort();
|
|
502
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
503
|
+
void persist().catch(() => {});
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
overlay: true,
|
|
509
|
+
overlayOptions,
|
|
510
|
+
onHandle: (handle) => {
|
|
511
|
+
overlayHandle = handle;
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
);
|
|
515
|
+
} finally {
|
|
516
|
+
activeReviews.get(state.id)?.abort();
|
|
517
|
+
activeReviews.delete(state.id);
|
|
518
|
+
}
|
|
519
|
+
}
|