@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/index.ts CHANGED
@@ -1,17 +1,21 @@
1
1
  import { createApp, defineFeature } from "@adeildo/pi-kit";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
 
4
+ import { PERMISSION_SETTINGS, readConfig, SECTIONS } from "#core/config/settings.ts";
4
5
  import { listenForTools } from "#pi/api.ts";
5
6
  import { registerBashTimer } from "#pi/bash-timer.ts";
6
- import { registerCommands } from "#pi/commands.ts";
7
+ import { registerShortcuts } from "#pi/commands.ts";
7
8
  import { registerEvents } from "#pi/events.ts";
8
9
  import { registerTypesafeProvider } from "#pi/provider.ts";
10
+ import { registerScreen } from "#pi/screen.ts";
9
11
  import { createSession } from "#pi/session.ts";
10
12
  import { registerJudgeEntry } from "#ui/judge-entry.ts";
11
13
 
12
14
  export const permission = defineFeature({
13
15
  id: "permission",
14
16
  description: "Ask before a tool call runs, with a judge model for the routine ones",
17
+ sections: SECTIONS,
18
+ settings: PERMISSION_SETTINGS,
15
19
  setup(scope) {
16
20
  registerBashTimer(scope);
17
21
  registerTypesafeProvider(scope);
@@ -20,7 +24,16 @@ export const permission = defineFeature({
20
24
  const session = createSession();
21
25
  registerEvents(scope, session);
22
26
  listenForTools(scope, session.customTools);
23
- registerCommands(scope, session);
27
+ registerShortcuts(scope, session);
28
+ registerScreen(scope, session);
29
+
30
+ // A change on the screen, or in another pi, applies to the next call.
31
+ for (const entry of PERMISSION_SETTINGS) {
32
+ entry.listen(scope, () => {
33
+ session.config = readConfig(scope);
34
+ session.judgeCache.clear();
35
+ });
36
+ }
24
37
  },
25
38
  });
26
39
 
@@ -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,27 +7,32 @@ 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,
@@ -35,8 +40,8 @@ import {
35
40
  } from "#pi/session.ts";
36
41
  import { AskDialog } from "#ui/dialog.ts";
37
42
  import { appendJudgeEntry } from "#ui/judge-entry.ts";
43
+ import { askThroughQuestions, type AskExtras } from "#ui/questions.ts";
38
44
  import { askViaSelector } from "#ui/selector.ts";
39
- import { notifyJudgePolicyWarning } from "#ui/settings/status.ts";
40
45
 
41
46
  const JUDGE_STATUS = `${NAME}:judge`;
42
47
  const TYPING_STATUS = "waiting for you to finish typing";
@@ -46,29 +51,27 @@ export function registerEvents(scope: FeatureScope, state: SessionState): void {
46
51
  loadSessionConfig(scope, state, ctx);
47
52
  openAlwaysYes(state, ctx);
48
53
  restoreSession(state, ctx);
49
- notifyJudgePolicyWarning(state.config, ctx);
50
- renderModeStatus(ctx, state.mode, state.config.workspace.outside);
54
+ notifyJudgePolicyWarning(state, ctx);
55
+ renderStatus(ctx, state);
51
56
  state.typing.start(ctx);
52
57
  });
53
58
 
54
59
  scope.on("session_tree", (_event, ctx) => {
55
60
  restoreSession(state, ctx);
56
- renderModeStatus(ctx, state.mode, state.config.workspace.outside);
61
+ renderStatus(ctx, state);
57
62
  });
58
63
 
59
64
  scope.on("session_shutdown", (_event, ctx) => {
60
- clearModeStatus(ctx);
65
+ clearStatus(ctx);
61
66
  state.typing.stop();
62
67
  });
63
68
 
64
69
  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
- );
70
+ const call = describeCall(event.toolName, event.input, ctx.cwd, state.config, {
71
+ custom: state.customTools,
72
+ facts: toolFacts(scope),
73
+ nested: event.parentToolCallId !== undefined,
74
+ });
72
75
  const outcome = await gate(scope, state, ctx, call, event);
73
76
 
