@narumitw/pi-chrome-devtools 0.53.2 → 0.53.3

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 (40) hide show
  1. package/README.md +1 -0
  2. package/dist/chunks/{browser-settings-menu-IMB6HXSU.js → browser-settings-menu-SR7KLZT6.js} +16 -42
  3. package/dist/chunks/browser-settings-menu-SR7KLZT6.js.map +7 -0
  4. package/dist/chunks/{chunk-GTTYS5GT.js → chunk-4KMN4SFF.js} +2 -2
  5. package/dist/chunks/chunk-4KMN4SFF.js.map +7 -0
  6. package/dist/chunks/{chunk-SRT3UUIP.js → chunk-F5VG35UI.js} +23 -74
  7. package/dist/chunks/chunk-F5VG35UI.js.map +7 -0
  8. package/dist/chunks/{chunk-LLXOCRNF.js → chunk-MZCGM6D3.js} +12 -33
  9. package/dist/chunks/chunk-MZCGM6D3.js.map +7 -0
  10. package/dist/chunks/{menu-UJETYMSB.js → menu-YR4VRT7D.js} +10 -23
  11. package/dist/chunks/menu-YR4VRT7D.js.map +7 -0
  12. package/dist/chunks/{tools-6P7EUX37.js → tools-6R7Z6HWJ.js} +17 -59
  13. package/dist/chunks/tools-6R7Z6HWJ.js.map +7 -0
  14. package/dist/index.ts +18 -42
  15. package/dist/index.ts.map +2 -2
  16. package/package.json +54 -55
  17. package/src/browser-manager.ts +872 -942
  18. package/src/browser-settings-menu.ts +315 -350
  19. package/src/cdp-client.ts +381 -398
  20. package/src/chrome-devtools.ts +205 -232
  21. package/src/display.ts +26 -29
  22. package/src/lazy-tools.ts +158 -181
  23. package/src/menu.ts +409 -446
  24. package/src/render.ts +72 -81
  25. package/src/runtime.ts +137 -140
  26. package/src/screenshot.ts +170 -186
  27. package/src/settings.ts +472 -523
  28. package/src/tool-names.ts +8 -14
  29. package/src/tool-selector.ts +242 -271
  30. package/src/tools.ts +218 -226
  31. package/src/webmcp/discovery.ts +184 -188
  32. package/src/webmcp/policy.ts +296 -329
  33. package/src/webmcp/protocol.ts +324 -337
  34. package/src/webmcp/tools.ts +136 -147
  35. package/dist/chunks/browser-settings-menu-IMB6HXSU.js.map +0 -7
  36. package/dist/chunks/chunk-GTTYS5GT.js.map +0 -7
  37. package/dist/chunks/chunk-LLXOCRNF.js.map +0 -7
  38. package/dist/chunks/chunk-SRT3UUIP.js.map +0 -7
  39. package/dist/chunks/menu-UJETYMSB.js.map +0 -7
  40. package/dist/chunks/tools-6P7EUX37.js.map +0 -7
package/src/menu.ts CHANGED
@@ -1,24 +1,19 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu, runTask } from "@narumitw/pi-tui-kit";
3
- import {
4
- browserLifecycleState,
5
- devToolsEndpoint,
6
- launchAttemptLines,
7
- launchModeLabel,
8
- } from "./browser-manager.js";
3
+ import { browserLifecycleState, devToolsEndpoint, launchAttemptLines, launchModeLabel } from "./browser-manager.js";
9
4
  import { availableChromeDevtoolsTools } from "./lazy-tools.js";
10
5
  import { state, webMcpEnabled } from "./runtime.js";
11
6
  import { loadSettings, type SettingsLoadResult } from "./settings.js";
12
7
  import {
13
- CHROME_DEVTOOLS_TOOL_NAMES,
14
- type ChromeDevToolsToolName,
15
- CORE_CHROME_DEVTOOLS_TOOL_NAMES,
8
+ CHROME_DEVTOOLS_TOOL_NAMES,
9
+ type ChromeDevToolsToolName,
10
+ CORE_CHROME_DEVTOOLS_TOOL_NAMES,
16
11
  } from "./tool-names.js";
17
12
  import {
18
- buildBrowserStatusMessage,
19
- buildCommandGuide,
20
- sanitizeChromeDevtoolsDisplay,
21
- setSelectedChromeDevtoolsTools,
13
+ buildBrowserStatusMessage,
14
+ buildCommandGuide,
15
+ sanitizeChromeDevtoolsDisplay,
16
+ setSelectedChromeDevtoolsTools,
22
17
  } from "./tool-selector.js";
23
18
 
24
19
  type CommandContext = ExtensionCommandContext;
@@ -28,497 +23,465 @@ type ToolScreen = "tools" | "review";
28
23
  type ToolAction = "toggle" | "selectAll" | "selectNone" | "review" | "apply" | "cancel";
29
24
 
30
25
  interface MenuSnapshot {
31
- activeTools: ChromeDevToolsToolName[];
32
- persistenceLabel: string;
33
- mutationBlockedReason?: string;
34
- settingsWarning?: string;
26
+ activeTools: ChromeDevToolsToolName[];
27
+ persistenceLabel: string;
28
+ mutationBlockedReason?: string;
29
+ settingsWarning?: string;
35
30
  }
36
31
 
37
32
  interface ToolWorkflowState {
38
- accepted: Set<ChromeDevToolsToolName>;
39
- draft: Set<ChromeDevToolsToolName>;
40
- mutationBlockedReason?: string;
41
- generation: number;
42
- applied: boolean;
33
+ accepted: Set<ChromeDevToolsToolName>;
34
+ draft: Set<ChromeDevToolsToolName>;
35
+ mutationBlockedReason?: string;
36
+ generation: number;
37
+ applied: boolean;
43
38
  }
