Skip to content

Commit 327f0be

Browse files
authored
Merge pull request #66 from narumiruna/fix/pr-63-codex-review
feat(chrome-devtools): make tool output collapsible
2 parents 8a79f82 + 6454954 commit 327f0be

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

extensions/pi-chrome-devtools/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
1616
- Recovers from stale active page selections by falling back to an available page.
1717
- Evaluates JavaScript in the selected page.
1818
- Captures PNG screenshots, including optional full-page screenshots.
19+
- Renders compact tool results that expand/collapse with Pi's default output toggle (`Ctrl+O`).
1920
- Uses a local Chrome DevTools Protocol endpoint.
2021
- Retries briefly while Chrome is starting and reports actionable endpoint errors.
2122
- Shows statusline activity only while Chrome DevTools tools are running.

extensions/pi-chrome-devtools/src/chrome-devtools.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
1+
import {
2+
defineTool,
3+
type AgentToolResult,
4+
type ExtensionAPI,
5+
type ToolRenderResultOptions,
6+
} from "@mariozechner/pi-coding-agent";
27
import { Type } from "typebox";
38

49
const DEFAULT_HOST = "127.0.0.1";
@@ -13,6 +18,16 @@ interface StatusContext {
1318
ui: { setStatus: (key: string, value: string | undefined) => void };
1419
}
1520

21+
interface RenderTheme {
22+
bold(text: string): string;
23+
fg(color: string, text: string): string;
24+
}
25+
26+
interface RenderComponent {
27+
invalidate(): void;
28+
render(width: number): string[];
29+
}
30+
1631
interface DevToolsPage {
1732
id: string;
1833
type: string;
@@ -48,6 +63,8 @@ const listPagesTool = defineTool({
4863
description: "List Chrome tabs/pages from a running Chrome DevTools Protocol endpoint.",
4964
promptSnippet: "List Chrome tabs/pages available over Chrome DevTools Protocol",
5065
parameters: Type.Object({}),
66+
renderCall: renderToolCall("list pages"),
67+
renderResult: renderTextResult,
5168
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
5269
return withStatus(ctx, "🌐 list pages", async () => {
5370
const pages = await listPages();
@@ -64,6 +81,8 @@ const selectPageTool = defineTool({
6481
parameters: Type.Object({
6582
pageId: Type.String({ description: "Page id from chrome_devtools_list_pages." }),
6683
}),
84+
renderCall: renderToolCall("select page"),
85+
renderResult: renderTextResult,
6786
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
6887
return withStatus(ctx, "🌐 select page", async () => {
6988
const page = await getPage(params.pageId);
@@ -87,6 +106,8 @@ const navigateTool = defineTool({
87106
Type.String({ description: "Optional page id. Defaults to selected or first page." }),
88107
),
89108
}),
109+
renderCall: renderToolCall("navigate"),
110+
renderResult: renderTextResult,
90111
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
91112
return withStatus(ctx, "🌐 navigate", async () => {
92113
const { created, page } = await resolvePageForNavigation(params.pageId);
@@ -120,6 +141,8 @@ const evaluateTool = defineTool({
120141
Type.Boolean({ description: "Whether to await a returned Promise. Defaults to true." }),
121142
),
122143
}),
144+
renderCall: renderToolCall("evaluate"),
145+
renderResult: renderTextResult,
123146
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
124147
return withStatus(ctx, "🌐 evaluate", async () => {
125148
const page = await resolvePage(params.pageId);
@@ -150,6 +173,8 @@ const screenshotTool = defineTool({
150173
Type.Boolean({ description: "Capture the full document, not just the viewport." }),
151174
),
152175
}),
176+
renderCall: renderToolCall("screenshot"),
177+
renderResult: renderScreenshotResult,
153178
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
154179
return withStatus(ctx, "🌐 screenshot", async () => {
155180
const page = await resolvePage(params.pageId);
@@ -405,6 +430,77 @@ function textResult(text: string, details: unknown) {
405430
};
406431
}
407432

433+
function renderToolCall(action: string) {
434+
return () => new PiTextComponent(`Chrome DevTools: ${action}`);
435+
}
436+
437+
function renderTextResult(
438+
result: AgentToolResult<unknown>,
439+
options: ToolRenderResultOptions,
440+
theme: RenderTheme,
441+
) {
442+
const output = formatCollapsibleOutput(textContent(result), options);
443+
return new PiTextComponent(output.text, theme, output.color);
444+
}
445+
446+
function renderScreenshotResult(
447+
result: AgentToolResult<unknown>,
448+
options: ToolRenderResultOptions,
449+
theme: RenderTheme,
450+
): RenderComponent {
451+
const output = formatCollapsibleOutput(textContent(result), options);
452+
return new PiTextComponent(output.text, theme, output.color);
453+
}
454+
455+
function textContent(result: AgentToolResult<unknown>) {
456+
return result.content
457+
.flatMap((content) => (content.type === "text" ? [content.text] : []))
458+
.join("\n");
459+
}
460+
461+
function formatCollapsibleOutput(
462+
text: string,
463+
options: ToolRenderResultOptions,
464+
): { text: string; color?: string } {
465+
if (options.isPartial) return { text: "Running...", color: "warning" };
466+
if (options.expanded) return { text, color: "toolOutput" };
467+
468+
return { text: "" };
469+
}
470+
471+
class PiTextComponent implements RenderComponent {
472+
constructor(
473+
private text = "",
474+
private readonly theme?: RenderTheme,
475+
private readonly color?: string,
476+
) {}
477+
478+
setText(text: string) {
479+
this.text = text;
480+
}
481+
482+
invalidate() {
483+
// Stateless renderer: no cached layout to invalidate.
484+
}
485+
486+
render(width: number) {
487+
if (!this.text.trim()) return [];
488+
return this.text
489+
.replace(/\t/g, " ")
490+
.split(/\r?\n/)
491+
.map((line) => {
492+
const truncatedLine = truncateLine(line, Math.max(1, width));
493+
return this.theme && this.color
494+
? this.theme.fg(this.color, truncatedLine)
495+
: truncatedLine;
496+
});
497+
}
498+
}
499+
500+
function truncateLine(line: string, maxWidth: number) {
501+
return Array.from(line).slice(0, maxWidth).join("");
502+
}
503+
408504
async function withStatus<T>(ctx: StatusContext, status: string, callback: () => Promise<T>) {
409505
ctx.ui.setStatus(STATUS_KEY, status);
410506
try {

0 commit comments

Comments
 (0)