@diegopetrucci/pi-annotate-last-message 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- 0.84.2
1
+ 0.84.4
package/README.md CHANGED
@@ -22,7 +22,15 @@ Run `/annotate-last-message` from an interactive pi session. The annotation wind
22
22
  - section comments for larger chunks of the message, and
23
23
  - inline notes tied to individual lines.
24
24
 
25
- When you submit, the extension appends a structured planning-oriented feedback prompt to the current editor buffer. It does not auto-apply changes or rewrite the previous assistant message in place.
25
+ The message preview renders headings, lists, blockquotes, emphasis, inline code, strikethrough, links, and fenced code while preserving the original one-based source-line coordinates. Raw HTML is displayed as text. Link destinations are shown as inert text beside the label; the window never navigates to an assistant-provided URL.
26
+
27
+ The window follows the active Pi theme through `ctx.ui.theme`, including Markdown colors and dark/light appearance, with readable local fallbacks when a theme token is unavailable. Opening the window does not change Pi's host theme or settings.
28
+
29
+ When you submit, the extension sends a structured planning-oriented feedback prompt directly to the agent as a follow-up message. Your existing editor text is left untouched. It does not auto-apply changes or rewrite the previous assistant message in place.
30
+
31
+ ## Rollback
32
+
33
+ There is no runtime setting or toggle for submit behavior. To roll back the rendering/theme change, reinstall the previous package version or revert this source change and rebuild/reinstall the package; no Pi theme or host setting needs to be restored.
26
34
 
27
35
  ## Requirements
28
36
 
package/index.ts CHANGED
@@ -3,30 +3,72 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
3
3
  import { openQuietGlimpse, type QuietGlimpseWindow } from "./quiet-glimpse.js";
4
4
  import { composeAnnotateLastMessagePrompt, hasAnnotateLastMessageFeedback } from "./prompt.js";
5
5
  import { findLastAssistantMessage } from "./session.js";
6
- import type { AnnotateLastMessageCancelPayload, AnnotateLastMessageSubmitPayload, AnnotateLastMessageWindowMessage, LastAssistantMessageData } from "./types.js";
6
+ import type {
7
+ AnnotateLastMessageCancelPayload,
8
+ AnnotateLastMessageInlineComment,
9
+ AnnotateLastMessageSectionComment,
10
+ AnnotateLastMessageSubmitPayload,
11
+ AnnotateLastMessageWindowMessage,
12
+ LastAssistantMessageData,
13
+ } from "./types.js";
7
14
  import { buildAnnotateLastMessageHtml } from "./ui.js";
8
15
 
16
+ function isInlineComment(value: unknown): value is AnnotateLastMessageInlineComment {
17
+ return (
18
+ typeof value === "object" &&
19
+ value != null &&
20
+ "line" in value &&
21
+ typeof value.line === "number" &&
22
+ Number.isInteger(value.line) &&
23
+ value.line > 0 &&
24
+ "body" in value &&
25
+ typeof value.body === "string"
26
+ );
27
+ }
28
+
29
+ function isSectionComment(value: unknown): value is AnnotateLastMessageSectionComment {
30
+ return (
31
+ typeof value === "object" &&
32
+ value != null &&
33
+ "sectionId" in value &&
34
+ typeof value.sectionId === "string" &&
35
+ "body" in value &&
36
+ typeof value.body === "string"
37
+ );
38
+ }
39
+
9
40
  function isSubmitPayload(value: unknown): value is AnnotateLastMessageSubmitPayload {
10
- return typeof value === "object" && value != null && "type" in value && value.type === "submit";
41
+ return (
42
+ typeof value === "object" &&
43
+ value != null &&
44
+ "type" in value &&
45
+ value.type === "submit" &&
46
+ "overallComment" in value &&
47
+ typeof value.overallComment === "string" &&
48
+ "inlineComments" in value &&
49
+ Array.isArray(value.inlineComments) &&
50
+ value.inlineComments.every(isInlineComment) &&
51
+ "sectionComments" in value &&
52
+ Array.isArray(value.sectionComments) &&
53
+ value.sectionComments.every(isSectionComment)
54
+ );
11
55
  }
