@narumitw/pi-chrome-devtools 0.49.3 → 0.50.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.
@@ -1,6 +1,8 @@
1
+ import { stripVTControlCharacters } from "node:util";
1
2
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
3
  import {
3
4
  browserCandidateHint,
5
+ browserLifecycleState,
4
6
  devToolsEndpoint,
5
7
  endpointConfigHint,
6
8
  endpointSourceLabel,
@@ -13,17 +15,11 @@ import { loadSettings, saveSettings, settingsFilePath } from "./settings.js";
13
15
  import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
14
16
 
15
17
  type CommandContext = ExtensionCommandContext;
16
- type ToolSelectorScreen = "tools";
17
- type ToolSelectorAction = "toggle" | "enableAll" | "disableAll";
18
18
 
19
19
  function unique<T>(values: T[]) {
20
20
  return Array.from(new Set(values));
21
21
  }
22
22
 
23
- function recordSettingsNotice(settings: { notice?: string }) {
24
- if (settings.notice) state.settingsNotice = settings.notice;
25
- }
26
-
27
23
  function formatError(error: unknown) {
28
24
  return error instanceof Error ? error.message : String(error);
29
25
  }
@@ -34,79 +30,7 @@ interface ToolStatusSummary {
34
30
  activeNonChromeToolCount: number;
35
31
  }
36
32
 
37
- export async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
38
- const generation = state.sessionGeneration;
39
- if (!ctx.hasUI) return;
40
- const menuSignal = state.sessionController.signal;
41
- const isCurrent = () => generation === state.sessionGeneration && !menuSignal.aborted;
42
- const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
43
- if (!isCurrent()) return;
44
- const menu = defineMenu<undefined, ToolSelectorScreen, ToolSelectorAction>({
45
- start: "tools",
46
- screens: {
47
- tools: () => {
48
- const selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
49
- return {
50
- kind: "multiSelect",
51
- title: toolSelectorTitle(selectedTools),
52
- items: CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({
53
- id: toolName,
54
- label: toolName,
55
- selected: selectedTools.has(toolName),
56
- })),
57
- action: "toggle",
58
- actions: [
59
- {
60
- id: "enable-all",
61
- label: "Enable all Chrome DevTools tools",
62
- action: "enableAll",
63
- },
64
- {
65
- id: "disable-all",
66
- label: "Disable all Chrome DevTools tools",
67
- action: "disableAll",
68
- },
69
- { id: "done", label: "Done", close: true },
70
- ],
71
- hint: "close",
72
- doneLabel: "Done",
73
- };
74
- },
75
- },
76
- actions: {
77
- toggle: async ({ itemId, selected }) => {
78
- if (!isChromeDevtoolsToolName(itemId)) return { kind: "rejected" };
79
- const selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
80
- if (selected) selectedTools.add(itemId);
81
- else selectedTools.delete(itemId);
82
- const saved = await transactSelectedTools(
83
- pi,
84
- ctx,
85
- orderedChromeDevtoolsTools(selectedTools),
86
- generation,
87
- );
88
- return saved ? { kind: "stay" } : { kind: "rejected" };
89
- },
90
- enableAll: async () => {
91
- const saved = await transactSelectedTools(pi, ctx, allChromeDevtoolsTools(), generation);
92
- return saved ? { kind: "stay" } : { kind: "rejected" };
93
- },
94
- disableAll: async () => {
95
- const saved = await transactSelectedTools(pi, ctx, [], generation);
96
- return saved ? { kind: "stay" } : { kind: "rejected" };
97
- },
98
- },
99
- });
100
- const result = await runMenu(ctx, menu, {
101
- getState: () => undefined,
102
- signal: menuSignal,
103
- isCurrent,
104
- });
105
- if (result.kind !== "closed" || generation !== state.sessionGeneration) return;
106
- const status = await buildToolStatusMessage(pi);
107
- if (generation !== state.sessionGeneration) return;
108
- ctx.ui.notify(status, "info");
109
- }
33
+ type ToolSelectionSaveResult = "saved" | "active-tools-changed" | "failed";
110
34
 
