@1agh/maude 0.38.1 → 0.39.1

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.
@@ -390,6 +390,114 @@ function safeJson(value) {
390
390
  }
391
391
  }
392
392
 
393
+ // ── chat-attachment refs (image thumbnails + lightbox) ──
394
+ // A pasted image lives in the feed under two spellings: the LIVE bubble holds
395
+ // the collapsed chip token ([image-1] — resolved via the per-chat attachments
396
+ // map), while a RELOADED bubble (transcript) holds the already-expanded
397
+ // absolute path under `_chat/attachments/`. Both funnel into the same
398
+ // `<sha8>.<ext>` name the GET /_api/acp/attachment route serves. Pure — tested
399
+ // in test/chat-attachments.test.ts.
400
+ const ATTACHMENT_NAME = '[0-9a-f]{8}\\.(?:png|jpe?g|gif|webp)';
401
+
402
+ /**
403
+ * The content-addressed basename when the string is a `_chat/attachments/` path
404
+ * (absolute or relative), else null. Non-attachment paths never match.
405
+ */
406
+ export function attachmentName(absPathOrText) {
407
+ const m = new RegExp(`(?:^|/)_chat/attachments/(${ATTACHMENT_NAME})$`).exec(
408
+ String(absPathOrText || '').trim()
409
+ );
410
+ return m ? m[1] : null;
411
+ }
412
+
413
+ /**
414
+ * Split bubble text into ordered segments the renderer walks:
415
+ * { type:'text', text } — plain run, rendered verbatim
416
+ * { type:'chip', token, kind } — [image|file|link-N] (live bubble)
417
+ * { type:'attachment', name, raw } — expanded _chat/attachments path (reload)
418
+ */
419
+ export function extractAttachmentRefs(text) {
420
+ const s = String(text || '');
421
+ const re = new RegExp(
422
+ `\\[(image|file|link)-\\d+\\]|\\S*/_chat/attachments/(${ATTACHMENT_NAME})`,
423
+ 'g'
424
+ );
425
+ const segs = [];
426
+ let last = 0;
427
+ let m;
428
+ while ((m = re.exec(s))) {
429
+ if (m.index > last) segs.push({ type: 'text', text: s.slice(last, m.index) });
430
+ if (m[1]) segs.push({ type: 'chip', token: m[0], kind: m[1] });
431
+ else segs.push({ type: 'attachment', name: m[2], raw: m[0] });
432
+ last = m.index + m[0].length;
433
+ }
434
+ if (last < s.length) segs.push({ type: 'text', text: s.slice(last) });
435
+ return segs;
436
+ }
437
+
438
+ /**
439
+ * Image paths under the project's design root that an ASSISTANT message text
440
+ * references (e.g. `/design:screenshot` replying "Saved to: .design/_history/…/
441
+ * 001.png") → servable same-origin URLs for the thumbnail strip (DDR-145).
442
+ * Render-only: the main origin already serves designRoot statics with
443
+ * containment; nothing outside `<designRel>/` ever matches, SVG stays excluded
444
+ * (scriptable), and the per-message cap bounds a hostile/hallucinated wall of
445
+ * paths. Returns unique URLs in first-mention order.
446
+ *
447
+ * Containment is enforced client-side, NOT delegated to the server (DDR-145
448
+ * security follow-up): the assistant text is partly untrusted (tool output /
449
+ * indirect injection), so a token carrying a `..` dot-segment or ANY
450
+ * percent-encoding is rejected outright — otherwise `.design/../../etc/x.png`
451
+ * would collapse to `/etc/x.png` in the browser (and `..%2f` would decode
452
+ * server-side), silently widening the fetch surface from designRel to the whole
453
+ * repoRoot. The server's `safePathUnderRoot` stays the backstop; this makes the
454
+ * client guarantee match the docstring instead of leaning on it.
455
+ */
456
+ export function designImageRefs(text, designRel = '.design', cap = 6) {
457
+ const rel = String(designRel || '.design').replace(/^\/+|\/+$/g, '');
458
+ if (!rel) return [];
459
+ const esc = rel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
460
+ // A path token: optional abs/relative prefix, then `<rel>/…/<img>`. Boundaries
461
+ // tolerate the markdown the paths arrive wrapped in (backticks, quotes,
462
+ // parens) and shed trailing punctuation via the lookahead.
463
+ // Bounded quantifiers ({0,256}/{1,256}) keep the per-start scan constant-time —
464
+ // a long failing candidate (attacker-influenceable assistant text) can't drive
465
+ // superlinear backtracking. No real designRoot path segment run approaches 256.
466
+ const re = new RegExp(
467
+ `(?:^|[\\s\`'"(\\[])((?:[^\\s\`'"()\\[\\]]{0,256}/)?${esc}/[^\\s\`'"()\\[\\]]{1,256}?\\.(?:png|jpe?g|gif|webp))(?=[\\s\`'")\\]]|[.,:;!?]|$)`,
468
+ 'gi'
469
+ );
470
+ const urls = [];
471
+ let m;
472
+ while ((m = re.exec(String(text || ''))) && urls.length < cap) {
473
+ const raw = m[1];
474
+ const at = raw.lastIndexOf(`${rel}/`);
475
+ if (at !== 0 && raw[at - 1] !== '/') continue; // `not-.design/x.png` must not match
476
+ const relPath = raw.slice(at);
477
+ const url = `/${relPath}`;
478
+ // Traversal guard — ALLOWLIST the canonical form, don't blocklist escape
479
+ // spellings (DDR-145 security follow-up). Two lanes the browser/server
480
+ // normalize differently:
481
+ // 1. Reject ANY percent-encoding outright — a plain designRoot path in
482
+ // assistant prose never carries `%`, but `..%2f` survives the WHATWG
483
+ // `URL` parse (pathname keeps `%2F` literal) and then the SERVER's
484
+ // `safePathUnderRoot` decodes it out of designRel.
485
+ // 2. Parse exactly as the browser will (WHATWG collapses `..` dot-segments
486
+ // AND rewrites `\`→`/` for http(s)) and require the result byte-identical
487
+ // AND still under `/rel/` — closes `..`, `..\`, and mixed spellings.
488
+ if (relPath.includes('%')) continue;
489
+ let canon;
490
+ try {
491
+ canon = new URL(url, 'http://x').pathname;
492
+ } catch {
493
+ continue;
494
+ }
495
+ if (canon !== url || !canon.startsWith(`/${rel}/`)) continue;
496
+ if (!urls.includes(url)) urls.push(url);
497
+ }
498
+ return urls;
499
+ }
500
+
393
501
  // Expand collapsed paste chips ([image-1]/[file-1]/[link-1]) back to the real
