@narumitw/pi-chrome-devtools 0.49.4 → 0.50.1

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.
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { shutdownManagedBrowser } from "./browser-manager.js";
3
- import { state } from "./runtime.js";
3
+ import { applyRuntimeBrowserSettings, state } from "./runtime.js";
4
4
  import { loadSettings } from "./settings.js";
5
5
  import {
6
6
  allChromeDevtoolsTools,
@@ -8,7 +8,7 @@ import {
8
8
  buildCommandGuide,
9
9
  buildQuickstartMessage,
10
10
  buildToolStatusMessage,
11
- showToolSelector,
11
+ sanitizeChromeDevtoolsDisplay,
12
12
  updateChromeDevtoolsTools,
13
13
  waitForChromeDevtoolsSettings,
14
14
  } from "./tool-selector.js";
@@ -28,19 +28,13 @@ const COMMAND_COMPLETIONS = [
28
28
  { value: "quickstart", label: "quickstart", description: "Show endpoint and launch help" },
29
29
  { value: "status", label: "status", description: "Show tool and settings status" },
30
30
  { value: "tools", label: "tools", description: "Select Chrome DevTools tools" },
31
- { value: "toggle", label: "toggle", description: "Select Chrome DevTools tools" },
31
+ { value: "toggle", label: "toggle", description: "Alias for tools" },
32
+ { value: "select", label: "select", description: "Compatibility alias for tools" },
32
33
  { value: "enable", label: "enable", description: "Enable all Chrome DevTools tools" },
34
+ { value: "on", label: "on", description: "Compatibility alias for enable" },
33
35
  { value: "disable", label: "disable", description: "Disable all Chrome DevTools tools" },
36
+ { value: "off", label: "off", description: "Compatibility alias for disable" },
34
37
  ];
35
- const MENU_OPTIONS = {
36
- quickstart: "Quick start / endpoint help",
37
- help: "Command usage guide",
38
- status: "Show tool status",
39
- tools: "Select Chrome DevTools tools",
40
- enable: "Enable all Chrome DevTools tools",
41
- disable: "Disable all Chrome DevTools tools",
42
- } as const;
43
-
44
38
  export default function chromeDevtools(pi: ExtensionAPI) {
45
39
  pi.registerTool(listPagesTool);
46
40
  pi.registerTool(selectPageTool);
@@ -63,16 +57,20 @@ export default function chromeDevtools(pi: ExtensionAPI) {
63
57
  state.shuttingDown = false;
64
58
  state.settingsNotice = undefined;
65
59
  ctx.ui.setStatus(STATUS_KEY, undefined);
66
- const settings = await loadSettings();
60
+ await shutdownManagedBrowser();
61
+ if (generation !== state.sessionGeneration) return;
62
+ state.activePageId = undefined;
63
+ state.lastLaunchAttempt = undefined;
64
+ const projectTrusted = ctx.isProjectTrusted();
65
+ const settings = await loadSettings({ cwd: ctx.cwd, projectTrusted });
67
66
  if (generation !== state.sessionGeneration) return;
67
+ applyRuntimeBrowserSettings(settings.effectiveBrowser, settings.paths, projectTrusted);
68
68
  state.settingsNotice = settings.notice;
69
- if (settings.notice) ctx.ui.notify(settings.notice, "warning");
70
- if (settings.kind === "loaded") {
71
- applyChromeDevtoolsTools(pi, settings.settings.tools);
72
- return;
69
+ for (const warning of settings.warnings) {
70
+ ctx.ui.notify(sanitizeChromeDevtoolsDisplay(warning), "warning");
73
71
  }
74
- if (settings.kind === "invalid") {
75
- ctx.ui.notify(`Chrome DevTools settings ignored: ${settings.reason}`, "warning");
72
+ if (settings.kind === "loaded" && settings.settings.tools) {
73
+ applyChromeDevtoolsTools(pi, settings.settings.tools);
76
74
  }
77
75
  });
78
76
 
@@ -98,20 +96,29 @@ async function handleChromeDevtoolsCommand(
98
96
  await showMenu(pi, ctx, generation);
99
97
  return;
100
98
  case "help":
99
+ requireObservableUi(ctx, "help");
101
100
  ctx.ui.notify(buildCommandGuide(), "info");
102
101
  return;
103
102
  case "quickstart":
103
+ requireObservableUi(ctx, "quickstart");
104
104
  ctx.ui.notify(buildQuickstartMessage(), "info");
105
105
  return;
106
106
  case "status": {
107
+ requireObservableUi(ctx, "status");
107
108
  const status = await buildToolStatusMessage(pi);
108
109
  if (generation !== state.sessionGeneration) return;
109
110
  ctx.ui.notify(status, "info");
110
111
  return;
111
112
  }
112
- case "tools":
113
- await showToolSelector(pi, ctx);
113
+ case "tools": {
114
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
115
+ throw new Error("/chrome-devtools tools requires TUI or RPC mode");
116
+ }
117
+ const { showChromeDevtoolsToolWorkflow } = await import("./menu.js");
118
+ if (generation !== state.sessionGeneration) return;
119
+ await showChromeDevtoolsToolWorkflow(pi, ctx, generation);
114
120
  return;
121
+ }
115
122
  case "enable":
116
123
  await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "enabled all");
117
124
  return;
@@ -120,6 +127,9 @@ async function handleChromeDevtoolsCommand(
120
127
  return;
121
128
  }
122
129
 
130
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
131
+ throw new Error(`Unknown /chrome-devtools command: ${args.trim()}`);
132
+ }
123
133
  ctx.ui.notify(
124
134
  `Unknown /chrome-devtools command: ${args.trim()}
125
135
 
@@ -128,67 +138,19 @@ ${buildCommandGuide()}`,
128
138
  );
129
139
  }
