@diegopetrucci/pi-annotate-last-message 0.1.0

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.
@@ -0,0 +1 @@
1
+ 0.78.0
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # annotate-last-message
2
+
3
+ A standalone pi extension that adds `/annotate-last-message`, a native Glimpse window for annotating the latest completed assistant message on the current session branch.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@diegopetrucci/pi-annotate-last-message
9
+ ```
10
+
11
+ Then reload pi:
12
+
13
+ ```text
14
+ /reload
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ Run `/annotate-last-message` from an interactive pi session. The annotation window lets you leave:
20
+
21
+ - overall guidance for the whole reply,
22
+ - section comments for larger chunks of the message, and
23
+ - inline notes tied to individual lines.
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.
26
+
27
+ ## Requirements
28
+
29
+ - Interactive pi session with editor access.
30
+ - A completed assistant message with text on the active branch.
31
+ - Local desktop support for opening a native [Glimpse](https://github.com/mariozechner/glimpse) window.
32
+
33
+ ## Troubleshooting
34
+
35
+ - `annotate-last-message requires interactive mode.` → run it from the pi TUI.
36
+ - `No assistant messages found on the current session branch.` → wait for an assistant reply, then rerun.
37
+ - `Latest assistant message is incomplete (...)` → wait for the assistant turn to finish, then rerun.
38
+ - `Latest assistant message has no text to annotate.` → rerun after a normal text reply.
39
+ - `A last-message annotation window is already open.` → reuse or close the existing window before opening another.
40
+ - `Annotation failed: Glimpse host not found ...` → the native window runtime is unavailable; reinstall/update the package and rerun from a machine/session that can open native windows.
package/glimpseui.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ declare module "glimpseui" {
2
+ import { EventEmitter } from "node:events";
3
+
4
+ export type FollowMode = "snap" | "spring";
5
+ export type CursorAnchor = "top-left" | "top-right" | "right" | "bottom-right" | "bottom-left" | "left";
6
+
7
+ export interface GlimpseOpenOptions {
8
+ width?: number;
9
+ height?: number;
10
+ title?: string;
11
+ x?: number;
12
+ y?: number;
13
+ frameless?: boolean;
14
+ floating?: boolean;
15
+ transparent?: boolean;
16
+ clickThrough?: boolean;
17
+ followCursor?: boolean;
18
+ followMode?: FollowMode;
19
+ cursorAnchor?: CursorAnchor;
20
+ cursorOffset?: {
21
+ x?: number;
22
+ y?: number;
23
+ };
24
+ hidden?: boolean;
25
+ autoClose?: boolean;
26
+ timeout?: number;
27
+ }
28
+
29
+ export interface GlimpseScreenInfo {
30
+ width: number;
31
+ height: number;
32
+ scaleFactor: number;
33
+ visibleX?: number;
34
+ visibleY?: number;
35
+ visibleWidth?: number;
36
+ visibleHeight?: number;
37
+ x?: number;
38
+ y?: number;
39
+ }
40
+
41
+ export interface GlimpseAppearanceInfo {
42
+ darkMode: boolean;
43
+ accentColor: string;
44
+ reduceMotion: boolean;
45
+ increaseContrast: boolean;
46
+ }
47
+
48
+ export interface GlimpseCursorInfo {
49
+ x: number;
50
+ y: number;
51
+ }
52
+
53
+ export interface GlimpseCursorTip {
54
+ x: number;
55
+ y: number;
56
+ }
57
+
58
+ export interface GlimpseInfo {
59
+ screen: GlimpseScreenInfo;
60
+ screens: GlimpseScreenInfo[];
61
+ appearance: GlimpseAppearanceInfo;
62
+ cursor: GlimpseCursorInfo;
63
+ cursorTip: GlimpseCursorTip | null;
64
+ }
65
+
66
+ export class GlimpseWindow extends EventEmitter {
67
+ on(event: "ready", listener: (info: GlimpseInfo) => void): this;
68
+ on(event: "message", listener: (data: unknown) => void): this;
69
+ on(event: "info", listener: (info: GlimpseInfo) => void): this;
70
+ on(event: "closed", listener: () => void): this;
71
+ on(event: "error", listener: (error: Error) => void): this;
72
+ once(event: "ready", listener: (info: GlimpseInfo) => void): this;
73
+ once(event: "message", listener: (data: unknown) => void): this;
74
+ once(event: "info", listener: (info: GlimpseInfo) => void): this;
75
+ once(event: "closed", listener: () => void): this;
76
+ once(event: "error", listener: (error: Error) => void): this;
77
+ send(js: string): void;
78
+ setHTML(html: string): void;
79
+ show(options?: { title?: string }): void;
80
+ close(): void;
81
+ loadFile(path: string): void;
82
+ get info(): GlimpseInfo | null;
83
+ getInfo(): void;
84
+ followCursor(enabled: boolean, anchor?: CursorAnchor, mode?: FollowMode): void;
85
+ }
86
+
87
+ export function open(html: string, options?: GlimpseOpenOptions): GlimpseWindow;
88
+ export function prompt<T = unknown>(html: string, options?: GlimpseOpenOptions): Promise<T | null>;
89
+ }
package/index.ts ADDED
@@ -0,0 +1,165 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { openQuietGlimpse, type QuietGlimpseWindow } from "./quiet-glimpse.js";
4
+ import { composeAnnotateLastMessagePrompt, hasAnnotateLastMessageFeedback } from "./prompt.js";
5
+ import { findLastAssistantMessage } from "./session.js";
6
+ import type { AnnotateLastMessageCancelPayload, AnnotateLastMessageSubmitPayload, AnnotateLastMessageWindowMessage, LastAssistantMessageData } from "./types.js";
7
+ import { buildAnnotateLastMessageHtml } from "./ui.js";
8
+
9
+ function isSubmitPayload(value: unknown): value is AnnotateLastMessageSubmitPayload {
10
+ return typeof value === "object" && value != null && "type" in value && value.type === "submit";
11
+ }
12
+
13
+ function isCancelPayload(value: unknown): value is AnnotateLastMessageCancelPayload {
14
+ return typeof value === "object" && value != null && "type" in value && value.type === "cancel";
15
+ }
16
+
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
+ export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
23
+ let activeWindow: QuietGlimpseWindow | null = null;
24
+ const suppressedWindows = new WeakSet<QuietGlimpseWindow>();
25
+
26
+ function closeActiveWindow(options: { suppressResults?: boolean } = {}): void {
27
+ if (activeWindow == null) return;
28
+ const windowToClose = activeWindow;
29
+ activeWindow = null;
30
+ if (options.suppressResults) {
31
+ suppressedWindows.add(windowToClose);
32
+ }
33
+ try {
34
+ windowToClose.close();
35
+ } catch {
36
+ // Ignore races when the native window is already closing.
37
+ }
38
+ }
39
+
40
+ async function openAnnotationWindow(ctx: ExtensionCommandContext): Promise<void> {
41
+ if (!ctx.hasUI) {
42
+ ctx.ui.notify("annotate-last-message requires interactive mode.", "error");
43
+ return;
44
+ }
45
+ if (activeWindow != null) {
46
+ ctx.ui.notify("A last-message annotation window is already open.", "warning");
47
+ return;
48
+ }
49
+
50
+ const messageResult = findLastAssistantMessage(ctx.sessionManager.getBranch());
51
+ if (!messageResult.ok) {
52
+ ctx.ui.notify(messageResult.message, "error");
53
+ return;
54
+ }
55
+
56
+ const messageData = messageResult.data;
57
+
58
+ try {
59
+ const html = buildAnnotateLastMessageHtml(messageData);
60
+ const window = await openQuietGlimpse(html, {
61
+ width: 1440,
62
+ height: 980,
63
+ title: "annotate last message",
64
+ });
65
+ activeWindow = window;
66
+
67
+ const terminalMessagePromise = new Promise<AnnotateLastMessageSubmitPayload | AnnotateLastMessageCancelPayload | null>(
68
+ (resolve, reject) => {
69
+ let settled = false;
70
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
71
+
72
+ const cleanup = (): void => {
73
+ if (closeTimer != null) {
74
+ clearTimeout(closeTimer);
75
+ closeTimer = null;
76
+ }
77
+ window.removeListener("message", onMessage);
78
+ window.removeListener("closed", onClosed);
79
+ window.removeListener("error", onError);
80
+ if (activeWindow === window) {
81
+ activeWindow = null;
82
+ }
83
+ };
84
+
85
+ const settle = (value: AnnotateLastMessageWindowMessage | null): void => {
86
+ if (settled) return;
87
+ settled = true;
88
+ cleanup();
89
+ resolve(value);
90
+ };
91
+
92
+ const onMessage = (data: unknown): void => {
93
+ if (isSubmitPayload(data) || isCancelPayload(data)) {
94
+ settle(data);
95
+ }
96
+ };
97
+
98
+ const onClosed = (): void => {
99
+ if (settled || closeTimer != null) return;
100
+ closeTimer = setTimeout(() => {
101
+ closeTimer = null;
102
+ settle(null);
103
+ }, 250);
104
+ };
105
+
106
+ const onError = (error: Error): void => {
107
+ if (settled) return;
108
+ settled = true;
109
+ cleanup();
110
+ reject(error);
111
+ };
112
+
113
+ window.on("message", onMessage);
114
+ window.on("closed", onClosed);
115
+ window.on("error", onError);
116
+ },
117
+ );
118
+
119
+ void (async (windowMessageSource: QuietGlimpseWindow, sourceData: LastAssistantMessageData) => {
120
+ try {
121
+ const result = await terminalMessagePromise;
122
+ if (suppressedWindows.has(windowMessageSource)) return;
123
+ if (result == null) return;
124
+ if (result.type === "cancel") {
125
+ ctx.ui.notify("Annotation cancelled.", "info");
126
+ return;
127
+ }
128
+ if (!hasAnnotateLastMessageFeedback(result)) {
129
+ ctx.ui.notify("No annotation feedback submitted.", "info");
130
+ return;
131
+ }
132
+
133
+ const prompt = composeAnnotateLastMessagePrompt(sourceData, result);
134
+ appendPrompt(ctx, prompt);
135
+ ctx.ui.notify("Appended annotation feedback to the editor.", "info");
136
+ } catch (error) {
137
+ if (suppressedWindows.has(windowMessageSource)) return;
138
+ const message = error instanceof Error ? error.message : String(error);
139
+ ctx.ui.notify(`Annotation failed: ${message}`, "error");
140
+ }
141
+ })(window, messageData);
142
+
143
+ ctx.ui.notify("Opened native annotation window.", "info");
144
+ } catch (error) {
145
+ closeActiveWindow({ suppressResults: true });
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ ctx.ui.notify(`Annotation failed: ${message}`, "error");
148
+ }
149
+ }
150
+
151
+ pi.registerCommand("annotate-last-message", {
152
+ description: "Open a native annotation window for the latest assistant message",
153
+ handler: async (_args, ctx) => {
154
+ await openAnnotationWindow(ctx);
155
+ },
156
+ });
157
+
158
+ pi.on("session_shutdown", async () => {
159
+ closeActiveWindow({ suppressResults: true });
160
+ });
161
+ }
162
+
163
+ export default function (pi: ExtensionAPI): void {
164
+ registerAnnotateLastMessageCommand(pi);
165
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@diegopetrucci/pi-annotate-last-message",
3
+ "version": "0.1.0",
4
+ "description": "A standalone pi extension that adds /annotate-last-message, a native Glimpse UI for annotating the latest assistant reply.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "annotate",
9
+ "feedback",
10
+ "assistant-message"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/diegopetrucci/pi-extensions.git",
16
+ "directory": "extensions/annotate-last-message"
17
+ },
18
+ "files": [
19
+ "index.ts",
20
+ "prompt.ts",
21
+ "quiet-glimpse.ts",
22
+ "session.ts",
23
+ "types.ts",
24
+ "ui.ts",
25
+ "web",
26
+ "glimpseui.d.ts",
27
+ "README.md",
28
+ ".pi-fleet-tested-version"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "pi": {
34
+ "extensions": [
35
+ "index.ts"
36
+ ]
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "*"
40
+ },
41
+ "dependencies": {
42
+ "glimpseui": "^0.8.1"
43
+ },
44
+ "type": "module"
45
+ }
package/prompt.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { AnnotateLastMessageInlineComment, AnnotateLastMessageSectionComment, AnnotateLastMessageSubmitPayload, LastAssistantMessageData, LastAssistantMessageLine, LastAssistantMessageSection } from "./types.js";
2
+
3
+ const EXCERPT_LIMIT = 120;
4
+
5
+ function truncateExcerpt(value: string, limit = EXCERPT_LIMIT): string {
6
+ const normalized = value.replace(/\s+/g, " ").trim();
7
+ if (normalized.length === 0) return "(blank line)";
8
+ if (normalized.length <= limit) return normalized;
9
+ return `${normalized.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
10
+ }
11
+
12
+ function formatLineLabel(line: number): string {
13
+ return `line ${line}`;
14
+ }
15
+
16
+ function formatLineComment(comment: AnnotateLastMessageInlineComment, line: LastAssistantMessageLine | undefined): string {
17
+ const excerpt = truncateExcerpt(line?.text ?? "");
18
+ return `${formatLineLabel(comment.line)} — “${excerpt}”`;
19
+ }
20
+
21
+ function formatSectionLineRange(section: LastAssistantMessageSection): string {
22
+ return section.startLine === section.endLine
23
+ ? `line ${section.startLine}`
24
+ : `lines ${section.startLine}-${section.endLine}`;
25
+ }
26
+
27
+ function formatSectionComment(comment: AnnotateLastMessageSectionComment, section: LastAssistantMessageSection | undefined): string {
28
+ if (section == null) {
29
+ return "Unknown section";
30
+ }
31
+ return `Section ${section.index} (${formatSectionLineRange(section)}) — “${truncateExcerpt(section.preview)}”`;
32
+ }
33
+
34
+ export function hasAnnotateLastMessageFeedback(payload: AnnotateLastMessageSubmitPayload): boolean {
35
+ if (payload.overallComment.trim().length > 0) {
36
+ return true;
37
+ }
38
+ if (payload.inlineComments.some((comment) => comment.body.trim().length > 0)) {
39
+ return true;
40
+ }
41
+ return payload.sectionComments.some((comment) => comment.body.trim().length > 0);
42
+ }
43
+
44
+ export function composeAnnotateLastMessagePrompt(
45
+ message: LastAssistantMessageData,
46
+ payload: AnnotateLastMessageSubmitPayload,
47
+ ): string {
48
+ const lineMap = new Map(message.lines.map((line) => [line.number, line]));
49
+ const sectionMap = new Map(message.sections.map((section) => [section.id, section]));
50
+ const inlineComments = payload.inlineComments
51
+ .filter((comment) => comment.body.trim().length > 0)
52
+ .sort((left, right) => left.line - right.line);
53
+ const sectionComments = payload.sectionComments
54
+ .filter((comment) => comment.body.trim().length > 0)
55
+ .sort((left, right) => (sectionMap.get(left.sectionId)?.index ?? Number.MAX_SAFE_INTEGER) - (sectionMap.get(right.sectionId)?.index ?? Number.MAX_SAFE_INTEGER));
56
+ const lines: string[] = [];
57
+
58
+ lines.push("Please revisit your last assistant message using the annotation feedback below.");
59
+ lines.push("");
60
+ lines.push("Treat this as planning-oriented feedback:");
61
+ lines.push("- update your explanation, plan, or proposed approach in chat;");
62
+ lines.push("- do not assume any code or file changes have already been applied;");
63
+ lines.push("- do not auto-apply anything outside the normal response flow.");
64
+ lines.push("");
65
+
66
+ const overallComment = payload.overallComment.trim();
67
+ if (overallComment.length > 0) {
68
+ lines.push("## Overall guidance");
69
+ lines.push(overallComment);
70
+ lines.push("");
71
+ }
72
+
73
+ if (sectionComments.length > 0) {
74
+ lines.push("## Section comments");
75
+ sectionComments.forEach((comment, index) => {
76
+ lines.push(`${index + 1}. ${formatSectionComment(comment, sectionMap.get(comment.sectionId))}`);
77
+ lines.push(` ${comment.body.trim()}`);
78
+ lines.push("");
79
+ });
80
+ }
81
+
82
+ if (inlineComments.length > 0) {
83
+ lines.push("## Inline comments");
84
+ inlineComments.forEach((comment, index) => {
85
+ lines.push(`${index + 1}. ${formatLineComment(comment, lineMap.get(comment.line))}`);
86
+ lines.push(` ${comment.body.trim()}`);
87
+ lines.push("");
88
+ });
89
+ }
90
+
91
+ lines.push("Please respond by revising your last message or its plan in chat, incorporating the feedback above.");
92
+ return lines.join("\n").trim();
93
+ }
@@ -0,0 +1,156 @@
1
+ /// <reference path="./glimpseui.d.ts" />
2
+
3
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
4
+ import { EventEmitter } from "node:events";
5
+ import { existsSync } from "node:fs";
6
+ import { createInterface } from "node:readline";
7
+ import type { GlimpseOpenOptions } from "glimpseui";
8
+
9
+ interface NativeHostInfo {
10
+ path: string;
11
+ extraArgs?: string[];
12
+ buildHint?: string;
13
+ }
14
+
15
+ interface GlimpseProtocolMessage {
16
+ type?: string;
17
+ data?: unknown;
18
+ screen?: unknown;
19
+ screens?: unknown;
20
+ appearance?: unknown;
21
+ cursor?: unknown;
22
+ cursorTip?: unknown;
23
+ }
24
+
25
+ export interface QuietGlimpseWindow {
26
+ on(event: "message", listener: (data: unknown) => void): this;
27
+ on(event: "closed", listener: () => void): this;
28
+ on(event: "error", listener: (error: Error) => void): this;
29
+ removeListener(event: "message", listener: (data: unknown) => void): this;
30
+ removeListener(event: "closed", listener: () => void): this;
31
+ removeListener(event: "error", listener: (error: Error) => void): this;
32
+ send(js: string): void;
33
+ close(): void;
34
+ }
35
+
36
+ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow {
37
+ #proc: ChildProcessWithoutNullStreams;
38
+ #closed = false;
39
+ #pendingHTML: string | null;
40
+ #stderr = "";
41
+
42
+ constructor(proc: ChildProcessWithoutNullStreams, initialHTML: string) {
43
+ super();
44
+ this.#proc = proc;
45
+ this.#pendingHTML = initialHTML;
46
+
47
+ proc.stdin.on("error", () => {});
48
+ proc.stderr.on("data", (chunk) => {
49
+ this.#stderr += chunk.toString();
50
+ });
51
+
52
+ const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
53
+ rl.on("line", (line) => {
54
+ let message: GlimpseProtocolMessage;
55
+ try {
56
+ message = JSON.parse(line) as GlimpseProtocolMessage;
57
+ } catch {
58
+ this.emit("error", new Error(`Malformed glimpse protocol line: ${line}`));
59
+ return;
60
+ }
61
+
62
+ switch (message.type) {
63
+ case "ready":
64
+ if (this.#pendingHTML != null) {
65
+ this.setHTML(this.#pendingHTML);
66
+ this.#pendingHTML = null;
67
+ }
68
+ break;
69
+ case "message":
70
+ this.emit("message", message.data);
71
+ break;
72
+ case "closed":
73
+ this.#markClosed();
74
+ break;
75
+ default:
76
+ break;
77
+ }
78
+ });
79
+
80
+ proc.on("error", (error) => this.emit("error", error));
81
+ proc.on("exit", (code, signal) => {
82
+ const stderr = this.#stderr.trim();
83
+ const failed = signal != null || (code != null && code !== 0);
84
+ if (!this.#closed && failed) {
85
+ const message = stderr || `Glimpse process exited abnormally (code: ${code}, signal: ${signal}).`;
86
+ this.emit("error", new Error(message));
87
+ }
88
+ this.#markClosed();
89
+ });
90
+ }
91
+
92
+ send(js: string): void {
93
+ this.#write({ type: "eval", js });
94
+ }
95
+
96
+ close(): void {
97
+ this.#write({ type: "close" });
98
+ }
99
+
100
+ #markClosed(): void {
101
+ if (this.#closed) return;
102
+ this.#closed = true;
103
+ this.emit("closed");
104
+ }
105
+
106
+ #write(obj: Record<string, unknown>): void {
107
+ if (this.#closed) return;
108
+ this.#proc.stdin.write(`${JSON.stringify(obj)}\n`);
109
+ }
110
+
111
+ setHTML(html: string): void {
112
+ this.#write({ type: "html", html: Buffer.from(html).toString("base64") });
113
+ }
114
+ }
115
+
116
+ async function getNativeHostInfo(): Promise<NativeHostInfo> {
117
+ const glimpseModule = (await import("glimpseui")) as unknown as { getNativeHostInfo: () => NativeHostInfo };
118
+ return glimpseModule.getNativeHostInfo();
119
+ }
120
+
121
+ export async function openQuietGlimpse(html: string, options: GlimpseOpenOptions = {}): Promise<QuietGlimpseWindow> {
122
+ const host = await getNativeHostInfo();
123
+ if (!existsSync(host.path)) {
124
+ const hint = host.buildHint ? ` ${host.buildHint}` : "";
125
+ throw new Error(`Glimpse host not found at '${host.path}'.${hint}`);
126
+ }
127
+ const args: string[] = [];
128
+
129
+ if (options.width != null) args.push("--width", String(options.width));
130
+ if (options.height != null) args.push("--height", String(options.height));
131
+ if (options.title != null) args.push("--title", options.title);
132
+ if (options.frameless) args.push("--frameless");
133
+ if (options.floating) args.push("--floating");
134
+ if (options.transparent) args.push("--transparent");
135
+ if (options.clickThrough) args.push("--click-through");
136
+ if (options.hidden) args.push("--hidden");
137
+ if (options.autoClose) args.push("--auto-close");
138
+ if (options.x != null) args.push(`--x=${options.x}`);
139
+ if (options.y != null) args.push(`--y=${options.y}`);
140
+ if (options.cursorOffset?.x != null) args.push(`--cursor-offset-x=${options.cursorOffset.x}`);
141
+ if (options.cursorOffset?.y != null) args.push(`--cursor-offset-y=${options.cursorOffset.y}`);
142
+ if (options.cursorAnchor != null) args.push("--cursor-anchor", options.cursorAnchor);
143
+ if (options.followMode != null) args.push("--follow-mode", options.followMode);
144
+ if (options.followCursor) args.push("--follow-cursor");
145
+
146
+ const proc = spawn(host.path, [...(host.extraArgs ?? []), ...args], {
147
+ stdio: ["pipe", "pipe", "pipe"],
148
+ windowsHide: process.platform === "win32",
149
+ env: {
150
+ ...process.env,
151
+ OS_ACTIVITY_MODE: process.env.OS_ACTIVITY_MODE ?? "disable",
152
+ },
153
+ });
154
+
155
+ return new QuietGlimpseWindowImpl(proc, html);
156
+ }
package/session.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { LastAssistantMessageData, LastAssistantMessageLookupResult, LastAssistantMessageSection } from "./types.js";
4
+
5
+ const SECTION_PREVIEW_LIMIT = 96;
6
+
7
+ function truncatePreview(value: string, limit = SECTION_PREVIEW_LIMIT): string {
8
+ const normalized = value.replace(/\s+/g, " ").trim();
9
+ if (normalized.length <= limit) return normalized;
10
+ return `${normalized.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
11
+ }
12
+
13
+ function assistantMessageText(
14
+ messageEntry: SessionMessageEntry,
15
+ ): { kind: "assistant"; text: string } | { kind: "incomplete"; stopReason: string } | null {
16
+ const { message } = messageEntry;
17
+ if (!("role" in message) || message.role !== "assistant") {
18
+ return null;
19
+ }
20
+ if (message.stopReason !== "stop") {
21
+ return { kind: "incomplete", stopReason: String(message.stopReason) };
22
+ }
23
+ if (!Array.isArray(message.content)) {
24
+ return { kind: "assistant", text: "" };
25
+ }
26
+ return {
27
+ kind: "assistant",
28
+ text: message.content
29
+ .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
30
+ .map((part) => part.text)
31
+ .join("\n")
32
+ .replace(/\r\n?/g, "\n"),
33
+ };
34
+ }
35
+
36
+ function buildSections(lines: string[]): LastAssistantMessageSection[] {
37
+ const sections: LastAssistantMessageSection[] = [];
38
+ let startIndex: number | null = null;
39
+
40
+ const flushSection = (endIndex: number): void => {
41
+ if (startIndex == null) return;
42
+ const sectionLines = lines.slice(startIndex, endIndex + 1);
43
+ const previewSource = sectionLines.find((line) => line.trim().length > 0) ?? "";
44
+ sections.push({
45
+ id: `section-${sections.length + 1}`,
46
+ index: sections.length + 1,
47
+ startLine: startIndex + 1,
48
+ endLine: endIndex + 1,
49
+ preview: truncatePreview(previewSource),
50
+ text: sectionLines.join("\n"),
51
+ });
52
+ startIndex = null;
53
+ };
54
+
55
+ for (let index = 0; index < lines.length; index += 1) {
56
+ const line = lines[index];
57
+ if (line.trim().length === 0) {
58
+ flushSection(index - 1);
59
+ continue;
60
+ }
61
+ if (startIndex == null) {
62
+ startIndex = index;
63
+ }
64
+ }
65
+
66
+ flushSection(lines.length - 1);
67
+ return sections;
68
+ }
69
+
70
+ export function findLastAssistantMessage(branch: SessionEntry[]): LastAssistantMessageLookupResult {
71
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
72
+ const entry = branch[index];
73
+ if (entry.type !== "message") {
74
+ continue;
75
+ }
76
+ const extracted = assistantMessageText(entry);
77
+ if (extracted == null) {
78
+ continue;
79
+ }
80
+ if (extracted.kind === "incomplete") {
81
+ return {
82
+ ok: false,
83
+ code: "incomplete",
84
+ message: `Latest assistant message is incomplete (${extracted.stopReason}). Wait for it to finish, then try again.`,
85
+ };
86
+ }
87
+
88
+ const text = extracted.text;
89
+ const trimmed = text.trim();
90
+ if (trimmed.length === 0) {
91
+ return {
92
+ ok: false,
93
+ code: "empty",
94
+ message: "Latest assistant message has no text to annotate.",
95
+ };
96
+ }
97
+
98
+ const lines = text.split("\n");
99
+ const data: LastAssistantMessageData = {
100
+ text,
101
+ lines: lines.map((line, lineIndex) => ({ number: lineIndex + 1, text: line })),
102
+ sections: buildSections(lines),
103
+ };
104
+ return { ok: true, data };
105
+ }
106
+
107
+ return {
108
+ ok: false,
109
+ code: "missing",
110
+ message: "No assistant messages found on the current session branch.",
111
+ };
112
+ }
package/types.ts ADDED
@@ -0,0 +1,46 @@
1
+ export interface LastAssistantMessageLine {
2
+ number: number;
3
+ text: string;
4
+ }
5
+
6
+ export interface LastAssistantMessageSection {
7
+ id: string;
8
+ index: number;
9
+ startLine: number;
10
+ endLine: number;
11
+ preview: string;
12
+ text: string;
13
+ }
14
+
15
+ export interface LastAssistantMessageData {
16
+ text: string;
17
+ lines: LastAssistantMessageLine[];
18
+ sections: LastAssistantMessageSection[];
19
+ }
20
+
21
+ export type LastAssistantMessageLookupResult =
22
+ | { ok: true; data: LastAssistantMessageData }
23
+ | { ok: false; code: "missing" | "incomplete" | "empty"; message: string };
24
+
25
+ export interface AnnotateLastMessageInlineComment {
26
+ line: number;
27
+ body: string;
28
+ }
29
+
30
+ export interface AnnotateLastMessageSectionComment {
31
+ sectionId: string;
32
+ body: string;
33
+ }
34
+
35
+ export interface AnnotateLastMessageSubmitPayload {
36
+ type: "submit";
37
+ overallComment: string;
38
+ inlineComments: AnnotateLastMessageInlineComment[];
39
+ sectionComments: AnnotateLastMessageSectionComment[];
40
+ }
41
+
42
+ export interface AnnotateLastMessageCancelPayload {
43
+ type: "cancel";
44
+ }
45
+
46
+ export type AnnotateLastMessageWindowMessage = AnnotateLastMessageSubmitPayload | AnnotateLastMessageCancelPayload;
package/ui.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import type { LastAssistantMessageData } from "./types.js";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const webDir = join(__dirname, "web");
9
+
10
+ function escapeForInlineScript(value: string): string {
11
+ return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
12
+ }
13
+
14
+ function escapeInlineScriptSource(value: string): string {
15
+ return value.replace(/<\/(script)/gi, "<\\/$1");
16
+ }
17
+
18
+ export function buildAnnotateLastMessageHtml(data: LastAssistantMessageData): string {
19
+ const templateHtml = readFileSync(join(webDir, "index.html"), "utf8");
20
+ const appJs = escapeInlineScriptSource(readFileSync(join(webDir, "app.js"), "utf8"));
21
+ const payload = escapeForInlineScript(JSON.stringify(data));
22
+ return templateHtml.replace('"__INLINE_DATA__"', payload).replace("__INLINE_JS__", appJs);
23
+ }
package/web/app.js ADDED
@@ -0,0 +1,229 @@
1
+ /* global window, document */
2
+
3
+ const messageData = JSON.parse(document.getElementById("annotate-last-message-data").textContent || "{}");
4
+
5
+ if (!Array.isArray(messageData.lines)) messageData.lines = [];
6
+ if (!Array.isArray(messageData.sections)) messageData.sections = [];
7
+
8
+ const state = {
9
+ overallComment: "",
10
+ inlineComments: new Map(),
11
+ sectionComments: new Map(),
12
+ };
13
+
14
+ const elements = {
15
+ messageLines: document.getElementById("message-lines"),
16
+ overallComment: document.getElementById("overall-comment"),
17
+ sectionComments: document.getElementById("section-comments"),
18
+ status: document.getElementById("status"),
19
+ submitButton: document.getElementById("submit-button"),
20
+ cancelButton: document.getElementById("cancel-button"),
21
+ };
22
+
23
+ function feedbackCount() {
24
+ let count = state.overallComment.trim().length > 0 ? 1 : 0;
25
+ for (const value of state.inlineComments.values()) {
26
+ if (value.trim().length > 0) count += 1;
27
+ }
28
+ for (const value of state.sectionComments.values()) {
29
+ if (value.trim().length > 0) count += 1;
30
+ }
31
+ return count;
32
+ }
33
+
34
+ function setStatus(message, status = "idle") {
35
+ elements.status.textContent = message;
36
+ elements.status.dataset.state = status;
37
+ }
38
+
39
+ function updateSubmitState() {
40
+ const count = feedbackCount();
41
+ elements.submitButton.disabled = count === 0;
42
+ if (count === 0) {
43
+ setStatus("Add any feedback you want to send back to the editor.");
44
+ return;
45
+ }
46
+ const noun = count === 1 ? "item" : "items";
47
+ setStatus(`Ready to submit ${count} feedback ${noun}.`, "ready");
48
+ }
49
+
50
+ function setInlineComment(lineNumber, value) {
51
+ state.inlineComments.set(lineNumber, value);
52
+ updateSubmitState();
53
+ }
54
+
55
+ function setSectionComment(sectionId, value) {
56
+ state.sectionComments.set(sectionId, value);
57
+ updateSubmitState();
58
+ }
59
+
60
+ function createInlineEditor(line) {
61
+ const container = document.createElement("div");
62
+ container.className = "inline-editor";
63
+ container.hidden = true;
64
+
65
+ const meta = document.createElement("p");
66
+ meta.className = "line-meta";
67
+ meta.textContent = `Inline note for line ${line.number}`;
68
+ container.append(meta);
69
+
70
+ const textarea = document.createElement("textarea");
71
+ textarea.placeholder = "Explain what should change here, what is unclear, or what planning detail is missing.";
72
+ textarea.value = state.inlineComments.get(line.number) || "";
73
+ container.append(textarea);
74
+
75
+ return { container, textarea };
76
+ }
77
+
78
+ function createLineRow(line) {
79
+ const wrapper = document.createElement("div");
80
+ wrapper.className = "message-line";
81
+
82
+ const row = document.createElement("div");
83
+ row.className = "message-line-row";
84
+
85
+ const lineNumber = document.createElement("div");
86
+ lineNumber.className = "line-number";
87
+ lineNumber.textContent = String(line.number);
88
+ row.append(lineNumber);
89
+
90
+ const lineText = document.createElement("pre");
91
+ lineText.className = "line-text";
92
+ lineText.textContent = line.text.length > 0 ? line.text : " ";
93
+ row.append(lineText);
94
+
95
+ const toggle = document.createElement("button");
96
+ toggle.className = "inline-toggle";
97
+ toggle.type = "button";
98
+ toggle.textContent = "Add inline note";
99
+ row.append(toggle);
100
+
101
+ const editor = createInlineEditor(line);
102
+ const syncToggle = () => {
103
+ const hasValue = (state.inlineComments.get(line.number) || "").trim().length > 0;
104
+ toggle.dataset.active = hasValue || !editor.container.hidden ? "true" : "false";
105
+ toggle.textContent = hasValue ? "Edit inline note" : "Add inline note";
106
+ };
107
+
108
+ editor.textarea.addEventListener("input", () => {
109
+ setInlineComment(line.number, editor.textarea.value);
110
+ syncToggle();
111
+ });
112
+
113
+ toggle.addEventListener("click", () => {
114
+ editor.container.hidden = !editor.container.hidden;
115
+ syncToggle();
116
+ if (!editor.container.hidden) {
117
+ editor.textarea.focus();
118
+ }
119
+ });
120
+
121
+ wrapper.append(row);
122
+ wrapper.append(editor.container);
123
+ syncToggle();
124
+ return wrapper;
125
+ }
126
+
127
+ function createSectionCard(section) {
128
+ const card = document.createElement("div");
129
+ card.className = "section-card";
130
+
131
+ const title = document.createElement("h3");
132
+ const lineRange = section.startLine === section.endLine
133
+ ? `line ${section.startLine}`
134
+ : `lines ${section.startLine}-${section.endLine}`;
135
+ title.textContent = `Section ${section.index}`;
136
+ card.append(title);
137
+
138
+ const meta = document.createElement("p");
139
+ meta.className = "section-meta";
140
+ meta.textContent = lineRange;
141
+ card.append(meta);
142
+
143
+ const preview = document.createElement("div");
144
+ preview.className = "section-preview";
145
+ preview.textContent = section.text;
146
+ card.append(preview);
147
+
148
+ const textarea = document.createElement("textarea");
149
+ textarea.placeholder = "Describe what should change across this section or what larger concern should be addressed.";
150
+ textarea.value = state.sectionComments.get(section.id) || "";
151
+ textarea.addEventListener("input", () => {
152
+ setSectionComment(section.id, textarea.value);
153
+ });
154
+ card.append(textarea);
155
+
156
+ return card;
157
+ }
158
+
159
+ function renderMessageLines() {
160
+ elements.messageLines.replaceChildren();
161
+ for (const line of messageData.lines) {
162
+ elements.messageLines.append(createLineRow(line));
163
+ }
164
+ }
165
+
166
+ function renderSectionComments() {
167
+ elements.sectionComments.replaceChildren();
168
+ if (messageData.sections.length === 0) {
169
+ const empty = document.createElement("p");
170
+ empty.className = "empty-hint";
171
+ empty.textContent = "This message does not have any non-empty sections to annotate.";
172
+ elements.sectionComments.append(empty);
173
+ return;
174
+ }
175
+ for (const section of messageData.sections) {
176
+ elements.sectionComments.append(createSectionCard(section));
177
+ }
178
+ }
179
+
180
+ function collectPayload() {
181
+ return {
182
+ type: "submit",
183
+ overallComment: state.overallComment,
184
+ inlineComments: Array.from(state.inlineComments.entries()).map(([line, body]) => ({ line, body })),
185
+ sectionComments: Array.from(state.sectionComments.entries()).map(([sectionId, body]) => ({ sectionId, body })),
186
+ };
187
+ }
188
+
189
+ function sendPayload(payload) {
190
+ window.glimpse?.send?.(payload);
191
+ window.glimpse?.close?.();
192
+ }
193
+
194
+ function submit() {
195
+ if (feedbackCount() === 0) {
196
+ setStatus("Add at least one comment before submitting.", "error");
197
+ return;
198
+ }
199
+ sendPayload(collectPayload());
200
+ }
201
+
202
+ function cancel() {
203
+ sendPayload({ type: "cancel" });
204
+ }
205
+
206
+ elements.overallComment.addEventListener("input", () => {
207
+ state.overallComment = elements.overallComment.value;
208
+ updateSubmitState();
209
+ });
210
+
211
+ elements.submitButton.addEventListener("click", submit);
212
+
213
+ elements.cancelButton.addEventListener("click", cancel);
214
+
215
+ document.addEventListener("keydown", (event) => {
216
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
217
+ event.preventDefault();
218
+ submit();
219
+ return;
220
+ }
221
+ if (event.key === "Escape") {
222
+ event.preventDefault();
223
+ cancel();
224
+ }
225
+ });
226
+
227
+ renderMessageLines();
228
+ renderSectionComments();
229
+ updateSubmitState();
package/web/index.html ADDED
@@ -0,0 +1,322 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>annotate last message</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ --bg: #0d1117;
11
+ --panel: #151b23;
12
+ --panel-hover: #1f242c;
13
+ --border: #30363d;
14
+ --text: #f0f6fc;
15
+ --muted: #9198a1;
16
+ --accent: #4493f8;
17
+ --accent-strong: #1f6feb;
18
+ --success: #238636;
19
+ --success-strong: #2ea043;
20
+ --danger: #da3633;
21
+ }
22
+
23
+ * { box-sizing: border-box; }
24
+ html, body {
25
+ margin: 0;
26
+ width: 100%;
27
+ height: 100%;
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ font: 14px/1.5 Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
31
+ }
32
+
33
+ body {
34
+ display: flex;
35
+ flex-direction: column;
36
+ overflow: hidden;
37
+ }
38
+
39
+ button, textarea {
40
+ font: inherit;
41
+ }
42
+
43
+ button {
44
+ border: 1px solid var(--border);
45
+ border-radius: 8px;
46
+ background: var(--panel);
47
+ color: var(--text);
48
+ cursor: pointer;
49
+ }
50
+
51
+ button:hover:not(:disabled) {
52
+ background: var(--panel-hover);
53
+ }
54
+
55
+ button:disabled {
56
+ opacity: 0.45;
57
+ cursor: default;
58
+ }
59
+
60
+ textarea {
61
+ width: 100%;
62
+ min-height: 104px;
63
+ padding: 10px 12px;
64
+ border: 1px solid var(--border);
65
+ border-radius: 8px;
66
+ resize: vertical;
67
+ background: #0b1118;
68
+ color: var(--text);
69
+ }
70
+
71
+ textarea:focus {
72
+ outline: 2px solid rgba(68, 147, 248, 0.35);
73
+ border-color: var(--accent);
74
+ }
75
+
76
+ .page-header,
77
+ .page-footer {
78
+ padding: 16px 20px;
79
+ border-bottom: 1px solid var(--border);
80
+ background: rgba(13, 17, 23, 0.96);
81
+ }
82
+
83
+ .page-footer {
84
+ border-top: 1px solid var(--border);
85
+ border-bottom: none;
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: space-between;
89
+ gap: 12px;
90
+ }
91
+
92
+ .page-header h1 {
93
+ margin: 0 0 6px;
94
+ font-size: 18px;
95
+ }
96
+
97
+ .page-header p,
98
+ .status,
99
+ .empty-hint,
100
+ .line-meta,
101
+ .section-meta {
102
+ margin: 0;
103
+ color: var(--muted);
104
+ }
105
+
106
+ .layout {
107
+ flex: 1;
108
+ min-height: 0;
109
+ display: grid;
110
+ grid-template-columns: minmax(0, 1.7fr) minmax(340px, 0.95fr);
111
+ gap: 20px;
112
+ padding: 20px;
113
+ overflow: hidden;
114
+ }
115
+
116
+ .panel {
117
+ min-height: 0;
118
+ border: 1px solid var(--border);
119
+ border-radius: 14px;
120
+ background: var(--panel);
121
+ overflow: hidden;
122
+ }
123
+
124
+ .panel-header {
125
+ padding: 14px 16px;
126
+ border-bottom: 1px solid var(--border);
127
+ }
128
+
129
+ .panel-header h2,
130
+ .sidebar-stack h2,
131
+ .sidebar-stack h3 {
132
+ margin: 0 0 4px;
133
+ font-size: 14px;
134
+ }
135
+
136
+ .message-lines {
137
+ height: 100%;
138
+ overflow: auto;
139
+ padding: 8px 0 16px;
140
+ }
141
+
142
+ .message-line {
143
+ padding: 10px 16px 0;
144
+ }
145
+
146
+ .message-line-row {
147
+ display: grid;
148
+ grid-template-columns: 52px minmax(0, 1fr) auto;
149
+ gap: 12px;
150
+ align-items: start;
151
+ }
152
+
153
+ .line-number {
154
+ color: var(--muted);
155
+ font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
156
+ text-align: right;
157
+ padding-top: 2px;
158
+ }
159
+
160
+ .line-text {
161
+ margin: 0;
162
+ white-space: pre-wrap;
163
+ word-break: break-word;
164
+ font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
165
+ }
166
+
167
+ .inline-toggle {
168
+ min-width: 112px;
169
+ padding: 7px 10px;
170
+ font-size: 12px;
171
+ }
172
+
173
+ .inline-toggle[data-active="true"] {
174
+ border-color: rgba(68, 147, 248, 0.45);
175
+ background: rgba(68, 147, 248, 0.12);
176
+ color: #c2ddff;
177
+ }
178
+
179
+ .inline-editor {
180
+ margin: 10px 0 0 64px;
181
+ padding: 12px;
182
+ border: 1px solid var(--border);
183
+ border-radius: 10px;
184
+ background: rgba(1, 4, 9, 0.28);
185
+ }
186
+
187
+ .inline-editor[hidden] {
188
+ display: none;
189
+ }
190
+
191
+ .inline-editor textarea {
192
+ min-height: 88px;
193
+ }
194
+
195
+ .sidebar-stack {
196
+ height: 100%;
197
+ overflow: auto;
198
+ display: flex;
199
+ flex-direction: column;
200
+ gap: 16px;
201
+ padding-right: 4px;
202
+ }
203
+
204
+ .sidebar-panel {
205
+ border: 1px solid var(--border);
206
+ border-radius: 14px;
207
+ background: var(--panel);
208
+ padding: 16px;
209
+ }
210
+
211
+ .section-card {
212
+ padding: 12px;
213
+ border: 1px solid var(--border);
214
+ border-radius: 10px;
215
+ background: rgba(1, 4, 9, 0.18);
216
+ }
217
+
218
+ .section-card + .section-card {
219
+ margin-top: 12px;
220
+ }
221
+
222
+ .section-preview {
223
+ margin: 8px 0 10px;
224
+ padding: 10px 12px;
225
+ border-radius: 8px;
226
+ background: #0b1118;
227
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
228
+ white-space: pre-wrap;
229
+ word-break: break-word;
230
+ }
231
+
232
+ .empty-hint {
233
+ padding: 12px 0 0;
234
+ }
235
+
236
+ .footer-actions {
237
+ display: flex;
238
+ gap: 10px;
239
+ }
240
+
241
+ .footer-actions button {
242
+ padding: 10px 14px;
243
+ }
244
+
245
+ .submit-button {
246
+ border-color: rgba(46, 160, 67, 0.5);
247
+ background: var(--success);
248
+ }
249
+
250
+ .submit-button:hover:not(:disabled) {
251
+ background: var(--success-strong);
252
+ }
253
+
254
+ .cancel-button:hover:not(:disabled) {
255
+ border-color: rgba(218, 54, 51, 0.45);
256
+ }
257
+
258
+ .status[data-state="error"] {
259
+ color: #ffb3b3;
260
+ }
261
+
262
+ .status[data-state="ready"] {
263
+ color: #7ee787;
264
+ }
265
+
266
+ @media (max-width: 1100px) {
267
+ .layout {
268
+ grid-template-columns: 1fr;
269
+ overflow: auto;
270
+ }
271
+
272
+ .panel,
273
+ .sidebar-stack {
274
+ min-height: 520px;
275
+ }
276
+ }
277
+ </style>
278
+ </head>
279
+ <body>
280
+ <header class="page-header">
281
+ <h1>Annotate last assistant message</h1>
282
+ <p>Review the latest assistant text, add inline or section notes, and submit a structured prompt back to the editor.</p>
283
+ </header>
284
+
285
+ <div class="layout">
286
+ <section class="panel">
287
+ <div class="panel-header">
288
+ <h2>Assistant text</h2>
289
+ <p class="line-meta">Click <strong>Add inline note</strong> beside any line to attach focused feedback.</p>
290
+ </div>
291
+ <div id="message-lines" class="message-lines"></div>
292
+ </section>
293
+
294
+ <aside class="sidebar-stack">
295
+ <section class="sidebar-panel">
296
+ <h2>Overall guidance</h2>
297
+ <p class="section-meta">Optional high-level direction for the next response or plan update.</p>
298
+ <textarea id="overall-comment" placeholder="Explain what should change overall, what to clarify, or what plan adjustments you want."></textarea>
299
+ </section>
300
+
301
+ <section class="sidebar-panel">
302
+ <h2>Section comments</h2>
303
+ <p class="section-meta">Add broader notes for a paragraph or block from the assistant message.</p>
304
+ <div id="section-comments"></div>
305
+ </section>
306
+ </aside>
307
+ </div>
308
+
309
+ <footer class="page-footer">
310
+ <p id="status" class="status">Add any feedback you want to send back to the editor.</p>
311
+ <div class="footer-actions">
312
+ <button id="cancel-button" class="cancel-button" type="button">Cancel</button>
313
+ <button id="submit-button" class="submit-button" type="button">Submit feedback</button>
314
+ </div>
315
+ </footer>
316
+
317
+ <script id="annotate-last-message-data" type="application/json">"__INLINE_DATA__"</script>
318
+ <script>
319
+ __INLINE_JS__
320
+ </script>
321
+ </body>
322
+ </html>