@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 CHANGED
@@ -22,11 +22,19 @@ 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
 
29
- - Interactive pi session with editor access.
37
+ - Interactive pi TUI session.
30
38
  - A completed assistant message with text on the active branch.
31
39
  - Local desktop support for opening a native [Glimpse](https://github.com/mariozechner/glimpse) window.
32
40
 
package/glimpseui.d.ts CHANGED
@@ -1,89 +1,98 @@
1
1
  declare module "glimpseui" {
2
- import { EventEmitter } from "node:events";
2
+ import { EventEmitter } from "node:events";
3
3
 
4
- export type FollowMode = "snap" | "spring";
5
- export type CursorAnchor = "top-left" | "top-right" | "right" | "bottom-right" | "bottom-left" | "left";
4
+ export type FollowMode = "snap" | "spring";
5
+ export type CursorAnchor =
6
+ | "top-left"
7
+ | "top-right"
8
+ | "right"
9
+ | "bottom-right"
10
+ | "bottom-left"
11
+ | "left";
6
12
 
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
- }
13
+ export interface GlimpseOpenOptions {
14
+ width?: number;
15
+ height?: number;
16
+ title?: string;
17
+ x?: number;
18
+ y?: number;
19
+ frameless?: boolean;
20
+ floating?: boolean;
21
+ transparent?: boolean;
22
+ clickThrough?: boolean;
23
+ followCursor?: boolean;
24
+ followMode?: FollowMode;
25
+ cursorAnchor?: CursorAnchor;
26
+ cursorOffset?: {
27
+ x?: number;
28
+ y?: number;
29
+ };
30
+ hidden?: boolean;
31
+ autoClose?: boolean;
32
+ timeout?: number;
33
+ }
28
34
 
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
- }
35
+ export interface GlimpseScreenInfo {
36
+ width: number;
37
+ height: number;
38
+ scaleFactor: number;
39
+ visibleX?: number;
40
+ visibleY?: number;
41
+ visibleWidth?: number;
42
+ visibleHeight?: number;
43
+ x?: number;
44
+ y?: number;
45
+ }
40
46
 
41
- export interface GlimpseAppearanceInfo {
42
- darkMode: boolean;
43
- accentColor: string;
44
- reduceMotion: boolean;
45
- increaseContrast: boolean;
46
- }
47
+ export interface GlimpseAppearanceInfo {
48
+ darkMode: boolean;
49
+ accentColor: string;
50
+ reduceMotion: boolean;
51
+ increaseContrast: boolean;
52
+ }
47
53
 
48
- export interface GlimpseCursorInfo {
49
- x: number;
50
- y: number;
51
- }
54
+ export interface GlimpseCursorInfo {
55
+ x: number;
56
+ y: number;
57
+ }
52
58
 
53
- export interface GlimpseCursorTip {
54
- x: number;
55
- y: number;
56
- }
59
+ export interface GlimpseCursorTip {
60
+ x: number;
61
+ y: number;
62
+ }
57
63
 
58
- export interface GlimpseInfo {
59
- screen: GlimpseScreenInfo;
60
- screens: GlimpseScreenInfo[];
61
- appearance: GlimpseAppearanceInfo;
62
- cursor: GlimpseCursorInfo;
63
- cursorTip: GlimpseCursorTip | null;
64
- }
64
+ export interface GlimpseInfo {
65
+ screen: GlimpseScreenInfo;
66
+ screens: GlimpseScreenInfo[];
67
+ appearance: GlimpseAppearanceInfo;
68
+ cursor: GlimpseCursorInfo;
69
+ cursorTip: GlimpseCursorTip | null;
70
+ }
65
71
 
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
- }
72
+ export class GlimpseWindow extends EventEmitter {
73
+ on(event: "ready", listener: (info: GlimpseInfo) => void): this;
74
+ on(event: "message", listener: (data: unknown) => void): this;
75
+ on(event: "info", listener: (info: GlimpseInfo) => void): this;
76
+ on(event: "closed", listener: () => void): this;
77
+ on(event: "error", listener: (error: Error) => void): this;
78
+ once(event: "ready", listener: (info: GlimpseInfo) => void): this;
79
+ once(event: "message", listener: (data: unknown) => void): this;
80
+ once(event: "info", listener: (info: GlimpseInfo) => void): this;
81
+ once(event: "closed", listener: () => void): this;
82
+ once(event: "error", listener: (error: Error) => void): this;
83
+ send(js: string): void;
84
+ setHTML(html: string): void;
85
+ show(options?: { title?: string }): void;
86
+ close(): void;
87
+ loadFile(path: string): void;
88
+ get info(): GlimpseInfo | null;
89
+ getInfo(): void;
90
+ followCursor(enabled: boolean, anchor?: CursorAnchor, mode?: FollowMode): void;
91
+ }
86
92
 
