fix(sessions): provide plain-text extension theme

This commit is contained in:
Federico Jaramillo Martinez
2026-07-20 14:01:47 +02:00
parent b48b147b5b
commit a884773357
5 changed files with 188 additions and 5 deletions
@@ -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.
@@ -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<void>) | 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" });
+5 -3
View File
@@ -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;
},
@@ -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("");
});
});
+133
View File
@@ -0,0 +1,133 @@
import { Theme } from "@earendil-works/pi-coding-agent";
type ThemeConstructorParameters = ConstructorParameters<typeof Theme>;
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<Theme["fg"]>[0], text: string): string {
void color;
return text;
}
override bg(color: Parameters<Theme["bg"]>[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<Theme["getFgAnsi"]>[0]): string {
void color;
return "";
}
override getBgAnsi(color: Parameters<Theme["getBgAnsi"]>[0]): string {
void color;
return "";
}
override getColorMode(): ReturnType<Theme["getColorMode"]> {
return "256color";
}
override getThinkingBorderColor(
level: Parameters<Theme["getThinkingBorderColor"]>[0],
): ReturnType<Theme["getThinkingBorderColor"]> {
void level;
return preserveText;
}
override getBashModeBorderColor(): ReturnType<Theme["getBashModeBorderColor"]> {
return preserveText;
}
}
/** Shared ANSI-free Theme facade for extension code running without a terminal. */
export const plainTextTheme: Theme = new PlainTextTheme();