@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.
- package/README.md +99 -21
- package/extensions/demur/cost-tracker.ts +31 -6
- package/extensions/demur/index.ts +150 -36
- package/extensions/demur/paths.ts +46 -0
- package/extensions/demur/settings.ts +77 -19
- package/extensions/demur/training-store.ts +334 -0
- package/package.json +12 -3
- package/src/cli.ts +508 -1
- package/src/settings-model.ts +60 -0
- package/src/training-review-model.ts +175 -0
- package/src/training-review-tui.tsx +1231 -0
|
@@ -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
|
+
}
|