@adeildo/pi-ask-permission 4.1.0 → 5.1.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 (47) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +114 -193
  3. package/assets/preview.png +0 -0
  4. package/package.json +3 -2
  5. package/src/core/answer.ts +8 -0
  6. package/src/core/config/decode.ts +40 -6
  7. package/src/core/config/patterns.ts +0 -6
  8. package/src/core/config/schema.ts +13 -0
  9. package/src/core/config/settings.ts +318 -226
  10. package/src/core/config/store.ts +11 -4
  11. package/src/core/decide.ts +131 -19
  12. package/src/core/folders.ts +159 -0
  13. package/src/core/judge/backends/factory.ts +2 -2
  14. package/src/core/judge/backends/pi-model.ts +2 -1
  15. package/src/core/judge/compose.ts +0 -1
  16. package/src/core/judge/config.ts +34 -11
  17. package/src/core/judge/decode.ts +0 -3
  18. package/src/core/judge/gate.ts +9 -4
  19. package/src/core/judge/pipeline.ts +7 -4
  20. package/src/core/judge/policy.ts +1 -1
  21. package/src/core/judge/report.ts +5 -4
  22. package/src/core/judge/request.ts +19 -4
  23. package/src/core/judge/types.ts +11 -0
  24. package/src/core/mcp.ts +130 -0
  25. package/src/core/mode.ts +47 -25
  26. package/src/core/readonly-bash.ts +167 -4
  27. package/src/core/tools.ts +7 -3
  28. package/src/core/workspace.ts +107 -16
  29. package/src/index.ts +19 -3
  30. package/src/pi/commands.ts +13 -185
  31. package/src/pi/events.ts +83 -46
  32. package/src/pi/intent.ts +34 -0
  33. package/src/pi/mode.ts +63 -16
  34. package/src/pi/screen.ts +293 -0
  35. package/src/pi/session-entries.ts +46 -8
  36. package/src/pi/session.ts +52 -11
  37. package/src/ui/decision-options.ts +56 -12
  38. package/src/ui/judge-entry.ts +6 -16
  39. package/src/ui/questions.ts +194 -0
  40. package/src/ui/selector.ts +13 -5
  41. package/src/ui/clipboard.ts +0 -51
  42. package/src/ui/dialog.ts +0 -395
  43. package/src/ui/paste.ts +0 -27
  44. package/src/ui/picker.ts +0 -90
  45. package/src/ui/settings/judge.ts +0 -299
  46. package/src/ui/settings/screen.ts +0 -268
  47. package/src/ui/settings/status.ts +0 -53
package/src/index.ts CHANGED
@@ -1,17 +1,22 @@
1
+ import { questions } from "@adeildo/pi-ask-questions";
1
2
  import { createApp, defineFeature } from "@adeildo/pi-kit";
2
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
4
 
5
+ import { PERMISSION_SETTINGS, readConfig, SECTIONS } from "#core/config/settings.ts";
4
6
  import { listenForTools } from "#pi/api.ts";
5
7
  import { registerBashTimer } from "#pi/bash-timer.ts";
6
- import { registerCommands } from "#pi/commands.ts";
8
+ import { registerShortcuts } from "#pi/commands.ts";
7
9
  import { registerEvents } from "#pi/events.ts";
8
10
  import { registerTypesafeProvider } from "#pi/provider.ts";
11
+ import { registerScreen } from "#pi/screen.ts";
9
12
  import { createSession } from "#pi/session.ts";
10
13
  import { registerJudgeEntry } from "#ui/judge-entry.ts";
11
14
 
