@narumitw/pi-chrome-devtools 0.31.0 → 0.36.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.
package/README.md CHANGED
@@ -25,7 +25,7 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
25
25
  - Retries briefly while Chrome is starting and reports actionable endpoint errors.
26
26
  - Shows statusline activity only while Chrome DevTools tools are running.
27
27
  - Provides a `/chrome-devtools` menu with quick-start help and tool controls.
28
- - Provides a Plan-mode-style selector for choosing individual Chrome DevTools tools.
28
+ - Uses `@narumitw/pi-tui-kit` for width-safe menus and individual tool selection.
29
29
  - Persists the selected Chrome DevTools tools across Pi restarts.
30
30
 
31
31
  ## 📦 Install
@@ -155,7 +155,7 @@ Direct subcommands are also available:
155
155
  candidates, last launch attempt, and launch hints.
156
156
  - `status` shows runtime tool state, persisted selection, settings file path, endpoint source,
157
157
  launch mode, last launch attempt, and active non-Chrome tool count.
158
- - `tools` opens a Plan-mode-style selector for choosing individual `chrome_devtools_*` tools.
158
+ - `tools` opens a width-safe selector for choosing individual `chrome_devtools_*` tools.
159
159
  - `toggle` is an alias for `tools`.
160
160
  - `enable` enables all `chrome_devtools_*` tools for future turns.
161
161
  - `disable` disables all `chrome_devtools_*` tools for future turns. The slash command remains
@@ -169,11 +169,14 @@ ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools.json
169
169
 
170
170
  When the file is missing or invalid, the extension preserves Pi's current active-tool policy
171
171
  instead of enabling tools by itself. A valid saved selection is restored on Pi startup and
172
- `/reload`.
173
-
174
- Compatibility: older versions used `pi-chrome-devtools-settings.json`. During the migration
175
- window, a legacy-only file is automatically migrated to `pi-chrome-devtools.json` with a warning.
176
- If both files exist, `pi-chrome-devtools.json` wins and the legacy file is ignored. The legacy
172
+ `/reload`. A missing file is created by the first successful selection change. Within one Pi process, selection saves run in invocation order, reread the latest valid document, and preserve unknown fields. Malformed JSON
173
+ or invalid recognized fields block the save without replacement; a failed save restores the prior
174
+ Chrome DevTools tool selection while preserving other extensions' current tools.
175
+
176
+ Compatibility: older versions used `pi-chrome-devtools-settings.json`. A legacy-only file remains
177
+ readable with a warning and is never modified automatically; rename it to
178
+ `pi-chrome-devtools.json`. The first subsequent settings save writes the canonical file. If both
179
+ files exist, `pi-chrome-devtools.json` wins and the legacy file is ignored. The legacy
177
180
  filename is deprecated and will be removed in a future major release.
178
181
 
179
182
  ## 🧠 Use cases
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-chrome-devtools",
3
- "version": "0.31.0",
3
+ "version": "0.36.0",
4
4
  "description": "Pi extension that exposes Chrome DevTools Protocol tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
+ "@narumitw/pi-tui-kit": "<1",
32
33
  "typebox": "^1.3.8"
33
34
  },