74
77
  announce(scope, {
@@ -101,6 +104,12 @@ export function registerEvents(scope: FeatureScope, state: SessionState): void {
101
104
 
102
105
  type Outcome = Pick<Decided, "action" | "by" | "reason" | "note">;
103
106
 
107
+ /** What pi reports about a tool beside its name: the server it belongs to, and its own hints. */
108
+ function toolFacts(pi: ExtensionAPI): ToolFacts {
109
+ const tools = pi.getAllTools();
110
+ return (toolName) => tools.find((tool) => tool.name === toolName);
111
+ }
112
+
104
113
  async function gate(
105
114
  pi: ExtensionAPI,
106
115
  state: SessionState,
@@ -108,8 +117,11 @@ async function gate(
108
117
  call: Call,
109
118
  event: ToolCallEvent,
110
119
  ): Promise<Outcome> {
111
- const judge: Layer = { name: "judge", decide: (next) => runJudge(state, pi, ctx, next) };
112
- const decision = await decide(call, [...gateLayers(state), judge]);
120
+ const layers = gateLayers(state);
121
+ if (MODES[state.mode].judge) {
122
+ layers.push({ name: "judge", decide: (next) => runJudge(state, pi, ctx, next) });
123
+ }
124
+ const decision = await decide(call, layers);
113
125
 
114
126
  if (decision.action === "allow") return { action: "allow", by: decision.by };
115
127
  if (decision.action === "block")
@@ -129,9 +141,13 @@ async function gate(
129
141
  });
130
142
 
131
143
  state.typing.pause();
132
- const answer = await ask(ctx, call.toolName, call.target, change?.diff).finally(() =>
133
- state.typing.resume(),
134
- );
144
+ const reason = "reason" in decision ? decision.reason : undefined;
145
+ const leaving = "by" in decision && decision.by === "workspace";
146
+ const answer = await ask(pi, ctx, call, {
147
+ diff: change?.diff,
148
+ reason,
149
+ offer: leaving ? offerFor(call) : undefined,
150
+ }).finally(() => state.typing.resume());
135
151
 
136
152
  if (answer.decision === "deny") {
137
153
  return { action: "block", by: "you", reason: denyReason(answer.note), note: answer.note };
@@ -141,11 +157,22 @@ async function gate(
141
157
  const scope = answer.scope ?? "session";
142
158
  rememberAlwaysYes(pi, state, ctx, scope, call.toolName, answer.remember);
143
159
  ctx.ui.notify(
144
- `${NAME}: always yes for ${call.toolName} \u00b7 ${answer.remember} (${SCOPE_LABEL[scope]})`,
160
+ `${NAME}: always yes for ${call.toolName}: ${answer.remember} (${SCOPE_LABEL[scope]})`,
145
161
  "info",
146
162
  );
147
163
  }
148
164
 
165
+ if (answer.open) {
166
+ const { path, access, scope } = answer.open;
167
+ openFolder(pi, state, ctx, scope, path, access);
168
+ renderStatus(ctx, state);
169
+ const what =
170
+ access === "read"
171
+ ? `reads open in ${shortenHome(path)}`
172
+ : `${shortenHome(path)} joins the workspace`;
173
+ ctx.ui.notify(`${NAME}: ${what}, ${SCOPE_LABEL[scope]}`, "info");
174
+ }
175
+
149
176
  if (change)
150
177
  state.pendingWrites.set(change.path, { toolCallId: event.toolCallId, after: change.after });
151
178
  return { action: "allow", by: "you", note: answer.note };
@@ -175,6 +202,9 @@ async function runJudge(
175
202
  toolName: call.toolName,
176
203
  target: call.target,
177
204
  rawInput: call.input,
205
+ source: judgeSource(call),
206
+ toolDescription: call.description,
207
+ intent: currentIntent(ctx),
178
208
  cache: state.judgeCache,
179
209
  onStatus: (status) => ctx.ui.setStatus(JUDGE_STATUS, status),
180
210
  });
@@ -210,12 +240,38 @@ async function runJudge(
210
240
  return undefined;
211
241
  }
212
242
 
243
+ // What the tool name does not say: the server behind an MCP tool and what it declares, and a call
244
+ // that a script issued rather than the model. The judge reads both as data.
245
+ function judgeSource(call: Call): string | undefined {
246
+ const parts: string[] = [];
247
+ if (call.mcp) {
248
+ parts.push(
249
+ `MCP server "${call.mcp.server}", which declares the tool ${describeHints(call.hints)}`,
250
+ );
251
+ }
252
+ if (call.nested) parts.push("issued by a codemode script, not by the model");
253
+ return parts.length === 0 ? undefined : parts.join("; ");
254
+ }
255
+
256
+ // No offer when folderChoices finds nothing to open, like a path in the home itself.
257
+ function offerFor(call: Call): FolderOffer | undefined {
258
+ if (call.reach.kind !== "outside") return undefined;
259
+ const choices = folderChoices(call.reach.paths);
260
+ return choices && { ...choices, access: accessOf(call) };
261
+ }
262
+
213
263
  async function ask(
264
+ pi: ExtensionAPI,
214
265
  ctx: ExtensionContext,
215
- toolName: string,
216
- target: CallDescriptor,
217
- diff: string | undefined,
266
+ call: Call,
267
+ extras: AskExtras,
218
268
  ): Promise<DialogAnswer> {
269
+ const { toolName, target } = call;
270
+ // One dialog for both: the questions feature draws this when it is installed.
271
+ if (ctx.mode === "tui" && canAsk(pi.events)) {
272
+ const answer = await askThroughQuestions(pi.events, call, extras);
273
+ if (answer !== undefined) return answer;
274
+ }
219
275
  if (ctx.mode === "tui") {
220
276
  try {
221
277
  const answer = await ctx.ui.custom<DialogAnswer>(
@@ -224,7 +280,10 @@ async function ask(
224
280
  theme,
225
281
  toolName,
226
282
  target,
227
- diff,
283
+ mcp: call.mcp,
284
+ hints: call.hints,
285
+ nested: call.nested,
286
+ ...extras,
228
287
  keybindings,
229
288
  requestRender: () => tui.requestRender(),
230
289
  complete: done,
@@ -236,7 +295,7 @@ async function ask(
236
295
  }
237
296
  }
238
297
 
239
- return askViaSelector(ctx, toolName, target);
298
+ return askViaSelector(ctx, toolName, target, extras.offer);
240
299
  }
241
300
 
242
301
  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
  }