87
- export function open(html: string, options?: GlimpseOpenOptions): GlimpseWindow;
88
- export function prompt<T = unknown>(html: string, options?: GlimpseOpenOptions): Promise<T | null>;
93
+ export function open(html: string, options?: GlimpseOpenOptions): GlimpseWindow;
94
+ export function prompt<T = unknown>(
95
+ html: string,
96
+ options?: GlimpseOpenOptions,
97
+ ): Promise<T | null>;
89
98
  }
package/index.ts CHANGED
@@ -3,189 +3,233 @@ 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
 
9
- function isSubmitPayload(value: unknown): value is AnnotateLastMessageSubmitPayload {
10
- return typeof value === "object" && value != null && "type" in value && value.type === "submit";
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
+ );
11
27
  }
12
28
 
13
- function isCancelPayload(value: unknown): value is AnnotateLastMessageCancelPayload {
14
- return typeof value === "object" && value != null && "type" in value && value.type === "cancel";
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
+
40
+ function isSubmitPayload(value: unknown): value is AnnotateLastMessageSubmitPayload {
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
+ );
15
55
  }
16
56
 
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}`);
57
+ function isCancelPayload(value: unknown): value is AnnotateLastMessageCancelPayload {
58
+ return typeof value === "object" && value != null && "type" in value && value.type === "cancel";
20
59
  }
21
60
 
22
- export function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
23
- let activeWindow: QuietGlimpseWindow | null = null;
24
- let annotationOpening = false;
25
- let annotationAttempt = 0;
26
- const suppressedWindows = new WeakSet<QuietGlimpseWindow>();
27
-
28
- function closeActiveWindow(options: { suppressResults?: boolean } = {}): void {
29
- annotationAttempt += 1;
30
- const windowToClose = activeWindow;
31
- activeWindow = null;
32
- if (windowToClose == null) return;
33
- if (options.suppressResults) {
34
- suppressedWindows.add(windowToClose);
35
- }
36
- try {
37
- windowToClose.close();
38
- } catch {
39
- // Ignore races when the native window is already closing.
40
- }
41
- }
42
-
43
- async function openAnnotationWindow(ctx: ExtensionCommandContext): Promise<void> {
44
- if (ctx.mode !== "tui") {
45
- ctx.ui.notify("annotate-last-message requires interactive mode.", "error");
46
- return;
47
- }
48
- if (annotationOpening || activeWindow != null) {
49
- ctx.ui.notify("A last-message annotation window is already open.", "warning");
50
- return;
51
- }
52
-
53
- const messageResult = findLastAssistantMessage(ctx.sessionManager.getBranch());
54
- if (!messageResult.ok) {
55
- ctx.ui.notify(messageResult.message, "error");
56
- return;
57
- }
58
-
59
- const messageData = messageResult.data;
60
- annotationOpening = true;
61
- const attempt = ++annotationAttempt;
62
-
63
- try {
64
- const html = buildAnnotateLastMessageHtml(messageData);
65
- const window = await openQuietGlimpse(html, {
66
- width: 1440,
67
- height: 980,
68
- title: "annotate last message",
69
- });
70
- if (attempt !== annotationAttempt) {
71
- suppressedWindows.add(window);
72
- window.close();
73
- return;
74
- }
75
- activeWindow = window;
76
- if (window.failure != null) throw window.failure;
77
- if (window.closed) throw new Error("Glimpse closed while the annotation window was starting.");
78
-
79
- const terminalMessagePromise = new Promise<AnnotateLastMessageSubmitPayload | AnnotateLastMessageCancelPayload | null>(
80
- (resolve, reject) => {
81
- let settled = false;
82
- let closeTimer: ReturnType<typeof setTimeout> | null = null;
83
-
84
- const requestWindowClose = (): void => {
85
- try {
86
- window.close();
87
- } catch {
88
- // Ignore races when the native process has already exited.
89
- }
90
- };
91
-
92
- const cleanup = (): void => {
93
- if (closeTimer != null) {
94
- clearTimeout(closeTimer);
95
- closeTimer = null;
96
- }
97
- window.removeListener("message", onMessage);
98
- window.removeListener("closed", onClosed);
99
- window.removeListener("error", onError);
100
- if (activeWindow === window) {
101
- activeWindow = null;
102
- }
103
- };
104
-
105
- const settle = (value: AnnotateLastMessageWindowMessage | null): void => {
106
- if (settled) return;
107
- settled = true;
108
- cleanup();
109
- resolve(value);
110
- };
111
-
112
- const onMessage = (data: unknown): void => {
113
- if (isSubmitPayload(data) || isCancelPayload(data)) {
114
- requestWindowClose();
115
- settle(data);
116
- }
117
- };
118
-
119
- const onClosed = (): void => {
120
- if (settled || closeTimer != null) return;
121
- closeTimer = setTimeout(() => {
122
- closeTimer = null;
123
- settle(null);
124
- }, 250);
125
- };
126
-
127
- const onError = (error: Error): void => {
128
- if (settled) return;
129
- settled = true;
130
- requestWindowClose();
131
- cleanup();
132
- reject(error);
133
- };
134
-
135
- window.on("message", onMessage);
136
- window.on("closed", onClosed);
137
- window.on("error", onError);
138
- if (window.failure != null) onError(window.failure);
139
- else if (window.closed) onClosed();
140
- },
141
- );
142
-
143
- void (async (windowMessageSource: QuietGlimpseWindow, sourceData: LastAssistantMessageData) => {
144
- try {
145
- const result = await terminalMessagePromise;
146
- if (suppressedWindows.has(windowMessageSource)) return;
147
- if (result == null) return;
148
- if (result.type === "cancel") {
149
- ctx.ui.notify("Annotation cancelled.", "info");
150
- return;
151
- }
152
- if (!hasAnnotateLastMessageFeedback(result)) {
153
- ctx.ui.notify("No annotation feedback submitted.", "info");
154
- return;
155
- }
156
-
157
- const prompt = composeAnnotateLastMessagePrompt(sourceData, result);
158
- appendPrompt(ctx, prompt);
159
- ctx.ui.notify("Appended annotation feedback to the editor.", "info");
160
- } catch (error) {
161
- if (suppressedWindows.has(windowMessageSource)) return;
162
- const message = error instanceof Error ? error.message : String(error);
163
- ctx.ui.notify(`Annotation failed: ${message}`, "error");
164
- }
165
- })(window, messageData);
166
-
167
- ctx.ui.notify("Opened native annotation window.", "info");
168
- } catch (error) {
169
- closeActiveWindow({ suppressResults: true });
170
- const message = error instanceof Error ? error.message : String(error);
171
- ctx.ui.notify(`Annotation failed: ${message}`, "error");
172
- } finally {
173
- annotationOpening = false;
174
- }
175
- }
176
-
177
- pi.registerCommand("annotate-last-message", {
178
- description: "Open a native annotation window for the latest assistant message",
179
- handler: async (_args, ctx) => {
180
- await openAnnotationWindow(ctx);
181
- },
182
- });
183
-
184
- pi.on("session_shutdown", async () => {
185
- closeActiveWindow({ suppressResults: true });
186
- });
61
+ function registerAnnotateLastMessageCommand(pi: ExtensionAPI): void {
62
+ let activeWindow: QuietGlimpseWindow | null = null;
63
+ let annotationOpening = false;
64
+ let annotationAttempt = 0;
65
+ const suppressedWindows = new WeakSet<QuietGlimpseWindow>();
66
+
67
+ function closeActiveWindow(options: { suppressResults?: boolean } = {}): void {
68
+ annotationAttempt += 1;
69
+ const windowToClose = activeWindow;
70
+ activeWindow = null;
71
+ if (windowToClose == null) return;
72
+ if (options.suppressResults) {
73
+ suppressedWindows.add(windowToClose);
74
+ }
75
+ try {
76
+ windowToClose.close();
77
+ } catch {
78
+ // Ignore races when the native window is already closing.
79
+ }
80
+ }
81
+
82
+ async function openAnnotationWindow(ctx: ExtensionCommandContext): Promise<void> {
83
+ if (ctx.mode !== "tui") {
84
+ ctx.ui.notify("annotate-last-message requires interactive mode.", "error");
85
+ return;
86
+ }
87
+ if (annotationOpening || activeWindow != null) {
88
+ ctx.ui.notify("A last-message annotation window is already open.", "warning");
89
+ return;
90
+ }
91
+
92
+ const messageResult = findLastAssistantMessage(ctx.sessionManager.getBranch());
93
+ if (!messageResult.ok) {
94
+ ctx.ui.notify(messageResult.message, "error");
95
+ return;
96
+ }
97
+
98
+ const messageData = messageResult.data;
99
+ annotationOpening = true;
100
+ const attempt = ++annotationAttempt;
101
+
102
+ try {
103
+ const html = buildAnnotateLastMessageHtml(messageData, ctx.ui.theme);
104
+ const window = await openQuietGlimpse(html, {
105
+ width: 1440,
106
+ height: 980,
107
+ title: "annotate last message",
108
+ });
109
+ if (attempt !== annotationAttempt) {
110
+ suppressedWindows.add(window);
111
+ window.close();
112
+ return;
113
+ }
114
+ activeWindow = window;
115
+ if (window.failure != null) throw window.failure;
116
+ if (window.closed)
117
+ throw new Error("Glimpse closed while the annotation window was starting.");
118
+
119
+ const terminalMessagePromise = new Promise<
120
+ AnnotateLastMessageSubmitPayload | AnnotateLastMessageCancelPayload | null
121
+ >((resolve, reject) => {
122
+ let settled = false;
123
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
124
+
125
+ const requestWindowClose = (): void => {
126
+ try {
127
+ window.close();
128
+ } catch {
129
+ // Ignore races when the native process has already exited.
130
+ }
131
+ };
132
+
133
+ const cleanup = (): void => {
134
+ if (closeTimer != null) {
135
+ clearTimeout(closeTimer);
136
+ closeTimer = null;
137
+ }
138
+ window.removeListener("message", onMessage);
139
+ window.removeListener("closed", onClosed);
140
+ window.removeListener("error", onError);
141
+ if (activeWindow === window) {
142
+ activeWindow = null;
143
+ }
144
+ };
145
+
146
+ const settle = (value: AnnotateLastMessageWindowMessage | null): void => {
147
+ if (settled) return;
148
+ settled = true;
149
+ cleanup();
150
+ resolve(value);
151
+ };
152
+
153
+ const onMessage = (data: unknown): void => {
154
+ if (isSubmitPayload(data) || isCancelPayload(data)) {
155
+ settle(data);
156
+ requestWindowClose();
157
+ }
158
+ };
159
+
160
+ const onClosed = (): void => {
161
+ if (settled || closeTimer != null) return;
162
+ closeTimer = setTimeout(() => {
163
+ closeTimer = null;
164
+ settle(null);
165
+ }, 250);
166
+ };
167
+
168
+ const onError = (error: Error): void => {
169
+ if (settled) return;
170
+ settled = true;
171
+ requestWindowClose();
172
+ cleanup();
173
+ reject(error);
174
+ };
175
+
176
+ window.on("message", onMessage);
177
+ window.on("closed", onClosed);
178
+ window.on("error", onError);
179
+ if (window.failure != null) onError(window.failure);
180
+ else if (window.closed) onClosed();
181
+ });
182
+
183
+ void (async (
184
+ windowMessageSource: QuietGlimpseWindow,
185
+ sourceData: LastAssistantMessageData,
186
+ ) => {
187
+ try {
188
+ const result = await terminalMessagePromise;
189
+ if (suppressedWindows.has(windowMessageSource) || attempt !== annotationAttempt) return;
190
+ if (result == null) return;
191
+ if (result.type === "cancel") {
192
+ ctx.ui.notify("Annotation cancelled.", "info");
193
+ return;
194
+ }
195
+ if (!hasAnnotateLastMessageFeedback(result)) {
196
+ ctx.ui.notify("No annotation feedback submitted.", "info");
197
+ return;
198
+ }
199
+
200
+ const prompt = composeAnnotateLastMessagePrompt(sourceData, result);
201
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
202
+ ctx.ui.notify("Annotation feedback sent to the agent.", "info");
203
+ } catch (error) {
204
+ if (suppressedWindows.has(windowMessageSource) || attempt !== annotationAttempt) return;
205
+ const message = error instanceof Error ? error.message : String(error);
206
+ ctx.ui.notify(`Annotation failed: ${message}`, "error");
207
+ }
208
+ })(window, messageData);
209
+
210
+ ctx.ui.notify("Opened native annotation window.", "info");
211
+ } catch (error) {
212
+ if (attempt !== annotationAttempt) return;
213
+ closeActiveWindow({ suppressResults: true });
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ ctx.ui.notify(`Annotation failed: ${message}`, "error");
216
+ } finally {
217
+ annotationOpening = false;
218
+ }
219
+ }
220
+
221
+ pi.registerCommand("annotate-last-message", {
222
+ description: "Open a native annotation window for the latest assistant message",
223
+ handler: async (_args, ctx) => {
224
+ await openAnnotationWindow(ctx);
225
+ },
226
+ });
227
+
228
+ pi.on("session_shutdown", async () => {
229
+ closeActiveWindow({ suppressResults: true });
230
+ });
187
231
  }
188
232
 
189
233
  export default function (pi: ExtensionAPI): void {
190
- registerAnnotateLastMessageCommand(pi);
234
+ registerAnnotateLastMessageCommand(pi);
191
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-annotate-last-message",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",