From 35bb984a74f3d1574b5b47cafb06036e139453f4 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Sat, 8 Aug 2026 01:13:07 -0700 Subject: [PATCH 1/2] Make pi-web extension API discoverable to agents --- examples/pi-web-extensions/download-artifact.ts | 1 + examples/pi-web-extensions/git-footer.ts | 1 + examples/pi-web-extensions/github-repo-panel.ts | 1 + examples/pi-web-extensions/recap.ts | 1 + server/session/service.ts | 11 ++++++++++- tests/context.test.ts | 12 ++++++++++++ 6 files changed, 26 insertions(+), 1 deletion(-) diff --git a/examples/pi-web-extensions/download-artifact.ts b/examples/pi-web-extensions/download-artifact.ts index ff8d62a..6d7aa45 100644 --- a/examples/pi-web-extensions/download-artifact.ts +++ b/examples/pi-web-extensions/download-artifact.ts @@ -1,3 +1,4 @@ +// Uses typed compatibility wrappers; see notepad.ts for the current contribute() API. import type { PiWebExtensionAPI, PiWebExtensionContext } from "@ashwin-pc/pi-web/extensions"; const ACTION_KEY = "download-artifact"; diff --git a/examples/pi-web-extensions/git-footer.ts b/examples/pi-web-extensions/git-footer.ts index fc3f709..a2b2b1a 100644 --- a/examples/pi-web-extensions/git-footer.ts +++ b/examples/pi-web-extensions/git-footer.ts @@ -1,3 +1,4 @@ +// Uses typed compatibility wrappers; see notepad.ts for the current contribute() API. import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { PiWebExtensionAPI, PiWebExtensionContext } from "@ashwin-pc/pi-web/extensions"; diff --git a/examples/pi-web-extensions/github-repo-panel.ts b/examples/pi-web-extensions/github-repo-panel.ts index b8e7a1f..5c26e5f 100644 --- a/examples/pi-web-extensions/github-repo-panel.ts +++ b/examples/pi-web-extensions/github-repo-panel.ts @@ -1,3 +1,4 @@ +// Uses typed compatibility wrappers; see notepad.ts for the current contribute() API. import type { PiWebExtensionAPI, PiWebExtensionContext, PiWebGitTabEvent } from "@ashwin-pc/pi-web/extensions"; const GIT_TAB_KEY = "github"; diff --git a/examples/pi-web-extensions/recap.ts b/examples/pi-web-extensions/recap.ts index 130970e..54d6de0 100644 --- a/examples/pi-web-extensions/recap.ts +++ b/examples/pi-web-extensions/recap.ts @@ -1,3 +1,4 @@ +// Uses typed compatibility wrappers; see notepad.ts for the current contribute() API. import { generateSummary } from "@earendil-works/pi-coding-agent"; import { buildSessionContext } from "@earendil-works/pi-coding-agent"; import type { PiWebExtensionAPI, PiWebExtensionContext } from "@ashwin-pc/pi-web/extensions"; diff --git a/server/session/service.ts b/server/session/service.ts index 538087c..290f2eb 100644 --- a/server/session/service.ts +++ b/server/session/service.ts @@ -618,7 +618,16 @@ export class LocalSessionService implements SessionService { this.knownSessionCwds.add(resolve(resolvedCwd)); await this.ensureStorage(resolvedCwd); const contextPath = fileURLToPath(new URL("../../contexts/web-ui.md", import.meta.url)); - const webUiContext = existsSync(contextPath) ? readFileSync(contextPath, "utf8") : ""; + const appDir = dirname(dirname(contextPath)); + const extensionAuthoringContext = [ + "pi-web extension documentation (read when asked to build pi-web extensions or browser UI):", + `- API + slots: ${join(appDir, "docs/pi-web-extensions.md")}`, + `- Examples: ${join(appDir, "examples/pi-web-extensions")} (notepad.ts shows the current contribute() API)`, + ].join("\n"); + const webUiContext = [ + existsSync(contextPath) ? readFileSync(contextPath, "utf8") : "", + extensionAuthoringContext, + ].filter(Boolean).join("\n\n"); const loader = new ResilientResourceLoader({ loadTimeoutMs: envMs("PI_WEB_EXTENSION_LOAD_TIMEOUT_MS", 8_000), fetchTimeoutMs: envMs("PI_WEB_EXTENSION_FETCH_TIMEOUT_MS", 3_000), diff --git a/tests/context.test.ts b/tests/context.test.ts index b542e4b..0c740b1 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -41,10 +41,22 @@ describe("agent context organization", () => { expect(service).toContain('new URL("../../contexts/web-ui.md", import.meta.url)'); expect(service).toContain("appendSystemPromptOverride"); expect(service).toContain("webUiContext"); + expect(service).toContain("pi-web extension documentation (read when asked to build pi-web extensions or browser UI)"); + expect(service).toContain('join(appDir, "docs/pi-web-extensions.md")'); + expect(service).toContain('join(appDir, "examples/pi-web-extensions")'); + expect(service).toContain("notepad.ts shows the current contribute() API"); expect(service).not.toContain("piWebDevelopmentContextFile"); expect(service).not.toContain("pi-web-development.md"); expect(service).not.toContain("piCwd === appDir ?"); expect(service).not.toContain("pi-web-agent-context.md"); }); + + it("labels compatibility-wrapper examples and points authors to the current API exemplar", async () => { + const legacyExamples = ["git-footer.ts", "recap.ts", "download-artifact.ts", "github-repo-panel.ts"]; + for (const filename of legacyExamples) { + const example = await text(`examples/pi-web-extensions/${filename}`); + expect(example).toContain("Uses typed compatibility wrappers; see notepad.ts for the current contribute() API."); + } + }); }); From df6789a50d7333ba54289d68546b1e97a7e917bb Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Sat, 8 Aug 2026 01:33:18 -0700 Subject: [PATCH 2/2] Migrate bundled UI examples to contribute API --- .../pi-web-extensions/download-artifact.ts | 11 ++++++---- examples/pi-web-extensions/git-footer.ts | 9 +++++--- .../pi-web-extensions/github-repo-panel.ts | 15 ++++++++----- examples/pi-web-extensions/recap.ts | 9 ++++---- tests/context.test.ts | 9 ++++---- tests/extensions.test.ts | 22 ++++++++++++------- 6 files changed, 47 insertions(+), 28 deletions(-) diff --git a/examples/pi-web-extensions/download-artifact.ts b/examples/pi-web-extensions/download-artifact.ts index 6d7aa45..b2b6795 100644 --- a/examples/pi-web-extensions/download-artifact.ts +++ b/examples/pi-web-extensions/download-artifact.ts @@ -1,18 +1,21 @@ -// Uses typed compatibility wrappers; see notepad.ts for the current contribute() API. import type { PiWebExtensionAPI, PiWebExtensionContext } from "@ashwin-pc/pi-web/extensions"; const ACTION_KEY = "download-artifact"; export default function downloadArtifactExtension(pi: PiWebExtensionAPI) { const add = (_event: unknown, ctx: PiWebExtensionContext) => { - ctx.ui.web.setArtifactAction(ACTION_KEY, { + ctx.ui.web.contribute(ACTION_KEY, { + slot: "artifact-action", + kind: "rendered", title: "Download artifact to this device", label: "Download", - invoke: ({ name }) => ({ download: { filename: name } }), + render: (event) => ({ + download: { filename: typeof event?.context?.name === "string" ? event.context.name : undefined }, + }), }); }; pi.on("session_start", add); pi.on("session_before_switch", add); - pi.on("session_shutdown", (_event, ctx) => ctx.ui.web.setArtifactAction(ACTION_KEY, undefined)); + pi.on("session_shutdown", (_event, ctx) => ctx.ui.web.contribute(ACTION_KEY, undefined)); } diff --git a/examples/pi-web-extensions/git-footer.ts b/examples/pi-web-extensions/git-footer.ts index a2b2b1a..32bac0f 100644 --- a/examples/pi-web-extensions/git-footer.ts +++ b/examples/pi-web-extensions/git-footer.ts @@ -1,4 +1,3 @@ -// Uses typed compatibility wrappers; see notepad.ts for the current contribute() API. import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { PiWebExtensionAPI, PiWebExtensionContext } from "@ashwin-pc/pi-web/extensions"; @@ -149,7 +148,11 @@ export function createGitFooterExtension(options: { || sessions.get(key) !== state || state.lastHtml === html) return; state.lastHtml = html; - ctx.ui.web.setFooter(FOOTER_KEY, { kind: "html", html }); + ctx.ui.web.contribute(FOOTER_KEY, { + slot: "footer", + kind: "static", + view: { kind: "html", html }, + }); }) .finally(() => { state.refreshing = undefined; @@ -190,7 +193,7 @@ export function createGitFooterExtension(options: { state.stopped = true; clearInterval(state.interval); sessions.delete(key); - ctx.ui.web.setFooter(FOOTER_KEY, undefined); + ctx.ui.web.contribute(FOOTER_KEY, undefined); } return function gitFooterExtension(pi: PiWebExtensionAPI) { diff --git a/examples/pi-web-extensions/github-repo-panel.ts b/examples/pi-web-extensions/github-repo-panel.ts index 5c26e5f..880fc49 100644 --- a/examples/pi-web-extensions/github-repo-panel.ts +++ b/examples/pi-web-extensions/github-repo-panel.ts @@ -1,4 +1,3 @@ -// Uses typed compatibility wrappers; see notepad.ts for the current contribute() API. import type { PiWebExtensionAPI, PiWebExtensionContext, PiWebGitTabEvent } from "@ashwin-pc/pi-web/extensions"; const GIT_TAB_KEY = "github"; @@ -574,17 +573,23 @@ async function updateGitTab(pi: PiWebExtensionAPI, ctx: PiWebExtensionContext) { if (!repo) { if (installedSessions.has(key)) { - web.setGitTab(GIT_TAB_KEY, undefined); + web.contribute(GIT_TAB_KEY, undefined); installedSessions.delete(key); } return; } installedSessions.add(key); - web.setGitTab(GIT_TAB_KEY, { + web.contribute(GIT_TAB_KEY, { + slot: "git-tab", + kind: "rendered", title: `GitHub issues and pull requests for ${repo.nameWithOwner}`, label: "GitHub", - render: (event) => renderGitTab(pi, ctx, event), + render: (event) => renderGitTab(pi, ctx, { + action: event?.action, + payload: event?.payload, + repo: event?.context as PiWebGitTabEvent["repo"], + }), }); } @@ -606,7 +611,7 @@ export default function githubRepoPanel(pi: PiWebExtensionAPI) { pi.on("session_shutdown", (_event, ctx) => { const key = sessionKey(ctx); if (installedSessions.has(key)) { - ctx.ui.web.setGitTab(GIT_TAB_KEY, undefined); + ctx.ui.web.contribute(GIT_TAB_KEY, undefined); installedSessions.delete(key); } }); diff --git a/examples/pi-web-extensions/recap.ts b/examples/pi-web-extensions/recap.ts index 54d6de0..f7bb4ec 100644 --- a/examples/pi-web-extensions/recap.ts +++ b/examples/pi-web-extensions/recap.ts @@ -1,4 +1,3 @@ -// Uses typed compatibility wrappers; see notepad.ts for the current contribute() API. import { generateSummary } from "@earendil-works/pi-coding-agent"; import { buildSessionContext } from "@earendil-works/pi-coding-agent"; import type { PiWebExtensionAPI, PiWebExtensionContext } from "@ashwin-pc/pi-web/extensions"; @@ -25,15 +24,17 @@ async function buildRecap(ctx: PiWebExtensionContext) { export default function recapExtension(pi: PiWebExtensionAPI) { const add = (_event: unknown, ctx: PiWebExtensionContext) => { - ctx.ui.web.setHeaderAction("recap", { + ctx.ui.web.contribute("recap", { + slot: "header-action", + kind: "rendered", icon: "scroll-text", title: "Session recap", label: "Recap", - invoke: () => buildRecap(ctx), + render: () => buildRecap(ctx), }); }; pi.on("session_start", add); pi.on("session_before_switch", add); - pi.on("session_shutdown", (_event, ctx) => ctx.ui.web.setHeaderAction("recap", undefined)); + pi.on("session_shutdown", (_event, ctx) => ctx.ui.web.contribute("recap", undefined)); } diff --git a/tests/context.test.ts b/tests/context.test.ts index 0c740b1..a4d1560 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -52,11 +52,12 @@ describe("agent context organization", () => { expect(service).not.toContain("pi-web-agent-context.md"); }); - it("labels compatibility-wrapper examples and points authors to the current API exemplar", async () => { - const legacyExamples = ["git-footer.ts", "recap.ts", "download-artifact.ts", "github-repo-panel.ts"]; - for (const filename of legacyExamples) { + it("teaches the canonical contribution API throughout the first-party UI examples", async () => { + const uiExamples = ["git-footer.ts", "recap.ts", "download-artifact.ts", "github-repo-panel.ts", "notepad.ts"]; + for (const filename of uiExamples) { const example = await text(`examples/pi-web-extensions/${filename}`); - expect(example).toContain("Uses typed compatibility wrappers; see notepad.ts for the current contribute() API."); + expect(example).toContain(".contribute("); + expect(example).not.toMatch(/\.set(?:Footer|HeaderAction|ArtifactAction|GitTab|Panel|FabAction)\(/); } }); }); diff --git a/tests/extensions.test.ts b/tests/extensions.test.ts index 25d23ed..72ff92c 100644 --- a/tests/extensions.test.ts +++ b/tests/extensions.test.ts @@ -69,13 +69,19 @@ describe("bundled extension path discovery", () => { const handlers = new Map unknown>(); downloadArtifactExtension({ on: (event: string, handler: (event: unknown, context: any) => unknown) => handlers.set(event, handler) } as any); const calls: Array<[string, any]> = []; - const context = { ui: { web: { setArtifactAction: (key: string, action: unknown) => calls.push([key, action]) } } }; + const context = { ui: { web: { contribute: (key: string, contribution: unknown) => calls.push([key, contribution]) } } }; await handlers.get("session_start")?.({}, context); const action = calls.at(-1)?.[1]; expect(calls.at(-1)?.[0]).toBe("download-artifact"); - expect(action).toMatchObject({ title: "Download artifact to this device", label: "Download" }); - expect(await action.invoke({ name: "report.md", path: "/api/artifacts/report.md", kind: "markdown" })).toEqual({ download: { filename: "report.md" } }); + expect(action).toMatchObject({ + slot: "artifact-action", + kind: "rendered", + title: "Download artifact to this device", + label: "Download", + }); + expect(await action.render({ context: { name: "report.md", path: "/api/artifacts/report.md", kind: "markdown" } })) + .toEqual({ download: { filename: "report.md" } }); await handlers.get("session_shutdown")?.({}, context); expect(calls.at(-1)).toEqual(["download-artifact", undefined]); @@ -305,7 +311,7 @@ describe("bundled extension path discovery", () => { context: { cwd: process.cwd(), sessionManager, - ui: { web: { setFooter: (key: string, footer: unknown) => calls.push([key, footer]) } }, + ui: { web: { contribute: (key: string, contribution: unknown) => calls.push([key, contribution]) } }, }, }; }; @@ -318,11 +324,11 @@ describe("bundled extension path discovery", () => { await vi.advanceTimersByTimeAsync(0); await start({}, replacement.context); await vi.advanceTimersByTimeAsync(0); - expect(first.calls.at(-1)?.[1]).toMatchObject({ kind: "html" }); - expect(replacement.calls.at(-1)?.[1]).toMatchObject({ kind: "html" }); + expect(first.calls.at(-1)?.[1]).toMatchObject({ slot: "footer", kind: "static", view: { kind: "html" } }); + expect(replacement.calls.at(-1)?.[1]).toMatchObject({ slot: "footer", kind: "static", view: { kind: "html" } }); await shutdown({}, first.context); - expect(replacement.calls.at(-1)?.[1]).toMatchObject({ kind: "html" }); + expect(replacement.calls.at(-1)?.[1]).toMatchObject({ slot: "footer", kind: "static", view: { kind: "html" } }); await shutdown({}, replacement.context); expect(replacement.calls.at(-1)).toEqual(["local-git-footer", undefined]); } finally { @@ -343,7 +349,7 @@ describe("bundled extension path discovery", () => { const context = { cwd: process.cwd(), sessionManager, - ui: { web: { setFooter: (key: string, footer: unknown) => calls.push([key, footer]) } }, + ui: { web: { contribute: (key: string, contribution: unknown) => calls.push([key, contribution]) } }, }; handlers.get("session_start")?.({}, context);