111
35
  export async function updateChromeDevtoolsTools(
112
36
  pi: ExtensionAPI,
@@ -115,8 +39,8 @@ export async function updateChromeDevtoolsTools(
115
39
  action: string,
116
40
  ) {
117
41
  const generation = state.sessionGeneration;
118
- const saved = await transactSelectedTools(pi, ctx, selectedTools, generation);
119
- if (!saved || generation !== state.sessionGeneration) return;
42
+ const result = await transactSelectedTools(pi, ctx, selectedTools, generation);
43
+ if (result !== "saved" || generation !== state.sessionGeneration) return;
120
44
  const status = await buildToolStatusMessage(pi);
121
45
  if (generation !== state.sessionGeneration) return;
122
46
  ctx.ui.notify(`Chrome DevTools tools ${action}.\n\n${status}`, "info");
@@ -126,8 +50,15 @@ export async function setSelectedChromeDevtoolsTools(
126
50
  pi: ExtensionAPI,
127
51
  ctx: CommandContext,
128
52
  selectedTools: readonly ChromeDevToolsToolName[],
129
- ): Promise<boolean> {
130
- return transactSelectedTools(pi, ctx, selectedTools, state.sessionGeneration);
53
+ expectedActiveTools: readonly ChromeDevToolsToolName[],
54
+ ): Promise<ToolSelectionSaveResult> {
55
+ return transactSelectedTools(
56
+ pi,
57
+ ctx,
58
+ selectedTools,
59
+ state.sessionGeneration,
60
+ expectedActiveTools,
61
+ );
131
62
  }
132
63
 
133
64
  let toolTransactionQueue = Promise.resolve();
@@ -141,9 +72,10 @@ function transactSelectedTools(
141
72
  ctx: CommandContext,
142
73
  selectedTools: readonly ChromeDevToolsToolName[],
143
74
  expectedGeneration: number,
144
- ): Promise<boolean> {
75
+ expectedActiveTools?: readonly ChromeDevToolsToolName[],
76
+ ): Promise<ToolSelectionSaveResult> {
145
77
  const operation = toolTransactionQueue.then(() =>
146
- transactSelectedToolsNow(pi, ctx, selectedTools, expectedGeneration),
78
+ transactSelectedToolsNow(pi, ctx, selectedTools, expectedGeneration, expectedActiveTools),
147
79
  );
148
80
  toolTransactionQueue = operation.then(
149
81
  () => undefined,
@@ -157,13 +89,21 @@ async function transactSelectedToolsNow(
157
89
  ctx: CommandContext,
158
90
  selectedTools: readonly ChromeDevToolsToolName[],
159
91
  expectedGeneration: number,
160
- ): Promise<boolean> {
161
- if (expectedGeneration !== state.sessionGeneration) return false;
92
+ expectedActiveTools?: readonly ChromeDevToolsToolName[],
93
+ ): Promise<ToolSelectionSaveResult> {
94
+ if (expectedGeneration !== state.sessionGeneration) return "failed";
95
+ if (expectedActiveTools && !arraysEqual(activeChromeDevtoolsTools(pi), expectedActiveTools)) {
96
+ ctx.ui.notify(
97
+ "Browser tool selection changed while review was open. Review the current state, then apply again.",
98
+ "warning",
99
+ );
100
+ return "active-tools-changed";
101
+ }
162
102
  const previousActiveTools = pi.getActiveTools();
163
103
  try {
164
104
  applyChromeDevtoolsTools(pi, selectedTools);
165
105
  await persistSettings(selectedTools);
166
- return expectedGeneration === state.sessionGeneration;
106
+ return expectedGeneration === state.sessionGeneration ? "saved" : "failed";
167
107
  } catch (error) {
168
108
  let rollbackError: unknown;
169
109
  try {
@@ -174,17 +114,28 @@ async function transactSelectedToolsNow(
174
114
  } catch (caught) {
175
115
  rollbackError = caught;
176
116
  }
177
- if (expectedGeneration !== state.sessionGeneration) return false;
117
+ if (expectedGeneration !== state.sessionGeneration) return "failed";
178
118
  ctx.ui.notify(
179
- rollbackError
180
- ? `Chrome DevTools settings save failed: ${formatError(error)}; active-tool rollback failed: ${formatError(rollbackError)}`
181
- : `Chrome DevTools settings save failed; active tools restored: ${formatError(error)}`,
119
+ sanitizeChromeDevtoolsDisplay(
120
+ rollbackError
121
+ ? `Chrome DevTools settings save failed: ${formatError(error)}; active-tool rollback failed: ${formatError(rollbackError)}`
122
+ : `Chrome DevTools settings save failed; active tools restored: ${formatError(error)}`,
123
+ ),
182
124
  "warning",
183
125
  );
184
- return false;
126
+ return "failed";
185
127
  }
186
128
  }
187
129
 
130
+ function activeChromeDevtoolsTools(pi: ExtensionAPI) {
131
+ const active = new Set(pi.getActiveTools());
132
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => active.has(toolName));
133
+ }
134
+
135
+ function arraysEqual<T>(left: readonly T[], right: readonly T[]) {
136
+ return left.length === right.length && left.every((value, index) => value === right[index]);
137
+ }
138
+
188
139
  export function applyChromeDevtoolsTools(
189
140
  pi: ExtensionAPI,
190
141
  selectedTools: readonly ChromeDevToolsToolName[],
@@ -217,29 +168,105 @@ function getToolStatusSummary(pi: ExtensionAPI): ToolStatusSummary {
217
168
  export async function buildToolStatusMessage(pi: ExtensionAPI) {
218
169
  const summary = getToolStatusSummary(pi);
219
170
  const persistedSetting = await persistedSettingLabel();
220
- return [
221
- `Chrome DevTools tools: ${formatRuntimeStatus(summary)}`,
222
- `Persisted selection: ${persistedSetting}`,
223
- `Settings file: ${settingsFilePath()}`,
224
- ...(state.settingsNotice ? [`Settings note: ${state.settingsNotice}`] : []),
225
- `Other active tools preserved: ${summary.activeNonChromeToolCount}`,
226
- `Endpoint: ${devToolsEndpoint()}`,
227
- `Endpoint source: ${endpointSourceLabel()}`,
228
- `Launch mode: ${launchModeLabel()}`,
229
- ...launchAttemptLines(),
230
- ].join("\n");
171
+ return sanitizeChromeDevtoolsDisplay(
172
+ [
173
+ `Chrome DevTools tools: ${formatRuntimeStatus(summary)}`,
174
+ `Persisted selection: ${persistedSetting}`,
175
+ ...browserSettingsStatusLines(),
176
+ ...(state.settingsNotice ? [`Settings note: ${state.settingsNotice}`] : []),
177
+ `Other active tools preserved: ${summary.activeNonChromeToolCount}`,
178
+ `Endpoint: ${devToolsEndpoint()}`,
179
+ `Endpoint source: ${endpointSourceLabel()}`,
180
+ `Launch mode: ${launchModeLabel()}`,
181
+ ...launchAttemptLines(),
182
+ ].join("\n"),
183
+ );
231
184
  }
232
185
 
233
186
  export function buildQuickstartMessage() {
187
+ return buildSettingsSetupMessage();
188
+ }
189
+
190
+ export function buildBrowserStatusMessage() {
191
+ const lifecycle = browserLifecycleState();
192
+ const browserState =
193
+ lifecycle === "starting"
194
+ ? "starting managed browser"
195
+ : lifecycle === "running"
196
+ ? "managed browser running"
197
+ : lifecycle === "exited"
198
+ ? "managed browser exited"
199
+ : lifecycle === "failed"
200
+ ? "last launch failed"
201
+ : "not started; connection has not been checked";
202
+ const needsRecovery = lifecycle === "exited" || lifecycle === "failed";
203
+ return sanitizeChromeDevtoolsDisplay(
204
+ [
205
+ `Browser: ${browserState}`,
206
+ "Viewing this status does not probe the endpoint or launch Chrome.",
207
+ `Endpoint: ${devToolsEndpoint()}`,
208
+ `Endpoint source: ${endpointSourceLabel()}`,
209
+ `Launch mode: ${launchModeLabel()}`,
210
+ `Unpacked extensions: ${state.extensionPaths.length} (${state.extensionPathsSource})`,
211
+ ...(state.extensionPaths.length > 0
212
+ ? ["Unpacked extensions execute trusted browser code in an isolated managed browser."]
213
+ : []),
214
+ ...launchAttemptLines(),
215
+ ...(needsRecovery ? [launchHint(), endpointConfigHint()] : []),
216
+ ].join("\n"),
217
+ );
218
+ }
219
+
220
+ export function buildSettingsSetupMessage() {
221
+ return sanitizeChromeDevtoolsDisplay(
222
+ [
223
+ `Chrome DevTools endpoint: ${devToolsEndpoint()}`,
224
+ `Endpoint source: ${endpointSourceLabel()}`,
225
+ `Launch mode: ${launchModeLabel()}`,
226
+ ...browserSettingsStatusLines(),
227
+ launchHint(),
228
+ browserCandidateHint(),
229
+ ...launchAttemptLines(),
230
+ endpointConfigHint(),
231
+ ].join("\n"),
232
+ );
233
+ }
234
+
235
+ export function sanitizeChromeDevtoolsDisplay(value: string, maxCharacters = 50_000) {
236
+ const sanitized = Array.from(stripVTControlCharacters(value), (character) => {
237
+ const codePoint = character.codePointAt(0) ?? 0;
238
+ const unsafeControl =
239
+ (codePoint >= 0 && codePoint <= 8) ||
240
+ (codePoint >= 11 && codePoint <= 31) ||
241
+ (codePoint >= 127 && codePoint <= 159);
242
+ return unsafeControl ? "�" : character;
243
+ }).join("");
244
+ if (sanitized.length <= maxCharacters) return sanitized;
245
+ return `${sanitized.slice(0, Math.max(0, maxCharacters - 1))}…`;
246
+ }
247
+
248
+ function browserSettingsStatusLines() {
249
+ const extensionLines =
250
+ state.extensionPaths.length > 0
251
+ ? state.extensionPaths.map((extensionPath) => ` - ${extensionPath}`)
252
+ : [" - none"];
234
253
  return [
235
- `Chrome DevTools endpoint: ${devToolsEndpoint()}`,
236
- `Endpoint source: ${endpointSourceLabel()}`,
237
- `Launch mode: ${launchModeLabel()}`,
238
- launchHint(),
239
- browserCandidateHint(),
240
- ...launchAttemptLines(),
241
- endpointConfigHint(),
242
- ].join("\n");
254
+ `Settings file: ${state.settingsFilePath ?? settingsFilePath()} (user)`,
255
+ ...(state.projectSettingsFilePath
256
+ ? [
257
+ `Project settings: ${state.projectSettingsFilePath} (${state.projectSettingsTrusted ? "trusted" : "untrusted; ignored"})`,
258
+ ]
259
+ : []),
260
+ `Browser executable: ${state.browserExecutable ?? "automatic discovery"} (${state.browserExecutableSource})`,
261
+ `Unpacked extensions (${state.extensionPathsSource}):`,
262
+ ...extensionLines,
263
+ "Settings changes apply to a new managed browser after /reload or session replacement.",
264
+ ...(state.extensionPaths.length > 0
265
+ ? [
266
+ "Unpacked extensions require Chrome for Testing or Chromium and execute trusted browser code.",
267
+ ]
268
+ : []),
269
+ ];
243
270
  }
244
271
 
245
272
  export function buildCommandGuide() {
@@ -250,25 +277,12 @@ export function buildCommandGuide() {
250
277
  "/chrome-devtools quickstart — show endpoint and launch help",
251
278
  "/chrome-devtools status — show tool and settings status",
252
279
  "/chrome-devtools tools — select individual Chrome DevTools tools",
253
- "/chrome-devtools toggle — alias for /chrome-devtools tools",
254
- "/chrome-devtools enable — enable all Chrome DevTools tools",
255
- "/chrome-devtools disable — disable all Chrome DevTools tools",
280
+ "/chrome-devtools toggle|select — compatibility aliases for tools",
281
+ "/chrome-devtools enable|on — enable all Chrome DevTools tools immediately",
282
+ "/chrome-devtools disable|off — disable all Chrome DevTools tools immediately",
256
283
  ].join("\n");
257
284
  }
258
285
 
259
- function toolSelectorTitle(selectedTools: ReadonlySet<ChromeDevToolsToolName>) {
260
- return `Chrome DevTools tools (${selectedTools.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}). Non-built-in tools run at user risk.`;
261
- }
262
-
263
- function isChromeDevtoolsToolName(value: string): value is ChromeDevToolsToolName {
264
- return CHROME_DEVTOOLS_TOOL_NAMES.includes(value as ChromeDevToolsToolName);
265
- }
266
-
267
- function getActiveChromeDevtoolsTools(pi: ExtensionAPI) {
268
- const activeToolNames = new Set(pi.getActiveTools());
269
- return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => activeToolNames.has(toolName));
270
- }
271
-
272
286
  export function allChromeDevtoolsTools() {
273
287
  return [...CHROME_DEVTOOLS_TOOL_NAMES];
274
288
  }
@@ -283,8 +297,9 @@ function formatRuntimeStatus(summary: ToolStatusSummary) {
283
297
 
284
298
  async function persistedSettingLabel() {
285
299
  const settings = await loadSettings();
286
- recordSettingsNotice(settings);
287
- if (settings.kind === "loaded") return formatPersistedSelection(settings.settings.tools);
300
+ if (settings.kind === "loaded" && settings.settings.tools) {
301
+ return formatPersistedSelection(settings.settings.tools);
302
+ }
288
303
  if (settings.kind === "invalid") {
289
304
  return `none; current active-tool policy preserved (invalid settings ignored: ${settings.reason})`;
290
305
  }