12
15
  export const permission = defineFeature({
13
16
  id: "permission",
14
17
  description: "Ask before a tool call runs, with a judge model for the routine ones",
18
+ sections: SECTIONS,
19
+ settings: PERMISSION_SETTINGS,
15
20
  setup(scope) {
16
21
  registerBashTimer(scope);
17
22
  registerTypesafeProvider(scope);
@@ -20,10 +25,21 @@ export const permission = defineFeature({
20
25
  const session = createSession();
21
26
  registerEvents(scope, session);
22
27
  listenForTools(scope, session.customTools);
23
- registerCommands(scope, session);
28
+ registerShortcuts(scope, session);
29
+ registerScreen(scope, session);
30
+
31
+ // A change on the screen, or in another pi, applies to the next call.
32
+ for (const entry of PERMISSION_SETTINGS) {
33
+ entry.listen(scope, () => {
34
+ session.config = readConfig(scope);
35
+ session.judgeCache.clear();
36
+ });
37
+ }
24
38
  },
25
39
  });
26
40
 
27
41
  export default function piAskPermission(pi: ExtensionAPI): void {
28
- createApp(pi, { name: "pi-ask-permission" }).use(permission).build();
42
+ // The dialog is the questions feature's. It comes with the package, and the kit keeps it from
43
+ // running twice when the questions package or the harness is installed as well.
44
+ createApp(pi, { name: "pi-ask-permission" }).use(questions).use(permission).build();
29
45
  }
@@ -1,193 +1,21 @@
1
+ // Two keys for what a session changes the most. Everything else is on the settings screen.
1
2
  import type { FeatureScope } from "@adeildo/pi-kit";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { type AutocompleteItem, Key } from "@earendil-works/pi-tui";
3
+ import { Key } from "@earendil-works/pi-tui";
4
4
 
5
- import { type Scope, SCOPE_LABEL } from "#core/always-yes.ts";
6
- import { TYPESAFE_PROVIDER } from "#core/judge/backends/jev.ts";
7
- import { probeJudge } from "#core/judge/probe.ts";
8
- import { judgeLogText } from "#core/judge/report.ts";
9
- import { MODE_DESCRIPTION, nextMode, parseMode, PERMISSION_MODES } from "#core/mode.ts";
5
+ import { nextMode, PERMISSION_MODES } from "#core/mode.ts";
6
+ import { toggleOutside } from "#core/workspace.ts";
10
7
  import { NAME } from "#identity";
11
- import { setSessionMode } from "#pi/mode.ts";
12
- import { forgetAlwaysYes, resetJudgeHealth, saveConfig, type SessionState } from "#pi/session.ts";
13
- import { alwaysYesCount, openSettings } from "#ui/settings/screen.ts";
14
- import { notifyJudgePolicyWarning, statusText } from "#ui/settings/status.ts";
15
-
16
- const JUDGE_STATUS = `${NAME}:judge`;
17
-
18
- export function registerCommands(scope: FeatureScope, state: SessionState): void {
19
- const openSettingsFor = (ctx: ExtensionContext): Promise<void> =>
20
- openSettings(ctx, {
21
- config: state.config,
22
- alwaysYes: state.alwaysYes,
23
- mode: () => state.mode,
24
- setMode: (mode) => setSessionMode(scope, state, mode, ctx, false),
25
- save: () => saveConfig(scope, state, ctx),
26
- onJudgeChange: () => state.judgeCache.clear(),
27
- });
28
-
29
- function notifyStatus(ctx: ExtensionContext): void {
30
- ctx.ui.notify(statusText(state.config, state.alwaysYes, ctx.cwd, state.mode), "info");
31
- }
32
-
33
- function cycleMode(ctx: ExtensionContext): void {
34
- setSessionMode(scope, state, nextMode(state.mode), ctx);
35
- }
36
-
37
- async function runJudgeProbe(ctx: ExtensionContext): Promise<void> {
38
- ctx.ui.setStatus(JUDGE_STATUS, "judge: testing\u2026");
39
- const auth = ctx.modelRegistry.getProviderAuthStatus(TYPESAFE_PROVIDER);
40
- const probe = await probeJudge(
41
- state.config.judge,
42
- {
43
- resolveApiKey: () => ctx.modelRegistry.getApiKeyForProvider(TYPESAFE_PROVIDER),
44
- modelRegistry: ctx.modelRegistry,
45
- },
46
- ctx.signal,
47
- ).finally(() => ctx.ui.setStatus(JUDGE_STATUS, undefined));
48
-
49
- if (!probe.ok) {
50
- const source = auth.configured
51
- ? `key from ${auth.label ?? auth.source}`
52
- : "no key configured";
53
- const suffix = state.config.judge.provider === "jev" ? ` (${source})` : "";
54
- ctx.ui.notify(`${NAME}: judge test failed: ${probe.detail}${suffix}`, "error");
55
- return;
56
- }
57
-
58
- resetJudgeHealth(state);
59
-
60
- const summary = `judge test ok \u00b7 ${probe.model ?? state.config.judge.model} \u00b7 ${probe.elapsedMs}ms`;
61
- if (probe.elapsedMs > state.config.judge.timeoutMs) {
62
- ctx.ui.notify(
63
- `${NAME}: ${summary}. Slower than the ${state.config.judge.timeoutMs}ms timeout, raise judge.timeoutMs`,
64
- "warning",
65
- );
66
- return;
67
- }
68
-
69
- ctx.ui.notify(`${NAME}: ${summary}`, "info");
70
- }
71
-
72
- async function judgeCommand(ctx: ExtensionContext, argument: string | undefined): Promise<void> {
73
- if (argument === "log") {
74
- ctx.ui.notify(judgeLogText(state.judgeLog), "info");
75
- return;
76
- }
77
-
78
- if (argument === "test") {
79
- await runJudgeProbe(ctx);
80
- return;
81
- }
82
-
83
- if (argument === "on" || argument === "off") {
84
- state.config.judge.enabled = argument === "on";
85
- saveConfig(scope, state, ctx);
86
- state.judgeCache.clear();
87
- if (argument === "on") {
88
- resetJudgeHealth(state);
89
- notifyJudgePolicyWarning(state.config, ctx);
90
- }
91
- ctx.ui.notify(`${NAME}: judge ${argument}`, "info");
92
- return;
93
- }
94
-
95
- await settingsOrStatus(ctx);
96
- }
97
-
98
- function modeCommand(ctx: ExtensionContext, argument: string | undefined): void {
99
- if (argument === undefined) {
100
- cycleMode(ctx);
101
- return;
102
- }
103
-
104
- const mode = parseMode(argument);
105
- if (!mode) {
106
- ctx.ui.notify(`${NAME}: mode takes ${PERMISSION_MODES.join(", ")}`, "warning");
107
- return;
108
- }
109
-
110
- setSessionMode(scope, state, mode, ctx);
111
- }
112
-
113
- function forgetCommand(ctx: ExtensionContext, argument = "session"): void {
114
- const level = FORGET_SCOPES[argument];
115
- if (!level) {
116
- ctx.ui.notify(`${NAME}: forget takes ${Object.keys(FORGET_SCOPES).join(", ")}`, "warning");
117
- return;
118
- }
119
-
120
- const where = level === "all" ? "every scope" : SCOPE_LABEL[level];
121
- const removed = alwaysYesCount(forgetAlwaysYes(scope, state, ctx, level));
122
- ctx.ui.notify(`${NAME}: forgot ${removed} from ${where}`, "info");
123
- }
124
-
125
- async function settingsOrStatus(ctx: ExtensionContext): Promise<void> {
126
- if (ctx.mode === "tui") await openSettingsFor(ctx);
127
- else notifyStatus(ctx);
128
- }
129
-
130
- scope.registerCommand("perm", {
131
- description: `${NAME}: settings, mode, status, forget, judge`,
132
- getArgumentCompletions: (prefix) => {
133
- const typed = prefix.trimStart().toLowerCase();
134
- const matches = SUBCOMMANDS.filter((command) => command.value.startsWith(typed));
135
- return matches.length > 0 ? matches : null;
136
- },
137
- handler: async (args, ctx) => {
138
- const [verb, argument] = args.trim().toLowerCase().split(/\s+/);
139
-
140
- switch (verb) {
141
- case "":
142
- await settingsOrStatus(ctx);
143
- return;
144
- case "mode":
145
- modeCommand(ctx, argument);
146
- return;
147
- case "status":
148
- notifyStatus(ctx);
149
- return;
150
- case "forget":
151
- forgetCommand(ctx, argument);
152
- return;
153
- case "judge":
154
- await judgeCommand(ctx, argument);
155
- return;
156
- default:
157
- ctx.ui.notify(`${NAME}: /perm takes ${VERBS.join(", ")}`, "warning");
158
- }
159
- },
160
- });
8
+ import { setSessionMode, setSessionOutside } from "#pi/mode.ts";
9
+ import type { SessionState } from "#pi/session.ts";
161
10
 
11
+ export function registerShortcuts(scope: FeatureScope, state: SessionState): void {
162
12
  scope.registerShortcut(Key.alt("m"), {
163
- description: `${NAME}: cycle mode (manual, accept edits, auto)`,
164
- handler: cycleMode,
13
+ description: `${NAME}: cycle mode (${PERMISSION_MODES.join(", ")})`,
14
+ handler: (ctx) => setSessionMode(scope, state, nextMode(state.mode), ctx),
165
15
  });
166
- }
167
-
168
- const FORGET_SCOPES: Record<string, Scope | "all"> = {
169
- session: "session",
170
- project: "project",
171
- everywhere: "global",
172
- all: "all",
173
- };
174
16
 
175
- const SUBCOMMANDS: AutocompleteItem[] = [
176
- suggestion("mode", "Switch to the next mode (also Alt+M)"),
177
- ...PERMISSION_MODES.map((mode) => suggestion(`mode ${mode}`, MODE_DESCRIPTION[mode])),
178
- suggestion("status", "Show the config, always yes, and file paths"),
179
- suggestion("forget", "Forget this session's always yes"),
180
- suggestion("forget project", "Forget this project's always yes"),
181
- suggestion("forget everywhere", "Forget the always yes that applies everywhere"),
182
- suggestion("forget all", "Forget every always yes"),
183
- suggestion("judge on", "Turn the judge on"),
184
- suggestion("judge off", "Turn the judge off"),
185
- suggestion("judge log", "Show this session's judge decisions"),
186
- suggestion("judge test", "Send one real request and report the result"),
187
- ];
188
-
189
- function suggestion(value: string, description: string): AutocompleteItem {
190
- return { value, label: value, description };
17
+ scope.registerShortcut(Key.alt("w"), {
18
+ description: `${NAME}: ask or allow calls outside the workspace, this session`,
19
+ handler: (ctx) => setSessionOutside(scope, state, toggleOutside(state.outside), ctx),
20
+ });
191
21
  }
192
-
193
- const VERBS = ["mode", "status", "forget", "judge"];
package/src/pi/events.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FeatureScope } from "@adeildo/pi-kit";
1
+ import { canAsk, type FeatureScope } from "@adeildo/pi-kit";
2
2
  import {
3
3
  type ExtensionAPI,
4
4
  type ExtensionContext,
@@ -7,36 +7,40 @@ import {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  import { SCOPE_LABEL } from "#core/always-yes.ts";
10
- import type { DialogAnswer } from "#core/answer.ts";
10
+ import type { DialogAnswer, FolderOffer } from "#core/answer.ts";
11
11
  import { noUIMode } from "#core/config/patterns.ts";
12
12
  import {
13
+ accessOf,
13
14
  type Call,
14
15
  decide,
15
16
  describeCall,
16
17
  gateLayers,
17
- type Layer,
18
18
  type Verdict,
19
19
  } from "#core/decide.ts";
20
20
  import { judgeGate } from "#core/judge/gate.ts";
21
21
  import { judgeVerdictText, remember, warnOnce } from "#core/judge/report.ts";
22
- import type { CallDescriptor } from "#core/tools.ts";
22
+ import { describeHints, type ToolFacts } from "#core/mcp.ts";
23
+ import { MODES } from "#core/mode.ts";
24
+ import { shortenHome } from "#core/tools.ts";
25
+ import { folderChoices } from "#core/workspace.ts";
23
26
  import { NAME } from "#identity";
24
27
  import { announce, type Decided } from "#pi/api.ts";
25
- import { clearModeStatus, renderModeStatus } from "#pi/mode.ts";
28
+ import { currentIntent } from "#pi/intent.ts";
29
+ import { clearStatus, notifyJudgePolicyWarning, renderStatus } from "#pi/mode.ts";
26
30
  import { type FileChange, type PendingWrites, previewEdit, previewWrite } from "#pi/preview.ts";
27
31
  import { restoreSession } from "#pi/session-entries.ts";
28
32
  import {
29
33
  loadSessionConfig,
30
34
  openAlwaysYes,
35
+ openFolder,
31
36
  noteJudgeFailure,
32
37
  rememberAlwaysYes,
33
38
  resetJudgeHealth,
34
39
  type SessionState,
35
40
  } from "#pi/session.ts";
36
- import { AskDialog } from "#ui/dialog.ts";
37
41
  import { appendJudgeEntry } from "#ui/judge-entry.ts";
42
+ import { askThroughQuestions, type AskExtras } from "#ui/questions.ts";
38
43
  import { askViaSelector } from "#ui/selector.ts";
39
- import { notifyJudgePolicyWarning } from "#ui/settings/status.ts";
40
44
 
41
45
  const JUDGE_STATUS = `${NAME}:judge`;
42
46
  const TYPING_STATUS = "waiting for you to finish typing";
@@ -46,29 +50,27 @@ export function registerEvents(scope: FeatureScope, state: SessionState): void {
46
50
  loadSessionConfig(scope, state, ctx);
47
51
  openAlwaysYes(state, ctx);
48
52
  restoreSession(state, ctx);
49
- notifyJudgePolicyWarning(state.config, ctx);
50
- renderModeStatus(ctx, state.mode, state.config.workspace.outside);
53
+ notifyJudgePolicyWarning(state, ctx);
54
+ renderStatus(ctx, state);
51
55
  state.typing.start(ctx);
52
56
  });
53
57
 
54
58
  scope.on("session_tree", (_event, ctx) => {
55
59
  restoreSession(state, ctx);
56
- renderModeStatus(ctx, state.mode, state.config.workspace.outside);
60
+ renderStatus(ctx, state);
57
61
  });
58
62
 
59
63
  scope.on("session_shutdown", (_event, ctx) => {
60
- clearModeStatus(ctx);
64
+ clearStatus(ctx);
61
65
  state.typing.stop();
62
66
  });
63
67
 
64
68
  scope.on("tool_call", async (event, ctx) => {
65
- const call = describeCall(
66
- event.toolName,
67
- event.input,
68
- ctx.cwd,
69
- state.config,
70
- state.customTools,
71
- );
69
+ const call = describeCall(event.toolName, event.input, ctx.cwd, state.config, {
70
+ custom: state.customTools,
71
+ facts: toolFacts(scope),
72
+ nested: event.parentToolCallId !== undefined,
73
+ });
72
74
  const outcome = await gate(scope, state, ctx, call, event);
73
75
 
74
76
  announce(scope, {
@@ -101,6 +103,12 @@ export function registerEvents(scope: FeatureScope, state: SessionState): void {
101
103
 
102
104
  type Outcome = Pick<Decided, "action" | "by" | "reason" | "note">;
103
105
 
106
+ /** What pi reports about a tool beside its name: the server it belongs to, and its own hints. */
107
+ function toolFacts(pi: ExtensionAPI): ToolFacts {
108
+ const tools = pi.getAllTools();
109
+ return (toolName) => tools.find((tool) => tool.name === toolName);
110
+ }
111
+
104
112
  async function gate(
105
113
  pi: ExtensionAPI,
106
114
  state: SessionState,
@@ -108,8 +116,11 @@ async function gate(
108
116
  call: Call,
109
117
  event: ToolCallEvent,
110
118
  ): Promise<Outcome> {
111
- const judge: Layer = { name: "judge", decide: (next) => runJudge(state, pi, ctx, next) };
112
- const decision = await decide(call, [...gateLayers(state), judge]);
119
+ const layers = gateLayers(state);
120
+ if (MODES[state.mode].judge) {
121
+ layers.push({ name: "judge", decide: (next) => runJudge(state, pi, ctx, next) });
122
+ }
123
+ const decision = await decide(call, layers);
113
124
 
114
125
  if (decision.action === "allow") return { action: "allow", by: decision.by };
115
126
  if (decision.action === "block")
@@ -129,9 +140,13 @@ async function gate(
129
140
  });
130
141
 
131
142
  state.typing.pause();
132
- const answer = await ask(ctx, call.toolName, call.target, change?.diff).finally(() =>
133
- state.typing.resume(),
134
- );
143
+ const reason = "reason" in decision ? decision.reason : undefined;
144
+ const leaving = "by" in decision && decision.by === "workspace";
145
+ const answer = await ask(pi, ctx, call, {
146
+ diff: change?.diff,
147
+ reason,
148
+ offer: leaving ? offerFor(call) : undefined,
149
+ }).finally(() => state.typing.resume());
135
150
 
136
151
  if (answer.decision === "deny") {
137
152
  return { action: "block", by: "you", reason: denyReason(answer.note), note: answer.note };
@@ -141,11 +156,22 @@ async function gate(
141
156
  const scope = answer.scope ?? "session";
142
157
  rememberAlwaysYes(pi, state, ctx, scope, call.toolName, answer.remember);
143
158
  ctx.ui.notify(
144
- `${NAME}: always yes for ${call.toolName} \u00b7 ${answer.remember} (${SCOPE_LABEL[scope]})`,
159
+ `${NAME}: always yes for ${call.toolName}: ${answer.remember} (${SCOPE_LABEL[scope]})`,
145
160
  "info",
146
161
  );
147
162
  }
148
163
 
164
+ if (answer.open) {
165
+ const { path, access, scope } = answer.open;
166
+ openFolder(pi, state, ctx, scope, path, access);
167
+ renderStatus(ctx, state);
168
+ const what =
169
+ access === "read"
170
+ ? `reads open in ${shortenHome(path)}`
171
+ : `${shortenHome(path)} joins the workspace`;
172
+ ctx.ui.notify(`${NAME}: ${what}, ${SCOPE_LABEL[scope]}`, "info");
173
+ }
174
+
149
175
  if (change)
150
176
  state.pendingWrites.set(change.path, { toolCallId: event.toolCallId, after: change.after });
151
177
  return { action: "allow", by: "you", note: answer.note };
@@ -175,6 +201,9 @@ async function runJudge(
175
201
  toolName: call.toolName,
176
202
  target: call.target,
177
203
  rawInput: call.input,
204
+ source: judgeSource(call),
205
+ toolDescription: call.description,
206
+ intent: currentIntent(ctx),
178
207
  cache: state.judgeCache,
179
208
  onStatus: (status) => ctx.ui.setStatus(JUDGE_STATUS, status),
180
209
  });
@@ -210,33 +239,41 @@ async function runJudge(
210
239
  return undefined;
211
240
  }
212
241
 
242
+ // What the tool name does not say: the server behind an MCP tool and what it declares, and a call
243
+ // that a script issued rather than the model. The judge reads both as data.
244
+ function judgeSource(call: Call): string | undefined {
245
+ const parts: string[] = [];
246
+ if (call.mcp) {
247
+ parts.push(
248
+ `MCP server "${call.mcp.server}", which declares the tool ${describeHints(call.hints)}`,
249
+ );
250
+ }
251
+ if (call.nested) parts.push("issued by a codemode script, not by the model");
252
+ return parts.length === 0 ? undefined : parts.join("; ");
253
+ }
254
+
255
+ // No offer when folderChoices finds nothing to open, like a path in the home itself.
256
+ function offerFor(call: Call): FolderOffer | undefined {
257
+ if (call.reach.kind !== "outside") return undefined;
258
+ const choices = folderChoices(call.reach.paths);
259
+ return choices && { ...choices, access: accessOf(call) };
260
+ }
261
+
213
262
  async function ask(
263
+ pi: ExtensionAPI,
214
264
  ctx: ExtensionContext,
215
- toolName: string,
216
- target: CallDescriptor,
217
- diff: string | undefined,
265
+ call: Call,
266
+ extras: AskExtras,
218
267
  ): Promise<DialogAnswer> {
219
- if (ctx.mode === "tui") {
220
- try {
221
- const answer = await ctx.ui.custom<DialogAnswer>(
222
- (tui, theme, keybindings, done) =>
223
- new AskDialog({
224
- theme,
225
- toolName,
226
- target,
227
- diff,
228
- keybindings,
229
- requestRender: () => tui.requestRender(),
230
- complete: done,
231
- }),
232
- );
233
- if (answer) return answer;
234
- } catch {
235
- // The plain selector, not an open gate.
236
- }
268
+ const { toolName, target } = call;
269
+ // The questions feature draws the dialog. Without it, and in RPC hosts that cannot draw a
270
+ // terminal component, the host's own selector asks.
271
+ if (ctx.mode === "tui" && canAsk(pi.events)) {
272
+ const answer = await askThroughQuestions(pi.events, call, extras);
273
+ if (answer !== undefined) return answer;
237
274
  }
238
275
 
239
- return askViaSelector(ctx, toolName, target);
276
+ return askViaSelector(ctx, toolName, target, extras.offer);
240
277
  }
241
278
 
242
279
  function sendNote(pi: ExtensionAPI, note: string, toolName: string): void {
@@ -0,0 +1,34 @@
1
+ // What the user is after, for the judge. Today the last thing they typed; a service that knows the
2
+ // current intent better takes this place later, and the judge does not change.
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ import { isRecord } from "#util/primitives.ts";
6
+
7
+ export function currentIntent(ctx: Pick<ExtensionContext, "sessionManager">): string | undefined {
8
+ const branch: readonly unknown[] = ctx.sessionManager.getBranch();
9
+ for (let index = branch.length - 1; index >= 0; index--) {
10
+ const text = userText(branch[index]);
11
+ if (text !== undefined) return text;
12
+ }
13
+ return undefined;
14
+ }
15
+
16
+ function userText(entry: unknown): string | undefined {
17
+ if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) return undefined;
18
+ if (entry.message.role !== "user") return undefined;
19
+
20
+ return contentText(entry.message.content).trim() || undefined;
21
+ }
22
+
23
+ /** A message holds a string, or parts where only the text ones say anything. */
24
+ function contentText(content: unknown): string {
25
+ if (typeof content === "string") return content;
26
+ if (!Array.isArray(content)) return "";
27
+
28
+ const texts: string[] = [];
29
+ for (const part of content) {
30
+ if (isRecord(part) && part.type === "text" && typeof part.text === "string")
31
+ texts.push(part.text);
32
+ }
33
+ return texts.join("\n");
34
+ }
package/src/pi/mode.ts CHANGED
@@ -1,13 +1,28 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { OutsideScope } from "#core/config/schema.ts";
4
- import { MODE_DESCRIPTION, MODE_LABEL, type PermissionMode } from "#core/mode.ts";
4
+ import { policyWarning } from "#core/judge/policy.ts";
5
+ import { MODES, type PermissionMode } from "#core/mode.ts";
6
+ import { OUTSIDE_DESCRIPTION } from "#core/workspace.ts";
5
7
  import { NAME } from "#identity";
6
8
  import { record } from "#pi/session-entries.ts";
7
9
  import type { SessionState } from "#pi/session.ts";
8
10
 
9
11
  export const MODE_STATUS = `${NAME}:mode`;
10
12
 
13
+ const ARROW: Record<PermissionMode, string> = {
14
+ manual: "",
15
+ edits: "\u23f5 ",
16
+ judge: "\u23f5 ",
17
+ full: "\u23f5\u23f5 ",
18
+ };
19
+
20
+ const OUTSIDE_TAG: Record<OutsideScope, string | undefined> = {
21
+ ask: undefined,
22
+ allow: "anywhere",
23
+ deny: "workspace only",
24
+ };
25
+
11
26
  export function setSessionMode(
12
27
  pi: ExtensionAPI,
13
28
  state: SessionState,
@@ -17,32 +32,64 @@ export function setSessionMode(
17
32
  ): void {
18
33
  if (mode !== state.mode) record(pi, { kind: "mode", mode });
19
34
  state.mode = mode;
20
- renderModeStatus(ctx, mode, state.config.workspace.outside);
35
+ renderStatus(ctx, state);
21
36
 
22
- if (announce) {
23
- ctx.ui.notify(`${NAME}: ${MODE_LABEL[mode]} \u00b7 ${MODE_DESCRIPTION[mode]}`, "info");
24
- }
37
+ if (!announce) return;
38
+ ctx.ui.notify(`${NAME}: ${mode}, ${MODES[mode].description}`, "info");
39
+ notifyJudgePolicyWarning(state, ctx);
25
40
  }
26
41
 
27
- export function clearModeStatus(ctx: ExtensionContext): void {
42
+ export function setSessionOutside(
43
+ pi: ExtensionAPI,
44
+ state: SessionState,
45
+ outside: OutsideScope,
46
+ ctx: ExtensionContext,
47
+ announce = true,
48
+ ): void {
49
+ if (outside !== state.outside) record(pi, { kind: "outside", outside });
50
+ state.outside = outside;
51
+ renderStatus(ctx, state);
52
+
53
+ if (!announce) return;
54
+ ctx.ui.notify(`${NAME}: outside ${outside}, ${OUTSIDE_DESCRIPTION[outside]}`, "info");
55
+ }
56
+
57
+ export function notifyJudgePolicyWarning(
58
+ state: Pick<SessionState, "config" | "mode">,
59
+ ctx: ExtensionContext,
60
+ ): void {
61
+ if (state.mode !== "judge") return;
62
+ const warning = policyWarning(state.config.judge.policy);
63
+ if (warning) ctx.ui.notify(`${NAME}: ${warning}`, "warning");
64
+ }
65
+
66
+ export function clearStatus(ctx: ExtensionContext): void {
28
67
  if (!ctx.hasUI) return;
29
68
  ctx.ui.setStatus(MODE_STATUS, undefined);
30
69
  }
31
70
 
32
- export function renderModeStatus(
71
+ export function renderStatus(
33
72
  ctx: ExtensionContext,
34
- mode: PermissionMode,
35
- outside: OutsideScope,
73
+ state: Pick<SessionState, "mode" | "outside" | "folders">,
36
74
  ): void {
37
- if (mode === "manual") {
38
- clearModeStatus(ctx);
75
+ const tag = OUTSIDE_TAG[state.outside];
76
+ const folders = state.folders.total();
77
+ const quiet = state.mode === "manual" && tag === undefined;
78
+ if (quiet && folders === 0) {
79
+ clearStatus(ctx);
39
80
  return;
40
81
  }
41
82
  if (!ctx.hasUI) return;
42
83
 
43
- const anywhere = outside === "allow";
44
- const arrow = mode === "auto" ? "\u23f5\u23f5" : "\u23f5";
45
- const color = anywhere ? "error" : "warning";
46
- const label = anywhere ? `${MODE_LABEL[mode]} \u00b7 anywhere` : MODE_LABEL[mode];
47
- ctx.ui.setStatus(MODE_STATUS, ctx.ui.theme.fg(color, `${arrow} ${label}`));
84
+ const theme = ctx.ui.theme;
85
+ const parts: string[] = [];
86
+ if (!quiet) {
87
+ const label = tag === undefined ? state.mode : `${state.mode} (${tag})`;
88
+ const color = state.outside === "allow" ? "error" : "warning";
89
+ parts.push(theme.fg(color, `${ARROW[state.mode]}${label}`));
90
+ }
91
+ // Open folders widen the workspace, so the status bar counts them.
92
+ if (folders > 0)
93
+ parts.push(theme.fg("dim", `+${folders} ${folders === 1 ? "folder" : "folders"}`));
94
+ ctx.ui.setStatus(MODE_STATUS, parts.join(" "));
48
95
  }