@wyattjoh/demur 0.5.0 → 0.7.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 +82 -25
- package/extensions/demur/index.ts +99 -15
- package/extensions/demur/training-store.ts +213 -35
- package/package.json +4 -2
- package/src/adapters/pi-worker.ts +21 -7
- package/src/cli.ts +554 -135
- package/src/guard.internal.ts +83 -33
- package/src/guard.ts +42 -2
- package/src/judge.ts +53 -7
- package/src/policy.ts +10 -1
- package/src/questions.ts +9 -1
- package/src/settings-model.ts +60 -0
- package/src/state.ts +14 -16
- package/src/training-evaluation.ts +408 -0
- package/src/training-review-model.ts +18 -2
- package/src/training-review-tui.tsx +408 -44
- package/src/types.ts +56 -0
|
@@ -12,15 +12,22 @@ import {
|
|
|
12
12
|
estimateInputCostUsd,
|
|
13
13
|
formatUsd,
|
|
14
14
|
} from "../extensions/demur/cost-tracker.ts";
|
|
15
|
+
import type { DemurSettings } from "../extensions/demur/settings.ts";
|
|
15
16
|
import type {
|
|
17
|
+
TrainingCorrectionReason,
|
|
16
18
|
TrainingReview,
|
|
17
19
|
TrainingReviewInput,
|
|
18
20
|
} from "../extensions/demur/training-store.ts";
|
|
21
|
+
import {
|
|
22
|
+
changeDemurSetting,
|
|
23
|
+
type DemurSettingKey,
|
|
24
|
+
} from "./settings-model.ts";
|
|
19
25
|
import {
|
|
20
26
|
buildTrainingReviewEntries,
|
|
21
27
|
createTrainingReviewInput,
|
|
22
28
|
filterTrainingReviewEntries,
|
|
23
29
|
getLatestTrainingReview,
|
|
30
|
+
getTrainingCorrectionReason,
|
|
24
31
|
getTrainingReviewFilter,
|
|
25
32
|
type TrainingReviewEntry,
|
|
26
33
|
type TrainingReviewFilter,
|
|
@@ -38,8 +45,11 @@ const COLORS = {
|
|
|
38
45
|
text: "#eceff4",
|
|
39
46
|
muted: "#8f98a8",
|
|
40
47
|
allow: "#a3be8c",
|
|
48
|
+
allowMuted: "#78906a",
|
|
41
49
|
ask: "#ebcb8b",
|
|
50
|
+
askMuted: "#a28f68",
|
|
42
51
|
deny: "#bf616a",
|
|
52
|
+
denyMuted: "#87515a",
|
|
43
53
|
selection: "#2e3440",
|
|
44
54
|
error: "#ff6b7a",
|
|
45
55
|
} as const;
|
|
@@ -55,6 +65,48 @@ const FILTERS: ReadonlyArray<{
|
|
|
55
65
|
{ value: "deny", label: "deny" },
|
|
56
66
|
];
|
|
57
67
|
|
|
68
|
+
const CORRECTION_REASONS: ReadonlyArray<{
|
|
69
|
+
value: TrainingCorrectionReason;
|
|
70
|
+
label: string;
|
|
71
|
+
}> = [
|
|
72
|
+
{ value: "inert-or-read-only", label: "Command is inert or read-only" },
|
|
73
|
+
{ value: "sensitive-data", label: "Sensitive-data judgment" },
|
|
74
|
+
{ value: "security-boundary", label: "Security-boundary judgment" },
|
|
75
|
+
{ value: "recoverability", label: "Recoverability judgment" },
|
|
76
|
+
{ value: "shared-infrastructure", label: "Local versus shared target" },
|
|
77
|
+
{ value: "blast-radius", label: "Blast-radius judgment" },
|
|
78
|
+
{ value: "static-uncertainty", label: "Static uncertainty gate" },
|
|
79
|
+
{ value: "missing-context", label: "Model was missing objective context" },
|
|
80
|
+
{ value: "service-failure", label: "TypeSafe or guard service failure" },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const SECTIONS: ReadonlyArray<{ value: AppSection; label: string }> = [
|
|
84
|
+
{ value: "reviews", label: "Reviews" },
|
|
85
|
+
{ value: "settings", label: "Settings" },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const SETTING_ROWS: ReadonlyArray<{
|
|
89
|
+
key: DemurSettingKey;
|
|
90
|
+
label: string;
|
|
91
|
+
description: string;
|
|
92
|
+
}> = [
|
|
93
|
+
{
|
|
94
|
+
key: "mode",
|
|
95
|
+
label: "Operating mode",
|
|
96
|
+
description: "Enforce decisions, observe passively, or bypass the guard.",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: "training",
|
|
100
|
+
label: "Training capture",
|
|
101
|
+
description: "Append full evaluations for later human review.",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: "failurePolicy",
|
|
105
|
+
label: "Failure policy",
|
|
106
|
+
description: "Action when no trustworthy judgment is available.",
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
58
110
|
/**
|
|
59
111
|
* Summary returned after an interactive training-review session.
|
|
60
112
|
*/
|
|
@@ -66,8 +118,10 @@ export type TrainingReviewTuiResult = {
|
|
|
66
118
|
|
|
67
119
|
type TrainingReviewAppProps = {
|
|
68
120
|
snapshot: TrainingReviewSnapshot;
|
|
121
|
+
settings: DemurSettings;
|
|
69
122
|
reloadSnapshot(): Promise<TrainingReviewSnapshot>;
|
|
70
123
|
recordReview(input: TrainingReviewInput): Promise<TrainingReview>;
|
|
124
|
+
saveSettings(settings: DemurSettings): Promise<void>;
|
|
71
125
|
pollIntervalMs: number | undefined;
|
|
72
126
|
onExit(result: TrainingReviewTuiResult): void;
|
|
73
127
|
};
|
|
@@ -75,9 +129,18 @@ type TrainingReviewAppProps = {
|
|
|
75
129
|
type NoteEditorState = {
|
|
76
130
|
recordId: string;
|
|
77
131
|
expectedDecision: Decision;
|
|
132
|
+
correctionReason: TrainingCorrectionReason | undefined;
|
|
78
133
|
};
|
|
79
134
|
|
|
80
|
-
type
|
|
135
|
+
type AppSection = "reviews" | "settings";
|
|
136
|
+
|
|
137
|
+
type FocusTarget =
|
|
138
|
+
| "sections"
|
|
139
|
+
| "tabs"
|
|
140
|
+
| "filter"
|
|
141
|
+
| "queue"
|
|
142
|
+
| "detail"
|
|
143
|
+
| "settings";
|
|
81
144
|
|
|
82
145
|
/**
|
|
83
146
|
* Render the interactive historical training-review queue.
|
|
@@ -93,6 +156,11 @@ export function TrainingReviewApp(
|
|
|
93
156
|
const [snapshot, setSnapshot] = useState<TrainingReviewSnapshot>(
|
|
94
157
|
props.snapshot,
|
|
95
158
|
);
|
|
159
|
+
const [section, setSection] = useState<AppSection>("reviews");
|
|
160
|
+
const [settings, setSettings] = useState<DemurSettings>(props.settings);
|
|
161
|
+
const [selectedSettingIndex, setSelectedSettingIndex] = useState(0);
|
|
162
|
+
const [savingSettings, setSavingSettings] = useState(false);
|
|
163
|
+
const [settingsError, setSettingsError] = useState<string | undefined>();
|
|
96
164
|
const [activeFilter, setActiveFilter] = useState<TrainingReviewFilter>(
|
|
97
165
|
"all",
|
|
98
166
|
);
|
|
@@ -147,7 +215,7 @@ export function TrainingReviewApp(
|
|
|
147
215
|
: Math.max(7, Math.min(11, Math.floor(height * 0.32)));
|
|
148
216
|
const queuePageSize = Math.max(
|
|
149
217
|
1,
|
|
150
|
-
Math.floor((horizontal ? height -
|
|
218
|
+
Math.floor((horizontal ? height - 16 : queueSize - 2) / 2),
|
|
151
219
|
);
|
|
152
220
|
useEffect(() => {
|
|
153
221
|
const timer = setTimeout(() => setReady(true), 250);
|
|
@@ -204,14 +272,39 @@ export function TrainingReviewApp(
|
|
|
204
272
|
props.onExit(nextResult);
|
|
205
273
|
};
|
|
206
274
|
|
|
275
|
+
const persistSetting = async (
|
|
276
|
+
key: DemurSettingKey,
|
|
277
|
+
direction: number,
|
|
278
|
+
) => {
|
|
279
|
+
if (savingSettings) return;
|
|
280
|
+
const nextSettings = changeDemurSetting(settings, key, direction);
|
|
281
|
+
if (nextSettings === settings) return;
|
|
282
|
+
|
|
283
|
+
setSavingSettings(true);
|
|
284
|
+
setSettingsError(undefined);
|
|
285
|
+
try {
|
|
286
|
+
await props.saveSettings(nextSettings);
|
|
287
|
+
setSettings(nextSettings);
|
|
288
|
+
} catch (cause: unknown) {
|
|
289
|
+
setSettingsError(errorDetail(cause));
|
|
290
|
+
} finally {
|
|
291
|
+
setSavingSettings(false);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
207
295
|
const persistDecision = async (
|
|
208
296
|
entry: TrainingReviewEntry,
|
|
209
297
|
expectedDecision: Decision,
|
|
298
|
+
correctionReason: TrainingCorrectionReason | undefined,
|
|
210
299
|
note: string | undefined,
|
|
211
300
|
) => {
|
|
212
301
|
if (saving) return;
|
|
213
302
|
const previous = getLatestTrainingReview(entry);
|
|
214
|
-
if (
|
|
303
|
+
if (
|
|
304
|
+
previous?.expectedDecision === expectedDecision &&
|
|
305
|
+
correctionReason === undefined &&
|
|
306
|
+
note === undefined
|
|
307
|
+
) {
|
|
215
308
|
setNoteEditor(undefined);
|
|
216
309
|
return;
|
|
217
310
|
}
|
|
@@ -221,7 +314,12 @@ export function TrainingReviewApp(
|
|
|
221
314
|
const corrected = expectedDecision !== entry.record.verdict.decision;
|
|
222
315
|
try {
|
|
223
316
|
const review = await props.recordReview(
|
|
224
|
-
createTrainingReviewInput(
|
|
317
|
+
createTrainingReviewInput(
|
|
318
|
+
entry.record,
|
|
319
|
+
expectedDecision,
|
|
320
|
+
correctionReason,
|
|
321
|
+
note,
|
|
322
|
+
),
|
|
225
323
|
);
|
|
226
324
|
setSnapshot((current) =>
|
|
227
325
|
current.reviews.some((existing) =>
|
|
@@ -252,13 +350,14 @@ export function TrainingReviewApp(
|
|
|
252
350
|
const chooseDecision = (decision: Decision) => {
|
|
253
351
|
if (selected === undefined || saving) return;
|
|
254
352
|
if (decision === selected.record.verdict.decision) {
|
|
255
|
-
void persistDecision(selected, decision, undefined);
|
|
353
|
+
void persistDecision(selected, decision, undefined, undefined);
|
|
256
354
|
return;
|
|
257
355
|
}
|
|
258
356
|
setError(undefined);
|
|
259
357
|
setNoteEditor({
|
|
260
358
|
recordId: selected.record.id,
|
|
261
359
|
expectedDecision: decision,
|
|
360
|
+
correctionReason: undefined,
|
|
262
361
|
});
|
|
263
362
|
};
|
|
264
363
|
|
|
@@ -317,8 +416,85 @@ export function TrainingReviewApp(
|
|
|
317
416
|
key.preventDefault();
|
|
318
417
|
setNoteEditor(undefined);
|
|
319
418
|
setError(undefined);
|
|
320
|
-
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (noteEditor.correctionReason === undefined) {
|
|
423
|
+
const reason = correctionReasonForKey(key.name, key.sequence);
|
|
424
|
+
if (reason !== undefined) {
|
|
425
|
+
key.preventDefault();
|
|
426
|
+
setNoteEditor({ ...noteEditor, correctionReason: reason });
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (key.name === "tab") key.preventDefault();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const sectionKey = key.sequence || key.name;
|
|
436
|
+
if (focus !== "filter" && (sectionKey === "[" || sectionKey === "]")) {
|
|
437
|
+
key.preventDefault();
|
|
438
|
+
const nextSection = sectionKey === "[" ? "reviews" : "settings";
|
|
439
|
+
setSection(nextSection);
|
|
440
|
+
setFocus(nextSection === "reviews" ? "tabs" : "settings");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (focus === "sections") {
|
|
445
|
+
if (key.name === "left" || key.name === "right") {
|
|
446
|
+
key.preventDefault();
|
|
447
|
+
setSection((current) =>
|
|
448
|
+
current === "reviews" ? "settings" : "reviews"
|
|
449
|
+
);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (key.name === "down" || key.name === "return") {
|
|
453
|
+
key.preventDefault();
|
|
454
|
+
setFocus(section === "reviews" ? "tabs" : "settings");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (key.name === "q" || key.name === "escape") {
|
|
458
|
+
key.preventDefault();
|
|
459
|
+
finish(result);
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (section === "settings") {
|
|
465
|
+
if (key.name === "q" || key.name === "escape") {
|
|
466
|
+
key.preventDefault();
|
|
467
|
+
finish(result);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (key.name === "up") {
|
|
471
|
+
key.preventDefault();
|
|
472
|
+
if (selectedSettingIndex === 0) {
|
|
473
|
+
setFocus("sections");
|
|
474
|
+
} else {
|
|
475
|
+
setSelectedSettingIndex((current) => current - 1);
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (key.name === "down") {
|
|
480
|
+
key.preventDefault();
|
|
481
|
+
setSelectedSettingIndex((current) =>
|
|
482
|
+
Math.min(current + 1, SETTING_ROWS.length - 1)
|
|
483
|
+
);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (
|
|
487
|
+
key.name === "left" ||
|
|
488
|
+
key.name === "right" ||
|
|
489
|
+
key.name === "return" ||
|
|
490
|
+
key.name === "space" ||
|
|
491
|
+
key.sequence === " "
|
|
492
|
+
) {
|
|
321
493
|
key.preventDefault();
|
|
494
|
+
const row = SETTING_ROWS[selectedSettingIndex];
|
|
495
|
+
if (row !== undefined) {
|
|
496
|
+
void persistSetting(row.key, key.name === "left" ? -1 : 1);
|
|
497
|
+
}
|
|
322
498
|
}
|
|
323
499
|
return;
|
|
324
500
|
}
|
|
@@ -330,6 +506,11 @@ export function TrainingReviewApp(
|
|
|
330
506
|
}
|
|
331
507
|
|
|
332
508
|
if (focus === "tabs") {
|
|
509
|
+
if (key.name === "up") {
|
|
510
|
+
key.preventDefault();
|
|
511
|
+
setFocus("sections");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
333
514
|
if (key.name === "left" || key.name === "right") {
|
|
334
515
|
key.preventDefault();
|
|
335
516
|
moveFilterSelection(key.name === "left" ? -1 : 1);
|
|
@@ -461,6 +642,42 @@ export function TrainingReviewApp(
|
|
|
461
642
|
</box>
|
|
462
643
|
|
|
463
644
|
<box
|
|
645
|
+
title=" View "
|
|
646
|
+
titleColor={focus === "sections" ? COLORS.accent : COLORS.muted}
|
|
647
|
+
flexDirection="row"
|
|
648
|
+
gap={1}
|
|
649
|
+
border
|
|
650
|
+
borderColor={focus === "sections" ? COLORS.accent : COLORS.border}
|
|
651
|
+
backgroundColor={COLORS.panel}
|
|
652
|
+
height={3}
|
|
653
|
+
paddingLeft={1}
|
|
654
|
+
paddingRight={1}
|
|
655
|
+
>
|
|
656
|
+
{SECTIONS.map((candidate) => {
|
|
657
|
+
const active = candidate.value === section;
|
|
658
|
+
return (
|
|
659
|
+
<text
|
|
660
|
+
key={candidate.value}
|
|
661
|
+
fg={active ? COLORS.accent : COLORS.muted}
|
|
662
|
+
bg={active ? COLORS.selection : COLORS.background}
|
|
663
|
+
onMouseDown={() => {
|
|
664
|
+
setSection(candidate.value);
|
|
665
|
+
setFocus("sections");
|
|
666
|
+
}}
|
|
667
|
+
>
|
|
668
|
+
{` ${candidate.label} `}
|
|
669
|
+
</text>
|
|
670
|
+
);
|
|
671
|
+
})}
|
|
672
|
+
<text fg={COLORS.muted}>[ / ] switch · Up to focus · ←/→ switch</text>
|
|
673
|
+
</box>
|
|
674
|
+
|
|
675
|
+
{section === "reviews"
|
|
676
|
+
? (
|
|
677
|
+
<>
|
|
678
|
+
<box
|
|
679
|
+
title=" Review status "
|
|
680
|
+
titleColor={focus === "tabs" ? COLORS.accent : COLORS.muted}
|
|
464
681
|
flexDirection="row"
|
|
465
682
|
gap={1}
|
|
466
683
|
border
|
|
@@ -568,13 +785,28 @@ export function TrainingReviewApp(
|
|
|
568
785
|
setFocus("queue");
|
|
569
786
|
}}
|
|
570
787
|
>
|
|
571
|
-
<text
|
|
572
|
-
|
|
573
|
-
|
|
788
|
+
<text
|
|
789
|
+
fg={entrySelected
|
|
790
|
+
? statusColor(getTrainingReviewFilter(entry))
|
|
791
|
+
: COLORS.text}
|
|
792
|
+
wrapMode="none"
|
|
793
|
+
truncate
|
|
794
|
+
>
|
|
574
795
|
{`${entrySelected ? "▶" : " "} ${summarizeCommand(entry.record.command, horizontal ? queueSize - 6 : width - 8)}`}
|
|
575
796
|
</text>
|
|
576
|
-
<text
|
|
577
|
-
{
|
|
797
|
+
<text
|
|
798
|
+
fg={entrySelected ? COLORS.accent : COLORS.muted}
|
|
799
|
+
wrapMode="none"
|
|
800
|
+
truncate
|
|
801
|
+
>
|
|
802
|
+
{` ${statusLabel(getTrainingReviewFilter(entry))} · `}
|
|
803
|
+
<span
|
|
804
|
+
fg={mutedDecisionColor(
|
|
805
|
+
entry.record.verdict.decision,
|
|
806
|
+
)}
|
|
807
|
+
>
|
|
808
|
+
{entry.record.verdict.decision.toUpperCase()}
|
|
809
|
+
</span>
|
|
578
810
|
</text>
|
|
579
811
|
</box>
|
|
580
812
|
);
|
|
@@ -654,6 +886,13 @@ export function TrainingReviewApp(
|
|
|
654
886
|
<text fg={decisionColor(review.expectedDecision)}>
|
|
655
887
|
{`${index + 1}. ${review.expectedDecision.toUpperCase()} · ${review.reviewedAt}`}
|
|
656
888
|
</text>
|
|
889
|
+
{getTrainingCorrectionReason(review) === undefined
|
|
890
|
+
? null
|
|
891
|
+
: (
|
|
892
|
+
<text fg={COLORS.muted} wrapMode="word">
|
|
893
|
+
{`Reason: ${correctionReasonLabel(getTrainingCorrectionReason(review)!)}`}
|
|
894
|
+
</text>
|
|
895
|
+
)}
|
|
657
896
|
{review.note === undefined
|
|
658
897
|
? null
|
|
659
898
|
: <text fg={COLORS.text} wrapMode="word">{review.note}</text>}
|
|
@@ -667,31 +906,52 @@ export function TrainingReviewApp(
|
|
|
667
906
|
</box>
|
|
668
907
|
|
|
669
908
|
{noteEditor !== undefined && editingEntry !== undefined
|
|
670
|
-
?
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
909
|
+
? noteEditor.correctionReason === undefined
|
|
910
|
+
? (
|
|
911
|
+
<box
|
|
912
|
+
title={` Why ${statusLabel(getTrainingReviewFilter(editingEntry))} → ${noteEditor.expectedDecision}? `}
|
|
913
|
+
titleColor={decisionColor(noteEditor.expectedDecision)}
|
|
914
|
+
border
|
|
915
|
+
borderColor={decisionColor(noteEditor.expectedDecision)}
|
|
916
|
+
height={CORRECTION_REASONS.length + 3}
|
|
917
|
+
paddingLeft={1}
|
|
918
|
+
paddingRight={1}
|
|
919
|
+
flexDirection="column"
|
|
920
|
+
>
|
|
921
|
+
{CORRECTION_REASONS.map((reason, index) => (
|
|
922
|
+
<text key={reason.value} fg={COLORS.text}>
|
|
923
|
+
{`${index + 1}. ${reason.label}`}
|
|
924
|
+
</text>
|
|
925
|
+
))}
|
|
926
|
+
<text fg={COLORS.muted}>1–9 select reason · Esc cancel</text>
|
|
927
|
+
</box>
|
|
928
|
+
)
|
|
929
|
+
: (
|
|
930
|
+
<box
|
|
931
|
+
title={` ${correctionReasonLabel(noteEditor.correctionReason)} `}
|
|
932
|
+
titleColor={decisionColor(noteEditor.expectedDecision)}
|
|
933
|
+
border
|
|
934
|
+
borderColor={decisionColor(noteEditor.expectedDecision)}
|
|
935
|
+
height={5}
|
|
936
|
+
paddingLeft={1}
|
|
937
|
+
paddingRight={1}
|
|
938
|
+
flexDirection="column"
|
|
939
|
+
>
|
|
940
|
+
<input
|
|
941
|
+
placeholder="Optional correction note — Enter saves, Esc cancels"
|
|
942
|
+
focused
|
|
943
|
+
onSubmit={(note) => {
|
|
944
|
+
void persistDecision(
|
|
945
|
+
editingEntry,
|
|
946
|
+
noteEditor.expectedDecision,
|
|
947
|
+
noteEditor.correctionReason,
|
|
948
|
+
typeof note === "string" ? note : undefined,
|
|
949
|
+
);
|
|
950
|
+
}}
|
|
951
|
+
/>
|
|
952
|
+
<text fg={COLORS.muted}>Enter save revision · Esc cancel</text>
|
|
953
|
+
</box>
|
|
954
|
+
)
|
|
695
955
|
: null}
|
|
696
956
|
|
|
697
957
|
{error === undefined
|
|
@@ -704,7 +964,7 @@ export function TrainingReviewApp(
|
|
|
704
964
|
<box flexDirection="row" justifyContent="space-between">
|
|
705
965
|
<text fg={COLORS.muted}>
|
|
706
966
|
{focus === "tabs"
|
|
707
|
-
? "←/→ select status · Down/Enter filter · Tab rotates"
|
|
967
|
+
? "←/→ select status · Up sections · Down/Enter filter · Tab rotates"
|
|
708
968
|
: focus === "filter"
|
|
709
969
|
? "Type to fuzzy-search cwd · Up tabs · Down/Enter queue · Tab rotates"
|
|
710
970
|
: focus === "detail"
|
|
@@ -715,6 +975,81 @@ export function TrainingReviewApp(
|
|
|
715
975
|
{saving ? "Saving…" : "q/Esc quit"}
|
|
716
976
|
</text>
|
|
717
977
|
</box>
|
|
978
|
+
</>
|
|
979
|
+
)
|
|
980
|
+
: (
|
|
981
|
+
<>
|
|
982
|
+
<box
|
|
983
|
+
title=" Pi extension settings "
|
|
984
|
+
titleColor={COLORS.accent}
|
|
985
|
+
border
|
|
986
|
+
borderColor={COLORS.accent}
|
|
987
|
+
backgroundColor={COLORS.panel}
|
|
988
|
+
flexGrow={1}
|
|
989
|
+
flexDirection="column"
|
|
990
|
+
padding={1}
|
|
991
|
+
gap={1}
|
|
992
|
+
>
|
|
993
|
+
<text fg={COLORS.muted} wrapMode="word">
|
|
994
|
+
Changes are saved globally and picked up by Pi before its next Bash call.
|
|
995
|
+
</text>
|
|
996
|
+
{SETTING_ROWS.map((row, index) => {
|
|
997
|
+
const selectedRow = index === selectedSettingIndex;
|
|
998
|
+
const unavailable = row.key === "training" &&
|
|
999
|
+
settings.mode === "disabled";
|
|
1000
|
+
return (
|
|
1001
|
+
<box
|
|
1002
|
+
key={row.key}
|
|
1003
|
+
flexDirection="column"
|
|
1004
|
+
backgroundColor={selectedRow
|
|
1005
|
+
? COLORS.selection
|
|
1006
|
+
: COLORS.panel}
|
|
1007
|
+
paddingLeft={1}
|
|
1008
|
+
paddingRight={1}
|
|
1009
|
+
onMouseDown={() => {
|
|
1010
|
+
setSelectedSettingIndex(index);
|
|
1011
|
+
setFocus("settings");
|
|
1012
|
+
}}
|
|
1013
|
+
>
|
|
1014
|
+
<box flexDirection="row">
|
|
1015
|
+
<text
|
|
1016
|
+
width={24}
|
|
1017
|
+
fg={selectedRow ? COLORS.accent : COLORS.text}
|
|
1018
|
+
>
|
|
1019
|
+
{`${selectedRow ? "▶" : " "} ${row.label}`}
|
|
1020
|
+
</text>
|
|
1021
|
+
<text fg={unavailable ? COLORS.muted : COLORS.allow}>
|
|
1022
|
+
{settingDisplayValue(settings, row.key)}
|
|
1023
|
+
</text>
|
|
1024
|
+
</box>
|
|
1025
|
+
<text fg={COLORS.muted} wrapMode="word">
|
|
1026
|
+
{unavailable
|
|
1027
|
+
? "Unavailable while the operating mode is disabled."
|
|
1028
|
+
: row.description}
|
|
1029
|
+
</text>
|
|
1030
|
+
</box>
|
|
1031
|
+
);
|
|
1032
|
+
})}
|
|
1033
|
+
</box>
|
|
1034
|
+
|
|
1035
|
+
{settingsError === undefined
|
|
1036
|
+
? null
|
|
1037
|
+
: (
|
|
1038
|
+
<text fg={COLORS.error}>
|
|
1039
|
+
Could not save settings: {settingsError}
|
|
1040
|
+
</text>
|
|
1041
|
+
)}
|
|
1042
|
+
|
|
1043
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
1044
|
+
<text fg={COLORS.muted}>
|
|
1045
|
+
↑/↓ select · ←/→ change · Enter/Space next · [ reviews
|
|
1046
|
+
</text>
|
|
1047
|
+
<text fg={savingSettings ? COLORS.ask : COLORS.muted}>
|
|
1048
|
+
{savingSettings ? "Saving…" : "q/Esc quit"}
|
|
1049
|
+
</text>
|
|
1050
|
+
</box>
|
|
1051
|
+
</>
|
|
1052
|
+
)}
|
|
718
1053
|
</box>
|
|
719
1054
|
);
|
|
720
1055
|
}
|
|
@@ -766,14 +1101,18 @@ function JudgmentsTable(props: { entry: TrainingReviewEntry }): React.ReactNode
|
|
|
766
1101
|
* Launch OpenTUI for complete training history and restore the terminal on exit.
|
|
767
1102
|
*
|
|
768
1103
|
* @param snapshot - Complete training state to present initially
|
|
1104
|
+
* @param settings - Current globally persisted Pi extension settings
|
|
769
1105
|
* @param reloadSnapshot - Polling callback that returns current training state
|
|
770
1106
|
* @param recordReview - Persistence callback for review revisions
|
|
1107
|
+
* @param saveSettings - Atomic persistence callback for Pi extension settings
|
|
771
1108
|
* @returns Counts for the completed interactive session
|
|
772
1109
|
*/
|
|
773
1110
|
export async function runTrainingReviewTui(
|
|
774
1111
|
snapshot: TrainingReviewSnapshot,
|
|
1112
|
+
settings: DemurSettings,
|
|
775
1113
|
reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
|
|
776
1114
|
recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
|
|
1115
|
+
saveSettings: (settings: DemurSettings) => Promise<void>,
|
|
777
1116
|
): Promise<TrainingReviewTuiResult> {
|
|
778
1117
|
const renderer = await createCliRenderer({
|
|
779
1118
|
exitOnCtrlC: false,
|
|
@@ -796,8 +1135,10 @@ export async function runTrainingReviewTui(
|
|
|
796
1135
|
root.render(
|
|
797
1136
|
<TrainingReviewApp
|
|
798
1137
|
snapshot={snapshot}
|
|
1138
|
+
settings={settings}
|
|
799
1139
|
reloadSnapshot={reloadSnapshot}
|
|
800
1140
|
recordReview={recordReview}
|
|
1141
|
+
saveSettings={saveSettings}
|
|
801
1142
|
pollIntervalMs={undefined}
|
|
802
1143
|
onExit={finish}
|
|
803
1144
|
/>,
|
|
@@ -805,6 +1146,15 @@ export async function runTrainingReviewTui(
|
|
|
805
1146
|
});
|
|
806
1147
|
}
|
|
807
1148
|
|
|
1149
|
+
function settingDisplayValue(
|
|
1150
|
+
settings: DemurSettings,
|
|
1151
|
+
key: DemurSettingKey,
|
|
1152
|
+
): string {
|
|
1153
|
+
if (key === "mode") return settings.mode;
|
|
1154
|
+
if (key === "training") return settings.training ? "on" : "off";
|
|
1155
|
+
return settings.failurePolicy;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
808
1158
|
function countByFilter(
|
|
809
1159
|
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
810
1160
|
): Record<TrainingReviewFilter, number> {
|
|
@@ -847,6 +1197,19 @@ function decisionForKey(
|
|
|
847
1197
|
return undefined;
|
|
848
1198
|
}
|
|
849
1199
|
|
|
1200
|
+
function correctionReasonForKey(
|
|
1201
|
+
name: string,
|
|
1202
|
+
sequence: string,
|
|
1203
|
+
): TrainingCorrectionReason | undefined {
|
|
1204
|
+
const index = Number(sequence || name) - 1;
|
|
1205
|
+
return Number.isInteger(index) ? CORRECTION_REASONS[index]?.value : undefined;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function correctionReasonLabel(reason: TrainingCorrectionReason): string {
|
|
1209
|
+
return CORRECTION_REASONS.find((candidate) => candidate.value === reason)
|
|
1210
|
+
?.label ?? reason;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
850
1213
|
function statusLabel(filter: TrainingReviewFilter): string {
|
|
851
1214
|
return {
|
|
852
1215
|
all: "ALL",
|
|
@@ -871,6 +1234,14 @@ function decisionColor(decision: Decision): string {
|
|
|
871
1234
|
}[decision];
|
|
872
1235
|
}
|
|
873
1236
|
|
|
1237
|
+
function mutedDecisionColor(decision: Decision): string {
|
|
1238
|
+
return {
|
|
1239
|
+
allow: COLORS.allowMuted,
|
|
1240
|
+
ask: COLORS.askMuted,
|
|
1241
|
+
deny: COLORS.denyMuted,
|
|
1242
|
+
}[decision];
|
|
1243
|
+
}
|
|
1244
|
+
|
|
874
1245
|
function emptyTitle(
|
|
875
1246
|
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
876
1247
|
cwdQuery: string,
|
|
@@ -928,13 +1299,6 @@ function judgmentRows(
|
|
|
928
1299
|
];
|
|
929
1300
|
}
|
|
930
1301
|
|
|
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
1302
|
function formatUsage(entry: TrainingReviewEntry): string {
|
|
939
1303
|
const usage = entry.record.verdict.usage;
|
|
940
1304
|
return usage === undefined
|