@diegopetrucci/pi-annotate-last-message 0.1.9 → 0.1.11
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.
- package/README.md +10 -2
- package/glimpseui.d.ts +87 -78
- package/index.ts +218 -174
- package/package.json +2 -1
- package/prompt.ts +87 -66
- package/quiet-glimpse.ts +161 -153
- package/session.ts +94 -89
- package/theme.ts +253 -0
- package/types.ts +23 -23
- package/ui.ts +35 -7
- package/web/app.js +348 -162
- package/web/index.html +153 -24
- package/web/md-renderer.js +458 -0
package/session.ts
CHANGED
|
@@ -1,112 +1,117 @@
|
|
|
1
1
|
import type { SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
LastAssistantMessageData,
|
|
5
|
+
LastAssistantMessageLookupResult,
|
|
6
|
+
LastAssistantMessageSection,
|
|
7
|
+
} from "./types.js";
|
|
4
8
|
|
|
5
9
|
const SECTION_PREVIEW_LIMIT = 96;
|
|
6
10
|
|
|
7
11
|
function truncatePreview(value: string, limit = SECTION_PREVIEW_LIMIT): string {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
13
|
+
if (normalized.length <= limit) return normalized;
|
|
14
|
+
return `${normalized.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
function assistantMessageText(
|
|
14
|
-
|
|
18
|
+
messageEntry: SessionMessageEntry,
|
|
15
19
|
): { kind: "assistant"; text: string } | { kind: "incomplete"; stopReason: string } | null {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
const { message } = messageEntry;
|
|
21
|
+
if (!("role" in message) || message.role !== "assistant") {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (message.stopReason !== "stop") {
|
|
25
|
+
return { kind: "incomplete", stopReason: String(message.stopReason) };
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(message.content)) {
|
|
28
|
+
return { kind: "assistant", text: "" };
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
kind: "assistant",
|
|
32
|
+
text: message.content
|
|
33
|
+
.filter(
|
|
34
|
+
(part): part is { type: "text"; text: string } =>
|
|
35
|
+
part.type === "text" && typeof part.text === "string",
|
|
36
|
+
)
|
|
37
|
+
.map((part) => part.text)
|
|
38
|
+
.join("\n")
|
|
39
|
+
.replace(/\r\n?/g, "\n"),
|
|
40
|
+
};
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
function buildSections(lines: string[]): LastAssistantMessageSection[] {
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
const sections: LastAssistantMessageSection[] = [];
|
|
45
|
+
let startIndex: number | null = null;
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
};
|
|
47
|
+
const flushSection = (endIndex: number): void => {
|
|
48
|
+
if (startIndex == null) return;
|
|
49
|
+
const sectionLines = lines.slice(startIndex, endIndex + 1);
|
|
50
|
+
const previewSource = sectionLines.find((line) => line.trim().length > 0) ?? "";
|
|
51
|
+
sections.push({
|
|
52
|
+
id: `section-${sections.length + 1}`,
|
|
53
|
+
index: sections.length + 1,
|
|
54
|
+
startLine: startIndex + 1,
|
|
55
|
+
endLine: endIndex + 1,
|
|
56
|
+
preview: truncatePreview(previewSource),
|
|
57
|
+
});
|
|
58
|
+
startIndex = null;
|
|
59
|
+
};
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
62
|
+
const line = lines[index];
|
|
63
|
+
if (line.trim().length === 0) {
|
|
64
|
+
flushSection(index - 1);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (startIndex == null) {
|
|
68
|
+
startIndex = index;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
flushSection(lines.length - 1);
|
|
73
|
+
return sections;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
export function findLastAssistantMessage(branch: SessionEntry[]): LastAssistantMessageLookupResult {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
78
|
+
const entry = branch[index];
|
|
79
|
+
if (entry.type !== "message") {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const extracted = assistantMessageText(entry);
|
|
83
|
+
if (extracted == null) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (extracted.kind === "incomplete") {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
code: "incomplete",
|
|
90
|
+
message: `Latest assistant message is incomplete (${extracted.stopReason}). Wait for it to finish, then try again.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
87
93
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
const text = extracted.text;
|
|
95
|
+
const trimmed = text.trim();
|
|
96
|
+
if (trimmed.length === 0) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
code: "empty",
|
|
100
|
+
message: "Latest assistant message has no text to annotate.",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
104
|
+
const lines = text.split("\n");
|
|
105
|
+
const data: LastAssistantMessageData = {
|
|
106
|
+
lines: lines.map((line, lineIndex) => ({ number: lineIndex + 1, text: line })),
|
|
107
|
+
sections: buildSections(lines),
|
|
108
|
+
};
|
|
109
|
+
return { ok: true, data };
|
|
110
|
+
}
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
code: "missing",
|
|
115
|
+
message: "No assistant messages found on the current session branch.",
|
|
116
|
+
};
|
|
112
117
|
}
|
package/theme.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { Theme, ThemeBg, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The small public part of Pi's active theme used by the native annotation UI.
|
|
5
|
+
* The command passes ctx.ui.theme directly; no Pi theme singleton is imported.
|
|
6
|
+
*/
|
|
7
|
+
export type AnnotateTheme = Pick<Theme, "appearance" | "colors" | "fg" | "bg">;
|
|
8
|
+
|
|
9
|
+
const DARK_FALLBACK: Readonly<Record<string, string>> = {
|
|
10
|
+
"--color-scheme": "dark",
|
|
11
|
+
"--bg": "#0d1117",
|
|
12
|
+
"--panel": "#151b23",
|
|
13
|
+
"--panel-hover": "#1f242c",
|
|
14
|
+
"--inset": "#0b1118",
|
|
15
|
+
"--border": "#30363d",
|
|
16
|
+
"--border-muted": "#21262d",
|
|
17
|
+
"--text": "#f0f6fc",
|
|
18
|
+
"--muted": "#8a8a8a",
|
|
19
|
+
"--dim": "#585858",
|
|
20
|
+
"--accent": "#f4c95d",
|
|
21
|
+
"--success": "#7ee787",
|
|
22
|
+
"--error": "#ffb3b3",
|
|
23
|
+
"--warning": "#f4c95d",
|
|
24
|
+
"--mdHeading": "#f4c95d",
|
|
25
|
+
"--mdLink": "#7dd3fc",
|
|
26
|
+
"--mdLinkUrl": "#8a8a8a",
|
|
27
|
+
"--mdCode": "#9b7bff",
|
|
28
|
+
"--mdCodeBlock": "inherit",
|
|
29
|
+
"--mdCodeBlockBorder": "#6f42c1",
|
|
30
|
+
"--mdQuote": "#8a8a8a",
|
|
31
|
+
"--mdQuoteBorder": "#6f42c1",
|
|
32
|
+
"--mdHr": "#585858",
|
|
33
|
+
"--mdListBullet": "#f4c95d",
|
|
34
|
+
"--mdBold": "inherit",
|
|
35
|
+
"--mdItalic": "inherit",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const LIGHT_FALLBACK: Readonly<Record<string, string>> = {
|
|
39
|
+
"--color-scheme": "light",
|
|
40
|
+
"--bg": "#f6f8fa",
|
|
41
|
+
"--panel": "#ffffff",
|
|
42
|
+
"--panel-hover": "#f3f4f6",
|
|
43
|
+
"--inset": "#f6f8fa",
|
|
44
|
+
"--border": "#d0d7de",
|
|
45
|
+
"--border-muted": "#afb8c1",
|
|
46
|
+
"--text": "#1f2328",
|
|
47
|
+
"--muted": "#57606a",
|
|
48
|
+
"--dim": "#6e7781",
|
|
49
|
+
"--accent": "#8250df",
|
|
50
|
+
"--success": "#1a7f37",
|
|
51
|
+
"--error": "#cf222e",
|
|
52
|
+
"--warning": "#9a6700",
|
|
53
|
+
"--mdHeading": "#9a6700",
|
|
54
|
+
"--mdLink": "#0969da",
|
|
55
|
+
"--mdLinkUrl": "#57606a",
|
|
56
|
+
"--mdCode": "#8250df",
|
|
57
|
+
"--mdCodeBlock": "#1a7f37",
|
|
58
|
+
"--mdCodeBlockBorder": "#6e7781",
|
|
59
|
+
"--mdQuote": "#57606a",
|
|
60
|
+
"--mdQuoteBorder": "#6e7781",
|
|
61
|
+
"--mdHr": "#8c959f",
|
|
62
|
+
"--mdListBullet": "#8250df",
|
|
63
|
+
"--mdBold": "inherit",
|
|
64
|
+
"--mdItalic": "inherit",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const BASIC_ANSI_COLORS: Readonly<Record<number, string>> = {
|
|
68
|
+
0: "#000000",
|
|
69
|
+
1: "#800000",
|
|
70
|
+
2: "#008000",
|
|
71
|
+
3: "#808000",
|
|
72
|
+
4: "#000080",
|
|
73
|
+
5: "#800080",
|
|
74
|
+
6: "#008080",
|
|
75
|
+
7: "#c0c0c0",
|
|
76
|
+
8: "#808080",
|
|
77
|
+
9: "#ff0000",
|
|
78
|
+
10: "#00ff00",
|
|
79
|
+
11: "#ffff00",
|
|
80
|
+
12: "#0000ff",
|
|
81
|
+
13: "#ff00ff",
|
|
82
|
+
14: "#00ffff",
|
|
83
|
+
15: "#ffffff",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const CUBE_LEVELS = [0, 95, 135, 175, 215, 255] as const;
|
|
87
|
+
const SENTINEL = "X";
|
|
88
|
+
|
|
89
|
+
const FOREGROUND_THEME_VARS: ReadonlyArray<{ token: ThemeColor; cssVar: string }> = [
|
|
90
|
+
{ token: "text", cssVar: "--text" },
|
|
91
|
+
{ token: "muted", cssVar: "--muted" },
|
|
92
|
+
{ token: "dim", cssVar: "--dim" },
|
|
93
|
+
{ token: "accent", cssVar: "--accent" },
|
|
94
|
+
{ token: "success", cssVar: "--success" },
|
|
95
|
+
{ token: "error", cssVar: "--error" },
|
|
96
|
+
{ token: "warning", cssVar: "--warning" },
|
|
97
|
+
{ token: "mdHeading", cssVar: "--mdHeading" },
|
|
98
|
+
{ token: "mdLink", cssVar: "--mdLink" },
|
|
99
|
+
{ token: "mdLinkUrl", cssVar: "--mdLinkUrl" },
|
|
100
|
+
{ token: "mdCode", cssVar: "--mdCode" },
|
|
101
|
+
{ token: "mdCodeBlock", cssVar: "--mdCodeBlock" },
|
|
102
|
+
{ token: "mdCodeBlockBorder", cssVar: "--mdCodeBlockBorder" },
|
|
103
|
+
{ token: "mdQuote", cssVar: "--mdQuote" },
|
|
104
|
+
{ token: "mdQuoteBorder", cssVar: "--mdQuoteBorder" },
|
|
105
|
+
{ token: "mdHr", cssVar: "--mdHr" },
|
|
106
|
+
{ token: "mdListBullet", cssVar: "--mdListBullet" },
|
|
107
|
+
{ token: "border", cssVar: "--border" },
|
|
108
|
+
{ token: "borderMuted", cssVar: "--border-muted" },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const BACKGROUND_THEME_VARS: ReadonlyArray<{ token: ThemeBg; cssVar: string }> = [
|
|
112
|
+
{ token: "customMessageBg", cssVar: "--panel" },
|
|
113
|
+
{ token: "selectedBg", cssVar: "--panel-hover" },
|
|
114
|
+
{ token: "toolPendingBg", cssVar: "--inset" },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
function toHex(value: number): string {
|
|
118
|
+
const clamped = Math.min(255, Math.max(0, Math.round(value)));
|
|
119
|
+
return clamped.toString(16).padStart(2, "0");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function xterm256ToHex(index: number): string | null {
|
|
123
|
+
if (!Number.isInteger(index) || index < 0 || index > 255) return null;
|
|
124
|
+
if (index < 16) return BASIC_ANSI_COLORS[index] ?? null;
|
|
125
|
+
if (index >= 232) {
|
|
126
|
+
const value = 8 + (index - 232) * 10;
|
|
127
|
+
const hex = toHex(value);
|
|
128
|
+
return `#${hex}${hex}${hex}`;
|
|
129
|
+
}
|
|
130
|
+
const cubeIndex = index - 16;
|
|
131
|
+
const blue = cubeIndex % 6;
|
|
132
|
+
const green = Math.floor(cubeIndex / 6) % 6;
|
|
133
|
+
const red = Math.floor(cubeIndex / 36) % 6;
|
|
134
|
+
return `#${toHex(CUBE_LEVELS[red])}${toHex(CUBE_LEVELS[green])}${toHex(CUBE_LEVELS[blue])}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function colorValueToHex(value: unknown): string | null {
|
|
138
|
+
if (typeof value !== "object" || value == null) return null;
|
|
139
|
+
const color = value as { kind?: unknown; r?: unknown; g?: unknown; b?: unknown; index?: unknown };
|
|
140
|
+
if (color.kind === "rgb") {
|
|
141
|
+
const channels = [color.r, color.g, color.b];
|
|
142
|
+
if (
|
|
143
|
+
!channels.every(
|
|
144
|
+
(channel) =>
|
|
145
|
+
typeof channel === "number" && Number.isFinite(channel) && channel >= 0 && channel <= 255,
|
|
146
|
+
)
|
|
147
|
+
) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return `#${toHex(color.r as number)}${toHex(color.g as number)}${toHex(color.b as number)}`;
|
|
151
|
+
}
|
|
152
|
+
if (color.kind === "indexed" && typeof color.index === "number") {
|
|
153
|
+
return xterm256ToHex(color.index);
|
|
154
|
+
}
|
|
155
|
+
// OKLCH values are intentionally handled through theme.fg/theme.bg below.
|
|
156
|
+
// Those public helpers account for Pi's configured terminal color mode.
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function ansiColorToHex(value: unknown, background: boolean): string | null {
|
|
161
|
+
if (typeof value !== "string") return null;
|
|
162
|
+
const match = new RegExp(`^\\u001b\\[([0-9;]+)m`).exec(value);
|
|
163
|
+
if (!match) return null;
|
|
164
|
+
const params = match[1].split(";").map(Number);
|
|
165
|
+
const [mode, kind, first, second, third] = params;
|
|
166
|
+
const expectedMode = background ? 48 : 38;
|
|
167
|
+
if (mode !== expectedMode) {
|
|
168
|
+
if (!background && ((mode >= 30 && mode <= 37) || (mode >= 90 && mode <= 97))) {
|
|
169
|
+
return xterm256ToHex(mode >= 90 ? mode - 82 : mode - 30);
|
|
170
|
+
}
|
|
171
|
+
if (background && ((mode >= 40 && mode <= 47) || (mode >= 100 && mode <= 107))) {
|
|
172
|
+
return xterm256ToHex(mode >= 100 ? mode - 92 : mode - 40);
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (kind === 2 && params.length >= 5) {
|
|
177
|
+
const channels = [first, second, third];
|
|
178
|
+
if (!channels.every((channel) => Number.isInteger(channel) && channel >= 0 && channel <= 255))
|
|
179
|
+
return null;
|
|
180
|
+
return `#${toHex(first)}${toHex(second)}${toHex(third)}`;
|
|
181
|
+
}
|
|
182
|
+
if (kind === 5 && params.length >= 3) return xterm256ToHex(first);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readThemeColor(
|
|
187
|
+
theme: AnnotateTheme,
|
|
188
|
+
token: ThemeColor | ThemeBg,
|
|
189
|
+
background: boolean,
|
|
190
|
+
): string | null {
|
|
191
|
+
let colors: Readonly<Record<string, unknown>> | undefined;
|
|
192
|
+
try {
|
|
193
|
+
colors = theme.colors as Readonly<Record<string, unknown>>;
|
|
194
|
+
} catch {
|
|
195
|
+
colors = undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let colorValue: unknown;
|
|
199
|
+
try {
|
|
200
|
+
colorValue = colors?.[token];
|
|
201
|
+
} catch {
|
|
202
|
+
colorValue = undefined;
|
|
203
|
+
}
|
|
204
|
+
const fromColors = colorValueToHex(colorValue);
|
|
205
|
+
if (fromColors != null) return fromColors;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const styled = background
|
|
209
|
+
? theme.bg(token as ThemeBg, SENTINEL)
|
|
210
|
+
: theme.fg(token as ThemeColor, SENTINEL);
|
|
211
|
+
return ansiColorToHex(styled, background);
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readAppearance(theme: AnnotateTheme | null | undefined): "dark" | "light" {
|
|
218
|
+
try {
|
|
219
|
+
return theme?.appearance === "light" ? "light" : "dark";
|
|
220
|
+
} catch {
|
|
221
|
+
return "dark";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Build safe CSS custom properties from the active Pi theme.
|
|
227
|
+
*
|
|
228
|
+
* Every value is either a local fallback or a validated hex color. Theme
|
|
229
|
+
* getters are isolated per token so one broken custom theme cannot make the
|
|
230
|
+
* native annotation window unreadable or prevent it from opening.
|
|
231
|
+
*/
|
|
232
|
+
export function getThemeCssVars(theme?: AnnotateTheme | null): Record<string, string> {
|
|
233
|
+
const vars: Record<string, string> = {
|
|
234
|
+
...(readAppearance(theme) === "light" ? LIGHT_FALLBACK : DARK_FALLBACK),
|
|
235
|
+
};
|
|
236
|
+
if (theme == null) return vars;
|
|
237
|
+
|
|
238
|
+
for (const { token, cssVar } of FOREGROUND_THEME_VARS) {
|
|
239
|
+
const color = readThemeColor(theme, token, false);
|
|
240
|
+
if (color != null) vars[cssVar] = color;
|
|
241
|
+
}
|
|
242
|
+
for (const { token, cssVar } of BACKGROUND_THEME_VARS) {
|
|
243
|
+
const color = readThemeColor(theme, token, true);
|
|
244
|
+
if (color != null) vars[cssVar] = color;
|
|
245
|
+
}
|
|
246
|
+
return vars;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const __testing = {
|
|
250
|
+
ansiColorToHex,
|
|
251
|
+
colorValueToHex,
|
|
252
|
+
xterm256ToHex,
|
|
253
|
+
};
|
package/types.ts
CHANGED
|
@@ -1,46 +1,46 @@
|
|
|
1
1
|
export interface LastAssistantMessageLine {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
number: number;
|
|
3
|
+
text: string;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
export interface LastAssistantMessageSection {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
text: string;
|
|
7
|
+
id: string;
|
|
8
|
+
index: number;
|
|
9
|
+
startLine: number;
|
|
10
|
+
endLine: number;
|
|
11
|
+
preview: string;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
14
|
export interface LastAssistantMessageData {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
sections: LastAssistantMessageSection[];
|
|
15
|
+
lines: LastAssistantMessageLine[];
|
|
16
|
+
sections: LastAssistantMessageSection[];
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
export type LastAssistantMessageLookupResult =
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
| { ok: true; data: LastAssistantMessageData }
|
|
21
|
+
| { ok: false; code: "missing" | "incomplete" | "empty"; message: string };
|
|
24
22
|
|
|
25
23
|
export interface AnnotateLastMessageInlineComment {
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
line: number;
|
|
25
|
+
body: string;
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
export interface AnnotateLastMessageSectionComment {
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
sectionId: string;
|
|
30
|
+
body: string;
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
export interface AnnotateLastMessageSubmitPayload {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
type: "submit";
|
|
35
|
+
overallComment: string;
|
|
36
|
+
inlineComments: AnnotateLastMessageInlineComment[];
|
|
37
|
+
sectionComments: AnnotateLastMessageSectionComment[];
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
export interface AnnotateLastMessageCancelPayload {
|
|
43
|
-
|
|
41
|
+
type: "cancel";
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
export type AnnotateLastMessageWindowMessage =
|
|
44
|
+
export type AnnotateLastMessageWindowMessage =
|
|
45
|
+
| AnnotateLastMessageSubmitPayload
|
|
46
|
+
| AnnotateLastMessageCancelPayload;
|
package/ui.ts
CHANGED
|
@@ -2,22 +2,50 @@ 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));
|
|
8
10
|
const webDir = join(__dirname, "web");
|
|
9
11
|
|
|
10
12
|
function escapeForInlineScript(value: string): string {
|
|
11
|
-
|
|
13
|
+
return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
function escapeInlineScriptSource(value: string): string {
|
|
15
|
-
|
|
17
|
+
return value.replace(/<\/(script)/gi, "<\\/$1");
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 {
|
|
31
|
+
const templateHtml = readFileSync(join(webDir, "index.html"), "utf8");
|
|
32
|
+
const mdRendererJs = escapeInlineScriptSource(
|
|
33
|
+
readFileSync(join(webDir, "md-renderer.js"), "utf8"),
|
|
34
|
+
);
|
|
35
|
+
const appJs = escapeInlineScriptSource(readFileSync(join(webDir, "app.js"), "utf8"));
|
|
36
|
+
const payload = escapeForInlineScript(JSON.stringify(data));
|
|
37
|
+
const themeBlock = buildThemeCssBlock(getThemeCssVars(theme));
|
|
38
|
+
const replacements = new Map([
|
|
39
|
+
['"__INLINE_DATA__"', payload],
|
|
40
|
+
["__INLINE_MD_RENDERER_JS__", mdRendererJs],
|
|
41
|
+
["__INLINE_JS__", appJs],
|
|
42
|
+
["__INLINE_THEME__", themeBlock],
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Replace the original template in one pass so marker-like message data,
|
|
46
|
+
// theme values, or runtime source is never scanned as another placeholder.
|
|
47
|
+
return templateHtml.replace(
|
|
48
|
+
/"__INLINE_DATA__"|__INLINE_MD_RENDERER_JS__|__INLINE_JS__|__INLINE_THEME__/g,
|
|
49
|
+
(marker) => replacements.get(marker) ?? marker,
|
|
50
|
+
);
|
|
23
51
|
}
|