@adeildo/pi-ask-permission 4.0.0 → 5.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/LICENSE +1 -1
- package/README.md +187 -72
- package/package.json +6 -3
- package/src/core/answer.ts +8 -0
- package/src/core/config/decode.ts +40 -6
- package/src/core/config/patterns.ts +0 -6
- package/src/core/config/schema.ts +13 -0
- package/src/core/config/settings.ts +318 -226
- package/src/core/config/store.ts +11 -4
- package/src/core/decide.ts +131 -19
- package/src/core/folders.ts +159 -0
- package/src/core/judge/backends/factory.ts +2 -2
- package/src/core/judge/backends/pi-model.ts +2 -1
- package/src/core/judge/compose.ts +0 -1
- package/src/core/judge/config.ts +34 -11
- package/src/core/judge/decode.ts +0 -3
- package/src/core/judge/gate.ts +9 -4
- package/src/core/judge/pipeline.ts +7 -4
- package/src/core/judge/policy.ts +1 -1
- package/src/core/judge/report.ts +5 -4
- package/src/core/judge/request.ts +19 -4
- package/src/core/judge/types.ts +11 -0
- package/src/core/mcp.ts +130 -0
- package/src/core/mode.ts +47 -25
- package/src/core/readonly-bash.ts +167 -4
- package/src/core/tools.ts +7 -3
- package/src/core/workspace.ts +107 -16
- package/src/index.ts +15 -2
- package/src/pi/commands.ts +13 -185
- package/src/pi/events.ts +87 -28
- package/src/pi/intent.ts +34 -0
- package/src/pi/mode.ts +63 -16
- package/src/pi/screen.ts +293 -0
- package/src/pi/session-entries.ts +46 -8
- package/src/pi/session.ts +52 -11
- package/src/ui/decision-options.ts +58 -12
- package/src/ui/dialog.ts +135 -23
- package/src/ui/judge-entry.ts +6 -16
- package/src/ui/questions.ts +170 -0
- package/src/ui/selector.ts +13 -5
- package/src/ui/picker.ts +0 -90
- package/src/ui/settings/judge.ts +0 -299
- package/src/ui/settings/screen.ts +0 -268
- package/src/ui/settings/status.ts +0 -53
package/src/ui/dialog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { renderDiff, type Theme, type ToolAnnotations } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
3
|
type Component,
|
|
4
4
|
type Focusable,
|
|
@@ -12,10 +12,11 @@ import {
|
|
|
12
12
|
} from "@earendil-works/pi-tui";
|
|
13
13
|
|
|
14
14
|
import { type Scope, SCOPE_LABEL, SCOPES } from "#core/always-yes.ts";
|
|
15
|
-
import type { DialogAnswer } from "#core/answer.ts";
|
|
15
|
+
import type { DialogAnswer, FolderOffer } from "#core/answer.ts";
|
|
16
|
+
import { describeHints, type McpCall } from "#core/mcp.ts";
|
|
16
17
|
import type { CallDescriptor } from "#core/tools.ts";
|
|
17
18
|
import { readClipboard } from "#ui/clipboard.ts";
|
|
18
|
-
import {
|
|
19
|
+
import { type Choice, choicesFor, openLabel } from "#ui/decision-options.ts";
|
|
19
20
|
import {
|
|
20
21
|
cleanPaste,
|
|
21
22
|
expandPastes,
|
|
@@ -39,7 +40,16 @@ interface AskDialogOptions {
|
|
|
39
40
|
theme: Theme;
|
|
40
41
|
toolName: string;
|
|
41
42
|
target: CallDescriptor;
|
|
43
|
+
/** Set for a call to an MCP server. */
|
|
44
|
+
mcp?: McpCall;
|
|
45
|
+
/** What the tool declares about itself, whatever registered it. */
|
|
46
|
+
hints?: ToolAnnotations;
|
|
47
|
+
/** Issued by another tool, as in a codemode script. */
|
|
48
|
+
nested?: boolean;
|
|
42
49
|
diff?: string;
|
|
50
|
+
/** Why the call asks, when a layer said. */
|
|
51
|
+
reason?: string;
|
|
52
|
+
offer?: FolderOffer;
|
|
43
53
|
keybindings: KeybindingsManager;
|
|
44
54
|
requestRender: () => void;
|
|
45
55
|
complete: (answer: DialogAnswer) => void;
|
|
@@ -47,9 +57,15 @@ interface AskDialogOptions {
|
|
|
47
57
|
|
|
48
58
|
export class AskDialog implements Component, Focusable {
|
|
49
59
|
private readonly theme: Theme;
|
|
50
|
-
private readonly
|
|
60
|
+
private readonly title: string;
|
|
61
|
+
private readonly mcp: McpCall | undefined;
|
|
62
|
+
private readonly hints: ToolAnnotations;
|
|
63
|
+
private readonly nested: boolean;
|
|
51
64
|
private readonly target: CallDescriptor;
|
|
52
65
|
private readonly diff: string | undefined;
|
|
66
|
+
private readonly reason: string | undefined;
|
|
67
|
+
private readonly offer: FolderOffer | undefined;
|
|
68
|
+
private readonly choices: Choice[];
|
|
53
69
|
private readonly requestRender: () => void;
|
|
54
70
|
private readonly complete: (answer: DialogAnswer) => void;
|
|
55
71
|
private readonly keybindings: KeybindingsManager;
|
|
@@ -66,6 +82,8 @@ export class AskDialog implements Component, Focusable {
|
|
|
66
82
|
private scopeIndex = 0;
|
|
67
83
|
private pending: Choice | null = null;
|
|
68
84
|
private noteIndex: number | null = null;
|
|
85
|
+
private folderIndex = 0;
|
|
86
|
+
private openScope: "session" | "project" = "session";
|
|
69
87
|
|
|
70
88
|
private pendingNote: string | undefined;
|
|
71
89
|
|
|
@@ -84,14 +102,23 @@ export class AskDialog implements Component, Focusable {
|
|
|
84
102
|
|
|
85
103
|
constructor(options: AskDialogOptions) {
|
|
86
104
|
this.theme = options.theme;
|
|
87
|
-
this.
|
|
105
|
+
this.title = options.mcp ? `${options.mcp.server}:${options.mcp.tool}` : options.toolName;
|
|
106
|
+
this.mcp = options.mcp;
|
|
107
|
+
this.hints = options.hints ?? {};
|
|
108
|
+
this.nested = options.nested === true;
|
|
88
109
|
this.target = options.target;
|
|
89
110
|
this.diff = options.diff;
|
|
111
|
+
this.reason = options.reason;
|
|
112
|
+
this.offer = options.offer;
|
|
113
|
+
this.choices = choicesFor(options.offer);
|
|
114
|
+
this.folderIndex = options.offer?.suggested ?? 0;
|
|
115
|
+
// Reading a sibling folder is the common case, so its answer is where the cursor starts.
|
|
116
|
+
if (options.offer?.access === "read") this.selected = 1;
|
|
90
117
|
this.requestRender = options.requestRender;
|
|
91
118
|
this.complete = options.complete;
|
|
92
119
|
this.keybindings = options.keybindings;
|
|
93
120
|
|
|
94
|
-
this.noteInputs = [...
|
|
121
|
+
this.noteInputs = [...this.choices.keys()].map((index) => {
|
|
95
122
|
const input = new Input({ prompt: "", placeholder: "" });
|
|
96
123
|
input.onSubmit = () => this.choose(index);
|
|
97
124
|
input.onEscape = () => {
|
|
@@ -115,27 +142,25 @@ export class AskDialog implements Component, Focusable {
|
|
|
115
142
|
|
|
116
143
|
render(width: number): string[] {
|
|
117
144
|
const inner = Math.max(1, width - 4);
|
|
118
|
-
const lines: string[] = [
|
|
145
|
+
const lines: string[] = [
|
|
146
|
+
...this.summaryLines(inner),
|
|
147
|
+
...this.sourceLines(),
|
|
148
|
+
...this.reasonLines(),
|
|
149
|
+
...this.diffLines(),
|
|
150
|
+
];
|
|
119
151
|
lines.push("");
|
|
120
152
|
|
|
121
153
|
if (this.phase === "levels") {
|
|
122
154
|
lines.push(...this.levelLines());
|
|
123
155
|
} else {
|
|
124
|
-
for (const [index, option] of
|
|
156
|
+
for (const [index, option] of this.choices.entries()) {
|
|
125
157
|
lines.push(this.renderOption(option, index, inner));
|
|
126
158
|
}
|
|
127
159
|
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
|
-
);
|
|
160
|
+
lines.push(this.theme.fg("dim", this.hint()));
|
|
136
161
|
}
|
|
137
162
|
|
|
138
|
-
return this.frame(lines, width, inner,
|
|
163
|
+
return this.frame(lines, width, inner, this.title);
|
|
139
164
|
}
|
|
140
165
|
|
|
141
166
|
private dispatch(data: string): void {
|
|
@@ -177,13 +202,51 @@ export class AskDialog implements Component, Focusable {
|
|
|
177
202
|
this.complete({ decision: "deny" });
|
|
178
203
|
return;
|
|
179
204
|
}
|
|
205
|
+
if (this.onOpenChoice && this.adjustFolder(data)) return;
|
|
180
206
|
|
|
181
|
-
const picked =
|
|
207
|
+
const picked = this.choices.findIndex((option) => option.key === data);
|
|
182
208
|
if (picked >= 0) this.selected = picked;
|
|
183
209
|
}
|
|
184
210
|
|
|
211
|
+
private get folder(): string | undefined {
|
|
212
|
+
return this.offer?.folders[this.folderIndex];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private get onOpenChoice(): boolean {
|
|
216
|
+
return this.choices[this.selected]?.open === true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Left goes up toward the root, right back down toward the paths.
|
|
220
|
+
private adjustFolder(data: string): boolean {
|
|
221
|
+
const last = (this.offer?.folders.length ?? 1) - 1;
|
|
222
|
+
if (matchesKey(data, Key.left)) {
|
|
223
|
+
this.folderIndex = Math.max(0, this.folderIndex - 1);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (matchesKey(data, Key.right)) {
|
|
227
|
+
this.folderIndex = Math.min(last, this.folderIndex + 1);
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (data === "s") {
|
|
231
|
+
this.openScope = this.openScope === "session" ? "project" : "session";
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private hint(): string {
|
|
238
|
+
if (this.noteIndex !== null) return "\u2191\u2193 pick enter confirm esc back";
|
|
239
|
+
|
|
240
|
+
const pick = `\u2191\u2193 or 1-${this.choices.length} pick`;
|
|
241
|
+
if (!this.onOpenChoice) return `${pick} enter confirm tab note esc deny`;
|
|
242
|
+
|
|
243
|
+
const folders = (this.offer?.folders.length ?? 0) > 1 ? "\u2190\u2192 folder " : "";
|
|
244
|
+
const scope = this.openScope === "session" ? "keep for this project" : "only this session";
|
|
245
|
+
return `${folders}s ${scope} tab note esc deny`;
|
|
246
|
+
}
|
|
247
|
+
|
|
185
248
|
private moveSelection(delta: number): void {
|
|
186
|
-
const count =
|
|
249
|
+
const count = this.choices.length;
|
|
187
250
|
this.selected = (this.selected + delta + count) % count;
|
|
188
251
|
}
|
|
189
252
|
|
|
@@ -194,10 +257,20 @@ export class AskDialog implements Component, Focusable {
|
|
|
194
257
|
}
|
|
195
258
|
|
|
196
259
|
private choose(index: number): void {
|
|
197
|
-
const option =
|
|
260
|
+
const option = this.choices[index];
|
|
198
261
|
if (!option) return;
|
|
199
262
|
this.selected = index;
|
|
200
263
|
|
|
264
|
+
const folder = this.folder;
|
|
265
|
+
if (option.open && this.offer && folder !== undefined) {
|
|
266
|
+
this.complete({
|
|
267
|
+
decision: "allow",
|
|
268
|
+
note: this.draftNote(),
|
|
269
|
+
open: { path: folder, access: this.offer.access, scope: this.openScope },
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
201
274
|
if (option.always) {
|
|
202
275
|
this.pending = option;
|
|
203
276
|
this.pendingNote = this.draftNote();
|
|
@@ -327,13 +400,15 @@ export class AskDialog implements Component, Focusable {
|
|
|
327
400
|
const marker = active ? this.theme.fg("accent", "\u276f ") : " ";
|
|
328
401
|
const key = this.theme.fg(active ? "accent" : "dim", option.key);
|
|
329
402
|
const prefix = `${marker}${key} `;
|
|
403
|
+
const text = this.labelOf(option);
|
|
330
404
|
|
|
331
405
|
if (!editing && !draft) {
|
|
332
|
-
|
|
406
|
+
const head = this.theme.fg(active ? option.tone : "text", text);
|
|
407
|
+
return truncateToWidth(`${prefix}${head}${this.openTags(option, active)}`, inner);
|
|
333
408
|
}
|
|
334
409
|
|
|
335
410
|
const room = Math.max(1, inner - visibleWidth(prefix));
|
|
336
|
-
const label = truncateToWidth(`${
|
|
411
|
+
const label = truncateToWidth(`${text}, `, room);
|
|
337
412
|
const noteRoom = Math.max(1, room - visibleWidth(label));
|
|
338
413
|
const note = editing
|
|
339
414
|
? (input?.render(noteRoom)[0] ?? "")
|
|
@@ -342,6 +417,38 @@ export class AskDialog implements Component, Focusable {
|
|
|
342
417
|
return truncateToWidth(`${prefix}${head}${note}`, inner);
|
|
343
418
|
}
|
|
344
419
|
|
|
420
|
+
private labelOf(option: Choice): string {
|
|
421
|
+
const folder = this.folder;
|
|
422
|
+
if (!option.open || !this.offer || folder === undefined) return option.label;
|
|
423
|
+
return openLabel(this.offer, folder);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// The scope shows only when it is not the default, and the repo tag only on the focused row.
|
|
427
|
+
private openTags(option: Choice, active: boolean): string {
|
|
428
|
+
if (!option.open || !this.offer) return "";
|
|
429
|
+
const tags: string[] = [];
|
|
430
|
+
if (this.openScope === "project") tags.push(this.theme.fg("accent", "for this project"));
|
|
431
|
+
if (active && this.folder !== undefined && this.folder === this.offer.repoRoot)
|
|
432
|
+
tags.push(this.theme.fg("dim", "repo root"));
|
|
433
|
+
return tags.length === 0 ? "" : ` ${tags.join(" ")}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Where the call comes from, when that is worth saying: the server's own hint about the tool, and
|
|
437
|
+
// the script that issued it.
|
|
438
|
+
private sourceLines(): string[] {
|
|
439
|
+
const parts: string[] = [];
|
|
440
|
+
if (this.mcp) parts.push(`${this.mcp.server}: ${describeHints(this.hints)}`);
|
|
441
|
+
if (this.nested) parts.push("from a codemode script");
|
|
442
|
+
return parts.length === 0 ? [] : [this.theme.fg("dim", parts.join(" "))];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private reasonLines(): string[] {
|
|
446
|
+
let text = this.reason;
|
|
447
|
+
if (this.offer?.access === "read") text = "reads outside the workspace";
|
|
448
|
+
if (this.offer?.access === "write") text = "writes outside the workspace";
|
|
449
|
+
return text === undefined ? [] : [this.theme.fg("warning", `\u25b2 ${text}`)];
|
|
450
|
+
}
|
|
451
|
+
|
|
345
452
|
private diffLines(): string[] {
|
|
346
453
|
if (!this.diff) return [];
|
|
347
454
|
|
|
@@ -383,7 +490,12 @@ export class AskDialog implements Component, Focusable {
|
|
|
383
490
|
|
|
384
491
|
private topBorder(width: number, title: string): string {
|
|
385
492
|
const room = Math.max(0, width - TITLE_CHROME_WIDTH);
|
|
386
|
-
|
|
493
|
+
// The colour tells the dialog from the call, so the two words need nothing between them.
|
|
494
|
+
const label = truncateToWidth(
|
|
495
|
+
this.theme.fg("muted", "permission ") + this.theme.fg("accent", title),
|
|
496
|
+
Math.max(0, room - 3),
|
|
497
|
+
"...",
|
|
498
|
+
);
|
|
387
499
|
const dashes = Math.max(0, room - visibleWidth(label));
|
|
388
500
|
|
|
389
501
|
return (
|
package/src/ui/judge-entry.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function registerJudgeEntry(scope: FeatureScope): void {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/** Old sessions hold one record per entry, newer ones an array. */
|
|
27
27
|
export function toRecords(data: unknown): JudgeRecord[] {
|
|
28
28
|
const list = Array.isArray(data) ? data : [data];
|
|
29
29
|
return list.filter(isJudgeRecord);
|
|
@@ -35,12 +35,7 @@ function isJudgeRecord(value: unknown): value is JudgeRecord {
|
|
|
35
35
|
return typeof record.summary === "string" && typeof record.toolName === "string";
|
|
36
36
|
}
|
|
37
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
|
-
*/
|
|
38
|
+
/** Written when the judge answers, so the card shows before the call runs. */
|
|
44
39
|
export function appendJudgeEntry(pi: ExtensionAPI, record: JudgeRecord): void {
|
|
45
40
|
pi.appendEntry(JUDGE_ENTRY, [record]);
|
|
46
41
|
}
|
|
@@ -59,7 +54,7 @@ class JudgeEntry implements Component {
|
|
|
59
54
|
try {
|
|
60
55
|
return this.build(width);
|
|
61
56
|
} catch {
|
|
62
|
-
return [this.theme.fg("dim", `${NAME}
|
|
57
|
+
return [this.theme.fg("dim", `${NAME} judge: unreadable entry`)];
|
|
63
58
|
}
|
|
64
59
|
}
|
|
65
60
|
|
|
@@ -69,8 +64,8 @@ class JudgeEntry implements Component {
|
|
|
69
64
|
|
|
70
65
|
if (this.records.length > 1) {
|
|
71
66
|
lines.push(
|
|
72
|
-
this.theme.fg("accent", this.theme.bold(`${NAME}
|
|
73
|
-
this.theme.fg("dim", `
|
|
67
|
+
this.theme.fg("accent", this.theme.bold(`${NAME} judge`)) +
|
|
68
|
+
this.theme.fg("dim", ` ${this.records.length} decisions`),
|
|
74
69
|
);
|
|
75
70
|
}
|
|
76
71
|
|
|
@@ -84,12 +79,7 @@ class JudgeEntry implements Component {
|
|
|
84
79
|
return lines;
|
|
85
80
|
}
|
|
86
81
|
|
|
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
|
-
*/
|
|
82
|
+
/** From this card's records only, plus one column so a field never touches the next. */
|
|
93
83
|
private columns(): Columns {
|
|
94
84
|
const action = Math.max(...this.records.map((r) => judgeActionLabel(r).length));
|
|
95
85
|
const tool = Math.max(...this.records.map((r) => r.toolName.length));
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The permission ask, drawn by the questions feature so both dialogs are the same one. The call
|
|
2
|
+
// becomes a question, and the answer becomes the decision the gate already knows how to apply.
|
|
3
|
+
import { type AskAnswer, type AskOption, type AskQuestion, askQuestions } from "@adeildo/pi-kit";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { SCOPE_LABEL, SCOPES, type Scope } from "#core/always-yes.ts";
|
|
7
|
+
import type { DialogAnswer, FolderOffer } from "#core/answer.ts";
|
|
8
|
+
import type { Call } from "#core/decide.ts";
|
|
9
|
+
import { describeHints } from "#core/mcp.ts";
|
|
10
|
+
import { openLabel } from "#ui/decision-options.ts";
|
|
11
|
+
|
|
12
|
+
const YES = "yes, run it";
|
|
13
|
+
const ALWAYS = "always yes";
|
|
14
|
+
const DENY = "no";
|
|
15
|
+
|
|
16
|
+
export interface AskExtras {
|
|
17
|
+
diff?: string;
|
|
18
|
+
reason?: string;
|
|
19
|
+
offer?: FolderOffer;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Undefined when the questions feature is not there to draw, or failed: the caller falls back. */
|
|
23
|
+
export async function askThroughQuestions(
|
|
24
|
+
events: ExtensionAPI["events"],
|
|
25
|
+
call: Call,
|
|
26
|
+
extras: AskExtras,
|
|
27
|
+
): Promise<DialogAnswer | undefined> {
|
|
28
|
+
const first = await askQuestions(events, [firstQuestion(call, extras)]);
|
|
29
|
+
if (first.error !== undefined) return undefined;
|
|
30
|
+
if (first.cancelled) return { decision: "deny" };
|
|
31
|
+
|
|
32
|
+
const answer = first.answers[0];
|
|
33
|
+
const note = noteOf(answer);
|
|
34
|
+
// A typed answer instead of a pick: the user is telling the model what to do instead.
|
|
35
|
+
if (answer === undefined || answer.picked.length === 0) return { decision: "deny", note };
|
|
36
|
+
|
|
37
|
+
const picked = answer.picked[0] ?? "";
|
|
38
|
+
if (picked === DENY) return { decision: "deny", note };
|
|
39
|
+
if (picked === ALWAYS) return remember(events, call, note);
|
|
40
|
+
|
|
41
|
+
const folder = folderOf(extras.offer, picked);
|
|
42
|
+
if (folder === undefined) return { decision: "allow", note };
|
|
43
|
+
const scope = await askScope(events, "Open the folder for how long?");
|
|
44
|
+
return { decision: "allow", note, open: { path: folder, access: extras.offer!.access, scope } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function firstQuestion(call: Call, extras: AskExtras): AskQuestion {
|
|
48
|
+
const options: AskOption[] = [
|
|
49
|
+
{ label: YES, description: "Run this call now, and ask again next time." },
|
|
50
|
+
...(levelsOf(call).length > 0
|
|
51
|
+
? [
|
|
52
|
+
{
|
|
53
|
+
label: ALWAYS,
|
|
54
|
+
description: "Run this call, and stop asking for the calls you pick next.",
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
: []),
|
|
58
|
+
...offerOptions(extras.offer),
|
|
59
|
+
{ label: DENY, description: "Block it. Anything you type becomes the reason the model reads." },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// Every option offers the same preview, whichever row the cursor is on.
|
|
63
|
+
if (extras.diff !== undefined) for (const option of options) option.preview = extras.diff;
|
|
64
|
+
|
|
65
|
+
return { header: "permission", question: questionText(call, extras.reason), options };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function offerOptions(offer: FolderOffer | undefined): { label: string; description: string }[] {
|
|
69
|
+
if (offer === undefined) return [];
|
|
70
|
+
const reads = offer.access === "read";
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
label: openLabel(offer, offer.folders[offer.suggested] ?? ""),
|
|
74
|
+
description: reads
|
|
75
|
+
? "Run it, and let later calls read there without asking."
|
|
76
|
+
: "Run it, and treat that folder as part of the workspace.",
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function questionText(call: Call, reason: string | undefined): string {
|
|
82
|
+
const lines = [`${whereOf(call)} wants to run:`, "", ` ${call.target.summary}`];
|
|
83
|
+
if (reason !== undefined) lines.push("", `\u25b2 ${reason}`);
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The tool name, plus what it does not say: the server behind an MCP tool and what it declares, and
|
|
88
|
+
// a call a script issued rather than the model.
|
|
89
|
+
function whereOf(call: Call): string {
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
if (call.mcp !== undefined)
|
|
92
|
+
parts.push(`${call.mcp.server}:${call.mcp.tool} (${describeHints(call.hints)})`);
|
|
93
|
+
else parts.push(call.toolName);
|
|
94
|
+
if (call.nested) parts.push("(from a codemode script)");
|
|
95
|
+
return parts.join(" ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function levelsOf(call: Call): string[] {
|
|
99
|
+
return [...call.target.levels].toReversed();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The always-yes path: which calls to remember, then for how long. */
|
|
103
|
+
async function remember(
|
|
104
|
+
events: ExtensionAPI["events"],
|
|
105
|
+
call: Call,
|
|
106
|
+
note: string | undefined,
|
|
107
|
+
): Promise<DialogAnswer | undefined> {
|
|
108
|
+
const levels = levelsOf(call);
|
|
109
|
+
if (levels.length === 0) return { decision: "allow", note };
|
|
110
|
+
|
|
111
|
+
const picked = await askQuestions(events, [
|
|
112
|
+
{
|
|
113
|
+
header: "remember",
|
|
114
|
+
question: `Always say yes to which calls, from ${call.toolName}?`,
|
|
115
|
+
options: levels.map((level, index) => ({
|
|
116
|
+
label: level,
|
|
117
|
+
description:
|
|
118
|
+
index === 0
|
|
119
|
+
? "Only this exact call."
|
|
120
|
+
: "This call, and anything else that starts with it.",
|
|
121
|
+
})),
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
if (picked.error !== undefined || picked.cancelled) return { decision: "allow", note };
|
|
125
|
+
|
|
126
|
+
const level = picked.answers[0]?.picked[0];
|
|
127
|
+
if (level === undefined || level === "") return { decision: "allow", note };
|
|
128
|
+
const scope = await askScope(events, "Remember it for how long?");
|
|
129
|
+
return { decision: "allow", note, remember: level, scope };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function askScope(events: ExtensionAPI["events"], question: string): Promise<Scope> {
|
|
133
|
+
const result = await askQuestions(events, [
|
|
134
|
+
{
|
|
135
|
+
header: "where",
|
|
136
|
+
question,
|
|
137
|
+
options: SCOPES.map((scope) => ({
|
|
138
|
+
label: SCOPE_LABEL[scope],
|
|
139
|
+
description:
|
|
140
|
+
scope === "session"
|
|
141
|
+
? "Gone when this session ends."
|
|
142
|
+
: scope === "project"
|
|
143
|
+
? "Saved for this project, in its own file."
|
|
144
|
+
: "Saved for every project.",
|
|
145
|
+
})),
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
const picked = result.answers[0]?.picked[0];
|
|
149
|
+
const scope = SCOPES.find((candidate) => SCOPE_LABEL[candidate] === picked);
|
|
150
|
+
return scope ?? "session";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function folderOf(offer: FolderOffer | undefined, picked: string): string | undefined {
|
|
154
|
+
if (offer === undefined) return undefined;
|
|
155
|
+
return offer.folders.find((folder) => openLabel(offer, folder) === picked);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Every note the user left, the picked option's first. */
|
|
159
|
+
function noteOf(answer: AskAnswer | undefined): string | undefined {
|
|
160
|
+
if (answer === undefined) return undefined;
|
|
161
|
+
const parts: string[] = [];
|
|
162
|
+
const picked = answer.notes.find((note) => answer.picked.includes(note.option));
|
|
163
|
+
if (picked !== undefined) parts.push(picked.note);
|
|
164
|
+
for (const note of answer.notes)
|
|
165
|
+
if (!answer.picked.includes(note.option)) parts.push(`on "${note.option}": ${note.note}`);
|
|
166
|
+
const typed = answer.typed?.trim();
|
|
167
|
+
if (typed !== undefined && typed !== "") parts.push(typed);
|
|
168
|
+
|
|
169
|
+
return parts.length === 0 ? undefined : parts.join("\n");
|
|
170
|
+
}
|
package/src/ui/selector.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import { type Scope, SCOPE_LABEL, SCOPES } from "#core/always-yes.ts";
|
|
4
|
-
import type { DialogAnswer } from "#core/answer.ts";
|
|
4
|
+
import type { DialogAnswer, FolderOffer } from "#core/answer.ts";
|
|
5
5
|
import type { CallDescriptor } from "#core/tools.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { fallbackChoices } from "#ui/decision-options.ts";
|
|
7
7
|
|
|
8
8
|
export async function askViaSelector(
|
|
9
9
|
ctx: ExtensionContext,
|
|
10
10
|
toolName: string,
|
|
11
11
|
target: CallDescriptor,
|
|
12
|
+
offer?: FolderOffer,
|
|
12
13
|
): Promise<DialogAnswer> {
|
|
13
|
-
const
|
|
14
|
+
const choices = fallbackChoices(offer);
|
|
15
|
+
const labels = choices.map((option) => `${option.key}. ${option.label}`);
|
|
14
16
|
const choice = await ctx.ui.select(`Allow ${toolName}?\n${target.summary}`, labels);
|
|
15
|
-
const option = choice ?
|
|
17
|
+
const option = choice ? choices[labels.indexOf(choice)] : undefined;
|
|
16
18
|
if (!option) return { decision: "deny" };
|
|
17
19
|
|
|
20
|
+
const folder = offer?.folders[offer.suggested];
|
|
21
|
+
const open =
|
|
22
|
+
option.open && offer && folder !== undefined
|
|
23
|
+
? { path: folder, access: offer.access, scope: "session" as const }
|
|
24
|
+
: undefined;
|
|
25
|
+
|
|
18
26
|
let remember: string | undefined;
|
|
19
27
|
let scope: Scope | undefined;
|
|
20
28
|
if (option.always) {
|
|
@@ -38,5 +46,5 @@ export async function askViaSelector(
|
|
|
38
46
|
note = (await ctx.ui.input(prompt))?.trim() || undefined;
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
return { decision: option.decision, note, remember, scope };
|
|
49
|
+
return { decision: option.decision, note, remember, scope, open };
|
|
42
50
|
}
|
package/src/ui/picker.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
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
|
-
}
|