Archived
fix(sessions): provide plain-text extension theme
This commit is contained in:
@@ -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 () => {
|
it("starts sessions through an injected runtime creator", async () => {
|
||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime();
|
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 createCalls = 0;
|
||||||
let runtimeAgentDir: string | undefined;
|
let runtimeAgentDir: string | undefined;
|
||||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||||
@@ -69,6 +75,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
expect(createCalls).toBe(1);
|
expect(createCalls).toBe(1);
|
||||||
expect(runtimeAgentDir).toBe(TEST_AGENT_DIR);
|
expect(runtimeAgentDir).toBe(TEST_AGENT_DIR);
|
||||||
expect(fake.calls.bindExtensions).toHaveLength(1);
|
expect(fake.calls.bindExtensions).toHaveLength(1);
|
||||||
|
expect(sessionStartText).toBe("session started");
|
||||||
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
|
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
|
||||||
expect(service.activeCount()).toBe(1);
|
expect(service.activeCount()).toBe(1);
|
||||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
|
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 hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime("session-1");
|
const fake = fakeRuntime("session-1");
|
||||||
const replacement = fakeRuntime("session-2");
|
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;
|
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
|
||||||
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
@@ -309,6 +322,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
|
|
||||||
expect(fake.calls.bindExtensions).toHaveLength(1);
|
expect(fake.calls.bindExtensions).toHaveLength(1);
|
||||||
expect(replacement.calls.bindExtensions).toHaveLength(1);
|
expect(replacement.calls.bindExtensions).toHaveLength(1);
|
||||||
|
expect(replacementSessionStartText).toBe("replacement started");
|
||||||
expect(service.activeCount()).toBe(1);
|
expect(service.activeCount()).toBe(1);
|
||||||
expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" });
|
expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" });
|
||||||
|
|
||||||
@@ -440,7 +454,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
await service.dispose();
|
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 hub = new CapturingSessionEventHub();
|
||||||
const store = notificationStore();
|
const store = notificationStore();
|
||||||
const fake = fakeRuntime("runtime-reload-notifications");
|
const fake = fakeRuntime("runtime-reload-notifications");
|
||||||
@@ -459,7 +473,8 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
fake.session.reload = async (options) => {
|
fake.session.reload = async (options) => {
|
||||||
oldNotify("shutdown notification", "info");
|
oldNotify("shutdown notification", "info");
|
||||||
await options?.beforeSessionStart?.();
|
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" });
|
await expect(service.runCommand(sessionRef("runtime-reload-notifications"), "/reload")).resolves.toMatchObject({ type: "done" });
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
type SessionNotificationGeneration,
|
type SessionNotificationGeneration,
|
||||||
type SessionNotificationMutation,
|
type SessionNotificationMutation,
|
||||||
} from "./sessionNotificationStore.js";
|
} from "./sessionNotificationStore.js";
|
||||||
|
import { plainTextTheme } from "./plainTextTheme.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal structured-logging seam, shaped like Fastify's logger so sessiond can
|
* 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,
|
notificationId: added.notification.id,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// PI WEB is a remote UI host, but currently only extension notifications
|
// PI WEB owns the browser-facing notification and text-formatting
|
||||||
// cross this boundary. Delegate every other UI method to Pi's headless
|
// boundaries. Delegate every other UI method to Pi's headless defaults so
|
||||||
// defaults so unsupported dialogs cancel safely instead of hanging.
|
// unsupported dialogs cancel safely instead of hanging.
|
||||||
return new Proxy(baseUiContext, {
|
return new Proxy(baseUiContext, {
|
||||||
get(target, property, receiver): unknown {
|
get(target, property, receiver): unknown {
|
||||||
if (property === "notify") return notify;
|
if (property === "notify") return notify;
|
||||||
|
if (property === "theme") return plainTextTheme;
|
||||||
const value: unknown = Reflect.get(target, property, receiver);
|
const value: unknown = Reflect.get(target, property, receiver);
|
||||||
return value;
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user