12
56
 
13
57
  function isCancelPayload(value: unknown): value is AnnotateLastMessageCancelPayload {
14
58
  return typeof value === "object" && value != null && "type" in value && value.type === "cancel";
15
59
  }
16
60
 
17
- function appendPrompt(ctx: ExtensionCommandContext, prompt: string): void {
18
- const prefix = ctx.ui.getEditorText().trim().length > 0 ? "\n\n" : "";
19
- ctx.ui.pasteToEditor(`${prefix}${prompt}`);
20
- }
21
-
22
61
  export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
23
62
  let activeWindow: QuietGlimpseWindow | null = null;
63
+ let annotationOpening = false;
64
+ let annotationAttempt = 0;
24
65
  const suppressedWindows = new WeakSet<QuietGlimpseWindow>();
25
66
 
26
67
  function closeActiveWindow(options: { suppressResults?: boolean } = {}): void {
27
- if (activeWindow == null) return;
68
+ annotationAttempt += 1;
28
69
  const windowToClose = activeWindow;
29
70
  activeWindow = null;
71
+ if (windowToClose == null) return;
30
72
  if (options.suppressResults) {
31
73
  suppressedWindows.add(windowToClose);
32
74
  }
@@ -42,7 +84,7 @@ export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
42
84
  ctx.ui.notify("annotate-last-message requires interactive mode.", "error");
43
85
  return;
44
86
  }
45
- if (activeWindow != null) {
87
+ if (annotationOpening || activeWindow != null) {
46
88
  ctx.ui.notify("A last-message annotation window is already open.", "warning");
47
89
  return;
48
90
  }
@@ -54,21 +96,38 @@ export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
54
96
  }
55
97
 
56
98
  const messageData = messageResult.data;
99
+ annotationOpening = true;
100
+ const attempt = ++annotationAttempt;
57
101
 