44
39
 
45
40
  const TOOL_PRESENTATION: Record<ChromeDevToolsToolName, { label: string; description: string }> = {
46
- chrome_devtools_list_pages: {
47
- label: "List open pages",
48
- description: "List inspectable Chrome tabs and pages (chrome_devtools_list_pages).",
49
- },
50
- chrome_devtools_select_page: {
51
- label: "Select the active page",
52
- description: "Choose the page later browser actions use (chrome_devtools_select_page).",
53
- },
54
- chrome_devtools_navigate: {
55
- label: "Navigate a page",
56
- description: "Open a URL, creating a page when needed (chrome_devtools_navigate).",
57
- },
58
- chrome_devtools_evaluate: {
59
- label: "Run JavaScript",
60
- description: "Evaluate JavaScript in the selected page (chrome_devtools_evaluate).",
61
- },
62
- chrome_devtools_screenshot: {
63
- label: "Capture a screenshot",
64
- description: "Save a PNG of the selected page (chrome_devtools_screenshot).",
65
- },
66
- chrome_devtools_webmcp_list_tools: {
67
- label: "List page WebMCP tools · Experimental",
68
- description: "Discover page-provided WebMCP capabilities (chrome_devtools_webmcp_list_tools).",
69
- },
70
- chrome_devtools_webmcp_call_tool: {
71
- label: "Call a page WebMCP tool · Experimental",
72
- description: "Invoke a listed page tool after confirmation (chrome_devtools_webmcp_call_tool).",
73
- },
41
+ chrome_devtools_list_pages: {
42
+ label: "List open pages",
43
+ description: "List inspectable Chrome tabs and pages (chrome_devtools_list_pages).",
44
+ },
45
+ chrome_devtools_select_page: {
46
+ label: "Select the active page",
47
+ description: "Choose the page later browser actions use (chrome_devtools_select_page).",
48
+ },
49
+ chrome_devtools_navigate: {
50
+ label: "Navigate a page",
51
+ description: "Open a URL, creating a page when needed (chrome_devtools_navigate).",
52
+ },
53
+ chrome_devtools_evaluate: {
54
+ label: "Run JavaScript",
55
+ description: "Evaluate JavaScript in the selected page (chrome_devtools_evaluate).",
56
+ },
57
+ chrome_devtools_screenshot: {
58
+ label: "Capture a screenshot",
59
+ description: "Save a PNG of the selected page (chrome_devtools_screenshot).",
60
+ },
61
+ chrome_devtools_webmcp_list_tools: {
62
+ label: "List page WebMCP tools · Experimental",
63
+ description: "Discover page-provided WebMCP capabilities (chrome_devtools_webmcp_list_tools).",
64
+ },
65
+ chrome_devtools_webmcp_call_tool: {
66
+ label: "Call a page WebMCP tool · Experimental",
67
+ description: "Invoke a listed page tool after confirmation (chrome_devtools_webmcp_call_tool).",
68
+ },
74
69
  };
75
70
 
