@plannotator/ui 0.38.2 → 0.40.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.
package/utils/parser.ts CHANGED
@@ -1099,6 +1099,92 @@ const blockEndLine = (block: Block): number => {
1099
1099
 
1100
1100
  /** Resolve the source-line label for a single annotation.
1101
1101
  * Returns null for global comments, diff-view annotations, or missing blocks. */
1102
+ /** Defense in depth for page-controlled strings in agent-read feedback:
1103
+ * collapse whitespace (no injected markdown structure) and defuse any
1104
+ * backtick run that could close an inline code span or a fence. */
1105
+ const safeInline = (value: unknown, max = 200): string => {
1106
+ const collapsed = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
1107
+ const defused = collapsed.replace(/`+/g, "'");
1108
+ return defused.length > max ? `${defused.slice(0, max)}…` : defused;
1109
+ };
1110
+
1111
+ /** The synthesized quote the bridge posts for a text-less element
1112
+ * (`[element: Navigation]`): a placeholder, not something the agent can use. */
1113
+ const isElementPlaceholderQuote = (text: unknown): boolean =>
1114
+ typeof text === 'string' && /^\[element: [^\]]*\]$/.test(text.trim());
1115
+
1116
+ /** The heading line for a COMMENT entry. When the quote is the bridge's
1117
+ * text-less placeholder and the annotation carries element context, name the
1118
+ * element instead (`Feedback on the <nav> element — "Primary"`); every other
1119
+ * annotation keeps the quote line exactly as before. */
1120
+ const commentHeadingLine = (ann: any): string => {
1121
+ const context = ann?.elementContext;
1122
+ if (context && typeof context.tag === 'string' && isElementPlaceholderQuote(ann.originalText)) {
1123
+ const tag = safeInline(context.tag, 32);
1124
+ const name = context.name ? safeInline(context.name, 120) : '';
1125
+ return `Feedback on the <${tag}> element${name ? ` — "${name}"` : ''}`;
1126
+ }
1127
+ return `Feedback on: "${ann.originalText}"`;
1128
+ };
1129
+
1130
+ export interface ElementContextExportOptions {
1131
+ /** Emit the live-app route line. The grouped export already prints a
1132
+ * `## Page:` heading, so it passes false; a single copied entry passes true. */
1133
+ includeRoute?: boolean;
1134
+ /** Print the identity lines WITHOUT the fenced outline, for model turns where
1135
+ * the 600-char outline is the expensive part. Default true. */
1136
+ includeOutline?: boolean;
1137
+ }
1138
+
1139
+ /** The agent-facing element block for a raw-HTML / live-app pinpoint: a
1140
+ * fenced HTML skeleton (the one markdown construct whose interior cannot
1141
+ * become structure) plus the identity lines an agent greps for. Emits
1142
+ * nothing when the annotation carries no context, keeping every other
1143
+ * annotation's output byte-identical. */
1144
+ export const elementContextExportBlock = (ann: any, opts: ElementContextExportOptions = {}): string => {
1145
+ const context = ann?.elementContext;
1146
+ if (!context || typeof context !== 'object' || typeof context.tag !== 'string') return '';
1147
+ const includeOutline = opts.includeOutline ?? true;
1148
+ let block = '';
1149
+ if (includeOutline && typeof context.outline === 'string' && context.outline.trim()) {
1150
+ // Fence at 4 backticks; the boundary already defuses 3+ runs inside the
1151
+ // outline, and a 4-run here cannot be closed by anything the page wrote.
1152
+ const outline = context.outline.replace(/`{3,}/g, "'''").trim();
1153
+ block += `\n\`\`\`\`html\n${outline}\n\`\`\`\`\n`;
1154
+ } else {
1155
+ block += '\n';
1156
+ }
1157
+ const selector = ann?.htmlAnchor?.selector;
1158
+ if (typeof selector === 'string' && selector) block += `- **selector** \`${safeInline(selector, 300)}\`\n`;
1159
+ if (context.path) block += `- **path** \`${safeInline(context.path, 512)}\`\n`;
1160
+ const identity: string[] = [];
1161
+ if (context.role) identity.push(`**role** ${safeInline(context.role, 32)}`);
1162
+ if (context.name) identity.push(`**name** "${safeInline(context.name, 120)}"`);
1163
+ if (context.component) identity.push(`**component** \`${safeInline(context.component, 100)}\``);
1164
+ if (identity.length) block += `- ${identity.join(' · ')}\n`;
1165
+ if (Array.isArray(context.attrs) && context.attrs.length) {
1166
+ const attrs = context.attrs
1167
+ .filter((pair: unknown) => Array.isArray(pair) && pair.length === 2)
1168
+ .map((pair: [string, string]) => `${safeInline(pair[0], 40)}="${safeInline(pair[1], 120)}"`)
1169
+ .join(' ');
1170
+ if (attrs) block += `- **attrs** \`${attrs.replace(/`/g, "'")}\`\n`;
1171
+ }
1172
+ if (context.text) block += `- **text** "${safeInline(context.text, 300)}"\n`;
1173
+ if (opts.includeRoute && context.page && context.page.url) {
1174
+ const title = context.page.title ? ` — "${safeInline(context.page.title, 200)}"` : '';
1175
+ block += `- **route** \`${safeInline(context.page.url, 2048)}\`${title}\n`;
1176
+ }
1177
+ const r = context.rect;
1178
+ if (r && ['x', 'y', 'w', 'h', 'vw', 'vh'].every((k) => typeof r[k] === 'number' && Number.isFinite(r[k]))) {
1179
+ block += `- **box** ${r.x},${r.y} ${r.w}×${r.h} (viewport ${r.vw}×${r.vh})\n`;
1180
+ }
1181
+ const near: string[] = [];
1182
+ if (context.landmark) near.push(safeInline(context.landmark, 80));
1183
+ if (context.heading) near.push(`heading ${safeInline(context.heading, 130)}`);
1184
+ if (near.length) block += `- **near** ${near.join(' · ')}\n`;
1185
+ return block;
1186
+ };
1187
+
1102
1188
  /** Multi-target raw-HTML comments: list every ADDITIONAL element the one
1103
1189
  * comment covers (the primary target is already quoted as `originalText`),
1104
1190
  * labeled with the semantic hover label plus a short excerpt so the agent
@@ -1120,11 +1206,67 @@ const additionalTargetsExportBlock = (ann: any): string => {
1120
1206
  const raw = typeof target?.text === 'string' ? target.text : '';
1121
1207
  const excerpt = raw.replace(/\s+/g, ' ').trim();
1122
1208
  const clipped = excerpt.length > 120 ? `${excerpt.slice(0, 120)}…` : excerpt;
1123
- block += `- ${label}"${clipped}"\n`;
1209
+ // Element identity for the agent, one line per extra target: the
1210
+ // selector and the path (a full context block per target would swamp
1211
+ // the comment; the rest of the context stays persisted, not exported).
1212
+ //
1213
+ // Gated on the target carrying element context, which is what makes the
1214
+ // element-context work additive in the literal sense it claims: a target
1215
+ // captured before it existed (or restored from an older draft) has an
1216
+ // anchor but no context, and exports byte-identically to before.
1217
+ const locators: string[] = [];
1218
+ if (target?.context && typeof target.context === 'object') {
1219
+ const selector = target?.anchor?.selector;
1220
+ if (typeof selector === 'string' && selector) locators.push(`\`${safeInline(selector, 300)}\``);
1221
+ const path = target?.context?.path;
1222
+ if (typeof path === 'string' && path) locators.push(`\`${safeInline(path, 512)}\``);
1223
+ }
1224
+ block += `- ${label}"${clipped}"${locators.length ? ` — ${locators.join(' · ')}` : ''}\n`;
1124
1225
  });
1125
1226
  return block;
1126
1227
  };
1127
1228
 
1229
+ /**
1230
+ * One annotation rendered as a standalone feedback entry (no number, no
1231
+ * document heading), for hosts that surface a single annotation to an agent.
1232
+ * The body is the same shape the full export emits for the entry, including
1233
+ * the element block, so a standalone entry never drifts from what Send
1234
+ * Feedback delivers; the live-app route line is included here because the
1235
+ * standalone entry has no `## Page:` heading above it. Plannotator's own
1236
+ * panel chrome does not use it.
1237
+ */
1238
+ export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOptions = { includeRoute: true }): string => {
1239
+ let output = '';
1240
+ switch (ann?.type) {
1241
+ case 'DELETION':
1242
+ output += `Remove this\n\`\`\`\n${ann.originalText}\n\`\`\`\n`;
1243
+ break;
1244
+ case 'GLOBAL_COMMENT':
1245
+ output += `General feedback\n> ${ann.text}\n`;
1246
+ break;
1247
+ default:
1248
+ if (ann?.isQuickLabel) {
1249
+ output += `[${ann.text}] ${commentHeadingLine(ann)}\n`;
1250
+ if (ann.quickLabelTip) output += `> ${ann.quickLabelTip}\n`;
1251
+ } else {
1252
+ output += `${commentHeadingLine(ann)}\n> ${ann?.text ?? ''}\n`;
1253
+ }
1254
+ }
1255
+ const resolvedOpts: ElementContextExportOptions = {
1256
+ includeRoute: opts.includeRoute ?? true,
1257
+ ...(opts.includeOutline !== undefined ? { includeOutline: opts.includeOutline } : {}),
1258
+ };
1259
+ output += elementContextExportBlock(ann, resolvedOpts);
1260
+ output += additionalTargetsExportBlock(ann);
1261
+ if (Array.isArray(ann?.images) && ann.images.length > 0) {
1262
+ output += `**Attached images:**\n`;
1263
+ ann.images.forEach((img: ImageAttachment) => {
1264
+ output += `- [${img.name}] \`${img.path}\`\n`;
1265
+ });
1266
+ }
1267
+ return output;
1268
+ };
1269
+
1128
1270
  const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
