@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.
Files changed (44) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +187 -72
  3. package/package.json +6 -3
  4. package/src/core/answer.ts +8 -0
  5. package/src/core/config/decode.ts +40 -6
  6. package/src/core/config/patterns.ts +0 -6
  7. package/src/core/config/schema.ts +13 -0
  8. package/src/core/config/settings.ts +318 -226
  9. package/src/core/config/store.ts +11 -4
  10. package/src/core/decide.ts +131 -19
  11. package/src/core/folders.ts +159 -0
  12. package/src/core/judge/backends/factory.ts +2 -2
  13. package/src/core/judge/backends/pi-model.ts +2 -1
  14. package/src/core/judge/compose.ts +0 -1
  15. package/src/core/judge/config.ts +34 -11
  16. package/src/core/judge/decode.ts +0 -3
  17. package/src/core/judge/gate.ts +9 -4
  18. package/src/core/judge/pipeline.ts +7 -4
  19. package/src/core/judge/policy.ts +1 -1
  20. package/src/core/judge/report.ts +5 -4
  21. package/src/core/judge/request.ts +19 -4
  22. package/src/core/judge/types.ts +11 -0
  23. package/src/core/mcp.ts +130 -0
  24. package/src/core/mode.ts +47 -25
  25. package/src/core/readonly-bash.ts +167 -4
  26. package/src/core/tools.ts +7 -3
  27. package/src/core/workspace.ts +107 -16
  28. package/src/index.ts +15 -2
  29. package/src/pi/commands.ts +13 -185
  30. package/src/pi/events.ts +87 -28
  31. package/src/pi/intent.ts +34 -0
  32. package/src/pi/mode.ts +63 -16
  33. package/src/pi/screen.ts +293 -0
  34. package/src/pi/session-entries.ts +46 -8
  35. package/src/pi/session.ts +52 -11
  36. package/src/ui/decision-options.ts +58 -12
  37. package/src/ui/dialog.ts +135 -23
  38. package/src/ui/judge-entry.ts +6 -16
  39. package/src/ui/questions.ts +170 -0
  40. package/src/ui/selector.ts +13 -5
  41. package/src/ui/picker.ts +0 -90
  42. package/src/ui/settings/judge.ts +0 -299
  43. package/src/ui/settings/screen.ts +0 -268
  44. 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 { CHOICES, type Choice } from "#ui/decision-options.ts";
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 toolName: string;
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.toolName = options.toolName;
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 = [...CHOICES.keys()].map((index) => {
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[] = [...this.summaryLines(inner), ...this.diffLines()];
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 CHOICES.entries()) {
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, `permission \u00b7 ${this.toolName}`);
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 = CHOICES.findIndex((option) => option.key === data);
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 = CHOICES.length;
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 = CHOICES[index];
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
- return `${prefix}${this.theme.fg(active ? option.tone : "text", option.label)}`;
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(`${option.label}, `, room);
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
- const label = this.theme.fg("accent", truncateToWidth(title, Math.max(0, room - 3), "..."));
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 (
@@ -23,7 +23,7 @@ export function registerJudgeEntry(scope: FeatureScope): void {
23
23
  });
24
24
  }
25
25
 
26
- /** Early versions persisted one record per entry; now a turn's records share one. */
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} \u00b7 judge \u00b7 unreadable entry`)];
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} \u00b7 judge`)) +
73
- this.theme.fg("dim", ` \u00b7 ${this.records.length} decisions`),
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
+ }
@@ -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 { FALLBACK_CHOICES } from "#ui/decision-options.ts";
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 labels = FALLBACK_CHOICES.map((option) => `${option.key}. ${option.label}`);
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 ? FALLBACK_CHOICES[labels.indexOf(choice)] : undefined;
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
- }