76
- export async function showChromeDevtoolsMenu(
77
- pi: ExtensionAPI,
78
- ctx: CommandContext,
79
- generation: number,
80
- ) {
81
- const snapshot = await loadSnapshotWithFeedback(pi, ctx, generation);
82
- if (!snapshot) return;
83
- const menuSignal = state.sessionController.signal;
84
- const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
85
- const menu = defineMenu<MenuSnapshot, MainScreen, MainAction>({
86
- start: "main",
87
- screens: {
88
- main: ({ state: current }) => ({
89
- kind: "actions",
90
- title: "Chrome DevTools",
91
- lines: mainStateLines(current, ctx.sessionManager),
92
- items: [
93
- {
94
- id: "tools",
95
- label: "Choose available browser tools…",
96
- description: "Choose which capabilities the loader may expose.",
97
- disabled: Boolean(current.mutationBlockedReason),
98
- disabledReason: current.mutationBlockedReason,
99
- action: "tools",
100
- },
101
- {
102
- id: "bulk",
103
- label:
104
- current.activeTools.length === availableCatalogNames(ctx.sessionManager).length
105
- ? "Make all browser tools unavailable…"
106
- : "Make all browser tools available…",
107
- description:
108
- current.activeTools.length === availableCatalogNames(ctx.sessionManager).length
109
- ? `Preview 0 of ${availableCatalogNames(ctx.sessionManager).length}; other active tools stay enabled.`
110
- : `Preview ${availableCatalogNames(ctx.sessionManager).length} of ${availableCatalogNames(ctx.sessionManager).length}; other active tools stay enabled.`,
111
- disabled: Boolean(current.mutationBlockedReason),
112
- disabledReason: current.mutationBlockedReason,
113
- action: "bulk",
114
- },
115
- {
116
- id: "browser-status",
117
- label: "Browser status",
118
- description: "Runtime, endpoint, and last launch attempt.",
119
- to: "browserStatus",
120
- },
121
- {
122
- id: "settings",
123
- label: "Browser settings",
124
- description: "Edit endpoint, auto-launch, and browser executable settings.",
125
- action: "settings",
126
- },
127
- {
128
- id: "help",
129
- label: "Help",
130
- description: "Commands and usage.",
131
- to: "help",
132
- },
133
- ],
134
- hint: "close",
135
- }),
136
- browserStatus: () => ({
137
- kind: "review",
138
- title: "Browser status",
139
- content: buildBrowserStatusMessage(ctx.sessionManager),
140
- format: { kind: "text" },
141
- viewportSize: "adaptive",
142
- hint: "back",
143
- }),
144
- help: () => ({
145
- kind: "review",
146
- title: "Chrome DevTools help",
147
- content: buildCommandGuide(ctx.sessionManager),
148
- format: { kind: "text" },
149
- viewportSize: "adaptive",
150
- hint: "back",
151
- }),
152
- },
153
- actions: {
154
- tools: async ({ state: current }) => {
155
- const result = await showChromeDevtoolsToolWorkflow(pi, ctx, generation, {
156
- snapshot: current,
157
- });
158
- if (result?.applied && isCurrent()) updateSnapshotAfterApply(current, result.selectedTools);
159
- return result?.closeParent ? { kind: "close" } : { kind: "stay" };
160
- },
161
- bulk: async ({ state: current }) => {
162
- const selectedTools =
163
- current.activeTools.length === availableCatalogNames(ctx.sessionManager).length
164
- ? []
165
- : availableCatalogNames(ctx.sessionManager);
166
- const result = await showChromeDevtoolsToolWorkflow(pi, ctx, generation, {
167
- snapshot: current,
168
- initialDraft: selectedTools,
169
- startAtReview: true,
170
- });
171
- if (result?.applied && isCurrent()) updateSnapshotAfterApply(current, result.selectedTools);
172
- return result?.closeParent ? { kind: "close" } : { kind: "stay" };
173
- },
174
- settings: async ({ state: current }) => {
175
- const { showChromeDevtoolsBrowserSettings } = await import("./browser-settings-menu.js");
176
- if (!isCurrent()) return { kind: "stay" };
177
- const result = await showChromeDevtoolsBrowserSettings(pi, ctx, generation);
178
- if (!isCurrent()) return { kind: "stay" };
179
- const refreshed = await loadChromeDevtoolsMenuSnapshot(pi, ctx);
180
- if (!isCurrent()) return { kind: "stay" };
181
- Object.assign(current, refreshed);
182
- return result.closeParent ? { kind: "close" } : { kind: "stay" };
183
- },
184
- },
185
- });
186
- await runMenu(ctx, menu, {
187
- getState: () => snapshot,
188
- signal: menuSignal,
189
- isCurrent,
190
- onError: (currentCtx, error) =>
191
- currentCtx.ui.notify(
192
- sanitizeChromeDevtoolsDisplay(`Chrome DevTools menu failed: ${formatError(error)}`),
193
- "error",
194
- ),
195
- });
71
+ export async function showChromeDevtoolsMenu(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
72
+ const snapshot = await loadSnapshotWithFeedback(pi, ctx, generation);
73
+ if (!snapshot) return;
74
+ const menuSignal = state.sessionController.signal;
75
+ const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
76
+ const menu = defineMenu<MenuSnapshot, MainScreen, MainAction>({
77
+ start: "main",
78
+ screens: {
79
+ main: ({ state: current }) => ({
80
+ kind: "actions",
81
+ title: "Chrome DevTools",
82
+ lines: mainStateLines(current, ctx.sessionManager),
83
+ items: [
84
+ {
85
+ id: "tools",
86
+ label: "Choose available browser tools…",
87
+ description: "Choose which capabilities the loader may expose.",
88
+ disabled: Boolean(current.mutationBlockedReason),
89
+ disabledReason: current.mutationBlockedReason,
90
+ action: "tools",
91
+ },
92
+ {
93
+ id: "bulk",
94
+ label:
95
+ current.activeTools.length === availableCatalogNames(ctx.sessionManager).length
96
+ ? "Make all browser tools unavailable…"
97
+ : "Make all browser tools available…",
98
+ description:
99
+ current.activeTools.length === availableCatalogNames(ctx.sessionManager).length
100
+ ? `Preview 0 of ${availableCatalogNames(ctx.sessionManager).length}; other active tools stay enabled.`
101
+ : `Preview ${availableCatalogNames(ctx.sessionManager).length} of ${availableCatalogNames(ctx.sessionManager).length}; other active tools stay enabled.`,
102
+ disabled: Boolean(current.mutationBlockedReason),
103
+ disabledReason: current.mutationBlockedReason,
104
+ action: "bulk",
105
+ },
106
+ {
107
+ id: "browser-status",
108
+ label: "Browser status",
109
+ description: "Runtime, endpoint, and last launch attempt.",
110
+ to: "browserStatus",
111
+ },
112
+ {
113
+ id: "settings",
114
+ label: "Browser settings",
115
+ description: "Edit endpoint, auto-launch, and browser executable settings.",
116
+ action: "settings",
117
+ },
118
+ {
119
+ id: "help",
120
+ label: "Help",
121
+ description: "Commands and usage.",
122
+ to: "help",
123
+ },
124
+ ],
125
+ hint: "close",
126
+ }),
127
+ browserStatus: () => ({
128
+ kind: "review",
129
+ title: "Browser status",
130
+ content: buildBrowserStatusMessage(ctx.sessionManager),
131
+ format: { kind: "text" },
132
+ viewportSize: "adaptive",
133
+ hint: "back",
134
+ }),
135
+ help: () => ({
136
+ kind: "review",
137
+ title: "Chrome DevTools help",
138
+ content: buildCommandGuide(ctx.sessionManager),
139
+ format: { kind: "text" },
140
+ viewportSize: "adaptive",
141
+ hint: "back",
142
+ }),
143
+ },
144
+ actions: {
145
+ tools: async ({ state: current }) => {
146
+ const result = await showChromeDevtoolsToolWorkflow(pi, ctx, generation, {
147
+ snapshot: current,
148
+ });
149
+ if (result?.applied && isCurrent()) updateSnapshotAfterApply(current, result.selectedTools);
150
+ return result?.closeParent ? { kind: "close" } : { kind: "stay" };
151
+ },
152
+ bulk: async ({ state: current }) => {
153
+ const selectedTools =
154
+ current.activeTools.length === availableCatalogNames(ctx.sessionManager).length
155
+ ? []
156
+ : availableCatalogNames(ctx.sessionManager);
157
+ const result = await showChromeDevtoolsToolWorkflow(pi, ctx, generation, {
158
+ snapshot: current,
159
+ initialDraft: selectedTools,
160
+ startAtReview: true,
161
+ });
162
+ if (result?.applied && isCurrent()) updateSnapshotAfterApply(current, result.selectedTools);
163
+ return result?.closeParent ? { kind: "close" } : { kind: "stay" };
164
+ },
165
+ settings: async ({ state: current }) => {
166
+ const { showChromeDevtoolsBrowserSettings } = await import("./browser-settings-menu.js");
167
+ if (!isCurrent()) return { kind: "stay" };
168
+ const result = await showChromeDevtoolsBrowserSettings(pi, ctx, generation);
169
+ if (!isCurrent()) return { kind: "stay" };
170
+ const refreshed = await loadChromeDevtoolsMenuSnapshot(pi, ctx);
171
+ if (!isCurrent()) return { kind: "stay" };
172
+ Object.assign(current, refreshed);
173
+ return result.closeParent ? { kind: "close" } : { kind: "stay" };
174
+ },
175
+ },
176
+ });
177
+ await runMenu(ctx, menu, {
178
+ getState: () => snapshot,
179
+ signal: menuSignal,
180
+ isCurrent,
181
+ onError: (currentCtx, error) =>
182
+ currentCtx.ui.notify(
183
+ sanitizeChromeDevtoolsDisplay(`Chrome DevTools menu failed: ${formatError(error)}`),
184
+ "error",
185
+ ),
186
+ });
196
187
  }
197
188
 
198
189
  export async function showChromeDevtoolsToolWorkflow(
199
- pi: ExtensionAPI,
200
- ctx: CommandContext,
201
- generation: number,
202
- options: {
203
- snapshot?: MenuSnapshot;
204
- initialDraft?: readonly ChromeDevToolsToolName[];
205
- startAtReview?: boolean;
206
- } = {},
207
- ): Promise<
208
- { applied: boolean; closeParent: boolean; selectedTools: ChromeDevToolsToolName[] } | undefined
209
- > {
210
- const snapshot = options.snapshot ?? (await loadSnapshotWithFeedback(pi, ctx, generation));
211
- if (!snapshot) return undefined;
212
- const menuSignal = state.sessionController.signal;
213
- const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
214
- const accepted = new Set(snapshot.activeTools);
215
- const workflow: ToolWorkflowState = {
216
- accepted,
217
- draft: new Set(options.initialDraft ?? snapshot.activeTools),
218
- mutationBlockedReason: snapshot.mutationBlockedReason,
219
- generation,
220
- applied: false,
221
- };
222
- const menu = defineMenu<ToolWorkflowState, ToolScreen, ToolAction>({
223
- start: options.startAtReview ? "review" : "tools",
224
- screens: {
225
- tools: ({ state: current }) => ({
226
- kind: "multiSelect",
227
- title: `Browser tools (${current.draft.size}/${availableCatalogNames(ctx.sessionManager).length})`,
228
- lines: toolDraftLines(current, ctx.sessionManager),
229
- items: availableCatalogNames(ctx.sessionManager).map((toolName) => ({
230
- id: toolName,
231
- label: TOOL_PRESENTATION[toolName].label,
232
- description: TOOL_PRESENTATION[toolName].description,
233
- searchText: `${toolName} ${TOOL_PRESENTATION[toolName].description}`,
234
- selected: current.draft.has(toolName),
235
- disabled: Boolean(current.mutationBlockedReason),
236
- disabledReason: current.mutationBlockedReason,
237
- })),
238
- action: "toggle",
239
- actions: [
240
- {
241
- id: "select-all",
242
- label: "Select all",
243
- disabled: Boolean(current.mutationBlockedReason),
244
- disabledReason: current.mutationBlockedReason,
245
- action: "selectAll",
246
- },
247
- {
248
- id: "select-none",
249
- label: "Select none",
250
- disabled: Boolean(current.mutationBlockedReason),
251
- disabledReason: current.mutationBlockedReason,
252
- action: "selectNone",
253
- },
254
- {
255
- id: "review",
256
- label: "Review changes…",
257
- disabled:
258
- Boolean(current.mutationBlockedReason) || setsEqual(current.accepted, current.draft),
259
- disabledReason:
260
- current.mutationBlockedReason ??
261
- (setsEqual(current.accepted, current.draft) ? "No unapplied changes" : undefined),
262
- to: "review",
263
- },
264
- { id: "cancel", label: "Cancel", action: "cancel" },
265
- ],
266
- hint: "back",
267
- }),
268
- review: ({ state: current }) => ({
269
- kind: "review",
270
- title: "Review tool changes",
271
- lines: [
272
- `Currently available: ${current.accepted.size}/${availableCatalogNames(ctx.sessionManager).length}`,
273
- `Proposed availability: ${current.draft.size}/${availableCatalogNames(ctx.sessionManager).length}`,
274
- ],
275
- content: buildToolReview(current, ctx.sessionManager),
276
- format: { kind: "text" },
277
- viewportSize: "adaptive",
278
- ...(current.mutationBlockedReason || setsEqual(current.accepted, current.draft)
279
- ? {}
280
- : {
281
- confirm: {
282
- id: "apply",
283
- label: "Apply tool changes",
284
- action: "apply" as const,
285
- },
286
- }),
287
- hint: "back",
288
- }),
289
- },
290
- actions: {
291
- toggle: ({ state: current, itemId, selected }) => {
292
- if (!isChromeDevtoolsToolName(itemId) || selected === undefined)
293
- return { kind: "rejected" };
294
- if (selected) current.draft.add(itemId);
295
- else current.draft.delete(itemId);
296
- return { kind: "stay" };
297
- },
298
- selectAll: ({ state: current }) => {
299
- current.draft = new Set(availableCatalogNames(ctx.sessionManager));
300
- return { kind: "stay" };
301
- },
302
- selectNone: ({ state: current }) => {
303
- current.draft = new Set();
304
- return { kind: "stay" };
305
- },
306
- review: () => ({ kind: "to", screen: "review" }),
307
- cancel: () => ({ kind: "back" }),
308
- apply: async ({ state: current, signal }) => {
309
- await ctx.waitForIdle();
310
- if (signal.aborted || !isCurrent() || current.generation !== state.sessionGeneration) {
311
- return { kind: "rejected" };
312
- }
313
- const selectedTools = orderedTools(current.draft);
314
- const saveResult = await setSelectedChromeDevtoolsTools(
315
- pi,
316
- ctx,
317
- selectedTools,
318
- orderedTools(current.accepted),
319
- );
320
- if (!isCurrent()) return { kind: "rejected" };
321
- if (saveResult === "active-tools-changed") {
322
- current.accepted = new Set(activeChromeTools(pi));
323
- }
324
- if (saveResult !== "saved") return { kind: "rejected" };
325
- current.accepted = new Set(selectedTools);
326
- current.draft = new Set(selectedTools);
327
- current.applied = true;
328
- ctx.ui.notify(
329
- `Saved: ${selectedTools.length} of ${availableCatalogNames(ctx.sessionManager).length} browser tools available.`,
330
- "info",
331
- );
332
- return { kind: "close" };
333
- },
334
- },
335
- });
336
- const result = await runMenu(ctx, menu, {
337
- getState: () => workflow,
338
- signal: menuSignal,
339
- isCurrent,
340
- onError: (currentCtx, error) =>
341
- currentCtx.ui.notify(
342
- sanitizeChromeDevtoolsDisplay(
343
- `Chrome DevTools tool selection failed: ${formatError(error)}`,
344
- ),
345
- "error",
346
- ),
347
- });
348
- return {
349
- applied: workflow.applied,
350
- closeParent: !workflow.applied && result.kind === "closed" && result.reason === "close",
351
- selectedTools: orderedTools(workflow.accepted),
352
- };
190
+ pi: ExtensionAPI,
191
+ ctx: CommandContext,
192
+ generation: number,
193
+ options: {
194
+ snapshot?: MenuSnapshot;
195
+ initialDraft?: readonly ChromeDevToolsToolName[];
196
+ startAtReview?: boolean;
197
+ } = {},
198
+ ): Promise<{ applied: boolean; closeParent: boolean; selectedTools: ChromeDevToolsToolName[] } | undefined> {
199
+ const snapshot = options.snapshot ?? (await loadSnapshotWithFeedback(pi, ctx, generation));
200
+ if (!snapshot) return undefined;
201
+ const menuSignal = state.sessionController.signal;
202
+ const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
203
+ const accepted = new Set(snapshot.activeTools);
204
+ const workflow: ToolWorkflowState = {
205
+ accepted,
206
+ draft: new Set(options.initialDraft ?? snapshot.activeTools),
207
+ mutationBlockedReason: snapshot.mutationBlockedReason,
208
+ generation,
209
+ applied: false,
210
+ };
211
+ const menu = defineMenu<ToolWorkflowState, ToolScreen, ToolAction>({
212
+ start: options.startAtReview ? "review" : "tools",
213
+ screens: {
214
+ tools: ({ state: current }) => ({
215
+ kind: "multiSelect",
216
+ title: `Browser tools (${current.draft.size}/${availableCatalogNames(ctx.sessionManager).length})`,
217
+ lines: toolDraftLines(current, ctx.sessionManager),
218
+ items: availableCatalogNames(ctx.sessionManager).map((toolName) => ({
219
+ id: toolName,
220
+ label: TOOL_PRESENTATION[toolName].label,
221
+ description: TOOL_PRESENTATION[toolName].description,
222
+ searchText: `${toolName} ${TOOL_PRESENTATION[toolName].description}`,
223
+ selected: current.draft.has(toolName),
224
+ disabled: Boolean(current.mutationBlockedReason),
225
+ disabledReason: current.mutationBlockedReason,
226
+ })),
227
+ action: "toggle",
228
+ actions: [
229
+ {
230
+ id: "select-all",
231
+ label: "Select all",
232
+ disabled: Boolean(current.mutationBlockedReason),
233
+ disabledReason: current.mutationBlockedReason,
234
+ action: "selectAll",
235
+ },
236
+ {
237
+ id: "select-none",
238
+ label: "Select none",
239
+ disabled: Boolean(current.mutationBlockedReason),
240
+ disabledReason: current.mutationBlockedReason,
241
+ action: "selectNone",
242
+ },
243
+ {
244
+ id: "review",
245
+ label: "Review changes…",
246
+ disabled: Boolean(current.mutationBlockedReason) || setsEqual(current.accepted, current.draft),
247
+ disabledReason:
248
+ current.mutationBlockedReason ??
249
+ (setsEqual(current.accepted, current.draft) ? "No unapplied changes" : undefined),
250
+ to: "review",
251
+ },
252
+ { id: "cancel", label: "Cancel", action: "cancel" },
253
+ ],
254
+ hint: "back",
255
+ }),
256
+ review: ({ state: current }) => ({
257
+ kind: "review",
258
+ title: "Review tool changes",
259
+ lines: [
260
+ `Currently available: ${current.accepted.size}/${availableCatalogNames(ctx.sessionManager).length}`,
261
+ `Proposed availability: ${current.draft.size}/${availableCatalogNames(ctx.sessionManager).length}`,
262
+ ],
263
+ content: buildToolReview(current, ctx.sessionManager),
264
+ format: { kind: "text" },
265
+ viewportSize: "adaptive",
266
+ ...(current.mutationBlockedReason || setsEqual(current.accepted, current.draft)
267
+ ? {}
268
+ : {
269
+ confirm: {
270
+ id: "apply",
271
+ label: "Apply tool changes",
272
+ action: "apply" as const,
273
+ },
274
+ }),
275
+ hint: "back",
276
+ }),
277
+ },
278
+ actions: {
279
+ toggle: ({ state: current, itemId, selected }) => {
280
+ if (!isChromeDevtoolsToolName(itemId) || selected === undefined) return { kind: "rejected" };
281
+ if (selected) current.draft.add(itemId);
282
+ else current.draft.delete(itemId);
283
+ return { kind: "stay" };
284
+ },
285
+ selectAll: ({ state: current }) => {
286
+ current.draft = new Set(availableCatalogNames(ctx.sessionManager));
287
+ return { kind: "stay" };
288
+ },
289
+ selectNone: ({ state: current }) => {
290
+ current.draft = new Set();
291
+ return { kind: "stay" };
292
+ },
293
+ review: () => ({ kind: "to", screen: "review" }),
294
+ cancel: () => ({ kind: "back" }),
295
+ apply: async ({ state: current, signal }) => {
296
+ await ctx.waitForIdle();
297
+ if (signal.aborted || !isCurrent() || current.generation !== state.sessionGeneration) {
298
+ return { kind: "rejected" };
299
+ }
300
+ const selectedTools = orderedTools(current.draft);
301
+ const saveResult = await setSelectedChromeDevtoolsTools(pi, ctx, selectedTools, orderedTools(current.accepted));
302
+ if (!isCurrent()) return { kind: "rejected" };
303
+ if (saveResult === "active-tools-changed") {
304
+ current.accepted = new Set(activeChromeTools(pi));
305
+ }
306
+ if (saveResult !== "saved") return { kind: "rejected" };
307
+ current.accepted = new Set(selectedTools);
308
+ current.draft = new Set(selectedTools);
309
+ current.applied = true;
310
+ ctx.ui.notify(
311
+ `Saved: ${selectedTools.length} of ${availableCatalogNames(ctx.sessionManager).length} browser tools available.`,
312
+ "info",
313
+ );
314
+ return { kind: "close" };
315
+ },
316
+ },
317
+ });
318
+ const result = await runMenu(ctx, menu, {
319
+ getState: () => workflow,
320
+ signal: menuSignal,
321
+ isCurrent,
322
+ onError: (currentCtx, error) =>
323
+ currentCtx.ui.notify(
324
+ sanitizeChromeDevtoolsDisplay(`Chrome DevTools tool selection failed: ${formatError(error)}`),
325
+ "error",
326
+ ),
327
+ });
328
+ return {
329
+ applied: workflow.applied,
330
+ closeParent: !workflow.applied && result.kind === "closed" && result.reason === "close",
331
+ selectedTools: orderedTools(workflow.accepted),
332
+ };
353
333
  }
354
334
 
355
335
  async function loadSnapshotWithFeedback(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
356
- const menuSignal = state.sessionController.signal;
357
- const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
358
- const result = await runTask(ctx, {
359
- label: "Loading Chrome DevTools settings…",
360
- signal: menuSignal,
361
- isCurrent,
362
- task: async ({ signal }) => {
363
- signal.throwIfAborted();
364
- const settings = await loadSettings();
365
- signal.throwIfAborted();
366
- return buildMenuSnapshot(pi, settings);
367
- },
368
- });
369
- return result.kind === "completed" ? result.value : undefined;
336
+ const menuSignal = state.sessionController.signal;
337
+ const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
338
+ const result = await runTask(ctx, {
339
+ label: "Loading Chrome DevTools settings…",
340
+ signal: menuSignal,
341
+ isCurrent,
342
+ task: async ({ signal }) => {
343
+ signal.throwIfAborted();
344
+ const settings = await loadSettings();
345
+ signal.throwIfAborted();
346
+ return buildMenuSnapshot(pi, settings);
347
+ },
348
+ });
349
+ return result.kind === "completed" ? result.value : undefined;
370
350
  }
371
351
 
372
- export async function loadChromeDevtoolsMenuSnapshot(
373
- pi: ExtensionAPI,
374
- ctx: ExtensionCommandContext,
375
- ) {
376
- const settings = await loadSettings({
377
- cwd: ctx.cwd,
378
- projectTrusted: ctx.isProjectTrusted(),
379
- });
380
- return buildMenuSnapshot(pi, settings);
352
+ export async function loadChromeDevtoolsMenuSnapshot(pi: ExtensionAPI, ctx: ExtensionCommandContext) {
353
+ const settings = await loadSettings({
354
+ cwd: ctx.cwd,
355
+ projectTrusted: ctx.isProjectTrusted(),
356
+ });
357
+ return buildMenuSnapshot(pi, settings);
381
358
  }
382
359
 
383
360
  function buildMenuSnapshot(pi: ExtensionAPI, settings: SettingsLoadResult): MenuSnapshot {
384
- const activeTools = activeChromeTools(pi);
385
- const persistedTools =
386
- settings.kind === "loaded" && settings.settings.tools
387
- ? orderedTools(new Set(settings.settings.tools))
388
- : undefined;
389
- const persistenceLabel =
390
- settings.kind === "invalid"
391
- ? "settings need repair"
392
- : persistedTools === undefined
393
- ? "not saved"
394
- : arraysEqual(activeTools, persistedTools)
395
- ? "saved"
396
- : "runtime catalog differs from saved catalog";
397
- const settingsWarning = [
398
- ...new Set([settings.notice, state.settingsNotice].filter(Boolean)),
399
- ].join("\n");
400
- return {
401
- activeTools,
402
- persistenceLabel,
403
- ...(settings.kind === "invalid"
404
- ? { mutationBlockedReason: `Repair ${settings.reason} before saving` }
405
- : {}),
406
- ...(settingsWarning ? { settingsWarning } : {}),
407
- };
361
+ const activeTools = activeChromeTools(pi);
362
+ const persistedTools =
363
+ settings.kind === "loaded" && settings.settings.tools ? orderedTools(new Set(settings.settings.tools)) : undefined;
364
+ const persistenceLabel =
365
+ settings.kind === "invalid"
366
+ ? "settings need repair"
367
+ : persistedTools === undefined
368
+ ? "not saved"
369
+ : arraysEqual(activeTools, persistedTools)
370
+ ? "saved"
371
+ : "runtime catalog differs from saved catalog";
372
+ const settingsWarning = [...new Set([settings.notice, state.settingsNotice].filter(Boolean))].join("\n");
373
+ return {
374
+ activeTools,
375
+ persistenceLabel,
376
+ ...(settings.kind === "invalid" ? { mutationBlockedReason: `Repair ${settings.reason} before saving` } : {}),
377
+ ...(settingsWarning ? { settingsWarning } : {}),
378
+ };
408
379
  }
409
380
 
410
381
  function mainStateLines(snapshot: MenuSnapshot, owner: object) {
411
- const launchError = launchAttemptLines(owner).find((line) =>
412
- line.startsWith("Last launch error:"),
413
- );
414
- return sanitizeLines([
415
- `Tool catalog: ${snapshot.activeTools.length} of ${availableCatalogNames(owner).length} available · ${snapshot.persistenceLabel}`,
416
- `WebMCP: ${webMcpEnabled(owner) ? "enabled · experimental · confirmation required for every call" : "disabled · experimental"}`,
417
- `Browser: ${browserLifecycleSummary(owner)}`,
418
- `Endpoint: ${devToolsEndpoint(owner)}`,
419
- ...(snapshot.settingsWarning ? [`Settings warning: ${snapshot.settingsWarning}`] : []),
420
- ...(launchError ? [`Launch warning: ${launchError.slice("Last launch error: ".length)}`] : []),
421
- ]);
382
+ const launchError = launchAttemptLines(owner).find((line) => line.startsWith("Last launch error:"));
383
+ return sanitizeLines([
384
+ `Tool catalog: ${snapshot.activeTools.length} of ${availableCatalogNames(owner).length} available · ${snapshot.persistenceLabel}`,
385
+ `WebMCP: ${webMcpEnabled(owner) ? "enabled · experimental · confirmation required for every call" : "disabled · experimental"}`,
386
+ `Browser: ${browserLifecycleSummary(owner)}`,
387
+ `Endpoint: ${devToolsEndpoint(owner)}`,
388
+ ...(snapshot.settingsWarning ? [`Settings warning: ${snapshot.settingsWarning}`] : []),
389
+ ...(launchError ? [`Launch warning: ${launchError.slice("Last launch error: ".length)}`] : []),
390
+ ]);
422
391
  }
423
392
 
424
393
  function browserLifecycleSummary(owner: object) {
425
- const lifecycle = browserLifecycleState(owner);
426
- if (lifecycle === "starting") return "starting managed browser";
427
- if (lifecycle === "running") return "managed browser running";
428
- if (lifecycle === "exited") return "managed browser exited · open Browser status to recover";
429
- if (lifecycle === "failed") return "last launch failed · open Browser status to recover";
430
- const launchMode = launchModeLabel(owner);
431
- return launchMode.startsWith("attach first")
432
- ? "not started · attaches or launches on first use"
433
- : `not started · ${launchMode}`;
394
+ const lifecycle = browserLifecycleState(owner);
395
+ if (lifecycle === "starting") return "starting managed browser";
396
+ if (lifecycle === "running") return "managed browser running";
397
+ if (lifecycle === "exited") return "managed browser exited · open Browser status to recover";
398
+ if (lifecycle === "failed") return "last launch failed · open Browser status to recover";
399
+ const launchMode = launchModeLabel(owner);
400
+ return launchMode.startsWith("attach first")
401
+ ? "not started · attaches or launches on first use"
402
+ : `not started · ${launchMode}`;
434
403
  }
435
404
 
436
405
  function toolDraftLines(current: ToolWorkflowState, owner: object) {
437
- if (current.mutationBlockedReason)
438
- return sanitizeLines([`Unavailable: ${current.mutationBlockedReason}`]);
439
- const changes = symmetricDifferenceSize(current.accepted, current.draft);
440
- return [
441
- `Currently available: ${current.accepted.size}/${availableCatalogNames(owner).length}`,
442
- changes === 0
443
- ? "No unapplied changes · Escape cancels"
444
- : `${changes} unapplied ${changes === 1 ? "change" : "changes"} · Escape cancels`,
445
- `WebMCP gateways: ${webMcpEnabled(owner) ? "available for selection · experimental" : "hidden while experimental WebMCP is disabled"}`,
446
- "Changes are not applied until Review changes and Apply tool changes.",
447
- ];
406
+ if (current.mutationBlockedReason) return sanitizeLines([`Unavailable: ${current.mutationBlockedReason}`]);
407
+ const changes = symmetricDifferenceSize(current.accepted, current.draft);
408
+ return [
409
+ `Currently available: ${current.accepted.size}/${availableCatalogNames(owner).length}`,
410
+ changes === 0
411
+ ? "No unapplied changes · Escape cancels"
412
+ : `${changes} unapplied ${changes === 1 ? "change" : "changes"} · Escape cancels`,
413
+ `WebMCP gateways: ${webMcpEnabled(owner) ? "available for selection · experimental" : "hidden while experimental WebMCP is disabled"}`,
414
+ "Changes are not applied until Review changes and Apply tool changes.",
415
+ ];
448
416
  }
449
417
 
450
418
  function buildToolReview(current: ToolWorkflowState, owner: object) {
451
- const catalog = availableCatalogNames(owner);
452
- const available = catalog.filter((name) => current.draft.has(name));
453
- const unavailable = catalog.filter((name) => !current.draft.has(name));
454
- return sanitizeChromeDevtoolsDisplay(
455
- [
456
- `Current available browser tools: ${current.accepted.size}/${catalog.length}`,
457
- `Proposed available browser tools: ${current.draft.size}/${catalog.length}`,
458
- "",
459
- "Available after apply:",
460
- ...(available.length > 0
461
- ? available.map((name) => ` - ${TOOL_PRESENTATION[name].label} (${name})`)
462
- : [" - none"]),
463
- "",
464
- "Unavailable after apply:",
465
- ...(unavailable.length > 0
466
- ? unavailable.map((name) => ` - ${TOOL_PRESENTATION[name].label} (${name})`)
467
- : [" - none"]),
468
- "",
469
- "Other active Pi tools remain unchanged.",
470
- "Native-capable models defer these tools until chrome_devtools_load selects them.",
471
- "Other models expose available tools eagerly before the next model request.",
472
- "The accepted availability policy is saved for future sessions.",
473
- ].join("\n"),
474
- );
419
+ const catalog = availableCatalogNames(owner);
420
+ const available = catalog.filter((name) => current.draft.has(name));
421
+ const unavailable = catalog.filter((name) => !current.draft.has(name));
422
+ return sanitizeChromeDevtoolsDisplay(
423
+ [
424
+ `Current available browser tools: ${current.accepted.size}/${catalog.length}`,
425
+ `Proposed available browser tools: ${current.draft.size}/${catalog.length}`,
426
+ "",
427
+ "Available after apply:",
428
+ ...(available.length > 0
429
+ ? available.map((name) => ` - ${TOOL_PRESENTATION[name].label} (${name})`)
430
+ : [" - none"]),
431
+ "",
432
+ "Unavailable after apply:",
433
+ ...(unavailable.length > 0
434
+ ? unavailable.map((name) => ` - ${TOOL_PRESENTATION[name].label} (${name})`)
435
+ : [" - none"]),
436
+ "",
437
+ "Other active Pi tools remain unchanged.",
438
+ "Native-capable models defer these tools until chrome_devtools_load selects them.",
439
+ "Other models expose available tools eagerly before the next model request.",
440
+ "The accepted availability policy is saved for future sessions.",
441
+ ].join("\n"),
442
+ );
475
443
  }
476
444
 
477
- function updateSnapshotAfterApply(
478
- snapshot: MenuSnapshot,
479
- selectedTools: readonly ChromeDevToolsToolName[],
480
- ) {
481
- snapshot.activeTools = [...selectedTools];
482
- snapshot.persistenceLabel = "saved";
445
+ function updateSnapshotAfterApply(snapshot: MenuSnapshot, selectedTools: readonly ChromeDevToolsToolName[]) {
446
+ snapshot.activeTools = [...selectedTools];
447
+ snapshot.persistenceLabel = "saved";
483
448
  }
484
449
 
485
450
  function activeChromeTools(pi: ExtensionAPI) {
486
- return availableChromeDevtoolsTools(pi);
451
+ return availableChromeDevtoolsTools(pi);
487
452
  }
488
453
 
489
454
  function orderedTools(tools: ReadonlySet<ChromeDevToolsToolName>) {
490
- return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => tools.has(toolName));
455
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => tools.has(toolName));
491
456
  }
492
457
 
493
458
  function isChromeDevtoolsToolName(value: string): value is ChromeDevToolsToolName {
494
- return CHROME_DEVTOOLS_TOOL_NAMES.includes(value as ChromeDevToolsToolName);
459
+ return CHROME_DEVTOOLS_TOOL_NAMES.includes(value as ChromeDevToolsToolName);
495
460
  }
496
461
 
497
462
  function setsEqual<T>(left: ReadonlySet<T>, right: ReadonlySet<T>) {
498
- return left.size === right.size && [...left].every((value) => right.has(value));
463
+ return left.size === right.size && [...left].every((value) => right.has(value));
499
464
  }
500
465
 
501
466
  function symmetricDifferenceSize<T>(left: ReadonlySet<T>, right: ReadonlySet<T>) {
502
- let changes = 0;
503
- for (const value of left) if (!right.has(value)) changes += 1;
504
- for (const value of right) if (!left.has(value)) changes += 1;
505
- return changes;
467
+ let changes = 0;
468
+ for (const value of left) if (!right.has(value)) changes += 1;
469
+ for (const value of right) if (!left.has(value)) changes += 1;
470
+ return changes;
506
471
  }
507
472
 
508
473
  function arraysEqual<T>(left: readonly T[], right: readonly T[]) {
509
- return left.length === right.length && left.every((value, index) => value === right[index]);
474
+ return left.length === right.length && left.every((value, index) => value === right[index]);
510
475
  }
511
476
 
512
477
  function sanitizeLines(lines: readonly string[]) {
513
- return sanitizeChromeDevtoolsDisplay(lines.join("\n")).split("\n");
478
+ return sanitizeChromeDevtoolsDisplay(lines.join("\n")).split("\n");
514
479
  }
515
480
 
516
481
  function availableCatalogNames(owner: object): ChromeDevToolsToolName[] {
517
- return webMcpEnabled(owner)
518
- ? [...CHROME_DEVTOOLS_TOOL_NAMES]
519
- : [...CORE_CHROME_DEVTOOLS_TOOL_NAMES];
482
+ return webMcpEnabled(owner) ? [...CHROME_DEVTOOLS_TOOL_NAMES] : [...CORE_CHROME_DEVTOOLS_TOOL_NAMES];
520
483
  }
521
484
 
522
485
  function formatError(error: unknown) {
523
- return error instanceof Error ? error.message : String(error);
486
+ return error instanceof Error ? error.message : String(error);
524
487
  }