@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.
@@ -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
+ }