130
140
 
131
- async function showMenu(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
132
- if (!ctx.hasUI) {
133
- const status = await buildToolStatusMessage(pi);
134
- if (generation !== state.sessionGeneration) return;
135
- ctx.ui.notify(`${buildCommandGuide()}\n\n${status}`, "info");
136
- return;
141
+ function requireObservableUi(ctx: CommandContext, route: string) {
142
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
143
+ throw new Error(`/chrome-devtools ${route} requires TUI or RPC mode`);
137
144
  }
138
- const menuSignal = state.sessionController.signal;
139
- const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
140
- const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
141
- if (!isCurrent()) return;
145
+ }
142
146
 
143
- type Screen = "main";
144
- type Action = keyof typeof MENU_OPTIONS;
145
- const menu = defineMenu<undefined, Screen, Action>({
146
- start: "main",
147
- screens: {
148
- main: () => ({
149
- kind: "actions",
150
- title: "Chrome DevTools",
151
- items: Object.entries(MENU_OPTIONS).map(([id, label]) => ({
152
- id,
153
- label,
154
- action: id as Action,
155
- })),
156
- hint: "close",
157
- }),
158
- },
159
- actions: {
160
- quickstart: async () => {
161
- ctx.ui.notify(buildQuickstartMessage(), "info");
162
- return { kind: "close" };
163
- },
164
- help: async () => {
165
- ctx.ui.notify(buildCommandGuide(), "info");
166
- return { kind: "close" };
167
- },
168
- status: async () => {
169
- const status = await buildToolStatusMessage(pi);
170
- if (generation === state.sessionGeneration) ctx.ui.notify(status, "info");
171
- return { kind: "close" };
172
- },
173
- tools: async () => {
174
- await showToolSelector(pi, ctx);
175
- return { kind: "close" };
176
- },
177
- enable: async () => {
178
- await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "enabled all");
179
- return { kind: "close" };
180
- },
181
- disable: async () => {
182
- await updateChromeDevtoolsTools(pi, ctx, [], "disabled all");
183
- return { kind: "close" };
184
- },
185
- },
186
- });
187
- await runMenu(ctx, menu, {
188
- getState: () => undefined,
189
- signal: menuSignal,
190
- isCurrent,
191
- });
147
+ async function showMenu(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
148
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
149
+ throw new Error("/chrome-devtools menu requires TUI or RPC mode; use a direct subcommand");
150
+ }
151
+ const { showChromeDevtoolsMenu } = await import("./menu.js");
152
+ if (generation !== state.sessionGeneration) return;
153
+ await showChromeDevtoolsMenu(pi, ctx, generation);
192
154
  }
