@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 +38 -1
- package/package.json +1 -1
- package/src/chrome-devtools.ts +621 -10
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
|
-
|
|
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
package/src/chrome-devtools.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: "
|
|
201
|
-
|
|
202
|
-
|
|
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 {
|