34
35
  "devDependencies": {
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
2
3
  import { shutdownManagedBrowser } from "./browser-manager.js";
3
4
  import { state } from "./runtime.js";
4
5
  import { loadSettings } from "./settings.js";
@@ -10,6 +11,7 @@ import {
10
11
  buildToolStatusMessage,
11
12
  showToolSelector,
12
13
  updateChromeDevtoolsTools,
14
+ waitForChromeDevtoolsSettings,
13
15
  } from "./tool-selector.js";
14
16
  import {
15
17
  evaluateTool,
@@ -51,15 +53,19 @@ export default function chromeDevtools(pi: ExtensionAPI) {
51
53
  description: "Open Chrome DevTools help and tool controls",
52
54
  getArgumentCompletions: (prefix) => commandCompletions(prefix),
53
55
  handler: async (args, ctx) => {
54
- await handleChromeDevtoolsCommand(pi, args, ctx);
56
+ const generation = state.sessionGeneration;
57
+ await handleChromeDevtoolsCommand(pi, args, ctx, generation);
55
58
  },
56
59
  });
57
60
 
58
61
  pi.on("session_start", async (_event, ctx) => {
62
+ const generation = ++state.sessionGeneration;
63
+ replaceSessionController("Chrome DevTools session replaced");
59
64
  state.shuttingDown = false;
60
65
  state.settingsNotice = undefined;
61
66
  ctx.ui.setStatus(STATUS_KEY, undefined);
62
67
  const settings = await loadSettings();
68
+ if (generation !== state.sessionGeneration) return;
63
69
  state.settingsNotice = settings.notice;
64
70
  if (settings.notice) ctx.ui.notify(settings.notice, "warning");
65
71
  if (settings.kind === "loaded") {
@@ -72,16 +78,25 @@ export default function chromeDevtools(pi: ExtensionAPI) {
72
78
  });
73
79
 
74
80
  pi.on("session_shutdown", async (_event, ctx) => {
81
+ state.sessionGeneration += 1;
82
+ replaceSessionController("Chrome DevTools session shut down");
75
83
  ctx.ui.setStatus(STATUS_KEY, undefined);
76
- await shutdownManagedBrowser(undefined, { cancelLaunch: true });
84
+ const browserShutdown = shutdownManagedBrowser(undefined, { cancelLaunch: true });
85
+ await waitForChromeDevtoolsSettings();
86
+ await browserShutdown;
77
87
  });
78
88
  }
79
89
 
80
- async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx: CommandContext) {
90
+ async function handleChromeDevtoolsCommand(
91
+ pi: ExtensionAPI,
92
+ args: string,
93
+ ctx: CommandContext,
94
+ generation: number,
95
+ ) {
81
96
  const command = parseCommand(args);
82
97
  switch (command) {
83
98
  case "menu":
84
- await showMenu(pi, ctx);
99
+ await showMenu(pi, ctx, generation);
85
100
  return;
86
101
  case "help":
87
102
  ctx.ui.notify(buildCommandGuide(), "info");
@@ -89,9 +104,12 @@ async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx:
89
104
  case "quickstart":
90
105
  ctx.ui.notify(buildQuickstartMessage(), "info");
91
106
  return;
92
- case "status":
93
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
107
+ case "status": {
108
+ const status = await buildToolStatusMessage(pi);
109
+ if (generation !== state.sessionGeneration) return;
110
+ ctx.ui.notify(status, "info");
94
111
  return;
112
+ }
95
113
  case "tools":
96
114
  await showToolSelector(pi, ctx);
97
115
  return;
@@ -111,38 +129,68 @@ ${buildCommandGuide()}`,
111
129
  );
112
130
  }
113
131
 
114
- async function showMenu(pi: ExtensionAPI, ctx: CommandContext) {
132
+ async function showMenu(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
115
133
  if (!ctx.hasUI) {
116
- ctx.ui.notify(
117
- `${buildCommandGuide()}
118
-
119
- ${await buildToolStatusMessage(pi)}`,
120
- "info",
121
- );
134
+ const status = await buildToolStatusMessage(pi);
135
+ if (generation !== state.sessionGeneration) return;
136
+ ctx.ui.notify(`${buildCommandGuide()}\n\n${status}`, "info");
122
137
  return;
123
138
  }
124
139
 
125
- const choice = await ctx.ui.select("Chrome DevTools", Object.values(MENU_OPTIONS));
126
- switch (choice) {
127
- case MENU_OPTIONS.quickstart:
128
- ctx.ui.notify(buildQuickstartMessage(), "info");
129
- return;
130
- case MENU_OPTIONS.help:
131
- ctx.ui.notify(buildCommandGuide(), "info");
132
- return;
133
- case MENU_OPTIONS.status:
134
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
135
- return;
136
- case MENU_OPTIONS.tools:
137
- await showToolSelector(pi, ctx);
138
- return;
139
- case MENU_OPTIONS.enable:
140
- await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "enabled all");
141
- return;
142
- case MENU_OPTIONS.disable:
143
- await updateChromeDevtoolsTools(pi, ctx, [], "disabled all");
144
- return;
145
- }
140
+ type Screen = "main";
141
+ type Action = keyof typeof MENU_OPTIONS;
142
+ const menu = defineMenu<undefined, Screen, Action>({
143
+ start: "main",
144
+ screens: {
145
+ main: () => ({
146
+ kind: "actions",
147
+ title: "Chrome DevTools",
148
+ items: Object.entries(MENU_OPTIONS).map(([id, label]) => ({
149
+ id,
150
+ label,
151
+ action: id as Action,
152
+ })),
153
+ hint: "close",
154
+ }),
155
+ },
156
+ actions: {
157
+ quickstart: async () => {
158
+ ctx.ui.notify(buildQuickstartMessage(), "info");
159
+ return { kind: "close" };
160
+ },
161
+ help: async () => {
162
+ ctx.ui.notify(buildCommandGuide(), "info");
163
+ return { kind: "close" };
164
+ },
165
+ status: async () => {
166
+ const status = await buildToolStatusMessage(pi);
167
+ if (generation === state.sessionGeneration) ctx.ui.notify(status, "info");
168
+ return { kind: "close" };
169
+ },
170
+ tools: async () => {
171
+ await showToolSelector(pi, ctx);
172
+ return { kind: "close" };
173
+ },
174
+ enable: async () => {
175
+ await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "enabled all");
176
+ return { kind: "close" };
177
+ },
178
+ disable: async () => {
179
+ await updateChromeDevtoolsTools(pi, ctx, [], "disabled all");
180
+ return { kind: "close" };
181
+ },
182
+ },
183
+ });
184
+ await runMenu(ctx, menu, {
185
+ getState: () => undefined,
186
+ signal: state.sessionController.signal,
187
+ isCurrent: () => generation === state.sessionGeneration,
188
+ });
189
+ }
190
+
191
+ function replaceSessionController(reason: string) {
192
+ state.sessionController.abort(new DOMException(reason, "AbortError"));
193
+ state.sessionController = new AbortController();
146
194
  }
147
195
 
148
196
  export function parseCommand(args: string): CommandAction | "unknown" {
@@ -179,5 +227,5 @@ export {
179
227
  resolveScreenshotPath,
180
228
  selectAllowedRoot,
181
229
  } from "./screenshot.js";
182
- export { installSettingsFileExclusively, normalizeChromeDevtoolsSettings } from "./settings.js";
230
+ export { normalizeChromeDevtoolsSettings } from "./settings.js";
183
231
  export { orderedChromeDevtoolsTools } from "./tool-selector.js";
package/src/runtime.ts CHANGED
@@ -31,6 +31,8 @@ export interface ChromeDevToolsState {
31
31
  launchPromise?: Promise<void>;
32
32
  lastLaunchAttempt?: BrowserLaunchAttempt;
33
33
  shuttingDown: boolean;
34
+ sessionGeneration: number;
35
+ sessionController: AbortController;
34
36
  settingsNotice?: string;
35
37
  }
36
38
 
@@ -82,4 +84,6 @@ export const state: ChromeDevToolsState = {
82
84
  autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
83
85
  browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
84
86
  shuttingDown: false,
87
+ sessionGeneration: 0,
88
+ sessionController: new AbortController(),
85
89
  };
package/src/settings.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { access, link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
- import { homedir } from "node:os";
3
+ import { access, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
4
  import { dirname, join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
7
7
 
8
8
  const NEW_SETTINGS_FILE_NAME = "pi-chrome-devtools.json";
@@ -12,17 +12,23 @@ export interface ChromeDevToolsSettings {
12
12
  updatedAt: number;
13
13
  }
14
14
 
15
+ export interface SettingsFileOperations {
16
+ write(path: string, data: string): Promise<void>;
17
+ rename(source: string, destination: string): Promise<void>;
18
+ }
19
+
20
+ const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
21
+ write: (path, data) => writeFile(path, data, "utf8").then(() => undefined),
22
+ rename,
23
+ };
24
+
15
25
  export type SettingsLoadResult =
16
26
  | { kind: "missing"; notice?: string }
17
27
  | { kind: "invalid"; reason: string; notice?: string }
18
28
  | { kind: "loaded"; settings: ChromeDevToolsSettings; notice?: string };
19
29
 
20
- type SettingsMigrationResult = {
21
- kind: "migrated" | "failed";
22
- notice: string;
23
- };
24
-
25
30
  export async function loadSettings(): Promise<SettingsLoadResult> {
31
+ await settingsSaveQueue;
26
32
  const newPath = settingsFilePath();
27
33
  const newSettings = await readSettingsFile(newPath);
28
34
  if (newSettings.kind !== "missing") {
@@ -38,39 +44,50 @@ export async function loadSettings(): Promise<SettingsLoadResult> {
38
44
  if (legacySettings.kind === "missing") return { kind: "missing" };
39
45
  if (legacySettings.kind === "invalid") return legacySettings;
40
46
 
41
- const migration = await migrateLegacySettings(legacyPath, legacySettings.settings);
42
- if (migration.kind === "failed") {
43
- const settingsCreatedDuringMigration = await readSettingsFile(newPath);
44
- if (settingsCreatedDuringMigration.kind !== "missing") {
45
- return withLegacyIgnoredNotice(settingsCreatedDuringMigration);
46
- }
47
- }
47
+ return {
48
+ ...legacySettings,
49
+ notice: `Using legacy ${LEGACY_SETTINGS_FILE_NAME}; rename it to ${NEW_SETTINGS_FILE_NAME}. Future saves write ${NEW_SETTINGS_FILE_NAME} without modifying the legacy file.`,
50
+ };
51
+ }
48
52
 
49
- return { ...legacySettings, notice: migration.notice };
53
+ interface SettingsDocumentResult {
54
+ result: SettingsLoadResult;
55
+ document?: Record<string, unknown>;
50
56
  }
51
57
 
52
- async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
58
+ async function readSettingsDocument(filePath: string): Promise<SettingsDocumentResult> {
53
59
  let text: string;
54
60
  try {
55
61
  text = await readFile(filePath, "utf8");
56
62
  } catch (error) {
57
- if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
58
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
63
+ if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
64
+ return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
59
65
  }
60
66
 
61
67
  try {
62
68
  const parsed = JSON.parse(text) as unknown;
63
69
  const settings = normalizeChromeDevtoolsSettings(parsed);
64
- if (settings) return { kind: "loaded", settings };
70
+ if (settings) {
71
+ return {
72
+ result: { kind: "loaded", settings },
73
+ document: { ...(parsed as Record<string, unknown>) },
74
+ };
75
+ }
65
76
  return {
66
- kind: "invalid",
67
- reason: `${filePath}: expected tools to be an array of Chrome DevTools tool names`,
77
+ result: {
78
+ kind: "invalid",
79
+ reason: `${filePath}: expected tools to be an array of Chrome DevTools tool names`,
80
+ },
68
81
  };
69
82
  } catch (error) {
70
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
83
+ return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
71
84
  }
72
85
  }
73
86
 
87
+ async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
88
+ return (await readSettingsDocument(filePath)).result;
89
+ }
90
+
74
91
  async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
75
92
  if (!(await fileExists(legacySettingsFilePath()))) return settings;
76
93
  return {
@@ -79,46 +96,6 @@ async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<Se
79
96
  };
80
97
  }
81
98
 
82
- export async function installSettingsFileExclusively(filePath: string, contents: string) {
83
- await mkdir(dirname(filePath), { recursive: true });
84
- const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
85
- try {
86
- await writeFile(tempFile, contents, { encoding: "utf8", flag: "wx" });
87
- await link(tempFile, filePath);
88
- } finally {
89
- await rm(tempFile, { force: true }).catch(() => undefined);
90
- }
91
- }
92
-
93
- async function migrateLegacySettings(
94
- legacyPath: string,
95
- settings: ChromeDevToolsSettings,
96
- ): Promise<SettingsMigrationResult> {
97
- const newPath = settingsFilePath();
98
- try {
99
- await installSettingsFileExclusively(newPath, `${JSON.stringify(settings, null, 2)}\n`);
100
- } catch (error) {
101
- return {
102
- kind: "failed",
103
- notice: `Chrome DevTools legacy settings migration failed: could not migrate ${legacyPath} to ${newPath}: ${formatError(error)}. The legacy file was used for this session; future saves will write ${NEW_SETTINGS_FILE_NAME}.`,
104
- };
105
- }
106
-
107
- try {
108
- await rm(legacyPath, { force: true });
109
- } catch (error) {
110
- return {
111
- kind: "migrated",
112
- notice: `Chrome DevTools settings migrated from ${legacyPath} to ${newPath}, but the legacy file could not be removed: ${formatError(error)}. Delete ${LEGACY_SETTINGS_FILE_NAME} after confirming your settings.`,
113
- };
114
- }
115
-
116
- return {
117
- kind: "migrated",
118
- notice: `Chrome DevTools settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE_NAME} is deprecated and will be removed in a future major release.`,
119
- };
120
- }
121
-
122
99
  async function fileExists(filePath: string) {
123
100
  try {
124
101
  await access(filePath, constants.F_OK);
@@ -128,6 +105,16 @@ async function fileExists(filePath: string) {
128
105
  }
129
106
  }
130
107
 
108
+ async function pathEntryExists(filePath: string) {
109
+ try {
110
+ await lstat(filePath);
111
+ return true;
112
+ } catch (error) {
113
+ if (isNodeError(error) && error.code === "ENOENT") return false;
114
+ throw error;
115
+ }
116
+ }
117
+
131
118
  export function normalizeChromeDevtoolsSettings(
132
119
  value: unknown,
133
120
  ): ChromeDevToolsSettings | undefined {
@@ -154,13 +141,48 @@ function orderedUniqueChromeDevtoolsTools(tools: readonly ChromeDevToolsToolName
154
141
  return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
155
142
  }
156
143
 
157
- export async function saveSettings(settings: ChromeDevToolsSettings) {
144
+ let settingsSaveQueue = Promise.resolve();
145
+
146
+ export function saveSettings(
147
+ settings: ChromeDevToolsSettings,
148
+ operations: Partial<SettingsFileOperations> = {},
149
+ ): Promise<void> {
150
+ const operation = settingsSaveQueue.then(() => saveSettingsNow(settings, operations));
151
+ settingsSaveQueue = operation.catch(() => undefined);
152
+ return operation;
153
+ }
154
+
155
+ async function saveSettingsNow(
156
+ settings: ChromeDevToolsSettings,
157
+ operations: Partial<SettingsFileOperations>,
158
+ ): Promise<void> {
158
159
  const filePath = settingsFilePath();
160
+ let current = await readSettingsDocument(filePath);
161
+ const replaceCanonical = current.result.kind !== "missing";
162
+ if (!replaceCanonical) current = await readSettingsDocument(legacySettingsFilePath());
163
+ if (current.result.kind === "invalid") {
164
+ throw new Error(
165
+ `Cannot save Chrome DevTools settings until you repair ${current.result.reason}`,
166
+ );
167
+ }
168
+ const nextDocument = {
169
+ ...(current.document ?? {}),
170
+ tools: [...settings.tools],
171
+ updatedAt: settings.updatedAt,
172
+ };
159
173
  await mkdir(dirname(filePath), { recursive: true });
160
- const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
174
+ const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
161
175
  try {
162
- await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
163
- await rename(tempFile, filePath);
176
+ await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
177
+ tempFile,
178
+ `${JSON.stringify(nextDocument, null, 2)}\n`,
179
+ );
180
+ if (!replaceCanonical && (await pathEntryExists(filePath))) {
181
+ throw new Error(
182
+ `${NEW_SETTINGS_FILE_NAME} was created concurrently; reopen settings and retry.`,
183
+ );
184
+ }
185
+ await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(tempFile, filePath);
164
186
  } catch (error) {
165
187
  await rm(tempFile, { force: true }).catch(() => undefined);
166
188
  throw error;
@@ -176,7 +198,7 @@ function legacySettingsFilePath() {
176
198
  }
177
199
 
178
200
  function agentDir() {
179
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
201
+ return getAgentDir();
180
202
  }
181
203
 
182
204
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
2
3
  import {
3
4
  browserCandidateHint,
4
5
  devToolsEndpoint,
@@ -13,13 +14,9 @@ import { loadSettings, saveSettings, settingsFilePath } from "./settings.js";
13
14
  import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
14
15
 
15
16
  type CommandContext = ExtensionCommandContext;
16
- const TOOL_SELECTOR_DONE = "Done";
17
- const TOOL_SELECTOR_ENABLE_ALL = "Enable all Chrome DevTools tools";
18
- const TOOL_SELECTOR_DISABLE_ALL = "Disable all Chrome DevTools tools";
19
- type ToolSelectorAction = "enableAll" | "disableAll" | "done";
20
- type ToolSelectorRow =
21
- | { kind: "tool"; toolName: ChromeDevToolsToolName }
22
- | { kind: "action"; action: ToolSelectorAction; label: string };
17
+ type ToolSelectorScreen = "tools";
18
+ type ToolSelectorAction = "toggle" | "enableAll" | "disableAll";
19
+
23
20
  function unique<T>(values: T[]) {
24
21
  return Array.from(new Set(values));
25
22
  }
@@ -39,165 +36,150 @@ interface ToolStatusSummary {
39
36
  }
40
37
 
41
38
  export async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
42
- if (!ctx.hasUI) {
43
- ctx.ui.notify(
44
- `Chrome DevTools tool selection needs an interactive UI.
45
-
46
- ${await buildToolStatusMessage(pi)}`,
47
- "info",
48
- );
49
- return;
50
- }
51
-
52
- let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
53
- let persistQueue = Promise.resolve();
54
- const commitSelectedTools = () => {
55
- const nextSelectedTools = orderedChromeDevtoolsTools(selectedTools);
56
- applyChromeDevtoolsTools(pi, nextSelectedTools);
57
- persistQueue = persistQueue.then(() => persistSettings(ctx, nextSelectedTools));
58
- };
59
-
60
- const customResult = await ctx.ui.custom<"closed" | undefined>(
61
- (tui, theme, keybindings, done) => {
62
- const rows = chromeDevtoolsToolSelectorRows();
63
- let selectedIndex = 0;
64
- const moveSelection = (delta: number) => {
65
- selectedIndex = (selectedIndex + delta + rows.length) % rows.length;
66
- };
67
- const activateSelectedRow = () => {
68
- const row = rows[selectedIndex];
69
- if (!row) return;
70
-
71
- if (row.kind === "tool") {
72
- if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
73
- else selectedTools.add(row.toolName);
74
- commitSelectedTools();
75
- return;
76
- }
77
-
78
- if (row.action === "enableAll") {
79
- selectedTools = new Set(allChromeDevtoolsTools());
80
- commitSelectedTools();
81
- return;
82
- }
83
- if (row.action === "disableAll") {
84
- selectedTools = new Set();
85
- commitSelectedTools();
86
- return;
87
- }
88
-
89
- done("closed");
90
- };
91
-
92
- return {
93
- invalidate() {},
94
- render() {
95
- return [
96
- theme.fg("accent", theme.bold(toolSelectorTitle(selectedTools))),
97
- "",
98
- ...rows.map((row, index) => {
99
- const label = formatToolSelectorRow(row, selectedTools);
100
- if (index === selectedIndex) {
101
- return `${theme.fg("accent", "›")} ${theme.fg("accent", label)}`;
102
- }
103
- return ` ${label}`;
104
- }),
105
- "",
106
- theme.fg("dim", "↑↓ navigate • Enter/Space toggle • Esc close"),
107
- ];
108
- },
109
- handleInput(data: string) {
110
- if (keybindings.matches(data, "tui.select.up")) {
111
- moveSelection(-1);
112
- tui.requestRender();
113
- return;
114
- }
115
- if (keybindings.matches(data, "tui.select.down")) {
116
- moveSelection(1);
117
- tui.requestRender();
118
- return;
119
- }
120
- if (keybindings.matches(data, "tui.select.pageUp")) {
121
- selectedIndex = 0;
122
- tui.requestRender();
123
- return;
124
- }
125
- if (keybindings.matches(data, "tui.select.pageDown")) {
126
- selectedIndex = rows.length - 1;
127
- tui.requestRender();
128
- return;
129
- }
130
- if (keybindings.matches(data, "tui.select.confirm") || data === " ") {
131
- activateSelectedRow();
132
- tui.requestRender();
133
- return;
134
- }
135
- if (keybindings.matches(data, "tui.select.cancel")) {
136
- done("closed");
137
- }
138
- },
139
- };
39
+ const generation = state.sessionGeneration;
40
+ if (!ctx.hasUI) return;
41
+ const menu = defineMenu<undefined, ToolSelectorScreen, ToolSelectorAction>({
42
+ start: "tools",
43
+ screens: {
44
+ tools: () => {
45
+ const selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
46
+ return {
47
+ kind: "multiSelect",
48
+ title: toolSelectorTitle(selectedTools),
49
+ items: CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({
50
+ id: toolName,
51
+ label: toolName,
52
+ selected: selectedTools.has(toolName),
53
+ })),
54
+ action: "toggle",
55
+ actions: [
56
+ {
57
+ id: "enable-all",
58
+ label: "Enable all Chrome DevTools tools",
59
+ action: "enableAll",
60
+ },
61
+ {
62
+ id: "disable-all",
63
+ label: "Disable all Chrome DevTools tools",
64
+ action: "disableAll",
65
+ },
66
+ { id: "done", label: "Done", close: true },
67
+ ],
68
+ hint: "close",
69
+ doneLabel: "Done",
70
+ };
71
+ },
140
72
  },
141
- );
142
-
143
- if (customResult !== "closed") {
144
- await showDialogToolSelector(pi, ctx);
145
- return;
146
- }
147
-
148
- await persistQueue;
149
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
73
+ actions: {
74
+ toggle: async ({ itemId, selected }) => {
75
+ if (!isChromeDevtoolsToolName(itemId)) return { kind: "rejected" };
76
+ const selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
77
+ if (selected) selectedTools.add(itemId);
78
+ else selectedTools.delete(itemId);
79
+ const saved = await transactSelectedTools(
80
+ pi,
81
+ ctx,
82
+ orderedChromeDevtoolsTools(selectedTools),
83
+ generation,
84
+ );
85
+ return saved ? { kind: "stay" } : { kind: "rejected" };
86
+ },
87
+ enableAll: async () => {
88
+ const saved = await transactSelectedTools(pi, ctx, allChromeDevtoolsTools(), generation);
89
+ return saved ? { kind: "stay" } : { kind: "rejected" };
90
+ },
91
+ disableAll: async () => {
92
+ const saved = await transactSelectedTools(pi, ctx, [], generation);
93
+ return saved ? { kind: "stay" } : { kind: "rejected" };
94
+ },
95
+ },
96
+ });
97
+ const result = await runMenu(ctx, menu, {
98
+ getState: () => undefined,
99
+ signal: state.sessionController.signal,
100
+ isCurrent: () => generation === state.sessionGeneration,
101
+ });
102
+ if (result.kind !== "closed" || generation !== state.sessionGeneration) return;
103
+ const status = await buildToolStatusMessage(pi);
104
+ if (generation !== state.sessionGeneration) return;
105
+ ctx.ui.notify(status, "info");
150
106
  }
151
107
 
152
- async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
153
- let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
154
- while (true) {
155
- const rows = chromeDevtoolsToolSelectorRows();
156
- const choices = rows.map((row) => formatToolSelectorRow(row, selectedTools));
157
- const choice = await ctx.ui.select(toolSelectorTitle(selectedTools), choices);
158
- if (!choice) break;
159
-
160
- const row = rows[choices.indexOf(choice)];
161
- if (!row) continue;
162
- if (row.kind === "action" && row.action === "done") break;
108
+ export async function updateChromeDevtoolsTools(
109
+ pi: ExtensionAPI,
110
+ ctx: CommandContext,
111
+ selectedTools: readonly ChromeDevToolsToolName[],
112
+ action: string,
113
+ ) {
114
+ const generation = state.sessionGeneration;
115
+ const saved = await transactSelectedTools(pi, ctx, selectedTools, generation);
116
+ if (!saved || generation !== state.sessionGeneration) return;
117
+ const status = await buildToolStatusMessage(pi);
118
+ if (generation !== state.sessionGeneration) return;
119
+ ctx.ui.notify(`Chrome DevTools tools ${action}.\n\n${status}`, "info");
120
+ }
163
121
 
164
- if (row.kind === "tool") {
165
- if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
166
- else selectedTools.add(row.toolName);
167
- } else if (row.action === "enableAll") {
168
- selectedTools = new Set(allChromeDevtoolsTools());
169
- } else if (row.action === "disableAll") {
170
- selectedTools = new Set();
171
- }
122
+ export async function setSelectedChromeDevtoolsTools(
123
+ pi: ExtensionAPI,
124
+ ctx: CommandContext,
125
+ selectedTools: readonly ChromeDevToolsToolName[],
126
+ ): Promise<boolean> {
127
+ return transactSelectedTools(pi, ctx, selectedTools, state.sessionGeneration);
128
+ }
172
129
 
173
- await setSelectedChromeDevtoolsTools(pi, ctx, orderedChromeDevtoolsTools(selectedTools));
174
- }
130
+ let toolTransactionQueue = Promise.resolve();
175
131
 
176
- ctx.ui.notify(await buildToolStatusMessage(pi), "info");
132
+ export async function waitForChromeDevtoolsSettings(): Promise<void> {
133
+ await toolTransactionQueue;
177
134
  }
178
135
 
179
- export async function updateChromeDevtoolsTools(
136
+ function transactSelectedTools(
180
137
  pi: ExtensionAPI,
181
138
  ctx: CommandContext,
182
139
  selectedTools: readonly ChromeDevToolsToolName[],
183
- action: string,
184
- ) {
185
- await setSelectedChromeDevtoolsTools(pi, ctx, selectedTools);
186
- ctx.ui.notify(
187
- `Chrome DevTools tools ${action}.
188
-
189
- ${await buildToolStatusMessage(pi)}`,
190
- "info",
140
+ expectedGeneration: number,
141
+ ): Promise<boolean> {
142
+ const operation = toolTransactionQueue.then(() =>
143
+ transactSelectedToolsNow(pi, ctx, selectedTools, expectedGeneration),
144
+ );
145
+ toolTransactionQueue = operation.then(
146
+ () => undefined,
147
+ () => undefined,
191
148
  );
149
+ return operation;
192
150
  }
193
151
 
194
- export async function setSelectedChromeDevtoolsTools(
152
+ async function transactSelectedToolsNow(
195
153
  pi: ExtensionAPI,
196
154
  ctx: CommandContext,
197
155
  selectedTools: readonly ChromeDevToolsToolName[],
198
- ) {
199
- applyChromeDevtoolsTools(pi, selectedTools);
200
- await persistSettings(ctx, selectedTools);
156
+ expectedGeneration: number,
157
+ ): Promise<boolean> {
158
+ if (expectedGeneration !== state.sessionGeneration) return false;
159
+ const previousActiveTools = pi.getActiveTools();
160
+ try {
161
+ applyChromeDevtoolsTools(pi, selectedTools);
162
+ await persistSettings(selectedTools);
163
+ return expectedGeneration === state.sessionGeneration;
164
+ } catch (error) {
165
+ let rollbackError: unknown;
166
+ try {
167
+ const previousChromeTools = previousActiveTools.filter((name) =>
168
+ CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName),
169
+ ) as ChromeDevToolsToolName[];
170
+ applyChromeDevtoolsTools(pi, previousChromeTools);
171
+ } catch (caught) {
172
+ rollbackError = caught;
173
+ }
174
+ if (expectedGeneration !== state.sessionGeneration) return false;
175
+ ctx.ui.notify(
176
+ rollbackError
177
+ ? `Chrome DevTools settings save failed: ${formatError(error)}; active-tool rollback failed: ${formatError(rollbackError)}`
178
+ : `Chrome DevTools settings save failed; active tools restored: ${formatError(error)}`,
179
+ "warning",
180
+ );
181
+ return false;
182
+ }
201
183
  }
202
184
 
203
185
  export function applyChromeDevtoolsTools(
@@ -275,21 +257,8 @@ function toolSelectorTitle(selectedTools: ReadonlySet<ChromeDevToolsToolName>) {
275
257
  return `Chrome DevTools tools (${selectedTools.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}). Non-built-in tools run at user risk.`;
276
258
  }
277
259
 
278
- function chromeDevtoolsToolSelectorRows(): ToolSelectorRow[] {
279
- return [
280
- ...CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({ kind: "tool" as const, toolName })),
281
- { kind: "action", action: "enableAll", label: TOOL_SELECTOR_ENABLE_ALL },
282
- { kind: "action", action: "disableAll", label: TOOL_SELECTOR_DISABLE_ALL },
283
- { kind: "action", action: "done", label: TOOL_SELECTOR_DONE },
284
- ];
285
- }
286
-
287
- function formatToolSelectorRow(
288
- row: ToolSelectorRow,
289
- selectedTools: ReadonlySet<ChromeDevToolsToolName>,
290
- ) {
291
- if (row.kind === "action") return row.label;
292
- return `${selectedTools.has(row.toolName) ? "[x]" : "[ ]"} ${row.toolName}`;
260
+ function isChromeDevtoolsToolName(value: string): value is ChromeDevToolsToolName {
261
+ return CHROME_DEVTOOLS_TOOL_NAMES.includes(value as ChromeDevToolsToolName);
293
262
  }
294
263
 
295
264
  function getActiveChromeDevtoolsTools(pi: ExtensionAPI) {
@@ -327,13 +296,6 @@ function formatPersistedSelection(tools: readonly ChromeDevToolsToolName[]) {
327
296
  return `${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected: ${tools.join(", ")}`;
328
297
  }
329
298
 
330
- async function persistSettings(
331
- ctx: CommandContext,
332
- selectedTools: readonly ChromeDevToolsToolName[],
333
- ) {
334
- try {
335
- await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
336
- } catch (error) {
337
- ctx.ui.notify(`Chrome DevTools settings save failed: ${formatError(error)}`, "warning");
338
- }
299
+ async function persistSettings(selectedTools: readonly ChromeDevToolsToolName[]) {
300
+ await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
339
301
  }