@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.
@@ -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 FocusTarget = "tabs" | "filter" | "queue" | "detail";
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 - 13 : queueSize - 2) / 2),
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 (previous?.expectedDecision === expectedDecision) {
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(entry.record, expectedDecision, note),
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
- } else if (key.name === "tab") {
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 fg={entrySelected
572
- ? statusColor(getTrainingReviewFilter(entry))
573
- : COLORS.text}>
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 fg={entrySelected ? COLORS.accent : COLORS.muted}>
577
- {` ${statusLabel(getTrainingReviewFilter(entry))} · ${formatEvaluationCost(entry)} · ${entry.reviews.length}r`}
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
- <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
- )
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