@narumitw/pi-chrome-devtools 0.1.32 → 0.1.34

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
@@ -16,9 +16,13 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
16
16
  - Recovers from stale active page selections by falling back to an available page.
17
17
  - Evaluates JavaScript in the selected page.
18
18
  - Captures PNG screenshots, including optional full-page screenshots.
19
+ - Renders compact tool results that expand/collapse with Pi's default output toggle (`Ctrl+O`).
19
20
  - Uses a local Chrome DevTools Protocol endpoint.
20
21
  - Retries briefly while Chrome is starting and reports actionable endpoint errors.
21
22
  - Shows statusline activity only while Chrome DevTools tools are running.
23
+ - Provides a `/chrome-devtools` menu with quick-start help and tool controls.
24
+ - Provides a Plan-mode-style selector for choosing individual Chrome DevTools tools.
25
+ - Persists the selected Chrome DevTools tools across Pi restarts.
22
26
 
23
27
  ## 📦 Install
24
28
 
@@ -74,7 +78,40 @@ PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extension
74
78
  /chrome-devtools
75
79
  ```
76
80
 
77
- Shows the configured CDP endpoint and a quick-start hint.
81
+ Opens a menu with quick-start help, command usage, tool status, controls for enabling or
82
+ disabling all Chrome DevTools tools, and a selector for choosing individual tools.
83
+
84
+ Direct subcommands are also available:
85
+
86
+ ```text
87
+ /chrome-devtools help
88
+ /chrome-devtools quickstart
89
+ /chrome-devtools status
90
+ /chrome-devtools tools
91
+ /chrome-devtools toggle
92
+ /chrome-devtools enable
93
+ /chrome-devtools disable
94
+ ```
95
+
96
+ - `help` shows command usage.
97
+ - `quickstart` shows the configured CDP endpoint and launch hint.
98
+ - `status` shows runtime tool state, persisted selection, settings file path, and active
99
+ non-Chrome tool count.
100
+ - `tools` opens a Plan-mode-style selector for choosing individual `chrome_devtools_*` tools.
101
+ - `toggle` is an alias for `tools`.
102
+ - `enable` enables all `chrome_devtools_*` tools for future turns.
103
+ - `disable` disables all `chrome_devtools_*` tools for future turns. The slash command remains
104
+ available.
105
+
106
+ The selected tool names are saved to:
107
+
108
+ ```text
109
+ ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools-settings.json
110
+ ```
111
+
112
+ When the file is missing or invalid, the extension preserves Pi's current active-tool policy
113
+ instead of enabling tools by itself. A valid saved selection is restored on Pi startup and
114
+ `/reload`.
78
115
 
79
116
  ## 🧠 Use cases
80
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-chrome-devtools",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Pi extension that exposes Chrome DevTools Protocol tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,4 +1,13 @@
1
- import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import {
5
+ defineTool,
6
+ type AgentToolResult,
7
+ type ExtensionAPI,
8
+ type ExtensionCommandContext,
9
+ type ToolRenderResultOptions,
10
+ } from "@mariozechner/pi-coding-agent";
2
11
  import { Type } from "typebox";
3
12
 
4
13
  const DEFAULT_HOST = "127.0.0.1";
@@ -8,11 +17,68 @@ const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
8
17
  const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
9
18
  const DEFAULT_ENDPOINT_RETRY_MS = 250;
10
19
  const STATUS_KEY = "chrome-devtools";
20
+ const SETTINGS_FILE = join(
21
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
22
+ "pi-chrome-devtools-settings.json",
23
+ );
24
+ const CHROME_DEVTOOLS_TOOL_NAMES = [
25
+ "chrome_devtools_list_pages",
26
+ "chrome_devtools_select_page",
27
+ "chrome_devtools_navigate",
28
+ "chrome_devtools_evaluate",
29
+ "chrome_devtools_screenshot",
30
+ ] as const;
31
+ const COMMAND_COMPLETIONS = [
32
+ { value: "help", label: "Show command usage" },
33
+ { value: "quickstart", label: "Show endpoint and launch help" },
34
+ { value: "status", label: "Show tool and settings status" },
35
+ { value: "tools", label: "Select Chrome DevTools tools" },
36
+ { value: "toggle", label: "Select Chrome DevTools tools" },
37
+ { value: "enable", label: "Enable all Chrome DevTools tools" },
38
+ { value: "disable", label: "Disable all Chrome DevTools tools" },
39
+ ];
40
+ const MENU_OPTIONS = {
41
+ quickstart: "Quick start / endpoint help",
42
+ help: "Command usage guide",
43
+ status: "Show tool status",
44
+ tools: "Select Chrome DevTools tools",
45
+ enable: "Enable all Chrome DevTools tools",
46
+ disable: "Disable all Chrome DevTools tools",
47
+ } as const;
48
+ const TOOL_SELECTOR_DONE = "Done";
49
+ const TOOL_SELECTOR_ENABLE_ALL = "Enable all Chrome DevTools tools";
50
+ const TOOL_SELECTOR_DISABLE_ALL = "Disable all Chrome DevTools tools";
51
+
52
+ type ChromeDevToolsToolName = (typeof CHROME_DEVTOOLS_TOOL_NAMES)[number];
53
+ type ToolRuntimeStatus = "enabled" | "disabled" | "partial";
54
+ type CommandAction =
55
+ | "menu"
56
+ | "help"
57
+ | "quickstart"
58
+ | "status"
59
+ | "tools"
60
+ | "enable"
61
+ | "disable";
62
+ type CommandContext = ExtensionCommandContext;
63
+ type ToolSelectorAction = "enableAll" | "disableAll" | "done";
64
+ type ToolSelectorRow =
65
+ | { kind: "tool"; toolName: ChromeDevToolsToolName }
66
+ | { kind: "action"; action: ToolSelectorAction; label: string };
11
67
 
12
68
  interface StatusContext {
13
69
  ui: { setStatus: (key: string, value: string | undefined) => void };
14
70
  }
15
71
 
72
+ interface RenderTheme {
73
+ bold(text: string): string;
74
+ fg(color: string, text: string): string;
75
+ }
76
+
77
+ interface RenderComponent {
78
+ invalidate(): void;
79
+ render(width: number): string[];
80
+ }
81
+
16
82
  interface DevToolsPage {
17
83
  id: string;
18
84
  type: string;
@@ -27,6 +93,17 @@ interface ChromeDevToolsState {
27
93
  activePageId?: string;
28
94
  }
29
95
 
96
+ interface ChromeDevToolsSettings {
97
+ tools: ChromeDevToolsToolName[];
98
+ updatedAt: number;
99
+ }
100
+
101
+ interface ToolStatusSummary {
102
+ runtimeStatus: ToolRuntimeStatus;
103
+ activeChromeToolCount: number;
104
+ activeNonChromeToolCount: number;
105
+ }
106
+
30
107
  interface CdpResponse<T = unknown> {
31
108
  id: number;
32
109
  result?: T;
@@ -43,11 +120,13 @@ const state: ChromeDevToolsState = {
43
120
  };
44
121
 
45
122
  const listPagesTool = defineTool({
46
- name: "chrome_devtools_list_pages",
123
+ name: CHROME_DEVTOOLS_TOOL_NAMES[0],
47
124
  label: "Chrome DevTools: List Pages",
48
125
  description: "List Chrome tabs/pages from a running Chrome DevTools Protocol endpoint.",
49
126
  promptSnippet: "List Chrome tabs/pages available over Chrome DevTools Protocol",
50
127
  parameters: Type.Object({}),
128
+ renderCall: renderToolCall("list pages"),
129
+ renderResult: renderTextResult,
51
130
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
52
131
  return withStatus(ctx, "🌐 list pages", async () => {
53
132
  const pages = await listPages();
@@ -57,13 +136,15 @@ const listPagesTool = defineTool({
57
136
  });
58
137
 
59
138
  const selectPageTool = defineTool({
60
- name: "chrome_devtools_select_page",
139
+ name: CHROME_DEVTOOLS_TOOL_NAMES[1],
61
140
  label: "Chrome DevTools: Select Page",
62
141
  description: "Select the active Chrome page for later chrome_devtools_* tool calls.",
63
142
  promptSnippet: "Select the Chrome tab/page to inspect or control",
64
143
  parameters: Type.Object({
65
144
  pageId: Type.String({ description: "Page id from chrome_devtools_list_pages." }),
66
145
  }),
146
+ renderCall: renderToolCall("select page"),
147
+ renderResult: renderTextResult,
67
148
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
68
149
  return withStatus(ctx, "🌐 select page", async () => {
69
150
  const page = await getPage(params.pageId);
@@ -76,7 +157,7 @@ const selectPageTool = defineTool({
76
157
  });
77
158
 
78
159
  const navigateTool = defineTool({
79
- name: "chrome_devtools_navigate",
160
+ name: CHROME_DEVTOOLS_TOOL_NAMES[2],
80
161
  label: "Chrome DevTools: Navigate",
81
162
  description:
82
163
  "Navigate a Chrome page to a URL through Chrome DevTools Protocol, creating a page first if none is available.",
@@ -87,6 +168,8 @@ const navigateTool = defineTool({
87
168
  Type.String({ description: "Optional page id. Defaults to selected or first page." }),
88
169
  ),
89
170
  }),
171
+ renderCall: renderToolCall("navigate"),
172
+ renderResult: renderTextResult,
90
173
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
91
174
  return withStatus(ctx, "🌐 navigate", async () => {
92
175
  const { created, page } = await resolvePageForNavigation(params.pageId);
@@ -107,7 +190,7 @@ const navigateTool = defineTool({
107
190
  });
108
191
 
109
192
  const evaluateTool = defineTool({
110
- name: "chrome_devtools_evaluate",
193
+ name: CHROME_DEVTOOLS_TOOL_NAMES[3],
111
194
  label: "Chrome DevTools: Evaluate",
112
195
  description: "Evaluate JavaScript in a Chrome page through Chrome DevTools Protocol.",
113
196
  promptSnippet: "Evaluate JavaScript in the selected Chrome tab",
@@ -120,6 +203,8 @@ const evaluateTool = defineTool({
120
203
  Type.Boolean({ description: "Whether to await a returned Promise. Defaults to true." }),
121
204
  ),
122
205
  }),
206
+ renderCall: renderToolCall("evaluate"),
207
+ renderResult: renderTextResult,
123
208
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
124
209
  return withStatus(ctx, "🌐 evaluate", async () => {
125
210
  const page = await resolvePage(params.pageId);
@@ -138,7 +223,7 @@ const evaluateTool = defineTool({
138
223
  });
139
224
 
140
225
  const screenshotTool = defineTool({
141
- name: "chrome_devtools_screenshot",
226
+ name: CHROME_DEVTOOLS_TOOL_NAMES[4],
142
227
  label: "Chrome DevTools: Screenshot",
143
228
  description: "Capture a PNG screenshot from a Chrome page through Chrome DevTools Protocol.",
144
229
  promptSnippet: "Capture a screenshot from the selected Chrome tab",
@@ -150,6 +235,8 @@ const screenshotTool = defineTool({
150
235
  Type.Boolean({ description: "Capture the full document, not just the viewport." }),
151
236
  ),
152
237
  }),
238
+ renderCall: renderToolCall("screenshot"),
239
+ renderResult: renderScreenshotResult,
153
240
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
154
241
  return withStatus(ctx, "🌐 screenshot", async () => {
155
242
  const page = await resolvePage(params.pageId);
@@ -197,14 +284,23 @@ export default function chromeDevtools(pi: ExtensionAPI) {
197
284
  pi.registerTool(screenshotTool);
198
285
 
199
286
  pi.registerCommand("chrome-devtools", {
200
- description: "Show Chrome DevTools endpoint and quick start help",
201
- handler: async (_args, ctx) => {
202
- ctx.ui.notify(`Chrome DevTools endpoint: ${devToolsEndpoint()}\n${launchHint()}`, "info");
287
+ description: "Open Chrome DevTools help and tool controls",
288
+ getArgumentCompletions: (prefix) => commandCompletions(prefix),
289
+ handler: async (args, ctx) => {
290
+ await handleChromeDevtoolsCommand(pi, args, ctx);
203
291
  },
204
292
  });
205
293
 
206
- pi.on("session_start", (_event, ctx) => {
294
+ pi.on("session_start", async (_event, ctx) => {
207
295
  ctx.ui.setStatus(STATUS_KEY, undefined);
296
+ const settings = await loadSettings();
297
+ if (settings.kind === "loaded") {
298
+ applyChromeDevtoolsTools(pi, settings.settings.tools);
299
+ return;
300
+ }
301
+ if (settings.kind === "invalid") {
302
+ ctx.ui.notify(`Chrome DevTools settings ignored: ${settings.reason}`, "warning");
303
+ }
208
304
  });
209
305
 
210
306
  pi.on("session_shutdown", (_event, ctx) => {
@@ -212,6 +308,450 @@ export default function chromeDevtools(pi: ExtensionAPI) {
212
308
  });
213
309
  }
214
310
 
311
+ async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx: CommandContext) {
312
+ const command = parseCommand(args);
313
+ switch (command) {
314
+ case "menu":
315
+ await showMenu(pi, ctx);
316
+ return;
317
+ case "help":
318
+ ctx.ui.notify(buildCommandGuide(), "info");
319
+ return;
320
+ case "quickstart":
321
+ ctx.ui.notify(buildQuickstartMessage(), "info");
322
+ return;
323
+ case "status":
324
+ ctx.ui.notify(await buildToolStatusMessage(pi), "info");
325
+ return;
326
+ case "tools":
327
+ await showToolSelector(pi, ctx);
328
+ return;
329
+ case "enable":
330
+ await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "enabled all");
331
+ return;
332
+ case "disable":
333
+ await updateChromeDevtoolsTools(pi, ctx, [], "disabled all");
334
+ return;
335
+ }
336
+
337
+ ctx.ui.notify(`Unknown /chrome-devtools command: ${args.trim()}
338
+
339
+ ${buildCommandGuide()}`, "warning");
340
+ }
341
+
342
+ async function showMenu(pi: ExtensionAPI, ctx: CommandContext) {
343
+ if (!ctx.hasUI) {
344
+ ctx.ui.notify(`${buildCommandGuide()}
345
+
346
+ ${await buildToolStatusMessage(pi)}`, "info");
347
+ return;
348
+ }
349
+
350
+ const choice = await ctx.ui.select("Chrome DevTools", Object.values(MENU_OPTIONS));
351
+ switch (choice) {
352
+ case MENU_OPTIONS.quickstart:
353
+ ctx.ui.notify(buildQuickstartMessage(), "info");
354
+ return;
355
+ case MENU_OPTIONS.help:
356
+ ctx.ui.notify(buildCommandGuide(), "info");
357
+ return;
358
+ case MENU_OPTIONS.status:
359
+ ctx.ui.notify(await buildToolStatusMessage(pi), "info");
360
+ return;
361
+ case MENU_OPTIONS.tools:
362
+ await showToolSelector(pi, ctx);
363
+ return;
364
+ case MENU_OPTIONS.enable:
365
+ await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "enabled all");
366
+ return;
367
+ case MENU_OPTIONS.disable:
368
+ await updateChromeDevtoolsTools(pi, ctx, [], "disabled all");
369
+ return;
370
+ }
371
+ }
372
+
373
+ function parseCommand(args: string): CommandAction | "unknown" {
374
+ const command = args.trim().toLowerCase();
375
+ if (!command) return "menu";
376
+ if (command === "help") return "help";
377
+ if (command === "quickstart") return "quickstart";
378
+ if (command === "status") return "status";
379
+ if (command === "tools" || command === "select" || command === "toggle") return "tools";
380
+ if (command === "enable" || command === "on") return "enable";
381
+ if (command === "disable" || command === "off") return "disable";
382
+ return "unknown";
383
+ }
384
+
385
+ function commandCompletions(prefix: string) {
386
+ const normalized = prefix.trim().toLowerCase();
387
+ if (normalized.includes(" ")) return null;
388
+
389
+ const matches = COMMAND_COMPLETIONS.filter((completion) =>
390
+ completion.value.startsWith(normalized),
391
+ );
392
+ return matches.length > 0 ? matches : null;
393
+ }
394
+
395
+ async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
396
+ if (!ctx.hasUI) {
397
+ ctx.ui.notify(
398
+ `Chrome DevTools tool selection needs an interactive UI.
399
+
400
+ ${await buildToolStatusMessage(pi)}`,
401
+ "info",
402
+ );
403
+ return;
404
+ }
405
+
406
+ let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
407
+ let persistQueue = Promise.resolve();
408
+ const commitSelectedTools = () => {
409
+ const nextSelectedTools = orderedChromeDevtoolsTools(selectedTools);
410
+ applyChromeDevtoolsTools(pi, nextSelectedTools);
411
+ persistQueue = persistQueue.then(() => persistSettings(ctx, nextSelectedTools));
412
+ };
413
+
414
+ const customResult = await ctx.ui.custom<"closed" | undefined>(
415
+ (tui, theme, keybindings, done) => {
416
+ const rows = chromeDevtoolsToolSelectorRows();
417
+ let selectedIndex = 0;
418
+ const moveSelection = (delta: number) => {
419
+ selectedIndex = (selectedIndex + delta + rows.length) % rows.length;
420
+ };
421
+ const activateSelectedRow = () => {
422
+ const row = rows[selectedIndex];
423
+ if (!row) return;
424
+
425
+ if (row.kind === "tool") {
426
+ if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
427
+ else selectedTools.add(row.toolName);
428
+ commitSelectedTools();
429
+ return;
430
+ }
431
+
432
+ if (row.action === "enableAll") {
433
+ selectedTools = new Set(allChromeDevtoolsTools());
434
+ commitSelectedTools();
435
+ return;
436
+ }
437
+ if (row.action === "disableAll") {
438
+ selectedTools = new Set();
439
+ commitSelectedTools();
440
+ return;
441
+ }
442
+
443
+ done("closed");
444
+ };
445
+
446
+ return {
447
+ invalidate() {},
448
+ render() {
449
+ return [
450
+ theme.fg("accent", theme.bold(toolSelectorTitle(selectedTools))),
451
+ "",
452
+ ...rows.map((row, index) => {
453
+ const label = formatToolSelectorRow(row, selectedTools);
454
+ if (index === selectedIndex) {
455
+ return `${theme.fg("accent", "›")} ${theme.fg("accent", label)}`;
456
+ }
457
+ return ` ${label}`;
458
+ }),
459
+ "",
460
+ theme.fg("dim", "↑↓ navigate • Enter/Space toggle • Esc close"),
461
+ ];
462
+ },
463
+ handleInput(data: string) {
464
+ if (keybindings.matches(data, "tui.select.up")) {
465
+ moveSelection(-1);
466
+ tui.requestRender();
467
+ return;
468
+ }
469
+ if (keybindings.matches(data, "tui.select.down")) {
470
+ moveSelection(1);
471
+ tui.requestRender();
472
+ return;
473
+ }
474
+ if (keybindings.matches(data, "tui.select.pageUp")) {
475
+ selectedIndex = 0;
476
+ tui.requestRender();
477
+ return;
478
+ }
479
+ if (keybindings.matches(data, "tui.select.pageDown")) {
480
+ selectedIndex = rows.length - 1;
481
+ tui.requestRender();
482
+ return;
483
+ }
484
+ if (keybindings.matches(data, "tui.select.confirm") || data === " ") {
485
+ activateSelectedRow();
486
+ tui.requestRender();
487
+ return;
488
+ }
489
+ if (keybindings.matches(data, "tui.select.cancel")) {
490
+ done("closed");
491
+ }
492
+ },
493
+ };
494
+ },
495
+ );
496
+
497
+ if (customResult !== "closed") {
498
+ await showDialogToolSelector(pi, ctx);
499
+ return;
500
+ }
501
+
502
+ await persistQueue;
503
+ ctx.ui.notify(await buildToolStatusMessage(pi), "info");
504
+ }
505
+
506
+ async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
507
+ let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
508
+ while (true) {
509
+ const rows = chromeDevtoolsToolSelectorRows();
510
+ const choices = rows.map((row) => formatToolSelectorRow(row, selectedTools));
511
+ const choice = await ctx.ui.select(toolSelectorTitle(selectedTools), choices);
512
+ if (!choice) break;
513
+
514
+ const row = rows[choices.indexOf(choice)];
515
+ if (!row) continue;
516
+ if (row.kind === "action" && row.action === "done") break;
517
+
518
+ if (row.kind === "tool") {
519
+ if (selectedTools.has(row.toolName)) selectedTools.delete(row.toolName);
520
+ else selectedTools.add(row.toolName);
521
+ } else if (row.action === "enableAll") {
522
+ selectedTools = new Set(allChromeDevtoolsTools());
523
+ } else if (row.action === "disableAll") {
524
+ selectedTools = new Set();
525
+ }
526
+
527
+ await setSelectedChromeDevtoolsTools(pi, ctx, orderedChromeDevtoolsTools(selectedTools));
528
+ }
529
+
530
+ ctx.ui.notify(await buildToolStatusMessage(pi), "info");
531
+ }
532
+
533
+ async function updateChromeDevtoolsTools(
534
+ pi: ExtensionAPI,
535
+ ctx: CommandContext,
536
+ selectedTools: readonly ChromeDevToolsToolName[],
537
+ action: string,
538
+ ) {
539
+ await setSelectedChromeDevtoolsTools(pi, ctx, selectedTools);
540
+ ctx.ui.notify(`Chrome DevTools tools ${action}.
541
+
542
+ ${await buildToolStatusMessage(pi)}`, "info");
543
+ }
544
+
545
+ async function setSelectedChromeDevtoolsTools(
546
+ pi: ExtensionAPI,
547
+ ctx: CommandContext,
548
+ selectedTools: readonly ChromeDevToolsToolName[],
549
+ ) {
550
+ applyChromeDevtoolsTools(pi, selectedTools);
551
+ await persistSettings(ctx, selectedTools);
552
+ }
553
+
554
+ function applyChromeDevtoolsTools(
555
+ pi: ExtensionAPI,
556
+ selectedTools: readonly ChromeDevToolsToolName[],
557
+ ) {
558
+ const activeToolNames = pi.getActiveTools();
559
+ const chromeToolNames = new Set<string>(CHROME_DEVTOOLS_TOOL_NAMES);
560
+ const activeNonChromeToolNames = activeToolNames.filter((name) => !chromeToolNames.has(name));
561
+ pi.setActiveTools(unique([...activeNonChromeToolNames, ...selectedTools]));
562
+ }
563
+
564
+ function getToolStatusSummary(pi: ExtensionAPI): ToolStatusSummary {
565
+ const chromeToolNames = new Set<string>(CHROME_DEVTOOLS_TOOL_NAMES);
566
+ const activeToolNames = new Set(pi.getActiveTools());
567
+ const activeChromeToolCount = CHROME_DEVTOOLS_TOOL_NAMES.filter((name) =>
568
+ activeToolNames.has(name),
569
+ ).length;
570
+ const activeNonChromeToolCount = Array.from(activeToolNames).filter(
571
+ (name) => !chromeToolNames.has(name),
572
+ ).length;
573
+ const runtimeStatus =
574
+ activeChromeToolCount === CHROME_DEVTOOLS_TOOL_NAMES.length
575
+ ? "enabled"
576
+ : activeChromeToolCount === 0
577
+ ? "disabled"
578
+ : "partial";
579
+
580
+ return { runtimeStatus, activeChromeToolCount, activeNonChromeToolCount };
581
+ }
582
+
583
+ async function buildToolStatusMessage(pi: ExtensionAPI) {
584
+ const summary = getToolStatusSummary(pi);
585
+ const persistedSetting = await persistedSettingLabel();
586
+ return [
587
+ `Chrome DevTools tools: ${formatRuntimeStatus(summary)}`,
588
+ `Persisted selection: ${persistedSetting}`,
589
+ `Settings file: ${SETTINGS_FILE}`,
590
+ `Other active tools preserved: ${summary.activeNonChromeToolCount}`,
591
+ `Endpoint: ${devToolsEndpoint()}`,
592
+ ].join("\n");
593
+ }
594
+
595
+ function buildQuickstartMessage() {
596
+ return [
597
+ `Chrome DevTools endpoint: ${devToolsEndpoint()}`,
598
+ launchHint(),
599
+ endpointConfigHint(),
600
+ ].join("\n");
601
+ }
602
+
603
+ function buildCommandGuide() {
604
+ return [
605
+ "Chrome DevTools commands:",
606
+ "/chrome-devtools — open this menu",
607
+ "/chrome-devtools help — show command usage",
608
+ "/chrome-devtools quickstart — show endpoint and launch help",
609
+ "/chrome-devtools status — show tool and settings status",
610
+ "/chrome-devtools tools — select individual Chrome DevTools tools",
611
+ "/chrome-devtools toggle — alias for /chrome-devtools tools",
612
+ "/chrome-devtools enable — enable all Chrome DevTools tools",
613
+ "/chrome-devtools disable — disable all Chrome DevTools tools",
614
+ ].join("\n");
615
+ }
616
+
617
+ function toolSelectorTitle(selectedTools: ReadonlySet<ChromeDevToolsToolName>) {
618
+ return `Chrome DevTools tools (${selectedTools.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}). Non-built-in tools run at user risk.`;
619
+ }
620
+
621
+ function chromeDevtoolsToolSelectorRows(): ToolSelectorRow[] {
622
+ return [
623
+ ...CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({ kind: "tool" as const, toolName })),
624
+ { kind: "action", action: "enableAll", label: TOOL_SELECTOR_ENABLE_ALL },
625
+ { kind: "action", action: "disableAll", label: TOOL_SELECTOR_DISABLE_ALL },
626
+ { kind: "action", action: "done", label: TOOL_SELECTOR_DONE },
627
+ ];
628
+ }
629
+
630
+ function formatToolSelectorRow(
631
+ row: ToolSelectorRow,
632
+ selectedTools: ReadonlySet<ChromeDevToolsToolName>,
633
+ ) {
634
+ if (row.kind === "action") return row.label;
635
+ return `${selectedTools.has(row.toolName) ? "[x]" : "[ ]"} ${row.toolName}`;
636
+ }
637
+
638
+ function getActiveChromeDevtoolsTools(pi: ExtensionAPI) {
639
+ const activeToolNames = new Set(pi.getActiveTools());
640
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => activeToolNames.has(toolName));
641
+ }
642
+
643
+ function allChromeDevtoolsTools() {
644
+ return [...CHROME_DEVTOOLS_TOOL_NAMES];
645
+ }
646
+
647
+ function orderedChromeDevtoolsTools(selectedTools: ReadonlySet<ChromeDevToolsToolName>) {
648
+ return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
649
+ }
650
+
651
+ function formatRuntimeStatus(summary: ToolStatusSummary) {
652
+ return `${summary.runtimeStatus} (${summary.activeChromeToolCount}/${CHROME_DEVTOOLS_TOOL_NAMES.length} active)`;
653
+ }
654
+
655
+ async function persistedSettingLabel() {
656
+ const settings = await loadSettings();
657
+ if (settings.kind === "loaded") return formatPersistedSelection(settings.settings.tools);
658
+ if (settings.kind === "invalid") {
659
+ return `none; current active-tool policy preserved (invalid settings ignored: ${settings.reason})`;
660
+ }
661
+ return "none; current active-tool policy preserved";
662
+ }
663
+
664
+ function formatPersistedSelection(tools: readonly ChromeDevToolsToolName[]) {
665
+ if (tools.length === CHROME_DEVTOOLS_TOOL_NAMES.length) {
666
+ return `all enabled (${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected)`;
667
+ }
668
+ if (tools.length === 0) return `all disabled (0/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected)`;
669
+ return `${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected: ${tools.join(", ")}`;
670
+ }
671
+
672
+ async function persistSettings(
673
+ ctx: CommandContext,
674
+ selectedTools: readonly ChromeDevToolsToolName[],
675
+ ) {
676
+ try {
677
+ await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
678
+ } catch (error) {
679
+ ctx.ui.notify(`Chrome DevTools settings save failed: ${formatError(error)}`, "warning");
680
+ }
681
+ }
682
+
683
+ async function loadSettings(): Promise<
684
+ | { kind: "missing" }
685
+ | { kind: "invalid"; reason: string }
686
+ | { kind: "loaded"; settings: ChromeDevToolsSettings }
687
+ > {
688
+ let text: string;
689
+ try {
690
+ text = await readFile(SETTINGS_FILE, "utf8");
691
+ } catch (error) {
692
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
693
+ return { kind: "invalid", reason: formatError(error) };
694
+ }
695
+
696
+ try {
697
+ const parsed = JSON.parse(text) as unknown;
698
+ const settings = normalizeChromeDevtoolsSettings(parsed);
699
+ if (settings) return { kind: "loaded", settings };
700
+ return { kind: "invalid", reason: "expected tools to be an array of Chrome DevTools tool names" };
701
+ } catch (error) {
702
+ return { kind: "invalid", reason: formatError(error) };
703
+ }
704
+ }
705
+
706
+ function normalizeChromeDevtoolsSettings(value: unknown): ChromeDevToolsSettings | undefined {
707
+ if (!value || typeof value !== "object") return undefined;
708
+ const settings = value as { tools?: unknown; updatedAt?: unknown };
709
+ if (typeof settings.updatedAt !== "number") return undefined;
710
+
711
+ if (settings.tools === "enabled") {
712
+ return { tools: allChromeDevtoolsTools(), updatedAt: settings.updatedAt };
713
+ }
714
+ if (settings.tools === "disabled") return { tools: [], updatedAt: settings.updatedAt };
715
+
716
+ if (!Array.isArray(settings.tools)) return undefined;
717
+ if (!settings.tools.every(isChromeDevtoolsToolName)) return undefined;
718
+ return { tools: orderedUniqueChromeDevtoolsTools(settings.tools), updatedAt: settings.updatedAt };
719
+ }
720
+
721
+ function isChromeDevtoolsToolName(value: unknown): value is ChromeDevToolsToolName {
722
+ return typeof value === "string" && CHROME_DEVTOOLS_TOOL_NAMES.includes(value as never);
723
+ }
724
+
725
+ function orderedUniqueChromeDevtoolsTools(tools: readonly ChromeDevToolsToolName[]) {
726
+ const selectedTools = new Set(tools);
727
+ return orderedChromeDevtoolsTools(selectedTools);
728
+ }
729
+
730
+ async function saveSettings(settings: ChromeDevToolsSettings) {
731
+ await mkdir(dirname(SETTINGS_FILE), { recursive: true });
732
+ const tempFile = `${SETTINGS_FILE}.${process.pid}.${Date.now()}.tmp`;
733
+ try {
734
+ await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}
735
+ `, "utf8");
736
+ await rename(tempFile, SETTINGS_FILE);
737
+ } catch (error) {
738
+ await rm(tempFile, { force: true }).catch(() => undefined);
739
+ throw error;
740
+ }
741
+ }
742
+
743
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
744
+ return error instanceof Error && "code" in error;
745
+ }
746
+
747
+ function formatError(error: unknown) {
748
+ return error instanceof Error ? error.message : String(error);
749
+ }
750
+
751
+ function unique<T>(values: T[]) {
752
+ return Array.from(new Set(values));
753
+ }
754
+
215
755
  async function listPages(options: { waitMs?: number } = {}) {
216
756
  return withEndpointRetry(async () => {
217
757
  const pages = await fetchDevToolsJson<DevToolsPage[]>("/json/list");
@@ -405,6 +945,77 @@ function textResult(text: string, details: unknown) {
405
945
  };
406
946
  }
407
947
 
948
+ function renderToolCall(action: string) {
949
+ return () => new PiTextComponent(`Chrome DevTools: ${action}`);
950
+ }
951
+
952
+ function renderTextResult(
953
+ result: AgentToolResult<unknown>,
954
+ options: ToolRenderResultOptions,
955
+ theme: RenderTheme,
956
+ ) {
957
+ const output = formatCollapsibleOutput(textContent(result), options);
958
+ return new PiTextComponent(output.text, theme, output.color);
959
+ }
960
+
961
+ function renderScreenshotResult(
962
+ result: AgentToolResult<unknown>,
963
+ options: ToolRenderResultOptions,
964
+ theme: RenderTheme,
965
+ ): RenderComponent {
966
+ const output = formatCollapsibleOutput(textContent(result), options);
967
+ return new PiTextComponent(output.text, theme, output.color);
968
+ }
969
+
970
+ function textContent(result: AgentToolResult<unknown>) {
971
+ return result.content
972
+ .flatMap((content) => (content.type === "text" ? [content.text] : []))
973
+ .join("\n");
974
+ }
975
+
976
+ function formatCollapsibleOutput(
977
+ text: string,
978
+ options: ToolRenderResultOptions,
979
+ ): { text: string; color?: string } {
980
+ if (options.isPartial) return { text: "Running...", color: "warning" };
981
+ if (options.expanded) return { text, color: "toolOutput" };
982
+
983
+ return { text: "" };
984
+ }
985
+
986
+ class PiTextComponent implements RenderComponent {
987
+ constructor(
988
+ private text = "",
989
+ private readonly theme?: RenderTheme,
990
+ private readonly color?: string,
991
+ ) {}
992
+
993
+ setText(text: string) {
994
+ this.text = text;
995
+ }
996
+
997
+ invalidate() {
998
+ // Stateless renderer: no cached layout to invalidate.
999
+ }
1000
+
1001
+ render(width: number) {
1002
+ if (!this.text.trim()) return [];
1003
+ return this.text
1004
+ .replace(/\t/g, " ")
1005
+ .split(/\r?\n/)
1006
+ .map((line) => {
1007
+ const truncatedLine = truncateLine(line, Math.max(1, width));
1008
+ return this.theme && this.color
1009
+ ? this.theme.fg(this.color, truncatedLine)
1010
+ : truncatedLine;
1011
+ });
1012
+ }
1013
+ }
1014
+
1015
+ function truncateLine(line: string, maxWidth: number) {
1016
+ return Array.from(line).slice(0, maxWidth).join("");
1017
+ }
1018
+
408
1019
  async function withStatus<T>(ctx: StatusContext, status: string, callback: () => Promise<T>) {
409
1020
  ctx.ui.setStatus(STATUS_KEY, status);
410
1021
  try {