193
155
 
194
156
  function replaceSessionController(reason: string) {
@@ -231,4 +193,7 @@ export {
231
193
  selectAllowedRoot,
232
194
  } from "./screenshot.js";
233
195
  export { normalizeChromeDevtoolsSettings } from "./settings.js";
234
- export { orderedChromeDevtoolsTools } from "./tool-selector.js";
196
+ export {
197
+ orderedChromeDevtoolsTools,
198
+ sanitizeChromeDevtoolsDisplay,
199
+ } from "./tool-selector.js";
package/src/menu.ts ADDED
@@ -0,0 +1,483 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu, runTask } from "@narumitw/pi-tui-kit";
3
+ import { browserLifecycleState, devToolsEndpoint, launchModeLabel } from "./browser-manager.js";
4
+ import { state } from "./runtime.js";
5
+ import { loadSettings, type SettingsLoadResult } from "./settings.js";
6
+ import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
7
+ import {
8
+ buildBrowserStatusMessage,
9
+ buildCommandGuide,
10
+ buildSettingsSetupMessage,
11
+ sanitizeChromeDevtoolsDisplay,
12
+ setSelectedChromeDevtoolsTools,
13
+ } from "./tool-selector.js";
14
+
15
+ type CommandContext = ExtensionCommandContext;
16
+ type MainScreen = "main" | "browserStatus" | "settings" | "help";
17
+ type MainAction = "tools" | "bulk";
18
+ type ToolScreen = "tools" | "review";
19
+ type ToolAction = "toggle" | "selectAll" | "selectNone" | "review" | "apply" | "cancel";
20
+
21
+ interface MenuSnapshot {
22
+ activeTools: ChromeDevToolsToolName[];
23
+ persistenceLabel: string;
24
+ mutationBlockedReason?: string;
25
+ settingsWarning?: string;
26
+ }
27
+
28
+ interface ToolWorkflowState {
29
+ accepted: Set<ChromeDevToolsToolName>;
30
+ draft: Set<ChromeDevToolsToolName>;
31
+ mutationBlockedReason?: string;
32
+ generation: number;
33
+ applied: boolean;
34
+ }
35
+
36
+ const TOOL_PRESENTATION: Record<ChromeDevToolsToolName, { label: string; description: string }> = {
37
+ chrome_devtools_list_pages: {
38
+ label: "List open pages",
39
+ description: "List inspectable Chrome tabs and pages (chrome_devtools_list_pages).",
40
+ },
41
+ chrome_devtools_select_page: {
42
+ label: "Select the active page",
43
+ description: "Choose the page later browser actions use (chrome_devtools_select_page).",
44
+ },
45
+ chrome_devtools_navigate: {
46
+ label: "Navigate a page",
47
+ description: "Open a URL, creating a page when needed (chrome_devtools_navigate).",
48
+ },
49
+ chrome_devtools_evaluate: {
50
+ label: "Run JavaScript",
51
+ description: "Evaluate JavaScript in the selected page (chrome_devtools_evaluate).",
52
+ },
53
+ chrome_devtools_screenshot: {
54
+ label: "Capture a screenshot",
55
+ description: "Save a PNG of the selected page (chrome_devtools_screenshot).",
56
+ },
57
+ };
58
+
59
+ export async function showChromeDevtoolsMenu(
60
+ pi: ExtensionAPI,
61
+ ctx: CommandContext,
62
+ generation: number,
63
+ ) {
64
+ const snapshot = await loadSnapshotWithFeedback(pi, ctx, generation);
65
+ if (!snapshot) return;
66
+ const menuSignal = state.sessionController.signal;
67
+ const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
68
+ const menu = defineMenu<MenuSnapshot, MainScreen, MainAction>({
69
+ start: "main",
70
+ screens: {
71
+ main: ({ state: current }) => ({
72
+ kind: "actions",
73
+ title: "Chrome DevTools",
74
+ lines: mainStateLines(current),
75
+ items: [
76
+ {
77
+ id: "tools",
78
+ label: "Choose browser tools…",
79
+ description: "Select what Pi may do in Chrome.",
80
+ disabled: Boolean(current.mutationBlockedReason),
81
+ disabledReason: current.mutationBlockedReason,
82
+ action: "tools",
83
+ },
84
+ {
85
+ id: "bulk",
86
+ label:
87
+ current.activeTools.length === CHROME_DEVTOOLS_TOOL_NAMES.length
88
+ ? "Disable all browser tools…"
89
+ : "Enable all browser tools…",
90
+ description:
91
+ current.activeTools.length === CHROME_DEVTOOLS_TOOL_NAMES.length
92
+ ? "Preview 0 of 5; other active tools stay enabled."
93
+ : "Preview 5 of 5; other active tools stay enabled.",
94
+ disabled: Boolean(current.mutationBlockedReason),
95
+ disabledReason: current.mutationBlockedReason,
96
+ action: "bulk",
97
+ },
98
+ {
99
+ id: "browser-status",
100
+ label: "Browser status",
101
+ description: "Runtime, endpoint, and last launch attempt.",
102
+ to: "browserStatus",
103
+ },
104
+ {
105
+ id: "settings",
106
+ label: "Settings & setup",
107
+ description: "Files, sources, trust, and reload steps.",
108
+ to: "settings",
109
+ },
110
+ {
111
+ id: "help",
112
+ label: "Help",
113
+ description: "Commands and usage.",
114
+ to: "help",
115
+ },
116
+ ],
117
+ hint: "close",
118
+ }),
119
+ browserStatus: () => ({
120
+ kind: "review",
121
+ title: "Browser status",
122
+ content: buildBrowserStatusMessage(),
123
+ format: { kind: "text" },
124
+ viewportSize: "adaptive",
125
+ hint: "back",
126
+ }),
127
+ settings: () => ({
128
+ kind: "review",
129
+ title: "Settings & setup",
130
+ content: buildSettingsSetupMessage(),
131
+ format: { kind: "text" },
132
+ viewportSize: "adaptive",
133
+ hint: "back",
134
+ }),
135
+ help: () => ({
136
+ kind: "review",
137
+ title: "Chrome DevTools help",
138
+ content: buildCommandGuide(),
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 === CHROME_DEVTOOLS_TOOL_NAMES.length
155
+ ? []
156
+ : [...CHROME_DEVTOOLS_TOOL_NAMES];
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
+ },
166
+ });
167
+ await runMenu(ctx, menu, {
168
+ getState: () => snapshot,
169
+ signal: menuSignal,
170
+ isCurrent,
171
+ onError: (currentCtx, error) =>
172
+ currentCtx.ui.notify(
173
+ sanitizeChromeDevtoolsDisplay(`Chrome DevTools menu failed: ${formatError(error)}`),
174
+ "error",
175
+ ),
176
+ });
177
+ }
178
+
179
+ export async function showChromeDevtoolsToolWorkflow(
180
+ pi: ExtensionAPI,
181
+ ctx: CommandContext,
182
+ generation: number,
183
+ options: {
184
+ snapshot?: MenuSnapshot;
185
+ initialDraft?: readonly ChromeDevToolsToolName[];
186
+ startAtReview?: boolean;
187
+ } = {},
188
+ ): Promise<
189
+ { applied: boolean; closeParent: boolean; selectedTools: ChromeDevToolsToolName[] } | undefined
190
+ > {
191
+ const snapshot = options.snapshot ?? (await loadSnapshotWithFeedback(pi, ctx, generation));
192
+ if (!snapshot) return undefined;
193
+ const menuSignal = state.sessionController.signal;
194
+ const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
195
+ const accepted = new Set(snapshot.activeTools);
196
+ const workflow: ToolWorkflowState = {
197
+ accepted,
198
+ draft: new Set(options.initialDraft ?? snapshot.activeTools),
199
+ mutationBlockedReason: snapshot.mutationBlockedReason,
200
+ generation,
201
+ applied: false,
202
+ };
203
+ const menu = defineMenu<ToolWorkflowState, ToolScreen, ToolAction>({
204
+ start: options.startAtReview ? "review" : "tools",
205
+ screens: {
206
+ tools: ({ state: current }) => ({
207
+ kind: "multiSelect",
208
+ title: `Browser tools (${current.draft.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length})`,
209
+ lines: toolDraftLines(current),
210
+ items: CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({
211
+ id: toolName,
212
+ label: TOOL_PRESENTATION[toolName].label,
213
+ description: TOOL_PRESENTATION[toolName].description,
214
+ searchText: `${toolName} ${TOOL_PRESENTATION[toolName].description}`,
215
+ selected: current.draft.has(toolName),
216
+ disabled: Boolean(current.mutationBlockedReason),
217
+ disabledReason: current.mutationBlockedReason,
218
+ })),
219
+ action: "toggle",
220
+ actions: [
221
+ {
222
+ id: "select-all",
223
+ label: "Select all",
224
+ disabled: Boolean(current.mutationBlockedReason),
225
+ disabledReason: current.mutationBlockedReason,
226
+ action: "selectAll",
227
+ },
228
+ {
229
+ id: "select-none",
230
+ label: "Select none",
231
+ disabled: Boolean(current.mutationBlockedReason),
232
+ disabledReason: current.mutationBlockedReason,
233
+ action: "selectNone",
234
+ },
235
+ {
236
+ id: "review",
237
+ label: "Review changes…",
238
+ disabled:
239
+ Boolean(current.mutationBlockedReason) || setsEqual(current.accepted, current.draft),
240
+ disabledReason:
241
+ current.mutationBlockedReason ??
242
+ (setsEqual(current.accepted, current.draft) ? "No unapplied changes" : undefined),
243
+ to: "review",
244
+ },
245
+ { id: "cancel", label: "Cancel", action: "cancel" },
246
+ ],
247
+ hint: "back",
248
+ }),
249
+ review: ({ state: current }) => ({
250
+ kind: "review",
251
+ title: "Review tool changes",
252
+ lines: [
253
+ `Current: ${current.accepted.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
254
+ `Proposed: ${current.draft.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
255
+ ],
256
+ content: buildToolReview(current),
257
+ format: { kind: "text" },
258
+ viewportSize: "adaptive",
259
+ ...(current.mutationBlockedReason || setsEqual(current.accepted, current.draft)
260
+ ? {}
261
+ : {
262
+ confirm: {
263
+ id: "apply",
264
+ label: "Apply tool changes",
265
+ action: "apply" as const,
266
+ },
267
+ }),
268
+ hint: "back",
269
+ }),
270
+ },
271
+ actions: {
272
+ toggle: ({ state: current, itemId, selected }) => {
273
+ if (!isChromeDevtoolsToolName(itemId) || selected === undefined)
274
+ return { kind: "rejected" };
275
+ if (selected) current.draft.add(itemId);
276
+ else current.draft.delete(itemId);
277
+ return { kind: "stay" };
278
+ },
279
+ selectAll: ({ state: current }) => {
280
+ current.draft = new Set(CHROME_DEVTOOLS_TOOL_NAMES);
281
+ return { kind: "stay" };
282
+ },
283
+ selectNone: ({ state: current }) => {
284
+ current.draft = new Set();
285
+ return { kind: "stay" };
286
+ },
287
+ review: () => ({ kind: "to", screen: "review" }),
288
+ cancel: () => ({ kind: "back" }),
289
+ apply: async ({ state: current, signal }) => {
290
+ await ctx.waitForIdle();
291
+ if (signal.aborted || !isCurrent() || current.generation !== state.sessionGeneration) {
292
+ return { kind: "rejected" };
293
+ }
294
+ const selectedTools = orderedTools(current.draft);
295
+ const saveResult = await setSelectedChromeDevtoolsTools(
296
+ pi,
297
+ ctx,
298
+ selectedTools,
299
+ orderedTools(current.accepted),
300
+ );
301
+ if (!isCurrent()) return { kind: "rejected" };
302
+ if (saveResult === "active-tools-changed") {
303
+ current.accepted = new Set(activeChromeTools(pi));
304
+ }
305
+ if (saveResult !== "saved") return { kind: "rejected" };
306
+ current.accepted = new Set(selectedTools);
307
+ current.draft = new Set(selectedTools);
308
+ current.applied = true;
309
+ ctx.ui.notify(
310
+ `Saved: ${selectedTools.length} of ${CHROME_DEVTOOLS_TOOL_NAMES.length} browser tools enabled.`,
311
+ "info",
312
+ );
313
+ return { kind: "close" };
314
+ },
315
+ },
316
+ });
317
+ const result = await runMenu(ctx, menu, {
318
+ getState: () => workflow,
319
+ signal: menuSignal,
320
+ isCurrent,
321
+ onError: (currentCtx, error) =>
322
+ currentCtx.ui.notify(
323
+ sanitizeChromeDevtoolsDisplay(
324
+ `Chrome DevTools tool selection failed: ${formatError(error)}`,
325
+ ),
326
+ "error",
327
+ ),
328
+ });
329
+ return {
330
+ applied: workflow.applied,
331
+ closeParent: !workflow.applied && result.kind === "closed" && result.reason === "close",
332
+ selectedTools: orderedTools(workflow.accepted),
333
+ };
334
+ }
335
+
336
+ async function loadSnapshotWithFeedback(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
337
+ const menuSignal = state.sessionController.signal;
338
+ const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
339
+ const result = await runTask(ctx, {
340
+ label: "Loading Chrome DevTools settings…",
341
+ signal: menuSignal,
342
+ isCurrent,
343
+ task: async ({ signal }) => {
344
+ signal.throwIfAborted();
345
+ const settings = await loadSettings();
346
+ signal.throwIfAborted();
347
+ return buildMenuSnapshot(pi, settings);
348
+ },
349
+ });
350
+ return result.kind === "completed" ? result.value : undefined;
351
+ }
352
+
353
+ function buildMenuSnapshot(pi: ExtensionAPI, settings: SettingsLoadResult): MenuSnapshot {
354
+ const activeTools = activeChromeTools(pi);
355
+ const persistedTools =
356
+ settings.kind === "loaded" && settings.settings.tools
357
+ ? orderedTools(new Set(settings.settings.tools))
358
+ : undefined;
359
+ const persistenceLabel =
360
+ settings.kind === "invalid"
361
+ ? "settings need repair"
362
+ : persistedTools === undefined
363
+ ? "not saved"
364
+ : arraysEqual(activeTools, persistedTools)
365
+ ? "saved"
366
+ : "runtime differs from saved selection";
367
+ const settingsWarning = [
368
+ ...new Set([settings.notice, state.settingsNotice].filter(Boolean)),
369
+ ].join("\n");
370
+ return {
371
+ activeTools,
372
+ persistenceLabel,
373
+ ...(settings.kind === "invalid"
374
+ ? { mutationBlockedReason: `Repair ${settings.reason} before saving` }
375
+ : {}),
376
+ ...(settingsWarning ? { settingsWarning } : {}),
377
+ };
378
+ }
379
+
380
+ function mainStateLines(snapshot: MenuSnapshot) {
381
+ return sanitizeLines([
382
+ `Tools: ${snapshot.activeTools.length} of ${CHROME_DEVTOOLS_TOOL_NAMES.length} enabled · ${snapshot.persistenceLabel}`,
383
+ `Browser: ${browserLifecycleSummary()}`,
384
+ `Endpoint: ${devToolsEndpoint()}`,
385
+ ...(snapshot.settingsWarning ? [`Settings warning: ${snapshot.settingsWarning}`] : []),
386
+ ...(state.lastLaunchAttempt?.lastError
387
+ ? [`Launch warning: ${state.lastLaunchAttempt.lastError}`]
388
+ : []),
389
+ ]);
390
+ }
391
+
392
+ function browserLifecycleSummary() {
393
+ const lifecycle = browserLifecycleState();
394
+ if (lifecycle === "starting") return "starting managed browser";
395
+ if (lifecycle === "running") return "managed browser running";
396
+ if (lifecycle === "exited") return "managed browser exited · open Browser status to recover";
397
+ if (lifecycle === "failed") return "last launch failed · open Browser status to recover";
398
+ const launchMode = launchModeLabel();
399
+ return launchMode.startsWith("attach first")
400
+ ? "not started · attaches or launches on first use"
401
+ : `not started · ${launchMode}`;
402
+ }
403
+
404
+ function toolDraftLines(current: ToolWorkflowState) {
405
+ if (current.mutationBlockedReason)
406
+ return sanitizeLines([`Unavailable: ${current.mutationBlockedReason}`]);
407
+ const changes = symmetricDifferenceSize(current.accepted, current.draft);
408
+ return [
409
+ `Current accepted: ${current.accepted.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
410
+ changes === 0
411
+ ? "No unapplied changes · Escape cancels"
412
+ : `${changes} unapplied ${changes === 1 ? "change" : "changes"} · Escape cancels`,
413
+ "Changes are not applied until Review changes and Apply tool changes.",
414
+ ];
415
+ }
416
+
417
+ function buildToolReview(current: ToolWorkflowState) {
418
+ const enabled = CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => current.draft.has(name));
419
+ const disabled = CHROME_DEVTOOLS_TOOL_NAMES.filter((name) => !current.draft.has(name));
420
+ return sanitizeChromeDevtoolsDisplay(
421
+ [
422
+ `Current browser tools: ${current.accepted.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
423
+ `Proposed browser tools: ${current.draft.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}`,
424
+ "",
425
+ "Enabled after apply:",
426
+ ...(enabled.length > 0
427
+ ? enabled.map((name) => ` - ${TOOL_PRESENTATION[name].label} (${name})`)
428
+ : [" - none"]),
429
+ "",
430
+ "Disabled after apply:",
431
+ ...(disabled.length > 0
432
+ ? disabled.map((name) => ` - ${TOOL_PRESENTATION[name].label} (${name})`)
433
+ : [" - none"]),
434
+ "",
435
+ "Other active Pi tools remain unchanged.",
436
+ "The accepted selection is saved for future sessions.",
437
+ ].join("\n"),
438
+ );
439
+ }
440
+
441
+ function updateSnapshotAfterApply(
442
+ snapshot: MenuSnapshot,
443
+ selectedTools: readonly ChromeDevToolsToolName[],
444
+ ) {
445
+ snapshot.activeTools = [...selectedTools];
446
+ snapshot.persistenceLabel = "saved";
447
+ }
448
+
449
+ function activeChromeTools(pi: ExtensionAPI) {
450
+ const active = new Set(pi.getActiveTools());
451
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => active.has(toolName));
452
+ }
453
+
454
+ function orderedTools(tools: ReadonlySet<ChromeDevToolsToolName>) {
455
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => tools.has(toolName));
456
+ }
457
+
458
+ function isChromeDevtoolsToolName(value: string): value is ChromeDevToolsToolName {
459
+ return CHROME_DEVTOOLS_TOOL_NAMES.includes(value as ChromeDevToolsToolName);
460
+ }
461
+
462
+ function setsEqual<T>(left: ReadonlySet<T>, right: ReadonlySet<T>) {
463
+ return left.size === right.size && [...left].every((value) => right.has(value));
464
+ }
465
+
466
+ function symmetricDifferenceSize<T>(left: ReadonlySet<T>, right: ReadonlySet<T>) {
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;
471
+ }
472
+
473
+ function arraysEqual<T>(left: readonly T[], right: readonly T[]) {
474
+ return left.length === right.length && left.every((value, index) => value === right[index]);
475
+ }
476
+
477
+ function sanitizeLines(lines: readonly string[]) {
478
+ return sanitizeChromeDevtoolsDisplay(lines.join("\n")).split("\n");
479
+ }
480
+
481
+ function formatError(error: unknown) {
482
+ return error instanceof Error ? error.message : String(error);
483
+ }