@adeildo/pi-ask-permission 4.0.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/CONTRIBUTING.md +43 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/assets/preview.png +0 -0
- package/package.json +63 -0
- package/src/core/always-yes.ts +183 -0
- package/src/core/answer.ts +8 -0
- package/src/core/config/decode.ts +118 -0
- package/src/core/config/patterns.ts +51 -0
- package/src/core/config/schema.ts +72 -0
- package/src/core/config/settings.ts +374 -0
- package/src/core/config/store.ts +57 -0
- package/src/core/decide.ts +106 -0
- package/src/core/judge/backends/factory.ts +30 -0
- package/src/core/judge/backends/jev.ts +227 -0
- package/src/core/judge/backends/pi-model.ts +174 -0
- package/src/core/judge/compose.ts +91 -0
- package/src/core/judge/config.ts +60 -0
- package/src/core/judge/decode.ts +51 -0
- package/src/core/judge/gate.ts +70 -0
- package/src/core/judge/pipeline.ts +106 -0
- package/src/core/judge/policy.ts +114 -0
- package/src/core/judge/probe.ts +51 -0
- package/src/core/judge/report.ts +73 -0
- package/src/core/judge/request.ts +78 -0
- package/src/core/judge/types.ts +72 -0
- package/src/core/mode.ts +42 -0
- package/src/core/readonly-bash.ts +689 -0
- package/src/core/tools.ts +208 -0
- package/src/core/workspace.ts +66 -0
- package/src/identity.ts +5 -0
- package/src/index.ts +29 -0
- package/src/pi/api.ts +44 -0
- package/src/pi/bash-timer.ts +49 -0
- package/src/pi/commands.ts +193 -0
- package/src/pi/events.ts +260 -0
- package/src/pi/mode.ts +48 -0
- package/src/pi/preview.ts +82 -0
- package/src/pi/provider.ts +11 -0
- package/src/pi/session-entries.ts +74 -0
- package/src/pi/session.ts +129 -0
- package/src/ui/clipboard.ts +51 -0
- package/src/ui/decision-options.ts +23 -0
- package/src/ui/dialog.ts +395 -0
- package/src/ui/judge-entry.ts +130 -0
- package/src/ui/paste.ts +27 -0
- package/src/ui/picker.ts +90 -0
- package/src/ui/selector.ts +42 -0
- package/src/ui/settings/judge.ts +299 -0
- package/src/ui/settings/screen.ts +268 -0
- package/src/ui/settings/status.ts +53 -0
- package/src/ui/typing.ts +72 -0
- package/src/util/primitives.ts +11 -0
package/src/ui/dialog.ts
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type Focusable,
|
|
5
|
+
Input,
|
|
6
|
+
Key,
|
|
7
|
+
type KeybindingsManager,
|
|
8
|
+
matchesKey,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
wrapTextWithAnsi,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
import { type Scope, SCOPE_LABEL, SCOPES } from "#core/always-yes.ts";
|
|
15
|
+
import type { DialogAnswer } from "#core/answer.ts";
|
|
16
|
+
import type { CallDescriptor } from "#core/tools.ts";
|
|
17
|
+
import { readClipboard } from "#ui/clipboard.ts";
|
|
18
|
+
import { CHOICES, type Choice } from "#ui/decision-options.ts";
|
|
19
|
+
import {
|
|
20
|
+
cleanPaste,
|
|
21
|
+
expandPastes,
|
|
22
|
+
PASTE_END,
|
|
23
|
+
PASTE_START,
|
|
24
|
+
pasteMarker,
|
|
25
|
+
shouldCollapse,
|
|
26
|
+
} from "#ui/paste.ts";
|
|
27
|
+
|
|
28
|
+
type Phase = "menu" | "levels";
|
|
29
|
+
|
|
30
|
+
const TITLE_PREFIX = "\u256d\u2500 ";
|
|
31
|
+
const TITLE_SUFFIX = "\u256e";
|
|
32
|
+
// The 1 is the space between the title and its dashes.
|
|
33
|
+
const TITLE_CHROME_WIDTH = visibleWidth(TITLE_PREFIX) + 1 + visibleWidth(TITLE_SUFFIX);
|
|
34
|
+
|
|
35
|
+
const SUMMARY_ROWS = 3;
|
|
36
|
+
const DIFF_ROWS = 16;
|
|
37
|
+
|
|
38
|
+
interface AskDialogOptions {
|
|
39
|
+
theme: Theme;
|
|
40
|
+
toolName: string;
|
|
41
|
+
target: CallDescriptor;
|
|
42
|
+
diff?: string;
|
|
43
|
+
keybindings: KeybindingsManager;
|
|
44
|
+
requestRender: () => void;
|
|
45
|
+
complete: (answer: DialogAnswer) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class AskDialog implements Component, Focusable {
|
|
49
|
+
private readonly theme: Theme;
|
|
50
|
+
private readonly toolName: string;
|
|
51
|
+
private readonly target: CallDescriptor;
|
|
52
|
+
private readonly diff: string | undefined;
|
|
53
|
+
private readonly requestRender: () => void;
|
|
54
|
+
private readonly complete: (answer: DialogAnswer) => void;
|
|
55
|
+
private readonly keybindings: KeybindingsManager;
|
|
56
|
+
|
|
57
|
+
private readonly noteInputs: Input[];
|
|
58
|
+
|
|
59
|
+
private readonly pastes = new Map<number, string>();
|
|
60
|
+
private pasteId = 0;
|
|
61
|
+
private pasteBuffer = "";
|
|
62
|
+
|
|
63
|
+
private phase: Phase = "menu";
|
|
64
|
+
private selected = 0;
|
|
65
|
+
private levelIndex = 0;
|
|
66
|
+
private scopeIndex = 0;
|
|
67
|
+
private pending: Choice | null = null;
|
|
68
|
+
private noteIndex: number | null = null;
|
|
69
|
+
|
|
70
|
+
private pendingNote: string | undefined;
|
|
71
|
+
|
|
72
|
+
private get activeNoteInput(): Input | undefined {
|
|
73
|
+
return this.noteIndex === null ? undefined : this.noteInputs[this.noteIndex];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private focusedFlag = false;
|
|
77
|
+
get focused(): boolean {
|
|
78
|
+
return this.focusedFlag;
|
|
79
|
+
}
|
|
80
|
+
set focused(value: boolean) {
|
|
81
|
+
this.focusedFlag = value;
|
|
82
|
+
for (const input of this.noteInputs) input.focused = value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
constructor(options: AskDialogOptions) {
|
|
86
|
+
this.theme = options.theme;
|
|
87
|
+
this.toolName = options.toolName;
|
|
88
|
+
this.target = options.target;
|
|
89
|
+
this.diff = options.diff;
|
|
90
|
+
this.requestRender = options.requestRender;
|
|
91
|
+
this.complete = options.complete;
|
|
92
|
+
this.keybindings = options.keybindings;
|
|
93
|
+
|
|
94
|
+
this.noteInputs = [...CHOICES.keys()].map((index) => {
|
|
95
|
+
const input = new Input({ prompt: "", placeholder: "" });
|
|
96
|
+
input.onSubmit = () => this.choose(index);
|
|
97
|
+
input.onEscape = () => {
|
|
98
|
+
this.noteIndex = null;
|
|
99
|
+
};
|
|
100
|
+
return input;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
handleInput(data: string): void {
|
|
105
|
+
try {
|
|
106
|
+
this.dispatch(data);
|
|
107
|
+
} finally {
|
|
108
|
+
this.requestRender();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
invalidate(): void {
|
|
113
|
+
for (const input of this.noteInputs) input.invalidate();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
render(width: number): string[] {
|
|
117
|
+
const inner = Math.max(1, width - 4);
|
|
118
|
+
const lines: string[] = [...this.summaryLines(inner), ...this.diffLines()];
|
|
119
|
+
lines.push("");
|
|
120
|
+
|
|
121
|
+
if (this.phase === "levels") {
|
|
122
|
+
lines.push(...this.levelLines());
|
|
123
|
+
} else {
|
|
124
|
+
for (const [index, option] of CHOICES.entries()) {
|
|
125
|
+
lines.push(this.renderOption(option, index, inner));
|
|
126
|
+
}
|
|
127
|
+
lines.push("");
|
|
128
|
+
lines.push(
|
|
129
|
+
this.theme.fg(
|
|
130
|
+
"dim",
|
|
131
|
+
this.noteIndex === null
|
|
132
|
+
? "\u2191\u2193 or 1-3 pick enter confirm tab note esc deny"
|
|
133
|
+
: "\u2191\u2193 pick enter confirm esc back",
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return this.frame(lines, width, inner, `permission \u00b7 ${this.toolName}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private dispatch(data: string): void {
|
|
142
|
+
if (this.phase === "menu" && this.noteIndex !== null) {
|
|
143
|
+
if (matchesKey(data, Key.up)) this.moveNote(-1);
|
|
144
|
+
else if (matchesKey(data, Key.down)) this.moveNote(1);
|
|
145
|
+
else if (matchesKey(data, Key.tab)) this.noteIndex = null;
|
|
146
|
+
else this.handleNoteInput(data);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.phase === "levels") {
|
|
151
|
+
if (matchesKey(data, Key.up)) this.levelIndex = Math.max(0, this.levelIndex - 1);
|
|
152
|
+
else if (matchesKey(data, Key.down))
|
|
153
|
+
this.levelIndex = Math.min(this.target.levels.length - 1, this.levelIndex + 1);
|
|
154
|
+
else if (matchesKey(data, Key.tab)) this.scopeIndex = (this.scopeIndex + 1) % SCOPES.length;
|
|
155
|
+
else if (matchesKey(data, Key.enter)) this.confirmLevel();
|
|
156
|
+
else if (matchesKey(data, Key.escape)) this.phase = "menu";
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (matchesKey(data, Key.up)) {
|
|
161
|
+
this.moveSelection(-1);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (matchesKey(data, Key.down)) {
|
|
165
|
+
this.moveSelection(1);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (matchesKey(data, Key.tab)) {
|
|
169
|
+
this.noteIndex = this.selected;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (matchesKey(data, Key.enter)) {
|
|
173
|
+
this.choose(this.selected);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (matchesKey(data, Key.escape)) {
|
|
177
|
+
this.complete({ decision: "deny" });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const picked = CHOICES.findIndex((option) => option.key === data);
|
|
182
|
+
if (picked >= 0) this.selected = picked;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private moveSelection(delta: number): void {
|
|
186
|
+
const count = CHOICES.length;
|
|
187
|
+
this.selected = (this.selected + delta + count) % count;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private moveNote(delta: number): void {
|
|
191
|
+
if (this.noteIndex === null) return;
|
|
192
|
+
this.moveSelection(delta);
|
|
193
|
+
this.noteIndex = this.selected;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private choose(index: number): void {
|
|
197
|
+
const option = CHOICES[index];
|
|
198
|
+
if (!option) return;
|
|
199
|
+
this.selected = index;
|
|
200
|
+
|
|
201
|
+
if (option.always) {
|
|
202
|
+
this.pending = option;
|
|
203
|
+
this.pendingNote = this.draftNote();
|
|
204
|
+
this.noteIndex = null;
|
|
205
|
+
this.levelIndex = this.target.levels.length - 1;
|
|
206
|
+
this.scopeIndex = 0;
|
|
207
|
+
this.phase = "levels";
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.complete({
|
|
212
|
+
decision: option.decision,
|
|
213
|
+
note: this.draftNote(),
|
|
214
|
+
remember: option.always ? this.target.levels[0] : undefined,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private confirmLevel(): void {
|
|
219
|
+
const level = this.target.levels[this.levelIndex];
|
|
220
|
+
if (!this.pending || level === undefined) return;
|
|
221
|
+
|
|
222
|
+
this.complete({
|
|
223
|
+
decision: this.pending.decision,
|
|
224
|
+
note: this.pendingNote,
|
|
225
|
+
remember: level,
|
|
226
|
+
scope: this.currentScope,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private get currentScope(): Scope {
|
|
231
|
+
return SCOPES[this.scopeIndex] ?? "session";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private draftNote(): string | undefined {
|
|
235
|
+
const input = this.activeNoteInput;
|
|
236
|
+
if (!input) return undefined;
|
|
237
|
+
|
|
238
|
+
const note = expandPastes(input.getValue(), this.pastes).trim();
|
|
239
|
+
return note || undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private handleNoteInput(data: string): void {
|
|
243
|
+
if (this.pasteBuffer !== "" || data.includes(PASTE_START)) {
|
|
244
|
+
this.bufferPaste(data);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
|
|
249
|
+
this.pasteClipboard();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this.activeNoteInput?.handleInput(data);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private bufferPaste(data: string): void {
|
|
257
|
+
const start = data.indexOf(PASTE_START);
|
|
258
|
+
const text = this.pasteBuffer + (start === -1 ? data : data.slice(start + PASTE_START.length));
|
|
259
|
+
const end = text.indexOf(PASTE_END);
|
|
260
|
+
|
|
261
|
+
if (end === -1) {
|
|
262
|
+
this.pasteBuffer = text;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.pasteBuffer = "";
|
|
267
|
+
const index = this.noteIndex;
|
|
268
|
+
if (index !== null) this.insertPaste(index, text.slice(0, end));
|
|
269
|
+
|
|
270
|
+
const rest = text.slice(end + PASTE_END.length);
|
|
271
|
+
if (rest) this.handleNoteInput(rest);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private pasteClipboard(): void {
|
|
275
|
+
const index = this.noteIndex;
|
|
276
|
+
if (index === null) return;
|
|
277
|
+
|
|
278
|
+
void readClipboard().then((text) => {
|
|
279
|
+
if (!text) return;
|
|
280
|
+
this.insertPaste(index, text);
|
|
281
|
+
this.requestRender();
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private insertPaste(index: number, pastedText: string): void {
|
|
286
|
+
const input = this.noteInputs[index];
|
|
287
|
+
if (!input) return;
|
|
288
|
+
|
|
289
|
+
const text = cleanPaste(pastedText);
|
|
290
|
+
if (shouldCollapse(text)) {
|
|
291
|
+
this.pasteId++;
|
|
292
|
+
this.pastes.set(this.pasteId, text);
|
|
293
|
+
input.handleInput(pasteMarker(this.pasteId, text));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const spacer = /^[/~.]/.test(text) && /\w$/.test(input.getValue()) ? " " : "";
|
|
298
|
+
input.handleInput(spacer + text);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private levelLines(): string[] {
|
|
302
|
+
const lines = [this.theme.fg("muted", "always yes for...")];
|
|
303
|
+
|
|
304
|
+
for (const [index, level] of this.target.levels.entries()) {
|
|
305
|
+
const active = index === this.levelIndex;
|
|
306
|
+
const marker = active ? this.theme.fg("accent", "\u276f ") : " ";
|
|
307
|
+
lines.push(marker + this.theme.fg(active ? "accent" : "text", level));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (this.pendingNote) lines.push(this.theme.fg("muted", `note: ${this.pendingNote}`));
|
|
311
|
+
lines.push("");
|
|
312
|
+
lines.push(
|
|
313
|
+
this.theme.fg("muted", "scope: ") +
|
|
314
|
+
this.theme.fg("accent", SCOPE_LABEL[this.currentScope]) +
|
|
315
|
+
this.theme.fg("dim", " (tab to change)"),
|
|
316
|
+
);
|
|
317
|
+
lines.push("");
|
|
318
|
+
lines.push(this.theme.fg("dim", "\u2191\u2193 depth tab scope enter confirm esc back"));
|
|
319
|
+
return lines;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private renderOption(option: Choice, index: number, inner: number): string {
|
|
323
|
+
const active = index === this.selected;
|
|
324
|
+
const editing = this.noteIndex === index;
|
|
325
|
+
const input = this.noteInputs[index];
|
|
326
|
+
const draft = input?.getValue().trim() ?? "";
|
|
327
|
+
const marker = active ? this.theme.fg("accent", "\u276f ") : " ";
|
|
328
|
+
const key = this.theme.fg(active ? "accent" : "dim", option.key);
|
|
329
|
+
const prefix = `${marker}${key} `;
|
|
330
|
+
|
|
331
|
+
if (!editing && !draft) {
|
|
332
|
+
return `${prefix}${this.theme.fg(active ? option.tone : "text", option.label)}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const room = Math.max(1, inner - visibleWidth(prefix));
|
|
336
|
+
const label = truncateToWidth(`${option.label}, `, room);
|
|
337
|
+
const noteRoom = Math.max(1, room - visibleWidth(label));
|
|
338
|
+
const note = editing
|
|
339
|
+
? (input?.render(noteRoom)[0] ?? "")
|
|
340
|
+
: this.theme.fg("dim", truncateToWidth(draft, noteRoom));
|
|
341
|
+
const head = this.theme.fg(active ? option.tone : "text", label);
|
|
342
|
+
return truncateToWidth(`${prefix}${head}${note}`, inner);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private diffLines(): string[] {
|
|
346
|
+
if (!this.diff) return [];
|
|
347
|
+
|
|
348
|
+
const lines = renderDiff(this.diff).split("\n");
|
|
349
|
+
const shown = lines.slice(0, DIFF_ROWS);
|
|
350
|
+
const hidden = lines.length - shown.length;
|
|
351
|
+
if (hidden > 0) shown.push(this.theme.fg("dim", `\u2026 ${hidden} more lines`));
|
|
352
|
+
return ["", ...shown];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private summaryLines(inner: number): string[] {
|
|
356
|
+
const wrapped = wrapTextWithAnsi(this.target.summary || "(no input)", inner);
|
|
357
|
+
const shown = wrapped.slice(0, SUMMARY_ROWS);
|
|
358
|
+
const elideLast = wrapped.length > shown.length;
|
|
359
|
+
|
|
360
|
+
return shown.map((line, index) => {
|
|
361
|
+
const text =
|
|
362
|
+
elideLast && index === shown.length - 1
|
|
363
|
+
? `${truncateToWidth(line, Math.max(0, inner - 3))}...`
|
|
364
|
+
: line;
|
|
365
|
+
return this.theme.fg("muted", text);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private frame(lines: string[], width: number, inner: number, title: string): string[] {
|
|
370
|
+
const out: string[] = [this.topBorder(width, title)];
|
|
371
|
+
|
|
372
|
+
for (const line of lines) {
|
|
373
|
+
const clipped = truncateToWidth(line, inner);
|
|
374
|
+
const pad = " ".repeat(Math.max(0, inner - visibleWidth(clipped)));
|
|
375
|
+
out.push(
|
|
376
|
+
`${this.theme.fg("border", "\u2502")} ${clipped}${pad} ${this.theme.fg("border", "\u2502")}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
out.push(this.theme.fg("border", `\u2570${"\u2500".repeat(Math.max(0, width - 2))}\u256f`));
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private topBorder(width: number, title: string): string {
|
|
385
|
+
const room = Math.max(0, width - TITLE_CHROME_WIDTH);
|
|
386
|
+
const label = this.theme.fg("accent", truncateToWidth(title, Math.max(0, room - 3), "..."));
|
|
387
|
+
const dashes = Math.max(0, room - visibleWidth(label));
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
this.theme.fg("border", TITLE_PREFIX) +
|
|
391
|
+
label +
|
|
392
|
+
this.theme.fg("border", ` ${"\u2500".repeat(dashes)}${TITLE_SUFFIX}`)
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { FeatureScope } from "@adeildo/pi-kit";
|
|
2
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
import { flatten, judgeActionLabel, judgeSignalText, judgeStatText } from "#core/judge/report.ts";
|
|
6
|
+
import type { JudgeRecord } from "#core/judge/types.ts";
|
|
7
|
+
import { NAME } from "#identity";
|
|
8
|
+
|
|
9
|
+
const JUDGE_ENTRY = `${NAME}:judge`;
|
|
10
|
+
|
|
11
|
+
const MIN_TARGET = 12;
|
|
12
|
+
|
|
13
|
+
interface Columns {
|
|
14
|
+
action: number;
|
|
15
|
+
tool: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerJudgeEntry(scope: FeatureScope): void {
|
|
19
|
+
scope.registerEntryRenderer<unknown>(JUDGE_ENTRY, (entry, { expanded }, theme) => {
|
|
20
|
+
const records = toRecords(entry.data);
|
|
21
|
+
if (records.length === 0) return undefined;
|
|
22
|
+
return new JudgeEntry(records, expanded, theme);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Early versions persisted one record per entry; now a turn's records share one. */
|
|
27
|
+
export function toRecords(data: unknown): JudgeRecord[] {
|
|
28
|
+
const list = Array.isArray(data) ? data : [data];
|
|
29
|
+
return list.filter(isJudgeRecord);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isJudgeRecord(value: unknown): value is JudgeRecord {
|
|
33
|
+
if (typeof value !== "object" || value === null) return false;
|
|
34
|
+
const record = value as Partial<JudgeRecord>;
|
|
35
|
+
return typeof record.summary === "string" && typeof record.toolName === "string";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One entry per decision, written as soon as the judge answers. Waiting for
|
|
40
|
+
* the turn to end held the card back until after execution, so a parallel
|
|
41
|
+
* call hid every verdict behind the slowest command. The array shape is kept
|
|
42
|
+
* because sessions written before this grouped a turn's records into one.
|
|
43
|
+
*/
|
|
44
|
+
export function appendJudgeEntry(pi: ExtensionAPI, record: JudgeRecord): void {
|
|
45
|
+
pi.appendEntry(JUDGE_ENTRY, [record]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class JudgeEntry implements Component {
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly records: JudgeRecord[],
|
|
51
|
+
private readonly expanded: boolean,
|
|
52
|
+
private readonly theme: Theme,
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
invalidate(): void {}
|
|
56
|
+
|
|
57
|
+
/** A throw here kills the whole TUI, so a drifted entry degrades to a stub. */
|
|
58
|
+
render(width: number): string[] {
|
|
59
|
+
try {
|
|
60
|
+
return this.build(width);
|
|
61
|
+
} catch {
|
|
62
|
+
return [this.theme.fg("dim", `${NAME} \u00b7 judge \u00b7 unreadable entry`)];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private build(width: number): string[] {
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
const columns = this.columns();
|
|
69
|
+
|
|
70
|
+
if (this.records.length > 1) {
|
|
71
|
+
lines.push(
|
|
72
|
+
this.theme.fg("accent", this.theme.bold(`${NAME} \u00b7 judge`)) +
|
|
73
|
+
this.theme.fg("dim", ` \u00b7 ${this.records.length} decisions`),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const record of this.records) {
|
|
78
|
+
lines.push(this.row(record, width, columns));
|
|
79
|
+
if (!this.expanded) continue;
|
|
80
|
+
lines.push(` ${this.theme.fg("dim", record.reason)}`);
|
|
81
|
+
lines.push(` ${this.theme.fg("dim", judgeSignalText(record))}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Widths come from this card's own records, so a lone `ask you` is not
|
|
89
|
+
* charged for a `would ask you` that lives in someone else's card. The
|
|
90
|
+
* extra column keeps at least one space before the next field: a tool
|
|
91
|
+
* named `webfetch` used to run straight into its target.
|
|
92
|
+
*/
|
|
93
|
+
private columns(): Columns {
|
|
94
|
+
const action = Math.max(...this.records.map((r) => judgeActionLabel(r).length));
|
|
95
|
+
const tool = Math.max(...this.records.map((r) => r.toolName.length));
|
|
96
|
+
return { action: action + 1, tool: tool + 1 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Stats hug the right edge, so the target uses whatever room is left. */
|
|
100
|
+
private row(record: JudgeRecord, width: number, columns: Columns): string {
|
|
101
|
+
const tone = toneFor(record);
|
|
102
|
+
const action = judgeActionLabel(record).padEnd(columns.action);
|
|
103
|
+
const tool = record.toolName.padEnd(columns.tool);
|
|
104
|
+
const stats = judgeStatText(record);
|
|
105
|
+
|
|
106
|
+
const head = ` \u25c8 ${action}${tool}`;
|
|
107
|
+
const room = width - visibleWidth(head) - visibleWidth(stats) - 1;
|
|
108
|
+
const target = truncateToWidth(flatten(record.summary), Math.max(MIN_TARGET, room), "...");
|
|
109
|
+
const gap = Math.max(
|
|
110
|
+
1,
|
|
111
|
+
width - visibleWidth(head) - visibleWidth(target) - visibleWidth(stats),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
` ${this.theme.fg(tone, "\u25c8")} ` +
|
|
116
|
+
this.theme.fg(tone, action) +
|
|
117
|
+
this.theme.fg("muted", tool) +
|
|
118
|
+
this.theme.fg("text", target) +
|
|
119
|
+
" ".repeat(gap) +
|
|
120
|
+
this.theme.fg("dim", stats)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toneFor(record: JudgeRecord): "success" | "error" | "warning" {
|
|
126
|
+
if (record.error) return "warning";
|
|
127
|
+
if (record.action === "allow") return "success";
|
|
128
|
+
if (record.action === "deny") return "error";
|
|
129
|
+
return "warning";
|
|
130
|
+
}
|
package/src/ui/paste.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const PASTE_START = "\x1b[200~";
|
|
2
|
+
export const PASTE_END = "\x1b[201~";
|
|
3
|
+
|
|
4
|
+
const MARKER = /\[paste #(\d+)(?:\s[^\]]*)?\]/g;
|
|
5
|
+
const MAX_CHARS = 1000;
|
|
6
|
+
|
|
7
|
+
export function cleanPaste(text: string): string {
|
|
8
|
+
return text
|
|
9
|
+
.replace(/\r\n?/g, "\n")
|
|
10
|
+
.replace(/\t/g, " ")
|
|
11
|
+
.split("")
|
|
12
|
+
.filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
|
|
13
|
+
.join("");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function shouldCollapse(text: string): boolean {
|
|
17
|
+
return text.includes("\n") || text.length > MAX_CHARS;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function pasteMarker(id: number, text: string): string {
|
|
21
|
+
const lines = text.split("\n").length;
|
|
22
|
+
return lines > 1 ? `[paste #${id} +${lines} lines]` : `[paste #${id} ${text.length} chars]`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function expandPastes(text: string, pastes: Map<number, string>): string {
|
|
26
|
+
return text.replace(MARKER, (match, id: string) => pastes.get(Number(id)) ?? match);
|
|
27
|
+
}
|
package/src/ui/picker.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
wrapTextWithAnsi,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
export interface PickerItem {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PickerList implements Component {
|
|
17
|
+
private selected: number;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly title: string,
|
|
21
|
+
private readonly items: PickerItem[],
|
|
22
|
+
private readonly theme: Theme,
|
|
23
|
+
private readonly onChoose: (id: string) => void,
|
|
24
|
+
private readonly onCancel: () => void,
|
|
25
|
+
private readonly maxVisible = 8,
|
|
26
|
+
selectedId?: string,
|
|
27
|
+
) {
|
|
28
|
+
const index = items.findIndex((item) => item.id === selectedId);
|
|
29
|
+
this.selected = index < 0 ? 0 : index;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
handleInput(data: string): void {
|
|
33
|
+
if (matchesKey(data, Key.up)) {
|
|
34
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (matchesKey(data, Key.down)) {
|
|
38
|
+
this.selected = Math.min(this.items.length - 1, this.selected + 1);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (matchesKey(data, Key.enter)) {
|
|
42
|
+
const item = this.items[this.selected];
|
|
43
|
+
if (item) this.onChoose(item.id);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (matchesKey(data, Key.escape)) this.onCancel();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
invalidate(): void {}
|
|
50
|
+
|
|
51
|
+
render(width: number): string[] {
|
|
52
|
+
const inner = Math.max(1, width - 4);
|
|
53
|
+
const lines: string[] = [this.theme.fg("accent", this.theme.bold(this.title))];
|
|
54
|
+
|
|
55
|
+
const { start, end } = this.window();
|
|
56
|
+
for (let index = start; index < end; index++) {
|
|
57
|
+
const item = this.items[index];
|
|
58
|
+
if (!item) continue;
|
|
59
|
+
|
|
60
|
+
const active = index === this.selected;
|
|
61
|
+
const marker = active ? this.theme.fg("accent", "\u276f ") : " ";
|
|
62
|
+
const label = this.theme.fg(active ? "accent" : "text", item.label);
|
|
63
|
+
lines.push(truncateToWidth(`${marker}${label}`, inner));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (end < this.items.length) {
|
|
67
|
+
lines.push(this.theme.fg("dim", ` \u2026 ${this.items.length - end} more`));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const description = this.items[this.selected]?.description;
|
|
71
|
+
if (description) {
|
|
72
|
+
lines.push("");
|
|
73
|
+
for (const line of wrapTextWithAnsi(description, inner)) {
|
|
74
|
+
lines.push(this.theme.fg("muted", line));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lines.push("");
|
|
79
|
+
lines.push(this.theme.fg("dim", "\u2191\u2193 pick enter select esc back"));
|
|
80
|
+
return lines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private window(): { start: number; end: number } {
|
|
84
|
+
if (this.items.length <= this.maxVisible) return { start: 0, end: this.items.length };
|
|
85
|
+
|
|
86
|
+
const half = Math.floor(this.maxVisible / 2);
|
|
87
|
+
const start = Math.min(Math.max(0, this.selected - half), this.items.length - this.maxVisible);
|
|
88
|
+
return { start, end: start + this.maxVisible };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { type Scope, SCOPE_LABEL, SCOPES } from "#core/always-yes.ts";
|
|
4
|
+
import type { DialogAnswer } from "#core/answer.ts";
|
|
5
|
+
import type { CallDescriptor } from "#core/tools.ts";
|
|
6
|
+
import { FALLBACK_CHOICES } from "#ui/decision-options.ts";
|
|
7
|
+
|
|
8
|
+
export async function askViaSelector(
|
|
9
|
+
ctx: ExtensionContext,
|
|
10
|
+
toolName: string,
|
|
11
|
+
target: CallDescriptor,
|
|
12
|
+
): Promise<DialogAnswer> {
|
|
13
|
+
const labels = FALLBACK_CHOICES.map((option) => `${option.key}. ${option.label}`);
|
|
14
|
+
const choice = await ctx.ui.select(`Allow ${toolName}?\n${target.summary}`, labels);
|
|
15
|
+
const option = choice ? FALLBACK_CHOICES[labels.indexOf(choice)] : undefined;
|
|
16
|
+
if (!option) return { decision: "deny" };
|
|
17
|
+
|
|
18
|
+
let remember: string | undefined;
|
|
19
|
+
let scope: Scope | undefined;
|
|
20
|
+
if (option.always) {
|
|
21
|
+
if (target.levels.length === 1) {
|
|
22
|
+
remember = target.levels[0];
|
|
23
|
+
} else {
|
|
24
|
+
const level = await ctx.ui.select("Always yes for...", target.levels);
|
|
25
|
+
if (!level) return { decision: "deny" };
|
|
26
|
+
remember = level;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const scopeLabels = SCOPES.map((candidate) => SCOPE_LABEL[candidate]);
|
|
30
|
+
const picked = await ctx.ui.select("Remember for...", scopeLabels);
|
|
31
|
+
if (!picked) return { decision: "deny" };
|
|
32
|
+
scope = SCOPES[scopeLabels.indexOf(picked)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let note: string | undefined;
|
|
36
|
+
if (option.note) {
|
|
37
|
+
const prompt = option.decision === "allow" ? "Note to the agent:" : "Reason for the agent:";
|
|
38
|
+
note = (await ctx.ui.input(prompt))?.trim() || undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { decision: option.decision, note, remember, scope };
|
|
42
|
+
}
|