1129
1271
  if (!ann.blockId || ann.type === 'GLOBAL_COMMENT') return null;
1130
1272
  if (typeof ann.blockId === 'string' && ann.blockId.startsWith('diff-block-')) return null;
@@ -1292,12 +1434,12 @@ export const exportAnnotations = (
1292
1434
 
1293
1435
  case 'COMMENT':
1294
1436
  if (ann.isQuickLabel) {
1295
- output += `[${ann.text}] Feedback on: "${ann.originalText}"\n`;
1437
+ output += `[${ann.text}] ${commentHeadingLine(ann)}\n`;
1296
1438
  if (ann.quickLabelTip) {
1297
1439
  output += `> ${ann.quickLabelTip}\n`;
1298
1440
  }
1299
1441
  } else {
1300
- output += `Feedback on: "${ann.originalText}"\n`;
1442
+ output += `${commentHeadingLine(ann)}\n`;
1301
1443
  output += `> ${ann.text}\n`;
1302
1444
  }
1303
1445
  break;
@@ -1308,6 +1450,9 @@ export const exportAnnotations = (
1308
1450
  break;
1309
1451
  }
1310
1452
 
1453
+ // Raw-HTML / live-app pinpoints describe their element for the agent
1454
+ // (the grouped export's `## Page:` heading already carries the route).
1455
+ output += elementContextExportBlock(ann, { includeRoute: false });
1311
1456
  // Multi-target raw-HTML comments list every additional covered element.
1312
1457
  output += additionalTargetsExportBlock(ann);
1313
1458
 
@@ -1408,7 +1553,7 @@ export const exportLinkedDocAnnotations = (
1408
1553
  break;
1409
1554
 
1410
1555
  case 'COMMENT':
1411
- output += `Feedback on: "${ann.originalText}"\n`;
1556
+ output += `${commentHeadingLine(ann)}\n`;
1412
1557
  output += `> ${ann.text}\n`;
1413
1558
  break;
1414
1559
 
@@ -1418,6 +1563,7 @@ export const exportLinkedDocAnnotations = (
1418
1563
  break;
1419
1564
  }
1420
1565
 
1566
+ output += elementContextExportBlock(ann, { includeRoute: false });
1421
1567
  // Multi-target raw-HTML comments list every additional covered element.
1422
1568
  output += additionalTargetsExportBlock(ann);
1423
1569
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * One-time gate for the terminal-tools announcement (Plannotator TUI and
3
+ * Herdr Annotate). Cookie-backed like the other announcement gates, so a
4
+ * dismissal survives Plannotator's random localhost ports, and shared by the
5
+ * plan editor, the annotate surfaces and the code review editor: dismissing it
6
+ * anywhere retires it everywhere.
7
+ */
8
+
9
+ import { storage } from './storage';
10
+
11
+ const STORAGE_KEY = 'plannotator-announce-tui-herdr-seen';
12
+ // Bump to re-announce after a meaningful revision.
13
+ const CURRENT_VERSION = '1';
14
+
15
+ export function needsTerminalToolsAnnouncement(): boolean {
16
+ return storage.getItem(STORAGE_KEY) !== CURRENT_VERSION;
17
+ }
18
+
19
+ export function markTerminalToolsAnnouncementSeen(): void {
20
+ storage.setItem(STORAGE_KEY, CURRENT_VERSION);
21
+ }
22
+
23
+ export interface TerminalToolsAnnouncementGateState {
24
+ /**
25
+ * Latched at mount from needsTerminalToolsAnnouncement(). Latched rather than
26
+ * read per render so a dismissal cannot unmount the dialog before its own
27
+ * click handler finishes.
28
+ */
29
+ readonly announcementPending: boolean;
30
+ /** The app has not finished loading its initial payload. */
31
+ readonly isLoading: boolean;
32
+ /**
33
+ * The session has no author to address: archive browsing, a read-only shared
34
+ * plan, and any session with no Plannotator server behind it (the share
35
+ * portal's root and its demo plan included — those are not "shared
36
+ * sessions", so the host must fold that in). Telling a viewer about a CLI
37
+ * they did not open is noise, and the cookie is deliberately NOT consumed,
38
+ * so the next authoring session still shows it.
39
+ */
40
+ readonly readOnlySession: boolean;
41
+ /**
42
+ * Plannotator's compact touch shell. The panel is desktop-shaped (install
43
+ * commands to copy, four outbound links) and a phone is not where anyone
44
+ * installs a terminal tool. Also deferred rather than consumed.
45
+ */
46
+ readonly compact: boolean;
47
+ /**
48
+ * Any other first-run dialog is on screen. The chain dialogs never stack.
49
+ */
50
+ readonly otherFirstRunDialogVisible: boolean;
51
+ }
52
+
53
+ /**
54
+ * Chain gate for the announcement. It is LAST in each app's first-run dialog
55
+ * chain, after every dialog that asks the user to decide something (code
56
+ * review: guide intro, look-and-feel, review setup, edit mode, token hover
57
+ * cards; plan and annotate: look-and-feel, goal setup, permission mode).
58
+ *
59
+ * Last rather than first because none of those dialogs consume this cookie:
60
+ * a session that is busy asking questions defers the announcement to the next
61
+ * load instead of burning it. That also puts it in front of the right reader.
62
+ * Someone opening Plannotator for the first time is still learning this app;
63
+ * the people who should hear that it now runs in a terminal are the ones who
64
+ * already answered every setup question, and they see it on their next load.
65
+ */
66
+ export function terminalToolsAnnouncementCanShow(
67
+ state: TerminalToolsAnnouncementGateState,
68
+ ): boolean {
69
+ return (
70
+ state.announcementPending &&
71
+ !state.isLoading &&
72
+ !state.readOnlySession &&
73
+ !state.compact &&
74
+ !state.otherFirstRunDialogVisible
75
+ );
76
+ }