58
102
  try {
59
- const html = buildAnnotateLastMessageHtml(messageData);
103
+ const html = buildAnnotateLastMessageHtml(messageData, ctx.ui.theme);
60
104
  const window = await openQuietGlimpse(html, {
61
105
  width: 1440,
62
106
  height: 980,
63
107
  title: "annotate last message",
64
108
  });
109
+ if (attempt !== annotationAttempt) {
110
+ suppressedWindows.add(window);
111
+ window.close();
112
+ return;
113
+ }
65
114
  activeWindow = window;
115
+ if (window.failure != null) throw window.failure;
116
+ if (window.closed) throw new Error("Glimpse closed while the annotation window was starting.");
66
117
 
67
118
  const terminalMessagePromise = new Promise<AnnotateLastMessageSubmitPayload | AnnotateLastMessageCancelPayload | null>(
68
119
  (resolve, reject) => {
69
120
  let settled = false;
70
121
  let closeTimer: ReturnType<typeof setTimeout> | null = null;
71
122
 
123
+ const requestWindowClose = (): void => {
124
+ try {
125
+ window.close();
126
+ } catch {
127
+ // Ignore races when the native process has already exited.
128
+ }
129
+ };
130
+
72
131
  const cleanup = (): void => {
73
132
  if (closeTimer != null) {
74
133
  clearTimeout(closeTimer);
@@ -92,6 +151,7 @@ export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
92
151
  const onMessage = (data: unknown): void => {
93
152
  if (isSubmitPayload(data) || isCancelPayload(data)) {
94
153
  settle(data);
154
+ requestWindowClose();
95
155
  }
96
156
  };
97
157
 
@@ -106,6 +166,7 @@ export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
106
166
  const onError = (error: Error): void => {
107
167
  if (settled) return;
108
168
  settled = true;
169
+ requestWindowClose();
109
170
  cleanup();
110
171
  reject(error);
111
172
  };
@@ -113,13 +174,15 @@ export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
113
174
  window.on("message", onMessage);
114
175
  window.on("closed", onClosed);
115
176
  window.on("error", onError);
177
+ if (window.failure != null) onError(window.failure);
178
+ else if (window.closed) onClosed();
116
179
  },
117
180
  );
118
181
 
119
182
  void (async (windowMessageSource: QuietGlimpseWindow, sourceData: LastAssistantMessageData) => {
120
183
  try {
121
184
  const result = await terminalMessagePromise;
122
- if (suppressedWindows.has(windowMessageSource)) return;
185
+ if (suppressedWindows.has(windowMessageSource) || attempt !== annotationAttempt) return;
123
186
  if (result == null) return;
124
187
  if (result.type === "cancel") {
125
188
  ctx.ui.notify("Annotation cancelled.", "info");
@@ -131,10 +194,10 @@ export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
131
194
  }
132
195
 
133
196
  const prompt = composeAnnotateLastMessagePrompt(sourceData, result);
134
- appendPrompt(ctx, prompt);
135
- ctx.ui.notify("Appended annotation feedback to the editor.", "info");
197
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
198
+ ctx.ui.notify("Annotation feedback sent to the agent.", "info");
136
199
  } catch (error) {
137
- if (suppressedWindows.has(windowMessageSource)) return;
200
+ if (suppressedWindows.has(windowMessageSource) || attempt !== annotationAttempt) return;
138
201
  const message = error instanceof Error ? error.message : String(error);
139
202
  ctx.ui.notify(`Annotation failed: ${message}`, "error");
140
203
  }
@@ -142,9 +205,12 @@ export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
142
205
 
143
206
  ctx.ui.notify("Opened native annotation window.", "info");
144
207
  } catch (error) {
208
+ if (attempt !== annotationAttempt) return;
145
209
  closeActiveWindow({ suppressResults: true });
146
210
  const message = error instanceof Error ? error.message : String(error);
147
211
  ctx.ui.notify(`Annotation failed: ${message}`, "error");
212
+ } finally {
213
+ annotationOpening = false;
148
214
  }
149
215
  }
150
216
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-annotate-last-message",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "A standalone pi extension that adds /annotate-last-message, a native Glimpse UI for annotating the latest assistant reply.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -20,6 +20,7 @@
20
20
  "prompt.ts",
21
21
  "quiet-glimpse.ts",
22
22
  "session.ts",
23
+ "theme.ts",
23
24
  "types.ts",
24
25
  "ui.ts",
25
26
  "web",
package/quiet-glimpse.ts CHANGED
@@ -23,6 +23,8 @@ interface GlimpseProtocolMessage {
23
23
  }
24
24
 
25
25
  export interface QuietGlimpseWindow {
26
+ readonly closed: boolean;
27
+ readonly failure: Error | null;
26
28
  on(event: "message", listener: (data: unknown) => void): this;
27
29
  on(event: "closed", listener: () => void): this;
28
30
  on(event: "error", listener: (error: Error) => void): this;
@@ -38,11 +40,15 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
38
40
  #closed = false;
39
41
  #pendingHTML: string | null;
40
42
  #stderr = "";
43
+ #failure: Error | null = null;
41
44
 
42
45
  constructor(proc: ChildProcessWithoutNullStreams, initialHTML: string) {
43
46
  super();
44
47
  this.#proc = proc;
45
48
  this.#pendingHTML = initialHTML;
49
+ // EventEmitter treats an unobserved `error` as fatal. Keep a permanent
50
+ // fallback while callers attach and detach their lifecycle listeners.
51
+ this.on("error", () => {});
46
52
 
47
53
  proc.stdin.on("error", () => {});
48
54
  proc.stderr.on("data", (chunk) => {
@@ -51,13 +57,15 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
51
57
 
52
58
  const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
53
59
  rl.on("line", (line) => {
54
- let message: GlimpseProtocolMessage;
60
+ let parsed: unknown;
55
61
  try {
56
- message = JSON.parse(line) as GlimpseProtocolMessage;
62
+ parsed = JSON.parse(line);
57
63
  } catch {
58
- this.emit("error", new Error(`Malformed glimpse protocol line: ${line}`));
64
+ this.#reportError(new Error(`Malformed glimpse protocol line: ${line}`));
59
65
  return;
60
66
  }
67
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return;
68
+ const message = parsed as GlimpseProtocolMessage;
61
69
 
62
70
  switch (message.type) {
63
71
  case "ready":
@@ -77,16 +85,18 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
77
85
  }
78
86
  });
79
87
 
80
- proc.on("error", (error) => this.emit("error", error));
88
+ proc.on("error", (error) => this.#reportError(error));
81
89
  proc.on("exit", (code, signal) => {
82
90
  const stderr = this.#stderr.trim();
83
91
  const failed = signal != null || (code != null && code !== 0);
84
92
  if (!this.#closed && failed) {
85
93
  const message = stderr || `Glimpse process exited abnormally (code: ${code}, signal: ${signal}).`;
86
- this.emit("error", new Error(message));
94
+ this.#reportError(new Error(message));
87
95
  }
88
96
  this.#markClosed();
89
97
  });
98
+ // Spawn failures can emit `error` and then `close` without `exit`.
99
+ proc.on("close", () => this.#markClosed());
90
100
  }
91
101
 
92
102
  send(js: string): void {
@@ -97,6 +107,19 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
97
107
  this.#write({ type: "close" });
98
108
  }
99
109
 
110
+ get closed(): boolean {
111
+ return this.#closed;
112
+ }
113
+
114
+ get failure(): Error | null {
115
+ return this.#failure;
116
+ }
117
+
118
+ #reportError(error: Error): void {
119
+ this.#failure ??= error;
120
+ this.emit("error", error);
121
+ }
122
+
100
123
  #markClosed(): void {
101
124
  if (this.#closed) return;
102
125
  this.#closed = true;
package/theme.ts ADDED
@@ -0,0 +1,247 @@
1
+ import type {
2
+ Theme,
3
+ ThemeBg,
4
+ ThemeColor,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ /**
8
+ * The small public part of Pi's active theme used by the native annotation UI.
9
+ * The command passes ctx.ui.theme directly; no Pi theme singleton is imported.
10
+ */
11
+ export type AnnotateTheme = Pick<Theme, "appearance" | "colors" | "fg" | "bg">;
12
+
13
+ const DARK_FALLBACK: Readonly<Record<string, string>> = {
14
+ "--color-scheme": "dark",
15
+ "--bg": "#0d1117",
16
+ "--panel": "#151b23",
17
+ "--panel-hover": "#1f242c",
18
+ "--inset": "#0b1118",
19
+ "--border": "#30363d",
20
+ "--border-muted": "#21262d",
21
+ "--text": "#f0f6fc",
22
+ "--muted": "#8a8a8a",
23
+ "--dim": "#585858",
24
+ "--accent": "#f4c95d",
25
+ "--success": "#7ee787",
26
+ "--error": "#ffb3b3",
27
+ "--warning": "#f4c95d",
28
+ "--mdHeading": "#f4c95d",
29
+ "--mdLink": "#7dd3fc",
30
+ "--mdLinkUrl": "#8a8a8a",
31
+ "--mdCode": "#9b7bff",
32
+ "--mdCodeBlock": "inherit",
33
+ "--mdCodeBlockBorder": "#6f42c1",
34
+ "--mdQuote": "#8a8a8a",
35
+ "--mdQuoteBorder": "#6f42c1",
36
+ "--mdHr": "#585858",
37
+ "--mdListBullet": "#f4c95d",
38
+ "--mdBold": "inherit",
39
+ "--mdItalic": "inherit",
40
+ };
41
+
42
+ const LIGHT_FALLBACK: Readonly<Record<string, string>> = {
43
+ "--color-scheme": "light",
44
+ "--bg": "#f6f8fa",
45
+ "--panel": "#ffffff",
46
+ "--panel-hover": "#f3f4f6",
47
+ "--inset": "#f6f8fa",
48
+ "--border": "#d0d7de",
49
+ "--border-muted": "#afb8c1",
50
+ "--text": "#1f2328",
51
+ "--muted": "#57606a",
52
+ "--dim": "#6e7781",
53
+ "--accent": "#8250df",
54
+ "--success": "#1a7f37",
55
+ "--error": "#cf222e",
56
+ "--warning": "#9a6700",
57
+ "--mdHeading": "#9a6700",
58
+ "--mdLink": "#0969da",
59
+ "--mdLinkUrl": "#57606a",
60
+ "--mdCode": "#8250df",
61
+ "--mdCodeBlock": "#1a7f37",
62
+ "--mdCodeBlockBorder": "#6e7781",
63
+ "--mdQuote": "#57606a",
64
+ "--mdQuoteBorder": "#6e7781",
65
+ "--mdHr": "#8c959f",
66
+ "--mdListBullet": "#8250df",
67
+ "--mdBold": "inherit",
68
+ "--mdItalic": "inherit",
69
+ };
70
+
71
+ const BASIC_ANSI_COLORS: Readonly<Record<number, string>> = {
72
+ 0: "#000000",
73
+ 1: "#800000",
74
+ 2: "#008000",
75
+ 3: "#808000",
76
+ 4: "#000080",
77
+ 5: "#800080",
78
+ 6: "#008080",
79
+ 7: "#c0c0c0",
80
+ 8: "#808080",
81
+ 9: "#ff0000",
82
+ 10: "#00ff00",
83
+ 11: "#ffff00",
84
+ 12: "#0000ff",
85
+ 13: "#ff00ff",
86
+ 14: "#00ffff",
87
+ 15: "#ffffff",
88
+ };
89
+
90
+ const CUBE_LEVELS = [0, 95, 135, 175, 215, 255] as const;
91
+ const SENTINEL = "X";
92
+
93
+ const FOREGROUND_THEME_VARS: ReadonlyArray<{ token: ThemeColor; cssVar: string }> = [
94
+ { token: "text", cssVar: "--text" },
95
+ { token: "muted", cssVar: "--muted" },
96
+ { token: "dim", cssVar: "--dim" },
97
+ { token: "accent", cssVar: "--accent" },
98
+ { token: "success", cssVar: "--success" },
99
+ { token: "error", cssVar: "--error" },
100
+ { token: "warning", cssVar: "--warning" },
101
+ { token: "mdHeading", cssVar: "--mdHeading" },
102
+ { token: "mdLink", cssVar: "--mdLink" },
103
+ { token: "mdLinkUrl", cssVar: "--mdLinkUrl" },
104
+ { token: "mdCode", cssVar: "--mdCode" },
105
+ { token: "mdCodeBlock", cssVar: "--mdCodeBlock" },
106
+ { token: "mdCodeBlockBorder", cssVar: "--mdCodeBlockBorder" },
107
+ { token: "mdQuote", cssVar: "--mdQuote" },
108
+ { token: "mdQuoteBorder", cssVar: "--mdQuoteBorder" },
109
+ { token: "mdHr", cssVar: "--mdHr" },
110
+ { token: "mdListBullet", cssVar: "--mdListBullet" },
111
+ { token: "border", cssVar: "--border" },
112
+ { token: "borderMuted", cssVar: "--border-muted" },
113
+ ];
114
+
115
+ const BACKGROUND_THEME_VARS: ReadonlyArray<{ token: ThemeBg; cssVar: string }> = [
116
+ { token: "customMessageBg", cssVar: "--panel" },
117
+ { token: "selectedBg", cssVar: "--panel-hover" },
118
+ { token: "toolPendingBg", cssVar: "--inset" },
119
+ ];
120
+
121
+ function toHex(value: number): string {
122
+ const clamped = Math.min(255, Math.max(0, Math.round(value)));
123
+ return clamped.toString(16).padStart(2, "0");
124
+ }
125
+
126
+ function xterm256ToHex(index: number): string | null {
127
+ if (!Number.isInteger(index) || index < 0 || index > 255) return null;
128
+ if (index < 16) return BASIC_ANSI_COLORS[index] ?? null;
129
+ if (index >= 232) {
130
+ const value = 8 + (index - 232) * 10;
131
+ const hex = toHex(value);
132
+ return `#${hex}${hex}${hex}`;
133
+ }
134
+ const cubeIndex = index - 16;
135
+ const blue = cubeIndex % 6;
136
+ const green = Math.floor(cubeIndex / 6) % 6;
137
+ const red = Math.floor(cubeIndex / 36) % 6;
138
+ return `#${toHex(CUBE_LEVELS[red])}${toHex(CUBE_LEVELS[green])}${toHex(CUBE_LEVELS[blue])}`;
139
+ }
140
+
141
+ function colorValueToHex(value: unknown): string | null {
142
+ if (typeof value !== "object" || value == null) return null;
143
+ const color = value as { kind?: unknown; r?: unknown; g?: unknown; b?: unknown; index?: unknown };
144
+ if (color.kind === "rgb") {
145
+ const channels = [color.r, color.g, color.b];
146
+ if (!channels.every((channel) => typeof channel === "number" && Number.isFinite(channel) && channel >= 0 && channel <= 255)) {
147
+ return null;
148
+ }
149
+ return `#${toHex(color.r as number)}${toHex(color.g as number)}${toHex(color.b as number)}`;
150
+ }
151
+ if (color.kind === "indexed" && typeof color.index === "number") {
152
+ return xterm256ToHex(color.index);
153
+ }
154
+ // OKLCH values are intentionally handled through theme.fg/theme.bg below.
155
+ // Those public helpers account for Pi's configured terminal color mode.
156
+ return null;
157
+ }
158
+
159
+ function ansiColorToHex(value: unknown, background: boolean): string | null {
160
+ if (typeof value !== "string") return null;
161
+ const match = new RegExp(`^\\u001b\\[([0-9;]+)m`).exec(value);
162
+ if (!match) return null;
163
+ const params = match[1].split(";").map(Number);
164
+ const [mode, kind, first, second, third] = params;
165
+ const expectedMode = background ? 48 : 38;
166
+ if (mode !== expectedMode) {
167
+ if (!background && ((mode >= 30 && mode <= 37) || (mode >= 90 && mode <= 97))) {
168
+ return xterm256ToHex(mode >= 90 ? mode - 82 : mode - 30);
169
+ }
170
+ if (background && ((mode >= 40 && mode <= 47) || (mode >= 100 && mode <= 107))) {
171
+ return xterm256ToHex(mode >= 100 ? mode - 92 : mode - 40);
172
+ }
173
+ return null;
174
+ }
175
+ if (kind === 2 && params.length >= 5) {
176
+ const channels = [first, second, third];
177
+ if (!channels.every((channel) => Number.isInteger(channel) && channel >= 0 && channel <= 255)) return null;
178
+ return `#${toHex(first)}${toHex(second)}${toHex(third)}`;
179
+ }
180
+ if (kind === 5 && params.length >= 3) return xterm256ToHex(first);
181
+ return null;
182
+ }
183
+
184
+ function readThemeColor(theme: AnnotateTheme, token: ThemeColor | ThemeBg, background: boolean): string | null {
185
+ let colors: Readonly<Record<string, unknown>> | undefined;
186
+ try {
187
+ colors = theme.colors as Readonly<Record<string, unknown>>;
188
+ } catch {
189
+ colors = undefined;
190
+ }
191
+
192
+ let colorValue: unknown;
193
+ try {
194
+ colorValue = colors?.[token];
195
+ } catch {
196
+ colorValue = undefined;
197
+ }
198
+ const fromColors = colorValueToHex(colorValue);
199
+ if (fromColors != null) return fromColors;
200
+
201
+ try {
202
+ const styled = background
203
+ ? theme.bg(token as ThemeBg, SENTINEL)
204
+ : theme.fg(token as ThemeColor, SENTINEL);
205
+ return ansiColorToHex(styled, background);
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ function readAppearance(theme: AnnotateTheme | null | undefined): "dark" | "light" {
212
+ try {
213
+ return theme?.appearance === "light" ? "light" : "dark";
214
+ } catch {
215
+ return "dark";
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Build safe CSS custom properties from the active Pi theme.
221
+ *
222
+ * Every value is either a local fallback or a validated hex color. Theme
223
+ * getters are isolated per token so one broken custom theme cannot make the
224
+ * native annotation window unreadable or prevent it from opening.
225
+ */
226
+ export function getThemeCssVars(theme?: AnnotateTheme | null): Record<string, string> {
227
+ const vars: Record<string, string> = {
228
+ ...(readAppearance(theme) === "light" ? LIGHT_FALLBACK : DARK_FALLBACK),
229
+ };
230
+ if (theme == null) return vars;
231
+
232
+ for (const { token, cssVar } of FOREGROUND_THEME_VARS) {
233
+ const color = readThemeColor(theme, token, false);
234
+ if (color != null) vars[cssVar] = color;
235
+ }
236
+ for (const { token, cssVar } of BACKGROUND_THEME_VARS) {
237
+ const color = readThemeColor(theme, token, true);
238
+ if (color != null) vars[cssVar] = color;
239
+ }
240
+ return vars;
241
+ }
242
+
243
+ export const __testing = {
244
+ ansiColorToHex,
245
+ colorValueToHex,
246
+ xterm256ToHex,
247
+ };
package/ui.ts CHANGED
@@ -2,6 +2,8 @@ import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
+ import type { AnnotateTheme } from "./theme.js";
6
+ import { getThemeCssVars } from "./theme.js";
5
7
  import type { LastAssistantMessageData } from "./types.js";
6
8
 
7
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -15,9 +17,33 @@ function escapeInlineScriptSource(value: string): string {
15
17
  return value.replace(/<\/(script)/gi, "<\\/$1");
16
18
  }
17
19
 
18
- export function buildAnnotateLastMessageHtml(data: LastAssistantMessageData): string {
20
+ function buildThemeCssBlock(vars: Record<string, string>): string {
21
+ const declarations = Object.entries(vars)
22
+ .map(([key, value]) => ` ${key}: ${value};`)
23
+ .join("\n");
24
+ return `:root {\n${declarations}\n}`;
25
+ }
26
+
27
+ export function buildAnnotateLastMessageHtml(
28
+ data: LastAssistantMessageData,
29
+ theme?: AnnotateTheme,
30
+ ): string {
19
31
  const templateHtml = readFileSync(join(webDir, "index.html"), "utf8");
32
+ const mdRendererJs = escapeInlineScriptSource(readFileSync(join(webDir, "md-renderer.js"), "utf8"));
20
33
  const appJs = escapeInlineScriptSource(readFileSync(join(webDir, "app.js"), "utf8"));
21
34
  const payload = escapeForInlineScript(JSON.stringify(data));
22
- return templateHtml.replace('"__INLINE_DATA__"', payload).replace("__INLINE_JS__", appJs);
35
+ const themeBlock = buildThemeCssBlock(getThemeCssVars(theme));
36
+ const replacements = new Map([
37
+ ['"__INLINE_DATA__"', payload],
38
+ ["__INLINE_MD_RENDERER_JS__", mdRendererJs],
39
+ ["__INLINE_JS__", appJs],
40
+ ["__INLINE_THEME__", themeBlock],
41
+ ]);
42
+
43
+ // Replace the original template in one pass so marker-like message data,
44
+ // theme values, or runtime source is never scanned as another placeholder.
45
+ return templateHtml.replace(
46
+ /"__INLINE_DATA__"|__INLINE_MD_RENDERER_JS__|__INLINE_JS__|__INLINE_THEME__/g,
47
+ (marker) => replacements.get(marker) ?? marker,
48
+ );
23
49
  }