@wyattjoh/demur 0.4.2 → 0.5.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/README.md +79 -21
- package/extensions/demur/cost-tracker.ts +31 -6
- package/extensions/demur/index.ts +150 -36
- package/extensions/demur/paths.ts +46 -0
- package/extensions/demur/settings.ts +77 -19
- package/extensions/demur/training-store.ts +334 -0
- package/package.json +10 -2
- package/src/cli.ts +237 -0
- package/src/training-review-model.ts +176 -0
- package/src/training-review-tui.tsx +947 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCliRenderer,
|
|
3
|
+
type ScrollBoxRenderable,
|
|
4
|
+
} from "@opentui/core";
|
|
5
|
+
import {
|
|
6
|
+
createRoot,
|
|
7
|
+
useKeyboard,
|
|
8
|
+
useTerminalDimensions,
|
|
9
|
+
} from "@opentui/react";
|
|
10
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import {
|
|
12
|
+
estimateInputCostUsd,
|
|
13
|
+
formatUsd,
|
|
14
|
+
} from "../extensions/demur/cost-tracker.ts";
|
|
15
|
+
import type {
|
|
16
|
+
TrainingReview,
|
|
17
|
+
TrainingReviewInput,
|
|
18
|
+
} from "../extensions/demur/training-store.ts";
|
|
19
|
+
import {
|
|
20
|
+
buildTrainingReviewEntries,
|
|
21
|
+
createTrainingReviewInput,
|
|
22
|
+
filterTrainingReviewEntries,
|
|
23
|
+
getLatestTrainingReview,
|
|
24
|
+
getTrainingReviewFilter,
|
|
25
|
+
type TrainingReviewEntry,
|
|
26
|
+
type TrainingReviewFilter,
|
|
27
|
+
type TrainingReviewSnapshot,
|
|
28
|
+
} from "./training-review-model.ts";
|
|
29
|
+
import type { Decision } from "./types.ts";
|
|
30
|
+
|
|
31
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
32
|
+
|
|
33
|
+
const COLORS = {
|
|
34
|
+
background: "#111318",
|
|
35
|
+
panel: "#181b22",
|
|
36
|
+
border: "#3b4252",
|
|
37
|
+
accent: "#88c0d0",
|
|
38
|
+
text: "#eceff4",
|
|
39
|
+
muted: "#8f98a8",
|
|
40
|
+
allow: "#a3be8c",
|
|
41
|
+
ask: "#ebcb8b",
|
|
42
|
+
deny: "#bf616a",
|
|
43
|
+
selection: "#2e3440",
|
|
44
|
+
error: "#ff6b7a",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
const FILTERS: ReadonlyArray<{
|
|
48
|
+
value: TrainingReviewFilter;
|
|
49
|
+
label: string;
|
|
50
|
+
}> = [
|
|
51
|
+
{ value: "all", label: "all" },
|
|
52
|
+
{ value: "unreviewed", label: "not reviewed" },
|
|
53
|
+
{ value: "allow", label: "approved" },
|
|
54
|
+
{ value: "ask", label: "ask" },
|
|
55
|
+
{ value: "deny", label: "deny" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Summary returned after an interactive training-review session.
|
|
60
|
+
*/
|
|
61
|
+
export type TrainingReviewTuiResult = {
|
|
62
|
+
reviewed: number;
|
|
63
|
+
corrected: number;
|
|
64
|
+
skipped: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type TrainingReviewAppProps = {
|
|
68
|
+
snapshot: TrainingReviewSnapshot;
|
|
69
|
+
reloadSnapshot(): Promise<TrainingReviewSnapshot>;
|
|
70
|
+
recordReview(input: TrainingReviewInput): Promise<TrainingReview>;
|
|
71
|
+
pollIntervalMs: number | undefined;
|
|
72
|
+
onExit(result: TrainingReviewTuiResult): void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type NoteEditorState = {
|
|
76
|
+
recordId: string;
|
|
77
|
+
expectedDecision: Decision;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type FocusTarget = "tabs" | "filter" | "queue" | "detail";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render the interactive historical training-review queue.
|
|
84
|
+
*
|
|
85
|
+
* @param props - Training state, persistence callbacks, and exit callback
|
|
86
|
+
* @returns OpenTUI React application tree
|
|
87
|
+
*/
|
|
88
|
+
export function TrainingReviewApp(
|
|
89
|
+
props: TrainingReviewAppProps,
|
|
90
|
+
): React.ReactNode {
|
|
91
|
+
const { width, height } = useTerminalDimensions();
|
|
92
|
+
const queueRef = useRef<ScrollBoxRenderable | null>(null);
|
|
93
|
+
const [snapshot, setSnapshot] = useState<TrainingReviewSnapshot>(
|
|
94
|
+
props.snapshot,
|
|
95
|
+
);
|
|
96
|
+
const [activeFilter, setActiveFilter] = useState<TrainingReviewFilter>(
|
|
97
|
+
"all",
|
|
98
|
+
);
|
|
99
|
+
const [cwdQuery, setCwdQuery] = useState("");
|
|
100
|
+
const [ready, setReady] = useState(false);
|
|
101
|
+
const [focus, setFocus] = useState<FocusTarget>("queue");
|
|
102
|
+
const [selectedRecordId, setSelectedRecordId] = useState<
|
|
103
|
+
string | undefined
|
|
104
|
+
>(undefined);
|
|
105
|
+
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(
|
|
106
|
+
new Set(),
|
|
107
|
+
);
|
|
108
|
+
const [noteEditor, setNoteEditor] = useState<NoteEditorState | undefined>();
|
|
109
|
+
const [saving, setSaving] = useState(false);
|
|
110
|
+
const [error, setError] = useState<string | undefined>();
|
|
111
|
+
const [pollError, setPollError] = useState<string | undefined>();
|
|
112
|
+
const [result, setResult] = useState<TrainingReviewTuiResult>({
|
|
113
|
+
reviewed: 0,
|
|
114
|
+
corrected: 0,
|
|
115
|
+
skipped: 0,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const entries = useMemo(
|
|
119
|
+
() => buildTrainingReviewEntries(snapshot),
|
|
120
|
+
[snapshot],
|
|
121
|
+
);
|
|
122
|
+
const visibleEntries = useMemo(
|
|
123
|
+
() => filterTrainingReviewEntries(entries, activeFilter, cwdQuery)
|
|
124
|
+
.filter((entry) => !dismissedIds.has(entry.record.id)),
|
|
125
|
+
[activeFilter, cwdQuery, dismissedIds, entries],
|
|
126
|
+
);
|
|
127
|
+
const selectedIndex = Math.max(
|
|
128
|
+
0,
|
|
129
|
+
visibleEntries.findIndex(
|
|
130
|
+
(entry) => entry.record.id === selectedRecordId,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
const selected = visibleEntries[selectedIndex];
|
|
134
|
+
const latestReview = selected === undefined
|
|
135
|
+
? undefined
|
|
136
|
+
: getLatestTrainingReview(selected);
|
|
137
|
+
const editingEntry = noteEditor === undefined
|
|
138
|
+
? undefined
|
|
139
|
+
: entries.find((entry) => entry.record.id === noteEditor.recordId);
|
|
140
|
+
const counts = useMemo(
|
|
141
|
+
() => countByFilter(entries),
|
|
142
|
+
[entries],
|
|
143
|
+
);
|
|
144
|
+
const horizontal = width >= 92;
|
|
145
|
+
const queueSize = horizontal
|
|
146
|
+
? Math.max(28, Math.min(42, Math.floor(width * 0.34)))
|
|
147
|
+
: Math.max(7, Math.min(11, Math.floor(height * 0.32)));
|
|
148
|
+
const queuePageSize = Math.max(
|
|
149
|
+
1,
|
|
150
|
+
Math.floor((horizontal ? height - 13 : queueSize - 2) / 2),
|
|
151
|
+
);
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const timer = setTimeout(() => setReady(true), 250);
|
|
154
|
+
return () => clearTimeout(timer);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
let active = true;
|
|
159
|
+
let polling = false;
|
|
160
|
+
|
|
161
|
+
const poll = async () => {
|
|
162
|
+
if (polling) return;
|
|
163
|
+
polling = true;
|
|
164
|
+
try {
|
|
165
|
+
const loaded = await props.reloadSnapshot();
|
|
166
|
+
if (!active) return;
|
|
167
|
+
setSnapshot((current) =>
|
|
168
|
+
trainingSnapshotsEqual(current, loaded) ? current : loaded
|
|
169
|
+
);
|
|
170
|
+
setPollError(undefined);
|
|
171
|
+
} catch (cause: unknown) {
|
|
172
|
+
if (active) setPollError(errorDetail(cause));
|
|
173
|
+
} finally {
|
|
174
|
+
polling = false;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
void poll();
|
|
179
|
+
const timer = setInterval(
|
|
180
|
+
() => void poll(),
|
|
181
|
+
props.pollIntervalMs ?? POLL_INTERVAL_MS,
|
|
182
|
+
);
|
|
183
|
+
return () => {
|
|
184
|
+
active = false;
|
|
185
|
+
clearInterval(timer);
|
|
186
|
+
};
|
|
187
|
+
}, [props.pollIntervalMs, props.reloadSnapshot]);
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (
|
|
191
|
+
noteEditor !== undefined &&
|
|
192
|
+
!entries.some((entry) => entry.record.id === noteEditor.recordId)
|
|
193
|
+
) {
|
|
194
|
+
setNoteEditor(undefined);
|
|
195
|
+
}
|
|
196
|
+
}, [entries, noteEditor]);
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (selected === undefined) return;
|
|
200
|
+
queueRef.current?.scrollChildIntoView(queueRowId(selected.record.id));
|
|
201
|
+
}, [selected]);
|
|
202
|
+
|
|
203
|
+
const finish = (nextResult: TrainingReviewTuiResult) => {
|
|
204
|
+
props.onExit(nextResult);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const persistDecision = async (
|
|
208
|
+
entry: TrainingReviewEntry,
|
|
209
|
+
expectedDecision: Decision,
|
|
210
|
+
note: string | undefined,
|
|
211
|
+
) => {
|
|
212
|
+
if (saving) return;
|
|
213
|
+
const previous = getLatestTrainingReview(entry);
|
|
214
|
+
if (previous?.expectedDecision === expectedDecision) {
|
|
215
|
+
setNoteEditor(undefined);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setSaving(true);
|
|
220
|
+
setError(undefined);
|
|
221
|
+
const corrected = expectedDecision !== entry.record.verdict.decision;
|
|
222
|
+
try {
|
|
223
|
+
const review = await props.recordReview(
|
|
224
|
+
createTrainingReviewInput(entry.record, expectedDecision, note),
|
|
225
|
+
);
|
|
226
|
+
setSnapshot((current) =>
|
|
227
|
+
current.reviews.some((existing) =>
|
|
228
|
+
existing.recordId === review.recordId &&
|
|
229
|
+
existing.reviewedAt === review.reviewedAt
|
|
230
|
+
)
|
|
231
|
+
? current
|
|
232
|
+
: {
|
|
233
|
+
records: current.records,
|
|
234
|
+
reviews: [...current.reviews, review],
|
|
235
|
+
globalEstimatedCostUsd: current.globalEstimatedCostUsd,
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
setResult((current) => ({
|
|
239
|
+
...current,
|
|
240
|
+
reviewed: current.reviewed + 1,
|
|
241
|
+
corrected: current.corrected + (corrected ? 1 : 0),
|
|
242
|
+
}));
|
|
243
|
+
setNoteEditor(undefined);
|
|
244
|
+
setError(undefined);
|
|
245
|
+
} catch (cause: unknown) {
|
|
246
|
+
setError(errorDetail(cause));
|
|
247
|
+
} finally {
|
|
248
|
+
setSaving(false);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const chooseDecision = (decision: Decision) => {
|
|
253
|
+
if (selected === undefined || saving) return;
|
|
254
|
+
if (decision === selected.record.verdict.decision) {
|
|
255
|
+
void persistDecision(selected, decision, undefined);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
setError(undefined);
|
|
259
|
+
setNoteEditor({
|
|
260
|
+
recordId: selected.record.id,
|
|
261
|
+
expectedDecision: decision,
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const skipSelected = () => {
|
|
266
|
+
if (selected === undefined || saving) return;
|
|
267
|
+
setDismissedIds((current) => new Set([
|
|
268
|
+
...current,
|
|
269
|
+
selected.record.id,
|
|
270
|
+
]));
|
|
271
|
+
setResult((current) => ({ ...current, skipped: current.skipped + 1 }));
|
|
272
|
+
setNoteEditor(undefined);
|
|
273
|
+
setError(undefined);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const rotateFilter = (reverse: boolean) => {
|
|
277
|
+
const currentIndex = FILTERS.findIndex(
|
|
278
|
+
(filter) => filter.value === activeFilter,
|
|
279
|
+
);
|
|
280
|
+
const offset = reverse ? FILTERS.length - 1 : 1;
|
|
281
|
+
const next = FILTERS[(currentIndex + offset) % FILTERS.length];
|
|
282
|
+
if (next !== undefined) setActiveFilter(next.value);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const moveFilterSelection = (offset: number) => {
|
|
286
|
+
const currentIndex = FILTERS.findIndex(
|
|
287
|
+
(filter) => filter.value === activeFilter,
|
|
288
|
+
);
|
|
289
|
+
const bounded = Math.max(
|
|
290
|
+
0,
|
|
291
|
+
Math.min(currentIndex + offset, FILTERS.length - 1),
|
|
292
|
+
);
|
|
293
|
+
const filter = FILTERS[bounded];
|
|
294
|
+
if (filter !== undefined) setActiveFilter(filter.value);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const moveQueueSelection = (index: number) => {
|
|
298
|
+
const bounded = Math.max(0, Math.min(index, visibleEntries.length - 1));
|
|
299
|
+
const entry = visibleEntries[bounded];
|
|
300
|
+
if (entry !== undefined) setSelectedRecordId(entry.record.id);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
useKeyboard((key) => {
|
|
304
|
+
if (!ready) {
|
|
305
|
+
key.preventDefault();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (key.ctrl && key.name === "c") {
|
|
310
|
+
key.preventDefault();
|
|
311
|
+
finish(result);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (noteEditor !== undefined) {
|
|
316
|
+
if (key.name === "escape") {
|
|
317
|
+
key.preventDefault();
|
|
318
|
+
setNoteEditor(undefined);
|
|
319
|
+
setError(undefined);
|
|
320
|
+
} else if (key.name === "tab") {
|
|
321
|
+
key.preventDefault();
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (key.name === "tab") {
|
|
327
|
+
key.preventDefault();
|
|
328
|
+
rotateFilter(key.shift);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (focus === "tabs") {
|
|
333
|
+
if (key.name === "left" || key.name === "right") {
|
|
334
|
+
key.preventDefault();
|
|
335
|
+
moveFilterSelection(key.name === "left" ? -1 : 1);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (key.name === "down" || key.name === "return") {
|
|
339
|
+
key.preventDefault();
|
|
340
|
+
setFocus("filter");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (key.name === "q" || key.name === "escape") {
|
|
344
|
+
key.preventDefault();
|
|
345
|
+
finish(result);
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (focus === "filter") {
|
|
351
|
+
if (key.name === "up") {
|
|
352
|
+
key.preventDefault();
|
|
353
|
+
setFocus("tabs");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (key.name === "down" || key.name === "return") {
|
|
357
|
+
key.preventDefault();
|
|
358
|
+
setFocus("queue");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (key.name === "escape") {
|
|
362
|
+
key.preventDefault();
|
|
363
|
+
if (cwdQuery === "") setFocus("queue");
|
|
364
|
+
else setCwdQuery("");
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (focus === "detail") {
|
|
370
|
+
if (key.name === "left") {
|
|
371
|
+
key.preventDefault();
|
|
372
|
+
setFocus("queue");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (key.name === "q" || key.name === "escape") {
|
|
376
|
+
key.preventDefault();
|
|
377
|
+
finish(result);
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (key.name === "right") {
|
|
383
|
+
key.preventDefault();
|
|
384
|
+
setFocus("detail");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (key.name === "j" || key.name === "k") {
|
|
388
|
+
key.preventDefault();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (key.name === "up") {
|
|
392
|
+
key.preventDefault();
|
|
393
|
+
if (visibleEntries.length === 0 || selectedIndex === 0) {
|
|
394
|
+
setFocus("filter");
|
|
395
|
+
} else {
|
|
396
|
+
moveQueueSelection(selectedIndex - 1);
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (key.name === "down") {
|
|
401
|
+
key.preventDefault();
|
|
402
|
+
moveQueueSelection(selectedIndex + 1);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (key.name === "pageup") {
|
|
406
|
+
key.preventDefault();
|
|
407
|
+
moveQueueSelection(selectedIndex - queuePageSize);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (key.name === "pagedown") {
|
|
411
|
+
key.preventDefault();
|
|
412
|
+
moveQueueSelection(selectedIndex + queuePageSize);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (saving) return;
|
|
417
|
+
if (key.name === "q" || key.name === "escape") {
|
|
418
|
+
key.preventDefault();
|
|
419
|
+
finish(result);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (key.name === "s") {
|
|
423
|
+
key.preventDefault();
|
|
424
|
+
skipSelected();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (key.name === "return") {
|
|
428
|
+
key.preventDefault();
|
|
429
|
+
if (selected !== undefined) {
|
|
430
|
+
chooseDecision(selected.record.verdict.decision);
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const decision = decisionForKey(key.name, key.sequence);
|
|
436
|
+
if (decision !== undefined) {
|
|
437
|
+
key.preventDefault();
|
|
438
|
+
chooseDecision(decision);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<box
|
|
444
|
+
width="100%"
|
|
445
|
+
height="100%"
|
|
446
|
+
flexDirection="column"
|
|
447
|
+
backgroundColor={COLORS.background}
|
|
448
|
+
padding={1}
|
|
449
|
+
gap={1}
|
|
450
|
+
>
|
|
451
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
452
|
+
<box flexDirection="row" gap={1}>
|
|
453
|
+
<text fg={COLORS.accent}><strong>demur</strong></text>
|
|
454
|
+
<text fg={COLORS.muted}>
|
|
455
|
+
{`global est. ${formatUsd(snapshot.globalEstimatedCostUsd)}`}
|
|
456
|
+
</text>
|
|
457
|
+
</box>
|
|
458
|
+
<text fg={COLORS.muted}>
|
|
459
|
+
{`${entries.length} total · ${counts.unreviewed} new · ${counts.allow} approved · ${counts.ask} ask · ${counts.deny} deny`}
|
|
460
|
+
</text>
|
|
461
|
+
</box>
|
|
462
|
+
|
|
463
|
+
<box
|
|
464
|
+
flexDirection="row"
|
|
465
|
+
gap={1}
|
|
466
|
+
border
|
|
467
|
+
borderColor={focus === "tabs" ? COLORS.accent : COLORS.border}
|
|
468
|
+
backgroundColor={COLORS.panel}
|
|
469
|
+
height={3}
|
|
470
|
+
paddingLeft={1}
|
|
471
|
+
paddingRight={1}
|
|
472
|
+
onMouseDown={() => setFocus("tabs")}
|
|
473
|
+
>
|
|
474
|
+
{FILTERS.map((filter) => {
|
|
475
|
+
const active = filter.value === activeFilter;
|
|
476
|
+
return (
|
|
477
|
+
<text
|
|
478
|
+
key={filter.value}
|
|
479
|
+
fg={active ? statusColor(filter.value) : COLORS.muted}
|
|
480
|
+
bg={active ? COLORS.selection : COLORS.background}
|
|
481
|
+
onMouseDown={() => {
|
|
482
|
+
setActiveFilter(filter.value);
|
|
483
|
+
setFocus("tabs");
|
|
484
|
+
}}
|
|
485
|
+
>
|
|
486
|
+
{` ${filter.label} `}
|
|
487
|
+
</text>
|
|
488
|
+
);
|
|
489
|
+
})}
|
|
490
|
+
<text fg={COLORS.muted}>Tab / Shift-Tab</text>
|
|
491
|
+
</box>
|
|
492
|
+
|
|
493
|
+
<box
|
|
494
|
+
title=" Working directory filter "
|
|
495
|
+
titleColor={focus === "filter" ? COLORS.accent : COLORS.muted}
|
|
496
|
+
border
|
|
497
|
+
borderColor={focus === "filter" ? COLORS.accent : COLORS.border}
|
|
498
|
+
backgroundColor={COLORS.panel}
|
|
499
|
+
height={3}
|
|
500
|
+
paddingLeft={1}
|
|
501
|
+
paddingRight={1}
|
|
502
|
+
onMouseDown={() => setFocus("filter")}
|
|
503
|
+
>
|
|
504
|
+
<input
|
|
505
|
+
value={cwdQuery}
|
|
506
|
+
placeholder="Up from the first result to filter by cwd"
|
|
507
|
+
focused={ready && focus === "filter"}
|
|
508
|
+
onInput={(value) => {
|
|
509
|
+
setCwdQuery(typeof value === "string" ? value : "");
|
|
510
|
+
}}
|
|
511
|
+
onSubmit={() => setFocus("queue")}
|
|
512
|
+
/>
|
|
513
|
+
</box>
|
|
514
|
+
|
|
515
|
+
<box
|
|
516
|
+
flexDirection={horizontal ? "row" : "column"}
|
|
517
|
+
flexGrow={1}
|
|
518
|
+
gap={1}
|
|
519
|
+
>
|
|
520
|
+
<box
|
|
521
|
+
title={` Queue (${visibleEntries.length}) `}
|
|
522
|
+
titleColor={COLORS.accent}
|
|
523
|
+
border
|
|
524
|
+
borderColor={focus === "queue" && noteEditor === undefined
|
|
525
|
+
? COLORS.accent
|
|
526
|
+
: COLORS.border}
|
|
527
|
+
backgroundColor={COLORS.panel}
|
|
528
|
+
width={horizontal ? queueSize : "100%"}
|
|
529
|
+
height={horizontal ? "100%" : queueSize}
|
|
530
|
+
onMouseDown={() => setFocus("queue")}
|
|
531
|
+
onMouseScroll={() => setFocus("queue")}
|
|
532
|
+
>
|
|
533
|
+
{selected === undefined
|
|
534
|
+
? (
|
|
535
|
+
<box
|
|
536
|
+
width="100%"
|
|
537
|
+
height="100%"
|
|
538
|
+
flexDirection="column"
|
|
539
|
+
alignItems="center"
|
|
540
|
+
justifyContent="center"
|
|
541
|
+
gap={1}
|
|
542
|
+
>
|
|
543
|
+
<text fg={COLORS.allow}><strong>{emptyTitle(entries, cwdQuery)}</strong></text>
|
|
544
|
+
<text fg={COLORS.muted}>{emptyDetail(entries, cwdQuery, activeFilter)}</text>
|
|
545
|
+
</box>
|
|
546
|
+
)
|
|
547
|
+
: (
|
|
548
|
+
<scrollbox
|
|
549
|
+
ref={queueRef}
|
|
550
|
+
height="100%"
|
|
551
|
+
focused={ready && focus === "queue" && noteEditor === undefined}
|
|
552
|
+
onMouseScroll={() => setFocus("queue")}
|
|
553
|
+
>
|
|
554
|
+
{visibleEntries.map((entry) => {
|
|
555
|
+
const entrySelected = entry.record.id === selected.record.id;
|
|
556
|
+
return (
|
|
557
|
+
<box
|
|
558
|
+
key={entry.record.id}
|
|
559
|
+
id={queueRowId(entry.record.id)}
|
|
560
|
+
flexDirection="column"
|
|
561
|
+
paddingLeft={1}
|
|
562
|
+
paddingRight={1}
|
|
563
|
+
backgroundColor={entrySelected
|
|
564
|
+
? COLORS.selection
|
|
565
|
+
: COLORS.panel}
|
|
566
|
+
onMouseDown={() => {
|
|
567
|
+
setSelectedRecordId(entry.record.id);
|
|
568
|
+
setFocus("queue");
|
|
569
|
+
}}
|
|
570
|
+
>
|
|
571
|
+
<text fg={entrySelected
|
|
572
|
+
? statusColor(getTrainingReviewFilter(entry))
|
|
573
|
+
: COLORS.text}>
|
|
574
|
+
{`${entrySelected ? "▶" : " "} ${summarizeCommand(entry.record.command, horizontal ? queueSize - 6 : width - 8)}`}
|
|
575
|
+
</text>
|
|
576
|
+
<text fg={entrySelected ? COLORS.accent : COLORS.muted}>
|
|
577
|
+
{` ${statusLabel(getTrainingReviewFilter(entry))} · ${formatEvaluationCost(entry)} · ${entry.reviews.length}r`}
|
|
578
|
+
</text>
|
|
579
|
+
</box>
|
|
580
|
+
);
|
|
581
|
+
})}
|
|
582
|
+
</scrollbox>
|
|
583
|
+
)}
|
|
584
|
+
</box>
|
|
585
|
+
|
|
586
|
+
<box
|
|
587
|
+
title=" Evidence and review history "
|
|
588
|
+
titleColor={selected === undefined
|
|
589
|
+
? COLORS.muted
|
|
590
|
+
: statusColor(getTrainingReviewFilter(selected))}
|
|
591
|
+
border
|
|
592
|
+
borderColor={focus === "detail" && noteEditor === undefined
|
|
593
|
+
? COLORS.accent
|
|
594
|
+
: COLORS.border}
|
|
595
|
+
backgroundColor={COLORS.panel}
|
|
596
|
+
flexGrow={1}
|
|
597
|
+
minHeight={10}
|
|
598
|
+
onMouseDown={() => setFocus("detail")}
|
|
599
|
+
onMouseScroll={() => setFocus("detail")}
|
|
600
|
+
>
|
|
601
|
+
{selected === undefined
|
|
602
|
+
? (
|
|
603
|
+
<box
|
|
604
|
+
width="100%"
|
|
605
|
+
height="100%"
|
|
606
|
+
alignItems="center"
|
|
607
|
+
justifyContent="center"
|
|
608
|
+
>
|
|
609
|
+
<text fg={COLORS.muted}>
|
|
610
|
+
Watching training state for new evaluations and reviews…
|
|
611
|
+
</text>
|
|
612
|
+
</box>
|
|
613
|
+
)
|
|
614
|
+
: (
|
|
615
|
+
<scrollbox
|
|
616
|
+
key={selected.record.id}
|
|
617
|
+
height="100%"
|
|
618
|
+
focused={ready && focus === "detail" && noteEditor === undefined}
|
|
619
|
+
onMouseScroll={() => setFocus("detail")}
|
|
620
|
+
>
|
|
621
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1} gap={1}>
|
|
622
|
+
<DetailRow
|
|
623
|
+
label="Latest human answer"
|
|
624
|
+
value={statusLabel(getTrainingReviewFilter(selected))}
|
|
625
|
+
color={statusColor(getTrainingReviewFilter(selected))}
|
|
626
|
+
/>
|
|
627
|
+
<DetailRow
|
|
628
|
+
label="Model decision"
|
|
629
|
+
value={selected.record.verdict.decision.toUpperCase()}
|
|
630
|
+
color={decisionColor(selected.record.verdict.decision)}
|
|
631
|
+
/>
|
|
632
|
+
<DetailRow label="Reason" value={selected.record.verdict.reason} color={undefined} />
|
|
633
|
+
<DetailRow label="Command" value={selected.record.command} color={COLORS.text} />
|
|
634
|
+
<DetailRow label="Working directory" value={selected.record.cwd} color={undefined} />
|
|
635
|
+
<DetailRow label="Captured" value={selected.record.recordedAt} color={undefined} />
|
|
636
|
+
<DetailRow label="Mode / host action" value={`${selected.record.mode} / ${selected.record.hostAction}`} color={undefined} />
|
|
637
|
+
{selected.record.verdict.failure === undefined
|
|
638
|
+
? null
|
|
639
|
+
: <DetailRow label="Failure" value={selected.record.verdict.failure} color={COLORS.error} />}
|
|
640
|
+
{selected.record.verdict.judgments === undefined
|
|
641
|
+
? null
|
|
642
|
+
: <JudgmentsTable entry={selected} />}
|
|
643
|
+
<DetailRow
|
|
644
|
+
label="Evaluation"
|
|
645
|
+
value={`${selected.record.verdict.latencyMs} ms${formatUsage(selected)}`}
|
|
646
|
+
color={undefined}
|
|
647
|
+
/>
|
|
648
|
+
<box flexDirection="column" gap={1}>
|
|
649
|
+
<text fg={COLORS.muted}>Review history</text>
|
|
650
|
+
{selected.reviews.length === 0
|
|
651
|
+
? <text fg={COLORS.text}>No human reviews yet.</text>
|
|
652
|
+
: selected.reviews.map((review, index) => (
|
|
653
|
+
<box key={`${review.reviewedAt}-${index}`} flexDirection="column">
|
|
654
|
+
<text fg={decisionColor(review.expectedDecision)}>
|
|
655
|
+
{`${index + 1}. ${review.expectedDecision.toUpperCase()} · ${review.reviewedAt}`}
|
|
656
|
+
</text>
|
|
657
|
+
{review.note === undefined
|
|
658
|
+
? null
|
|
659
|
+
: <text fg={COLORS.text} wrapMode="word">{review.note}</text>}
|
|
660
|
+
</box>
|
|
661
|
+
))}
|
|
662
|
+
</box>
|
|
663
|
+
</box>
|
|
664
|
+
</scrollbox>
|
|
665
|
+
)}
|
|
666
|
+
</box>
|
|
667
|
+
</box>
|
|
668
|
+
|
|
669
|
+
{noteEditor !== undefined && editingEntry !== undefined
|
|
670
|
+
? (
|
|
671
|
+
<box
|
|
672
|
+
title={` Review revision: ${statusLabel(getTrainingReviewFilter(editingEntry))} → ${noteEditor.expectedDecision} `}
|
|
673
|
+
titleColor={decisionColor(noteEditor.expectedDecision)}
|
|
674
|
+
border
|
|
675
|
+
borderColor={decisionColor(noteEditor.expectedDecision)}
|
|
676
|
+
height={5}
|
|
677
|
+
paddingLeft={1}
|
|
678
|
+
paddingRight={1}
|
|
679
|
+
flexDirection="column"
|
|
680
|
+
>
|
|
681
|
+
<input
|
|
682
|
+
placeholder="Optional correction note — Enter saves, Esc cancels"
|
|
683
|
+
focused
|
|
684
|
+
onSubmit={(note) => {
|
|
685
|
+
void persistDecision(
|
|
686
|
+
editingEntry,
|
|
687
|
+
noteEditor.expectedDecision,
|
|
688
|
+
typeof note === "string" ? note : undefined,
|
|
689
|
+
);
|
|
690
|
+
}}
|
|
691
|
+
/>
|
|
692
|
+
<text fg={COLORS.muted}>Enter save revision · Esc cancel</text>
|
|
693
|
+
</box>
|
|
694
|
+
)
|
|
695
|
+
: null}
|
|
696
|
+
|
|
697
|
+
{error === undefined
|
|
698
|
+
? null
|
|
699
|
+
: <text fg={COLORS.error}>Could not save review: {error}</text>}
|
|
700
|
+
{pollError === undefined
|
|
701
|
+
? null
|
|
702
|
+
: <text fg={COLORS.error}>Could not refresh training state: {pollError}</text>}
|
|
703
|
+
|
|
704
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
705
|
+
<text fg={COLORS.muted}>
|
|
706
|
+
{focus === "tabs"
|
|
707
|
+
? "←/→ select status · Down/Enter filter · Tab rotates"
|
|
708
|
+
: focus === "filter"
|
|
709
|
+
? "Type to fuzzy-search cwd · Up tabs · Down/Enter queue · Tab rotates"
|
|
710
|
+
: focus === "detail"
|
|
711
|
+
? "↑/↓ or j/k scroll · Page Up/Down page · Left queue · Tab rotates"
|
|
712
|
+
: "↑/↓ navigate · Page Up/Down page · Right details · ↑ from first filters · Enter original · 1/2/3 decide · s skip"}
|
|
713
|
+
</text>
|
|
714
|
+
<text fg={saving ? COLORS.ask : COLORS.muted}>
|
|
715
|
+
{saving ? "Saving…" : "q/Esc quit"}
|
|
716
|
+
</text>
|
|
717
|
+
</box>
|
|
718
|
+
</box>
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
type DetailRowProps = {
|
|
723
|
+
label: string;
|
|
724
|
+
value: string;
|
|
725
|
+
color: string | undefined;
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
function DetailRow(props: DetailRowProps): React.ReactNode {
|
|
729
|
+
return (
|
|
730
|
+
<box flexDirection="column">
|
|
731
|
+
<text fg={COLORS.muted}>{props.label}</text>
|
|
732
|
+
<text fg={props.color ?? COLORS.text} wrapMode="word" selectable>
|
|
733
|
+
{props.value}
|
|
734
|
+
</text>
|
|
735
|
+
</box>
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
type JudgmentRow = {
|
|
740
|
+
label: string;
|
|
741
|
+
value: string;
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
function JudgmentsTable(props: { entry: TrainingReviewEntry }): React.ReactNode {
|
|
745
|
+
const rows = judgmentRows(props.entry);
|
|
746
|
+
return (
|
|
747
|
+
<box flexDirection="column">
|
|
748
|
+
<text fg={COLORS.muted}>Judgments</text>
|
|
749
|
+
<box border borderColor={COLORS.border} flexDirection="column">
|
|
750
|
+
<box flexDirection="row" backgroundColor={COLORS.selection}>
|
|
751
|
+
<text width={30} fg={COLORS.accent}><strong>Judgment</strong></text>
|
|
752
|
+
<text flexGrow={1} fg={COLORS.accent}><strong>Score</strong></text>
|
|
753
|
+
</box>
|
|
754
|
+
{rows.map((row) => (
|
|
755
|
+
<box key={row.label} flexDirection="row">
|
|
756
|
+
<text width={30} fg={COLORS.text}>{row.label}</text>
|
|
757
|
+
<text flexGrow={1} fg={COLORS.text}>{row.value}</text>
|
|
758
|
+
</box>
|
|
759
|
+
))}
|
|
760
|
+
</box>
|
|
761
|
+
</box>
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Launch OpenTUI for complete training history and restore the terminal on exit.
|
|
767
|
+
*
|
|
768
|
+
* @param snapshot - Complete training state to present initially
|
|
769
|
+
* @param reloadSnapshot - Polling callback that returns current training state
|
|
770
|
+
* @param recordReview - Persistence callback for review revisions
|
|
771
|
+
* @returns Counts for the completed interactive session
|
|
772
|
+
*/
|
|
773
|
+
export async function runTrainingReviewTui(
|
|
774
|
+
snapshot: TrainingReviewSnapshot,
|
|
775
|
+
reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
|
|
776
|
+
recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
|
|
777
|
+
): Promise<TrainingReviewTuiResult> {
|
|
778
|
+
const renderer = await createCliRenderer({
|
|
779
|
+
exitOnCtrlC: false,
|
|
780
|
+
clearOnShutdown: true,
|
|
781
|
+
useMouse: true,
|
|
782
|
+
targetFps: 30,
|
|
783
|
+
});
|
|
784
|
+
const root = createRoot(renderer);
|
|
785
|
+
|
|
786
|
+
return await new Promise<TrainingReviewTuiResult>((resolve) => {
|
|
787
|
+
let finished = false;
|
|
788
|
+
const finish = (result: TrainingReviewTuiResult) => {
|
|
789
|
+
if (finished) return;
|
|
790
|
+
finished = true;
|
|
791
|
+
root.unmount();
|
|
792
|
+
renderer.destroy();
|
|
793
|
+
resolve(result);
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
root.render(
|
|
797
|
+
<TrainingReviewApp
|
|
798
|
+
snapshot={snapshot}
|
|
799
|
+
reloadSnapshot={reloadSnapshot}
|
|
800
|
+
recordReview={recordReview}
|
|
801
|
+
pollIntervalMs={undefined}
|
|
802
|
+
onExit={finish}
|
|
803
|
+
/>,
|
|
804
|
+
);
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function countByFilter(
|
|
809
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
810
|
+
): Record<TrainingReviewFilter, number> {
|
|
811
|
+
const counts: Record<TrainingReviewFilter, number> = {
|
|
812
|
+
all: entries.length,
|
|
813
|
+
unreviewed: 0,
|
|
814
|
+
allow: 0,
|
|
815
|
+
ask: 0,
|
|
816
|
+
deny: 0,
|
|
817
|
+
};
|
|
818
|
+
for (const entry of entries) counts[getTrainingReviewFilter(entry)] += 1;
|
|
819
|
+
return counts;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function trainingSnapshotsEqual(
|
|
823
|
+
left: TrainingReviewSnapshot,
|
|
824
|
+
right: TrainingReviewSnapshot,
|
|
825
|
+
): boolean {
|
|
826
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function queueRowId(recordId: string): string {
|
|
830
|
+
return `training-queue-${recordId}`;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function summarizeCommand(command: string, maximumLength: number): string {
|
|
834
|
+
const singleLine = command.replaceAll(/\s+/g, " ").trim();
|
|
835
|
+
if (singleLine.length <= maximumLength) return singleLine;
|
|
836
|
+
return `${singleLine.slice(0, Math.max(1, maximumLength - 1))}…`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function decisionForKey(
|
|
840
|
+
name: string,
|
|
841
|
+
sequence: string,
|
|
842
|
+
): Decision | undefined {
|
|
843
|
+
const key = sequence || name;
|
|
844
|
+
if (key === "1") return "allow";
|
|
845
|
+
if (key === "2") return "ask";
|
|
846
|
+
if (key === "3") return "deny";
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function statusLabel(filter: TrainingReviewFilter): string {
|
|
851
|
+
return {
|
|
852
|
+
all: "ALL",
|
|
853
|
+
unreviewed: "not reviewed",
|
|
854
|
+
allow: "APPROVED",
|
|
855
|
+
ask: "ASK",
|
|
856
|
+
deny: "DENY",
|
|
857
|
+
}[filter];
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function statusColor(filter: TrainingReviewFilter): string {
|
|
861
|
+
return filter === "all" || filter === "unreviewed"
|
|
862
|
+
? COLORS.accent
|
|
863
|
+
: decisionColor(filter);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function decisionColor(decision: Decision): string {
|
|
867
|
+
return {
|
|
868
|
+
allow: COLORS.allow,
|
|
869
|
+
ask: COLORS.ask,
|
|
870
|
+
deny: COLORS.deny,
|
|
871
|
+
}[decision];
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function emptyTitle(
|
|
875
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
876
|
+
cwdQuery: string,
|
|
877
|
+
): string {
|
|
878
|
+
if (entries.length === 0) return "No training history yet";
|
|
879
|
+
if (cwdQuery.trim() !== "") return "No matching working directories";
|
|
880
|
+
return "Nothing in this status";
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function emptyDetail(
|
|
884
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
885
|
+
cwdQuery: string,
|
|
886
|
+
filter: TrainingReviewFilter,
|
|
887
|
+
): string {
|
|
888
|
+
if (entries.length === 0) return "Waiting for captured evaluations";
|
|
889
|
+
if (cwdQuery.trim() !== "") return "Up to edit the cwd filter · Tab changes status";
|
|
890
|
+
if (filter === "all") return "No visible entries · Tab changes status";
|
|
891
|
+
return `No ${statusLabel(filter).toLowerCase()} entries · Tab changes status`;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function judgmentRows(
|
|
895
|
+
entry: TrainingReviewEntry,
|
|
896
|
+
): ReadonlyArray<JudgmentRow> {
|
|
897
|
+
const judgments = entry.record.verdict.judgments;
|
|
898
|
+
if (judgments === undefined) return [];
|
|
899
|
+
return [
|
|
900
|
+
{
|
|
901
|
+
label: "Executes destruction",
|
|
902
|
+
value: judgments.executesDestruction.toFixed(3),
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
label: "Sensitive-data exposure",
|
|
906
|
+
value: judgments.exposesSensitiveData.toFixed(3),
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
label: "Weakens security boundary",
|
|
910
|
+
value: judgments.weakensSecurityBoundary.toFixed(3),
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
label: "Unrecoverable",
|
|
914
|
+
value: judgments.unrecoverable.toFixed(3),
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
label: "Shared infrastructure",
|
|
918
|
+
value: judgments.targetsSharedInfrastructure.toFixed(3),
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
label: "Blast radius",
|
|
922
|
+
value: `${judgments.blastRadius.toFixed(2)} / 3`,
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
label: "Blast-radius confidence",
|
|
926
|
+
value: judgments.blastRadiusConfidence.toFixed(2),
|
|
927
|
+
},
|
|
928
|
+
];
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function formatEvaluationCost(entry: TrainingReviewEntry): string {
|
|
932
|
+
const inputTokens = entry.record.verdict.usage?.inputTokens;
|
|
933
|
+
return inputTokens === undefined
|
|
934
|
+
? "cost n/a"
|
|
935
|
+
: formatUsd(estimateInputCostUsd(inputTokens));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function formatUsage(entry: TrainingReviewEntry): string {
|
|
939
|
+
const usage = entry.record.verdict.usage;
|
|
940
|
+
return usage === undefined
|
|
941
|
+
? ""
|
|
942
|
+
: ` · ${usage.inputTokens} input / ${usage.outputTokens} output tokens · estimated cost ${formatUsd(estimateInputCostUsd(usage.inputTokens))}`;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function errorDetail(error: unknown): string {
|
|
946
|
+
return error instanceof Error ? error.message : String(error);
|
|
947
|
+
}
|