394
502
  // path/URL the user pasted, so Claude receives the actual value while the chat
395
503
  // bubble keeps the compact badge. Unknown tokens (e.g. a stale one the user typed
@@ -1002,15 +1002,137 @@
1002
1002
  flex-shrink: 0;
1003
1003
  }
1004
1004
 
1005
+ /* Pasted-image thumbnail — a focusable button in the user bubble; clicking
1006
+ opens the lightbox. Sized like a chip row, never dominating the bubble. */
1007
+ .chat-thumb-btn {
1008
+ appearance: none;
1009
+ display: inline-block;
1010
+ padding: 0;
1011
+ margin: 2px 0;
1012
+ border: 0;
1013
+ background: transparent;
1014
+ border-radius: var(--radius-sm);
1015
+ cursor: zoom-in;
1016
+ line-height: 0;
1017
+ vertical-align: middle;
1018
+ }
1019
+ .chat-thumb {
1020
+ display: block;
1021
+ max-width: 180px;
1022
+ max-height: 140px;
1023
+ object-fit: cover;
1024
+ border: 1px solid var(--border-default);
1025
+ border-radius: var(--radius-sm);
1026
+ transition: border-color var(--dur-fast, 120ms) ease;
1027
+ }
1028
+ .chat-thumb-btn:hover .chat-thumb {
1029
+ border-color: var(--border-strong);
1030
+ }
1031
+ .chat-thumb-btn:focus-visible {
1032
+ outline: none;
1033
+ }
1034
+ .chat-thumb-btn:focus-visible .chat-thumb {
1035
+ border-color: var(--accent);
1036
+ box-shadow: 0 0 0 3px var(--accent-tint);
1037
+ }
1038
+
1039
+ /* Thumbnail strip under an assistant bubble — every designRoot image path the
1040
+ agent mentioned (DDR-145), previewed without leaving the chat. */
1041
+ .chat-thumbrow {
1042
+ display: flex;
1043
+ flex-wrap: wrap;
1044
+ gap: var(--space-2);
1045
+ margin-top: var(--space-2);
1046
+ }
1047
+ /* A REFERENCED image (assistant merely named the path) — captioned with its
1048
+ filename so it reads as "a file that was referenced", not first-class agent
1049
+ media (attacker F2, DDR-145). */
1050
+ .chat-thumb-fig {
1051
+ margin: 0;
1052
+ display: flex;
1053
+ flex-direction: column;
1054
+ gap: 2px;
1055
+ max-width: 180px;
1056
+ }
1057
+ .chat-thumb-cap {
1058
+ font-family: var(--font-mono);
1059
+ font-size: var(--type-xs);
1060
+ color: var(--fg-3);
1061
+ overflow: hidden;
1062
+ text-overflow: ellipsis;
1063
+ white-space: nowrap;
1064
+ }
1065
+
1066
+ /* Lightbox — single fixed overlay above the whole shell (ESC / backdrop / ×).
1067
+ Scrim mixes the base background so it tracks light/dark themes. z sits above
1068
+ the shell modal band (.gi-modal 9600) but below the tour/topmost layer
1069
+ (10050+) — an enlarged image must never bury the guided tour. */
1070
+ .chat-lightbox {
1071
+ position: fixed;
1072
+ inset: 0;
1073
+ z-index: 9700;
1074
+ display: grid;
1075
+ place-items: center;
1076
+ background: color-mix(in oklch, var(--bg-0) 80%, transparent);
1077
+ animation: chat-lightbox-in 120ms ease;
1078
+ }
1079
+ .chat-lightbox img {
1080
+ max-width: 90vw;
1081
+ max-height: 90vh;
1082
+ object-fit: contain;
1083
+ border: 1px solid var(--border-default);
1084
+ border-radius: var(--radius-sm);
1085
+ background: var(--bg-1);
1086
+ box-shadow: var(--shadow-lg);
1087
+ }
1088
+ .chat-lightbox-close {
1089
+ position: absolute;
1090
+ top: var(--space-4);
1091
+ right: var(--space-4);
1092
+ appearance: none;
1093
+ width: 32px;
1094
+ height: 32px;
1095
+ display: grid;
1096
+ place-items: center;
1097
+ border: 1px solid var(--border-default);
1098
+ border-radius: var(--radius-sm);
1099
+ background: var(--bg-2);
1100
+ color: var(--fg-1);
1101
+ font-size: var(--type-base);
1102
+ line-height: 1;
1103
+ cursor: pointer;
1104
+ }
1105
+ .chat-lightbox-close:hover {
1106
+ color: var(--fg-0);
1107
+ background: var(--bg-3);
1108
+ }
1109
+ .chat-lightbox-close:focus-visible {
1110
+ outline: none;
1111
+ border-color: var(--accent);
1112
+ box-shadow: 0 0 0 3px var(--accent-tint);
1113
+ }
1114
+ @keyframes chat-lightbox-in {
1115
+ from {
1116
+ opacity: 0;
1117
+ }
1118
+ to {
1119
+ opacity: 1;
1120
+ }
1121
+ }
1122
+
1005
1123
  @media (prefers-reduced-motion: reduce) {
1006
1124
  .chat-status-dot--working,
1007
1125
  .chat-tool-dot--run,
1008
1126
  .chat-activity-spin,
1009
1127
  .st-assistant[data-busy='true'],
1010
1128
  .chat-dot--busy,
1011
- .chat-msg {
1129
+ .chat-msg,
1130
+ .chat-lightbox {
1012
1131
  animation: none;
1013
1132
  }
1133
+ .chat-thumb {
1134
+ transition: none;
1135
+ }
1014
1136
  }
1015
1137
  }
1016
1138