From a884773357bb2b868e560eb4893e6af3bbbd924c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 20 Jul 2026 14:01:47 +0200 Subject: [PATCH] fix(sessions): provide plain-text extension theme --- .changeset/keep-extension-theme-text-plain.md | 5 + .../piSessionService.lifecycle.test.ts | 19 ++- src/server/sessions/piSessionService.ts | 8 +- src/server/sessions/plainTextTheme.test.ts | 28 ++++ src/server/sessions/plainTextTheme.ts | 133 ++++++++++++++++++ 5 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 .changeset/keep-extension-theme-text-plain.md create mode 100644 src/server/sessions/plainTextTheme.test.ts create mode 100644 src/server/sessions/plainTextTheme.ts diff --git a/.changeset/keep-extension-theme-text-plain.md b/.changeset/keep-extension-theme-text-plain.md new file mode 100644 index 0000000..b17c6ae --- /dev/null +++ b/.changeset/keep-extension-theme-text-plain.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep PI WEB-managed sessions running when extensions use `ctx.ui.theme`, preserving formatted output as readable plain text. diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts index 006e568..beeb862 100644 --- a/src/server/sessions/piSessionService.lifecycle.test.ts +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -48,6 +48,12 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("starts sessions through an injected runtime creator", async () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime(); + let sessionStartText: string | undefined; + const bindExtensions = fake.session.bindExtensions.bind(fake.session); + fake.session.bindExtensions = (bindings) => { + sessionStartText = bindings.uiContext?.theme.fg("accent", "session started"); + return bindExtensions(bindings); + }; let createCalls = 0; let runtimeAgentDir: string | undefined; const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { @@ -69,6 +75,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { expect(createCalls).toBe(1); expect(runtimeAgentDir).toBe(TEST_AGENT_DIR); expect(fake.calls.bindExtensions).toHaveLength(1); + expect(sessionStartText).toBe("session started"); expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 }); expect(service.activeCount()).toBe(1); expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true); @@ -293,6 +300,12 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime("session-1"); const replacement = fakeRuntime("session-2"); + let replacementSessionStartText: string | undefined; + const bindReplacementExtensions = replacement.session.bindExtensions.bind(replacement.session); + replacement.session.bindExtensions = (bindings) => { + replacementSessionStartText = bindings.uiContext?.theme.fg("success", "replacement started"); + return bindReplacementExtensions(bindings); + }; let rebindSession: ((session: PiAgentSession) => Promise) | undefined; fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; const service = new PiSessionService(hub, { @@ -309,6 +322,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { expect(fake.calls.bindExtensions).toHaveLength(1); expect(replacement.calls.bindExtensions).toHaveLength(1); + expect(replacementSessionStartText).toBe("replacement started"); expect(service.activeCount()).toBe(1); expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" }); @@ -440,7 +454,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { await service.dispose(); }); - it("commits Pi /reload only after replacement session_start notifications are bound", async () => { + it("commits Pi /reload only after replacement session_start notifications use the plain-text theme", async () => { const hub = new CapturingSessionEventHub(); const store = notificationStore(); const fake = fakeRuntime("runtime-reload-notifications"); @@ -459,7 +473,8 @@ describe("PiSessionService lifecycle, listing, and reload", () => { fake.session.reload = async (options) => { oldNotify("shutdown notification", "info"); await options?.beforeSessionStart?.(); - currentNotify(fake)("replacement startup", "error"); + const replacementStartup = fake.session.extensionRunner.getUIContext().theme.fg("error", "replacement startup"); + currentNotify(fake)(replacementStartup, "error"); }; await expect(service.runCommand(sessionRef("runtime-reload-notifications"), "/reload")).resolves.toMatchObject({ type: "done" }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index f613d06..fc869f1 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -62,6 +62,7 @@ import { type SessionNotificationGeneration, type SessionNotificationMutation, } from "./sessionNotificationStore.js"; +import { plainTextTheme } from "./plainTextTheme.js"; /** * Minimal structured-logging seam, shaped like Fastify's logger so sessiond can @@ -2361,12 +2362,13 @@ export class PiSessionService implements SessionRouteService { notificationId: added.notification.id, }); }; - // PI WEB is a remote UI host, but currently only extension notifications - // cross this boundary. Delegate every other UI method to Pi's headless - // defaults so unsupported dialogs cancel safely instead of hanging. + // PI WEB owns the browser-facing notification and text-formatting + // boundaries. Delegate every other UI method to Pi's headless defaults so + // unsupported dialogs cancel safely instead of hanging. return new Proxy(baseUiContext, { get(target, property, receiver): unknown { if (property === "notify") return notify; + if (property === "theme") return plainTextTheme; const value: unknown = Reflect.get(target, property, receiver); return value; }, diff --git a/src/server/sessions/plainTextTheme.test.ts b/src/server/sessions/plainTextTheme.test.ts new file mode 100644 index 0000000..6dd695c --- /dev/null +++ b/src/server/sessions/plainTextTheme.test.ts @@ -0,0 +1,28 @@ +import { Theme } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { plainTextTheme } from "./plainTextTheme.js"; + +describe("plainTextTheme", () => { + it("preserves Pi Theme compatibility without adding formatting", () => { + const text = "plain extension output"; + const formatters: ((value: string) => string)[] = [ + (value) => plainTextTheme.fg("accent", value), + (value) => plainTextTheme.bg("selectedBg", value), + (value) => plainTextTheme.bold(value), + (value) => plainTextTheme.italic(value), + (value) => plainTextTheme.underline(value), + (value) => plainTextTheme.inverse(value), + (value) => plainTextTheme.strikethrough(value), + plainTextTheme.getThinkingBorderColor("high"), + plainTextTheme.getBashModeBorderColor(), + ]; + + expect(plainTextTheme).toBeInstanceOf(Theme); + for (const format of formatters) expect(format(text)).toBe(text); + }); + + it("returns no terminal ANSI prefixes", () => { + expect(plainTextTheme.getFgAnsi("accent")).toBe(""); + expect(plainTextTheme.getBgAnsi("selectedBg")).toBe(""); + }); +}); diff --git a/src/server/sessions/plainTextTheme.ts b/src/server/sessions/plainTextTheme.ts new file mode 100644 index 0000000..4c142d4 --- /dev/null +++ b/src/server/sessions/plainTextTheme.ts @@ -0,0 +1,133 @@ +import { Theme } from "@earendil-works/pi-coding-agent"; + +type ThemeConstructorParameters = ConstructorParameters; + +const RESET_COLOR = ""; + +// Theme requires complete color tables for nominal class construction. Every +// method that could expose the resulting reset codes is overridden below. +const SUPERCLASS_FOREGROUND_COLORS = { + accent: RESET_COLOR, + border: RESET_COLOR, + borderAccent: RESET_COLOR, + borderMuted: RESET_COLOR, + success: RESET_COLOR, + error: RESET_COLOR, + warning: RESET_COLOR, + muted: RESET_COLOR, + dim: RESET_COLOR, + text: RESET_COLOR, + thinkingText: RESET_COLOR, + userMessageText: RESET_COLOR, + customMessageText: RESET_COLOR, + customMessageLabel: RESET_COLOR, + toolTitle: RESET_COLOR, + toolOutput: RESET_COLOR, + mdHeading: RESET_COLOR, + mdLink: RESET_COLOR, + mdLinkUrl: RESET_COLOR, + mdCode: RESET_COLOR, + mdCodeBlock: RESET_COLOR, + mdCodeBlockBorder: RESET_COLOR, + mdQuote: RESET_COLOR, + mdQuoteBorder: RESET_COLOR, + mdHr: RESET_COLOR, + mdListBullet: RESET_COLOR, + toolDiffAdded: RESET_COLOR, + toolDiffRemoved: RESET_COLOR, + toolDiffContext: RESET_COLOR, + syntaxComment: RESET_COLOR, + syntaxKeyword: RESET_COLOR, + syntaxFunction: RESET_COLOR, + syntaxVariable: RESET_COLOR, + syntaxString: RESET_COLOR, + syntaxNumber: RESET_COLOR, + syntaxType: RESET_COLOR, + syntaxOperator: RESET_COLOR, + syntaxPunctuation: RESET_COLOR, + thinkingOff: RESET_COLOR, + thinkingMinimal: RESET_COLOR, + thinkingLow: RESET_COLOR, + thinkingMedium: RESET_COLOR, + thinkingHigh: RESET_COLOR, + thinkingXhigh: RESET_COLOR, + thinkingMax: RESET_COLOR, + bashMode: RESET_COLOR, +} satisfies ThemeConstructorParameters[0]; + +const SUPERCLASS_BACKGROUND_COLORS = { + selectedBg: RESET_COLOR, + userMessageBg: RESET_COLOR, + customMessageBg: RESET_COLOR, + toolPendingBg: RESET_COLOR, + toolSuccessBg: RESET_COLOR, + toolErrorBg: RESET_COLOR, +} satisfies ThemeConstructorParameters[1]; + +function preserveText(text: string): string { + return text; +} + +class PlainTextTheme extends Theme { + constructor() { + super(SUPERCLASS_FOREGROUND_COLORS, SUPERCLASS_BACKGROUND_COLORS, "256color", { name: "pi-web-plain-text" }); + } + + override fg(color: Parameters[0], text: string): string { + void color; + return text; + } + + override bg(color: Parameters[0], text: string): string { + void color; + return text; + } + + override bold(text: string): string { + return text; + } + + override italic(text: string): string { + return text; + } + + override underline(text: string): string { + return text; + } + + override inverse(text: string): string { + return text; + } + + override strikethrough(text: string): string { + return text; + } + + override getFgAnsi(color: Parameters[0]): string { + void color; + return ""; + } + + override getBgAnsi(color: Parameters[0]): string { + void color; + return ""; + } + + override getColorMode(): ReturnType { + return "256color"; + } + + override getThinkingBorderColor( + level: Parameters[0], + ): ReturnType { + void level; + return preserveText; + } + + override getBashModeBorderColor(): ReturnType { + return preserveText; + } +} + +/** Shared ANSI-free Theme facade for extension code running without a terminal. */ +export const plainTextTheme: Theme = new PlainTextTheme();