@wyattjoh/demur 0.4.2 → 0.6.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,1231 @@
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 { DemurSettings } from "../extensions/demur/settings.ts";
16
+ import type {
17
+ TrainingReview,
18
+ TrainingReviewInput,
19
+ } from "../extensions/demur/training-store.ts";
20
+ import {
21
+ changeDemurSetting,
22
+ type DemurSettingKey,
23
+ } from "./settings-model.ts";
24
+ import {
25
+ buildTrainingReviewEntries,
26
+ createTrainingReviewInput,
27
+ filterTrainingReviewEntries,
28
+ getLatestTrainingReview,
29
+ getTrainingReviewFilter,
30
+ type TrainingReviewEntry,
31
+ type TrainingReviewFilter,
32
+ type TrainingReviewSnapshot,
33
+ } from "./training-review-model.ts";
34
+ import type { Decision } from "./types.ts";
35
+
36
+ const POLL_INTERVAL_MS = 1_000;
37
+
38
+ const COLORS = {
39
+ background: "#111318",
40
+ panel: "#181b22",
41
+ border: "#3b4252",
42
+ accent: "#88c0d0",
43
+ text: "#eceff4",
44
+ muted: "#8f98a8",
45
+ allow: "#a3be8c",
46
+ allowMuted: "#78906a",
47
+ ask: "#ebcb8b",
48
+ askMuted: "#a28f68",
49
+ deny: "#bf616a",
50
+ denyMuted: "#87515a",
51
+ selection: "#2e3440",
52
+ error: "#ff6b7a",
53
+ } as const;
54
+
55
+ const FILTERS: ReadonlyArray<{
56
+ value: TrainingReviewFilter;
57
+ label: string;
58
+ }> = [
59
+ { value: "all", label: "all" },
60
+ { value: "unreviewed", label: "not reviewed" },
61
+ { value: "allow", label: "approved" },
62
+ { value: "ask", label: "ask" },
63
+ { value: "deny", label: "deny" },
64
+ ];
65
+
66
+ const SECTIONS: ReadonlyArray<{ value: AppSection; label: string }> = [
67
+ { value: "reviews", label: "Reviews" },
68
+ { value: "settings", label: "Settings" },
69
+ ];
70
+
71
+ const SETTING_ROWS: ReadonlyArray<{
72
+ key: DemurSettingKey;
73
+ label: string;
74
+ description: string;
75
+ }> = [
76
+ {
77
+ key: "mode",
78
+ label: "Operating mode",
79
+ description: "Enforce decisions, observe passively, or bypass the guard.",
80
+ },
81
+ {
82
+ key: "training",
83
+ label: "Training capture",
84
+ description: "Append full evaluations for later human review.",
85
+ },
86
+ {
87
+ key: "failurePolicy",
88
+ label: "Failure policy",
89
+ description: "Action when no trustworthy judgment is available.",
90
+ },
91
+ ];
92
+
93
+ /**
94
+ * Summary returned after an interactive training-review session.
95
+ */
96
+ export type TrainingReviewTuiResult = {
97
+ reviewed: number;
98
+ corrected: number;
99
+ skipped: number;
100
+ };
101
+
102
+ type TrainingReviewAppProps = {
103
+ snapshot: TrainingReviewSnapshot;
104
+ settings: DemurSettings;
105
+ reloadSnapshot(): Promise<TrainingReviewSnapshot>;
106
+ recordReview(input: TrainingReviewInput): Promise<TrainingReview>;
107
+ saveSettings(settings: DemurSettings): Promise<void>;
108
+ pollIntervalMs: number | undefined;
109
+ onExit(result: TrainingReviewTuiResult): void;
110
+ };
111
+
112
+ type NoteEditorState = {
113
+ recordId: string;
114
+ expectedDecision: Decision;
115
+ };
116
+
117
+ type AppSection = "reviews" | "settings";
118
+
119
+ type FocusTarget =
120
+ | "sections"
121
+ | "tabs"
122
+ | "filter"
123
+ | "queue"
124
+ | "detail"
125
+ | "settings";
126
+
127
+ /**
128
+ * Render the interactive historical training-review queue.
129
+ *
130
+ * @param props - Training state, persistence callbacks, and exit callback
131
+ * @returns OpenTUI React application tree
132
+ */
133
+ export function TrainingReviewApp(
134
+ props: TrainingReviewAppProps,
135
+ ): React.ReactNode {
136
+ const { width, height } = useTerminalDimensions();
137
+ const queueRef = useRef<ScrollBoxRenderable | null>(null);
138
+ const [snapshot, setSnapshot] = useState<TrainingReviewSnapshot>(
139
+ props.snapshot,
140
+ );
141
+ const [section, setSection] = useState<AppSection>("reviews");
142
+ const [settings, setSettings] = useState<DemurSettings>(props.settings);
143
+ const [selectedSettingIndex, setSelectedSettingIndex] = useState(0);
144
+ const [savingSettings, setSavingSettings] = useState(false);
145
+ const [settingsError, setSettingsError] = useState<string | undefined>();
146
+ const [activeFilter, setActiveFilter] = useState<TrainingReviewFilter>(
147
+ "all",
148
+ );
149
+ const [cwdQuery, setCwdQuery] = useState("");
150
+ const [ready, setReady] = useState(false);
151
+ const [focus, setFocus] = useState<FocusTarget>("queue");
152
+ const [selectedRecordId, setSelectedRecordId] = useState<
153
+ string | undefined
154
+ >(undefined);
155
+ const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(
156
+ new Set(),
157
+ );
158
+ const [noteEditor, setNoteEditor] = useState<NoteEditorState | undefined>();
159
+ const [saving, setSaving] = useState(false);
160
+ const [error, setError] = useState<string | undefined>();
161
+ const [pollError, setPollError] = useState<string | undefined>();
162
+ const [result, setResult] = useState<TrainingReviewTuiResult>({
163
+ reviewed: 0,
164
+ corrected: 0,
165
+ skipped: 0,
166
+ });
167
+
168
+ const entries = useMemo(
169
+ () => buildTrainingReviewEntries(snapshot),
170
+ [snapshot],
171
+ );
172
+ const visibleEntries = useMemo(
173
+ () => filterTrainingReviewEntries(entries, activeFilter, cwdQuery)
174
+ .filter((entry) => !dismissedIds.has(entry.record.id)),
175
+ [activeFilter, cwdQuery, dismissedIds, entries],
176
+ );
177
+ const selectedIndex = Math.max(
178
+ 0,
179
+ visibleEntries.findIndex(
180
+ (entry) => entry.record.id === selectedRecordId,
181
+ ),
182
+ );
183
+ const selected = visibleEntries[selectedIndex];
184
+ const latestReview = selected === undefined
185
+ ? undefined
186
+ : getLatestTrainingReview(selected);
187
+ const editingEntry = noteEditor === undefined
188
+ ? undefined
189
+ : entries.find((entry) => entry.record.id === noteEditor.recordId);
190
+ const counts = useMemo(
191
+ () => countByFilter(entries),
192
+ [entries],
193
+ );
194
+ const horizontal = width >= 92;
195
+ const queueSize = horizontal
196
+ ? Math.max(28, Math.min(42, Math.floor(width * 0.34)))
197
+ : Math.max(7, Math.min(11, Math.floor(height * 0.32)));
198
+ const queuePageSize = Math.max(
199
+ 1,
200
+ Math.floor((horizontal ? height - 16 : queueSize - 2) / 2),
201
+ );
202
+ useEffect(() => {
203
+ const timer = setTimeout(() => setReady(true), 250);
204
+ return () => clearTimeout(timer);
205
+ }, []);
206
+
207
+ useEffect(() => {
208
+ let active = true;
209
+ let polling = false;
210
+
211
+ const poll = async () => {
212
+ if (polling) return;
213
+ polling = true;
214
+ try {
215
+ const loaded = await props.reloadSnapshot();
216
+ if (!active) return;
217
+ setSnapshot((current) =>
218
+ trainingSnapshotsEqual(current, loaded) ? current : loaded
219
+ );
220
+ setPollError(undefined);
221
+ } catch (cause: unknown) {
222
+ if (active) setPollError(errorDetail(cause));
223
+ } finally {
224
+ polling = false;
225
+ }
226
+ };
227
+
228
+ void poll();
229
+ const timer = setInterval(
230
+ () => void poll(),
231
+ props.pollIntervalMs ?? POLL_INTERVAL_MS,
232
+ );
233
+ return () => {
234
+ active = false;
235
+ clearInterval(timer);
236
+ };
237
+ }, [props.pollIntervalMs, props.reloadSnapshot]);
238
+
239
+ useEffect(() => {
240
+ if (
241
+ noteEditor !== undefined &&
242
+ !entries.some((entry) => entry.record.id === noteEditor.recordId)
243
+ ) {
244
+ setNoteEditor(undefined);
245
+ }
246
+ }, [entries, noteEditor]);
247
+
248
+ useEffect(() => {
249
+ if (selected === undefined) return;
250
+ queueRef.current?.scrollChildIntoView(queueRowId(selected.record.id));
251
+ }, [selected]);
252
+
253
+ const finish = (nextResult: TrainingReviewTuiResult) => {
254
+ props.onExit(nextResult);
255
+ };
256
+
257
+ const persistSetting = async (
258
+ key: DemurSettingKey,
259
+ direction: number,
260
+ ) => {
261
+ if (savingSettings) return;
262
+ const nextSettings = changeDemurSetting(settings, key, direction);
263
+ if (nextSettings === settings) return;
264
+
265
+ setSavingSettings(true);
266
+ setSettingsError(undefined);
267
+ try {
268
+ await props.saveSettings(nextSettings);
269
+ setSettings(nextSettings);
270
+ } catch (cause: unknown) {
271
+ setSettingsError(errorDetail(cause));
272
+ } finally {
273
+ setSavingSettings(false);
274
+ }
275
+ };
276
+
277
+ const persistDecision = async (
278
+ entry: TrainingReviewEntry,
279
+ expectedDecision: Decision,
280
+ note: string | undefined,
281
+ ) => {
282
+ if (saving) return;
283
+ const previous = getLatestTrainingReview(entry);
284
+ if (previous?.expectedDecision === expectedDecision) {
285
+ setNoteEditor(undefined);
286
+ return;
287
+ }
288
+
289
+ setSaving(true);
290
+ setError(undefined);
291
+ const corrected = expectedDecision !== entry.record.verdict.decision;
292
+ try {
293
+ const review = await props.recordReview(
294
+ createTrainingReviewInput(entry.record, expectedDecision, note),
295
+ );
296
+ setSnapshot((current) =>
297
+ current.reviews.some((existing) =>
298
+ existing.recordId === review.recordId &&
299
+ existing.reviewedAt === review.reviewedAt
300
+ )
301
+ ? current
302
+ : {
303
+ records: current.records,
304
+ reviews: [...current.reviews, review],
305
+ globalEstimatedCostUsd: current.globalEstimatedCostUsd,
306
+ }
307
+ );
308
+ setResult((current) => ({
309
+ ...current,
310
+ reviewed: current.reviewed + 1,
311
+ corrected: current.corrected + (corrected ? 1 : 0),
312
+ }));
313
+ setNoteEditor(undefined);
314
+ setError(undefined);
315
+ } catch (cause: unknown) {
316
+ setError(errorDetail(cause));
317
+ } finally {
318
+ setSaving(false);
319
+ }
320
+ };
321
+
322
+ const chooseDecision = (decision: Decision) => {
323
+ if (selected === undefined || saving) return;
324
+ if (decision === selected.record.verdict.decision) {
325
+ void persistDecision(selected, decision, undefined);
326
+ return;
327
+ }
328
+ setError(undefined);
329
+ setNoteEditor({
330
+ recordId: selected.record.id,
331
+ expectedDecision: decision,
332
+ });
333
+ };
334
+
335
+ const skipSelected = () => {
336
+ if (selected === undefined || saving) return;
337
+ setDismissedIds((current) => new Set([
338
+ ...current,
339
+ selected.record.id,
340
+ ]));
341
+ setResult((current) => ({ ...current, skipped: current.skipped + 1 }));
342
+ setNoteEditor(undefined);
343
+ setError(undefined);
344
+ };
345
+
346
+ const rotateFilter = (reverse: boolean) => {
347
+ const currentIndex = FILTERS.findIndex(
348
+ (filter) => filter.value === activeFilter,
349
+ );
350
+ const offset = reverse ? FILTERS.length - 1 : 1;
351
+ const next = FILTERS[(currentIndex + offset) % FILTERS.length];
352
+ if (next !== undefined) setActiveFilter(next.value);
353
+ };
354
+
355
+ const moveFilterSelection = (offset: number) => {
356
+ const currentIndex = FILTERS.findIndex(
357
+ (filter) => filter.value === activeFilter,
358
+ );
359
+ const bounded = Math.max(
360
+ 0,
361
+ Math.min(currentIndex + offset, FILTERS.length - 1),
362
+ );
363
+ const filter = FILTERS[bounded];
364
+ if (filter !== undefined) setActiveFilter(filter.value);
365
+ };
366
+
367
+ const moveQueueSelection = (index: number) => {
368
+ const bounded = Math.max(0, Math.min(index, visibleEntries.length - 1));
369
+ const entry = visibleEntries[bounded];
370
+ if (entry !== undefined) setSelectedRecordId(entry.record.id);
371
+ };
372
+
373
+ useKeyboard((key) => {
374
+ if (!ready) {
375
+ key.preventDefault();
376
+ return;
377
+ }
378
+
379
+ if (key.ctrl && key.name === "c") {
380
+ key.preventDefault();
381
+ finish(result);
382
+ return;
383
+ }
384
+
385
+ if (noteEditor !== undefined) {
386
+ if (key.name === "escape") {
387
+ key.preventDefault();
388
+ setNoteEditor(undefined);
389
+ setError(undefined);
390
+ } else if (key.name === "tab") {
391
+ key.preventDefault();
392
+ }
393
+ return;
394
+ }
395
+
396
+ const sectionKey = key.sequence || key.name;
397
+ if (focus !== "filter" && (sectionKey === "[" || sectionKey === "]")) {
398
+ key.preventDefault();
399
+ const nextSection = sectionKey === "[" ? "reviews" : "settings";
400
+ setSection(nextSection);
401
+ setFocus(nextSection === "reviews" ? "tabs" : "settings");
402
+ return;
403
+ }
404
+
405
+ if (focus === "sections") {
406
+ if (key.name === "left" || key.name === "right") {
407
+ key.preventDefault();
408
+ setSection((current) =>
409
+ current === "reviews" ? "settings" : "reviews"
410
+ );
411
+ return;
412
+ }
413
+ if (key.name === "down" || key.name === "return") {
414
+ key.preventDefault();
415
+ setFocus(section === "reviews" ? "tabs" : "settings");
416
+ return;
417
+ }
418
+ if (key.name === "q" || key.name === "escape") {
419
+ key.preventDefault();
420
+ finish(result);
421
+ }
422
+ return;
423
+ }
424
+
425
+ if (section === "settings") {
426
+ if (key.name === "q" || key.name === "escape") {
427
+ key.preventDefault();
428
+ finish(result);
429
+ return;
430
+ }
431
+ if (key.name === "up") {
432
+ key.preventDefault();
433
+ if (selectedSettingIndex === 0) {
434
+ setFocus("sections");
435
+ } else {
436
+ setSelectedSettingIndex((current) => current - 1);
437
+ }
438
+ return;
439
+ }
440
+ if (key.name === "down") {
441
+ key.preventDefault();
442
+ setSelectedSettingIndex((current) =>
443
+ Math.min(current + 1, SETTING_ROWS.length - 1)
444
+ );
445
+ return;
446
+ }
447
+ if (
448
+ key.name === "left" ||
449
+ key.name === "right" ||
450
+ key.name === "return" ||
451
+ key.name === "space" ||
452
+ key.sequence === " "
453
+ ) {
454
+ key.preventDefault();
455
+ const row = SETTING_ROWS[selectedSettingIndex];
456
+ if (row !== undefined) {
457
+ void persistSetting(row.key, key.name === "left" ? -1 : 1);
458
+ }
459
+ }
460
+ return;
461
+ }
462
+
463
+ if (key.name === "tab") {
464
+ key.preventDefault();
465
+ rotateFilter(key.shift);
466
+ return;
467
+ }
468
+
469
+ if (focus === "tabs") {
470
+ if (key.name === "up") {
471
+ key.preventDefault();
472
+ setFocus("sections");
473
+ return;
474
+ }
475
+ if (key.name === "left" || key.name === "right") {
476
+ key.preventDefault();
477
+ moveFilterSelection(key.name === "left" ? -1 : 1);
478
+ return;
479
+ }
480
+ if (key.name === "down" || key.name === "return") {
481
+ key.preventDefault();
482
+ setFocus("filter");
483
+ return;
484
+ }
485
+ if (key.name === "q" || key.name === "escape") {
486
+ key.preventDefault();
487
+ finish(result);
488
+ }
489
+ return;
490
+ }
491
+
492
+ if (focus === "filter") {
493
+ if (key.name === "up") {
494
+ key.preventDefault();
495
+ setFocus("tabs");
496
+ return;
497
+ }
498
+ if (key.name === "down" || key.name === "return") {
499
+ key.preventDefault();
500
+ setFocus("queue");
501
+ return;
502
+ }
503
+ if (key.name === "escape") {
504
+ key.preventDefault();
505
+ if (cwdQuery === "") setFocus("queue");
506
+ else setCwdQuery("");
507
+ }
508
+ return;
509
+ }
510
+
511
+ if (focus === "detail") {
512
+ if (key.name === "left") {
513
+ key.preventDefault();
514
+ setFocus("queue");
515
+ return;
516
+ }
517
+ if (key.name === "q" || key.name === "escape") {
518
+ key.preventDefault();
519
+ finish(result);
520
+ }
521
+ return;
522
+ }
523
+
524
+ if (key.name === "right") {
525
+ key.preventDefault();
526
+ setFocus("detail");
527
+ return;
528
+ }
529
+ if (key.name === "j" || key.name === "k") {
530
+ key.preventDefault();
531
+ return;
532
+ }
533
+ if (key.name === "up") {
534
+ key.preventDefault();
535
+ if (visibleEntries.length === 0 || selectedIndex === 0) {
536
+ setFocus("filter");
537
+ } else {
538
+ moveQueueSelection(selectedIndex - 1);
539
+ }
540
+ return;
541
+ }
542
+ if (key.name === "down") {
543
+ key.preventDefault();
544
+ moveQueueSelection(selectedIndex + 1);
545
+ return;
546
+ }
547
+ if (key.name === "pageup") {
548
+ key.preventDefault();
549
+ moveQueueSelection(selectedIndex - queuePageSize);
550
+ return;
551
+ }
552
+ if (key.name === "pagedown") {
553
+ key.preventDefault();
554
+ moveQueueSelection(selectedIndex + queuePageSize);
555
+ return;
556
+ }
557
+
558
+ if (saving) return;
559
+ if (key.name === "q" || key.name === "escape") {
560
+ key.preventDefault();
561
+ finish(result);
562
+ return;
563
+ }
564
+ if (key.name === "s") {
565
+ key.preventDefault();
566
+ skipSelected();
567
+ return;
568
+ }
569
+ if (key.name === "return") {
570
+ key.preventDefault();
571
+ if (selected !== undefined) {
572
+ chooseDecision(selected.record.verdict.decision);
573
+ }
574
+ return;
575
+ }
576
+
577
+ const decision = decisionForKey(key.name, key.sequence);
578
+ if (decision !== undefined) {
579
+ key.preventDefault();
580
+ chooseDecision(decision);
581
+ }
582
+ });
583
+
584
+ return (
585
+ <box
586
+ width="100%"
587
+ height="100%"
588
+ flexDirection="column"
589
+ backgroundColor={COLORS.background}
590
+ padding={1}
591
+ gap={1}
592
+ >
593
+ <box flexDirection="row" justifyContent="space-between">
594
+ <box flexDirection="row" gap={1}>
595
+ <text fg={COLORS.accent}><strong>demur</strong></text>
596
+ <text fg={COLORS.muted}>
597
+ {`global est. ${formatUsd(snapshot.globalEstimatedCostUsd)}`}
598
+ </text>
599
+ </box>
600
+ <text fg={COLORS.muted}>
601
+ {`${entries.length} total · ${counts.unreviewed} new · ${counts.allow} approved · ${counts.ask} ask · ${counts.deny} deny`}
602
+ </text>
603
+ </box>
604
+
605
+ <box
606
+ title=" View "
607
+ titleColor={focus === "sections" ? COLORS.accent : COLORS.muted}
608
+ flexDirection="row"
609
+ gap={1}
610
+ border
611
+ borderColor={focus === "sections" ? COLORS.accent : COLORS.border}
612
+ backgroundColor={COLORS.panel}
613
+ height={3}
614
+ paddingLeft={1}
615
+ paddingRight={1}
616
+ >
617
+ {SECTIONS.map((candidate) => {
618
+ const active = candidate.value === section;
619
+ return (
620
+ <text
621
+ key={candidate.value}
622
+ fg={active ? COLORS.accent : COLORS.muted}
623
+ bg={active ? COLORS.selection : COLORS.background}
624
+ onMouseDown={() => {
625
+ setSection(candidate.value);
626
+ setFocus("sections");
627
+ }}
628
+ >
629
+ {` ${candidate.label} `}
630
+ </text>
631
+ );
632
+ })}
633
+ <text fg={COLORS.muted}>[ / ] switch · Up to focus · ←/→ switch</text>
634
+ </box>
635
+
636
+ {section === "reviews"
637
+ ? (
638
+ <>
639
+ <box
640
+ title=" Review status "
641
+ titleColor={focus === "tabs" ? COLORS.accent : COLORS.muted}
642
+ flexDirection="row"
643
+ gap={1}
644
+ border
645
+ borderColor={focus === "tabs" ? COLORS.accent : COLORS.border}
646
+ backgroundColor={COLORS.panel}
647
+ height={3}
648
+ paddingLeft={1}
649
+ paddingRight={1}
650
+ onMouseDown={() => setFocus("tabs")}
651
+ >
652
+ {FILTERS.map((filter) => {
653
+ const active = filter.value === activeFilter;
654
+ return (
655
+ <text
656
+ key={filter.value}
657
+ fg={active ? statusColor(filter.value) : COLORS.muted}
658
+ bg={active ? COLORS.selection : COLORS.background}
659
+ onMouseDown={() => {
660
+ setActiveFilter(filter.value);
661
+ setFocus("tabs");
662
+ }}
663
+ >
664
+ {` ${filter.label} `}
665
+ </text>
666
+ );
667
+ })}
668
+ <text fg={COLORS.muted}>Tab / Shift-Tab</text>
669
+ </box>
670
+
671
+ <box
672
+ title=" Working directory filter "
673
+ titleColor={focus === "filter" ? COLORS.accent : COLORS.muted}
674
+ border
675
+ borderColor={focus === "filter" ? COLORS.accent : COLORS.border}
676
+ backgroundColor={COLORS.panel}
677
+ height={3}
678
+ paddingLeft={1}
679
+ paddingRight={1}
680
+ onMouseDown={() => setFocus("filter")}
681
+ >
682
+ <input
683
+ value={cwdQuery}
684
+ placeholder="Up from the first result to filter by cwd"
685
+ focused={ready && focus === "filter"}
686
+ onInput={(value) => {
687
+ setCwdQuery(typeof value === "string" ? value : "");
688
+ }}
689
+ onSubmit={() => setFocus("queue")}
690
+ />
691
+ </box>
692
+
693
+ <box
694
+ flexDirection={horizontal ? "row" : "column"}
695
+ flexGrow={1}
696
+ gap={1}
697
+ >
698
+ <box
699
+ title={` Queue (${visibleEntries.length}) `}
700
+ titleColor={COLORS.accent}
701
+ border
702
+ borderColor={focus === "queue" && noteEditor === undefined
703
+ ? COLORS.accent
704
+ : COLORS.border}
705
+ backgroundColor={COLORS.panel}
706
+ width={horizontal ? queueSize : "100%"}
707
+ height={horizontal ? "100%" : queueSize}
708
+ onMouseDown={() => setFocus("queue")}
709
+ onMouseScroll={() => setFocus("queue")}
710
+ >
711
+ {selected === undefined
712
+ ? (
713
+ <box
714
+ width="100%"
715
+ height="100%"
716
+ flexDirection="column"
717
+ alignItems="center"
718
+ justifyContent="center"
719
+ gap={1}
720
+ >
721
+ <text fg={COLORS.allow}><strong>{emptyTitle(entries, cwdQuery)}</strong></text>
722
+ <text fg={COLORS.muted}>{emptyDetail(entries, cwdQuery, activeFilter)}</text>
723
+ </box>
724
+ )
725
+ : (
726
+ <scrollbox
727
+ ref={queueRef}
728
+ height="100%"
729
+ focused={ready && focus === "queue" && noteEditor === undefined}
730
+ onMouseScroll={() => setFocus("queue")}
731
+ >
732
+ {visibleEntries.map((entry) => {
733
+ const entrySelected = entry.record.id === selected.record.id;
734
+ return (
735
+ <box
736
+ key={entry.record.id}
737
+ id={queueRowId(entry.record.id)}
738
+ flexDirection="column"
739
+ paddingLeft={1}
740
+ paddingRight={1}
741
+ backgroundColor={entrySelected
742
+ ? COLORS.selection
743
+ : COLORS.panel}
744
+ onMouseDown={() => {
745
+ setSelectedRecordId(entry.record.id);
746
+ setFocus("queue");
747
+ }}
748
+ >
749
+ <text
750
+ fg={entrySelected
751
+ ? statusColor(getTrainingReviewFilter(entry))
752
+ : COLORS.text}
753
+ wrapMode="none"
754
+ truncate
755
+ >
756
+ {`${entrySelected ? "▶" : " "} ${summarizeCommand(entry.record.command, horizontal ? queueSize - 6 : width - 8)}`}
757
+ </text>
758
+ <text
759
+ fg={entrySelected ? COLORS.accent : COLORS.muted}
760
+ wrapMode="none"
761
+ truncate
762
+ >
763
+ {` ${statusLabel(getTrainingReviewFilter(entry))} · `}
764
+ <span
765
+ fg={mutedDecisionColor(
766
+ entry.record.verdict.decision,
767
+ )}
768
+ >
769
+ {entry.record.verdict.decision.toUpperCase()}
770
+ </span>
771
+ </text>
772
+ </box>
773
+ );
774
+ })}
775
+ </scrollbox>
776
+ )}
777
+ </box>
778
+
779
+ <box
780
+ title=" Evidence and review history "
781
+ titleColor={selected === undefined
782
+ ? COLORS.muted
783
+ : statusColor(getTrainingReviewFilter(selected))}
784
+ border
785
+ borderColor={focus === "detail" && noteEditor === undefined
786
+ ? COLORS.accent
787
+ : COLORS.border}
788
+ backgroundColor={COLORS.panel}
789
+ flexGrow={1}
790
+ minHeight={10}
791
+ onMouseDown={() => setFocus("detail")}
792
+ onMouseScroll={() => setFocus("detail")}
793
+ >
794
+ {selected === undefined
795
+ ? (
796
+ <box
797
+ width="100%"
798
+ height="100%"
799
+ alignItems="center"
800
+ justifyContent="center"
801
+ >
802
+ <text fg={COLORS.muted}>
803
+ Watching training state for new evaluations and reviews…
804
+ </text>
805
+ </box>
806
+ )
807
+ : (
808
+ <scrollbox
809
+ key={selected.record.id}
810
+ height="100%"
811
+ focused={ready && focus === "detail" && noteEditor === undefined}
812
+ onMouseScroll={() => setFocus("detail")}
813
+ >
814
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} gap={1}>
815
+ <DetailRow
816
+ label="Latest human answer"
817
+ value={statusLabel(getTrainingReviewFilter(selected))}
818
+ color={statusColor(getTrainingReviewFilter(selected))}
819
+ />
820
+ <DetailRow
821
+ label="Model decision"
822
+ value={selected.record.verdict.decision.toUpperCase()}
823
+ color={decisionColor(selected.record.verdict.decision)}
824
+ />
825
+ <DetailRow label="Reason" value={selected.record.verdict.reason} color={undefined} />
826
+ <DetailRow label="Command" value={selected.record.command} color={COLORS.text} />
827
+ <DetailRow label="Working directory" value={selected.record.cwd} color={undefined} />
828
+ <DetailRow label="Captured" value={selected.record.recordedAt} color={undefined} />
829
+ <DetailRow label="Mode / host action" value={`${selected.record.mode} / ${selected.record.hostAction}`} color={undefined} />
830
+ {selected.record.verdict.failure === undefined
831
+ ? null
832
+ : <DetailRow label="Failure" value={selected.record.verdict.failure} color={COLORS.error} />}
833
+ {selected.record.verdict.judgments === undefined
834
+ ? null
835
+ : <JudgmentsTable entry={selected} />}
836
+ <DetailRow
837
+ label="Evaluation"
838
+ value={`${selected.record.verdict.latencyMs} ms${formatUsage(selected)}`}
839
+ color={undefined}
840
+ />
841
+ <box flexDirection="column" gap={1}>
842
+ <text fg={COLORS.muted}>Review history</text>
843
+ {selected.reviews.length === 0
844
+ ? <text fg={COLORS.text}>No human reviews yet.</text>
845
+ : selected.reviews.map((review, index) => (
846
+ <box key={`${review.reviewedAt}-${index}`} flexDirection="column">
847
+ <text fg={decisionColor(review.expectedDecision)}>
848
+ {`${index + 1}. ${review.expectedDecision.toUpperCase()} · ${review.reviewedAt}`}
849
+ </text>
850
+ {review.note === undefined
851
+ ? null
852
+ : <text fg={COLORS.text} wrapMode="word">{review.note}</text>}
853
+ </box>
854
+ ))}
855
+ </box>
856
+ </box>
857
+ </scrollbox>
858
+ )}
859
+ </box>
860
+ </box>
861
+
862
+ {noteEditor !== undefined && editingEntry !== undefined
863
+ ? (
864
+ <box
865
+ title={` Review revision: ${statusLabel(getTrainingReviewFilter(editingEntry))} → ${noteEditor.expectedDecision} `}
866
+ titleColor={decisionColor(noteEditor.expectedDecision)}
867
+ border
868
+ borderColor={decisionColor(noteEditor.expectedDecision)}
869
+ height={5}
870
+ paddingLeft={1}
871
+ paddingRight={1}
872
+ flexDirection="column"
873
+ >
874
+ <input
875
+ placeholder="Optional correction note — Enter saves, Esc cancels"
876
+ focused
877
+ onSubmit={(note) => {
878
+ void persistDecision(
879
+ editingEntry,
880
+ noteEditor.expectedDecision,
881
+ typeof note === "string" ? note : undefined,
882
+ );
883
+ }}
884
+ />
885
+ <text fg={COLORS.muted}>Enter save revision · Esc cancel</text>
886
+ </box>
887
+ )
888
+ : null}
889
+
890
+ {error === undefined
891
+ ? null
892
+ : <text fg={COLORS.error}>Could not save review: {error}</text>}
893
+ {pollError === undefined
894
+ ? null
895
+ : <text fg={COLORS.error}>Could not refresh training state: {pollError}</text>}
896
+
897
+ <box flexDirection="row" justifyContent="space-between">
898
+ <text fg={COLORS.muted}>
899
+ {focus === "tabs"
900
+ ? "←/→ select status · Up sections · Down/Enter filter · Tab rotates"
901
+ : focus === "filter"
902
+ ? "Type to fuzzy-search cwd · Up tabs · Down/Enter queue · Tab rotates"
903
+ : focus === "detail"
904
+ ? "↑/↓ or j/k scroll · Page Up/Down page · Left queue · Tab rotates"
905
+ : "↑/↓ navigate · Page Up/Down page · Right details · ↑ from first filters · Enter original · 1/2/3 decide · s skip"}
906
+ </text>
907
+ <text fg={saving ? COLORS.ask : COLORS.muted}>
908
+ {saving ? "Saving…" : "q/Esc quit"}
909
+ </text>
910
+ </box>
911
+ </>
912
+ )
913
+ : (
914
+ <>
915
+ <box
916
+ title=" Pi extension settings "
917
+ titleColor={COLORS.accent}
918
+ border
919
+ borderColor={COLORS.accent}
920
+ backgroundColor={COLORS.panel}
921
+ flexGrow={1}
922
+ flexDirection="column"
923
+ padding={1}
924
+ gap={1}
925
+ >
926
+ <text fg={COLORS.muted} wrapMode="word">
927
+ Changes are saved globally and picked up by Pi before its next Bash call.
928
+ </text>
929
+ {SETTING_ROWS.map((row, index) => {
930
+ const selectedRow = index === selectedSettingIndex;
931
+ const unavailable = row.key === "training" &&
932
+ settings.mode === "disabled";
933
+ return (
934
+ <box
935
+ key={row.key}
936
+ flexDirection="column"
937
+ backgroundColor={selectedRow
938
+ ? COLORS.selection
939
+ : COLORS.panel}
940
+ paddingLeft={1}
941
+ paddingRight={1}
942
+ onMouseDown={() => {
943
+ setSelectedSettingIndex(index);
944
+ setFocus("settings");
945
+ }}
946
+ >
947
+ <box flexDirection="row">
948
+ <text
949
+ width={24}
950
+ fg={selectedRow ? COLORS.accent : COLORS.text}
951
+ >
952
+ {`${selectedRow ? "▶" : " "} ${row.label}`}
953
+ </text>
954
+ <text fg={unavailable ? COLORS.muted : COLORS.allow}>
955
+ {settingDisplayValue(settings, row.key)}
956
+ </text>
957
+ </box>
958
+ <text fg={COLORS.muted} wrapMode="word">
959
+ {unavailable
960
+ ? "Unavailable while the operating mode is disabled."
961
+ : row.description}
962
+ </text>
963
+ </box>
964
+ );
965
+ })}
966
+ </box>
967
+
968
+ {settingsError === undefined
969
+ ? null
970
+ : (
971
+ <text fg={COLORS.error}>
972
+ Could not save settings: {settingsError}
973
+ </text>
974
+ )}
975
+
976
+ <box flexDirection="row" justifyContent="space-between">
977
+ <text fg={COLORS.muted}>
978
+ ↑/↓ select · ←/→ change · Enter/Space next · [ reviews
979
+ </text>
980
+ <text fg={savingSettings ? COLORS.ask : COLORS.muted}>
981
+ {savingSettings ? "Saving…" : "q/Esc quit"}
982
+ </text>
983
+ </box>
984
+ </>
985
+ )}
986
+ </box>
987
+ );
988
+ }
989
+
990
+ type DetailRowProps = {
991
+ label: string;
992
+ value: string;
993
+ color: string | undefined;
994
+ };
995
+
996
+ function DetailRow(props: DetailRowProps): React.ReactNode {
997
+ return (
998
+ <box flexDirection="column">
999
+ <text fg={COLORS.muted}>{props.label}</text>
1000
+ <text fg={props.color ?? COLORS.text} wrapMode="word" selectable>
1001
+ {props.value}
1002
+ </text>
1003
+ </box>
1004
+ );
1005
+ }
1006
+
1007
+ type JudgmentRow = {
1008
+ label: string;
1009
+ value: string;
1010
+ };
1011
+
1012
+ function JudgmentsTable(props: { entry: TrainingReviewEntry }): React.ReactNode {
1013
+ const rows = judgmentRows(props.entry);
1014
+ return (
1015
+ <box flexDirection="column">
1016
+ <text fg={COLORS.muted}>Judgments</text>
1017
+ <box border borderColor={COLORS.border} flexDirection="column">
1018
+ <box flexDirection="row" backgroundColor={COLORS.selection}>
1019
+ <text width={30} fg={COLORS.accent}><strong>Judgment</strong></text>
1020
+ <text flexGrow={1} fg={COLORS.accent}><strong>Score</strong></text>
1021
+ </box>
1022
+ {rows.map((row) => (
1023
+ <box key={row.label} flexDirection="row">
1024
+ <text width={30} fg={COLORS.text}>{row.label}</text>
1025
+ <text flexGrow={1} fg={COLORS.text}>{row.value}</text>
1026
+ </box>
1027
+ ))}
1028
+ </box>
1029
+ </box>
1030
+ );
1031
+ }
1032
+
1033
+ /**
1034
+ * Launch OpenTUI for complete training history and restore the terminal on exit.
1035
+ *
1036
+ * @param snapshot - Complete training state to present initially
1037
+ * @param settings - Current globally persisted Pi extension settings
1038
+ * @param reloadSnapshot - Polling callback that returns current training state
1039
+ * @param recordReview - Persistence callback for review revisions
1040
+ * @param saveSettings - Atomic persistence callback for Pi extension settings
1041
+ * @returns Counts for the completed interactive session
1042
+ */
1043
+ export async function runTrainingReviewTui(
1044
+ snapshot: TrainingReviewSnapshot,
1045
+ settings: DemurSettings,
1046
+ reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
1047
+ recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
1048
+ saveSettings: (settings: DemurSettings) => Promise<void>,
1049
+ ): Promise<TrainingReviewTuiResult> {
1050
+ const renderer = await createCliRenderer({
1051
+ exitOnCtrlC: false,
1052
+ clearOnShutdown: true,
1053
+ useMouse: true,
1054
+ targetFps: 30,
1055
+ });
1056
+ const root = createRoot(renderer);
1057
+
1058
+ return await new Promise<TrainingReviewTuiResult>((resolve) => {
1059
+ let finished = false;
1060
+ const finish = (result: TrainingReviewTuiResult) => {
1061
+ if (finished) return;
1062
+ finished = true;
1063
+ root.unmount();
1064
+ renderer.destroy();
1065
+ resolve(result);
1066
+ };
1067
+
1068
+ root.render(
1069
+ <TrainingReviewApp
1070
+ snapshot={snapshot}
1071
+ settings={settings}
1072
+ reloadSnapshot={reloadSnapshot}
1073
+ recordReview={recordReview}
1074
+ saveSettings={saveSettings}
1075
+ pollIntervalMs={undefined}
1076
+ onExit={finish}
1077
+ />,
1078
+ );
1079
+ });
1080
+ }
1081
+
1082
+ function settingDisplayValue(
1083
+ settings: DemurSettings,
1084
+ key: DemurSettingKey,
1085
+ ): string {
1086
+ if (key === "mode") return settings.mode;
1087
+ if (key === "training") return settings.training ? "on" : "off";
1088
+ return settings.failurePolicy;
1089
+ }
1090
+
1091
+ function countByFilter(
1092
+ entries: ReadonlyArray<TrainingReviewEntry>,
1093
+ ): Record<TrainingReviewFilter, number> {
1094
+ const counts: Record<TrainingReviewFilter, number> = {
1095
+ all: entries.length,
1096
+ unreviewed: 0,
1097
+ allow: 0,
1098
+ ask: 0,
1099
+ deny: 0,
1100
+ };
1101
+ for (const entry of entries) counts[getTrainingReviewFilter(entry)] += 1;
1102
+ return counts;
1103
+ }
1104
+
1105
+ function trainingSnapshotsEqual(
1106
+ left: TrainingReviewSnapshot,
1107
+ right: TrainingReviewSnapshot,
1108
+ ): boolean {
1109
+ return JSON.stringify(left) === JSON.stringify(right);
1110
+ }
1111
+
1112
+ function queueRowId(recordId: string): string {
1113
+ return `training-queue-${recordId}`;
1114
+ }
1115
+
1116
+ function summarizeCommand(command: string, maximumLength: number): string {
1117
+ const singleLine = command.replaceAll(/\s+/g, " ").trim();
1118
+ if (singleLine.length <= maximumLength) return singleLine;
1119
+ return `${singleLine.slice(0, Math.max(1, maximumLength - 1))}…`;
1120
+ }
1121
+
1122
+ function decisionForKey(
1123
+ name: string,
1124
+ sequence: string,
1125
+ ): Decision | undefined {
1126
+ const key = sequence || name;
1127
+ if (key === "1") return "allow";
1128
+ if (key === "2") return "ask";
1129
+ if (key === "3") return "deny";
1130
+ return undefined;
1131
+ }
1132
+
1133
+ function statusLabel(filter: TrainingReviewFilter): string {
1134
+ return {
1135
+ all: "ALL",
1136
+ unreviewed: "not reviewed",
1137
+ allow: "APPROVED",
1138
+ ask: "ASK",
1139
+ deny: "DENY",
1140
+ }[filter];
1141
+ }
1142
+
1143
+ function statusColor(filter: TrainingReviewFilter): string {
1144
+ return filter === "all" || filter === "unreviewed"
1145
+ ? COLORS.accent
1146
+ : decisionColor(filter);
1147
+ }
1148
+
1149
+ function decisionColor(decision: Decision): string {
1150
+ return {
1151
+ allow: COLORS.allow,
1152
+ ask: COLORS.ask,
1153
+ deny: COLORS.deny,
1154
+ }[decision];
1155
+ }
1156
+
1157
+ function mutedDecisionColor(decision: Decision): string {
1158
+ return {
1159
+ allow: COLORS.allowMuted,
1160
+ ask: COLORS.askMuted,
1161
+ deny: COLORS.denyMuted,
1162
+ }[decision];
1163
+ }
1164
+
1165
+ function emptyTitle(
1166
+ entries: ReadonlyArray<TrainingReviewEntry>,
1167
+ cwdQuery: string,
1168
+ ): string {
1169
+ if (entries.length === 0) return "No training history yet";
1170
+ if (cwdQuery.trim() !== "") return "No matching working directories";
1171
+ return "Nothing in this status";
1172
+ }
1173
+
1174
+ function emptyDetail(
1175
+ entries: ReadonlyArray<TrainingReviewEntry>,
1176
+ cwdQuery: string,
1177
+ filter: TrainingReviewFilter,
1178
+ ): string {
1179
+ if (entries.length === 0) return "Waiting for captured evaluations";
1180
+ if (cwdQuery.trim() !== "") return "Up to edit the cwd filter · Tab changes status";
1181
+ if (filter === "all") return "No visible entries · Tab changes status";
1182
+ return `No ${statusLabel(filter).toLowerCase()} entries · Tab changes status`;
1183
+ }
1184
+
1185
+ function judgmentRows(
1186
+ entry: TrainingReviewEntry,
1187
+ ): ReadonlyArray<JudgmentRow> {
1188
+ const judgments = entry.record.verdict.judgments;
1189
+ if (judgments === undefined) return [];
1190
+ return [
1191
+ {
1192
+ label: "Executes destruction",
1193
+ value: judgments.executesDestruction.toFixed(3),
1194
+ },
1195
+ {
1196
+ label: "Sensitive-data exposure",
1197
+ value: judgments.exposesSensitiveData.toFixed(3),
1198
+ },
1199
+ {
1200
+ label: "Weakens security boundary",
1201
+ value: judgments.weakensSecurityBoundary.toFixed(3),
1202
+ },
1203
+ {
1204
+ label: "Unrecoverable",
1205
+ value: judgments.unrecoverable.toFixed(3),
1206
+ },
1207
+ {
1208
+ label: "Shared infrastructure",
1209
+ value: judgments.targetsSharedInfrastructure.toFixed(3),
1210
+ },
1211
+ {
1212
+ label: "Blast radius",
1213
+ value: `${judgments.blastRadius.toFixed(2)} / 3`,
1214
+ },
1215
+ {
1216
+ label: "Blast-radius confidence",
1217
+ value: judgments.blastRadiusConfidence.toFixed(2),
1218
+ },
1219
+ ];
1220
+ }
1221
+
1222
+ function formatUsage(entry: TrainingReviewEntry): string {
1223
+ const usage = entry.record.verdict.usage;
1224
+ return usage === undefined
1225
+ ? ""
1226
+ : ` · ${usage.inputTokens} input / ${usage.outputTokens} output tokens · estimated cost ${formatUsd(estimateInputCostUsd(usage.inputTokens))}`;
1227
+ }
1228
+
1229
+ function errorDetail(error: unknown): string {
1230
+ return error instanceof Error ? error.message : String(error);
1231
+ }