clay-server 2.40.1-beta.1 → 2.41.0-beta.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.
@@ -0,0 +1,723 @@
1
+ // tui-grab.js
2
+ //
3
+ // Old-BBS-style hover-and-click "grab" on top of a Claude TUI xterm.
4
+ // When the user moves the mouse over a chunk of assistant output, we
5
+ // figure out which assistant message it belongs to (by substring match
6
+ // against the on-disk JSONL transcript), draw a subtle overlay across
7
+ // the matching rows, and copy the *original* markdown to the clipboard
8
+ // on click — bypassing xterm's column-wrapped selection.
9
+ //
10
+ // Server pieces this talks to (see lib/tui-transcript-index.js and
11
+ // the tui_transcript_request / tui_transcript_state messages in
12
+ // lib/project-sessions.js):
13
+ //
14
+ // c2s tui_transcript_request { id }
15
+ // Asked once when attachTuiGrab() runs against a Claude TUI
16
+ // session, so we have the index before the user starts moving
17
+ // the mouse.
18
+ //
19
+ // s2c tui_transcript_state { id, cliSessionId, messages: [...] }
20
+ // Full replacement of the assistant index for one session.
21
+ // Also pushed by the server when the JSONL file grows after a
22
+ // new assistant turn.
23
+ //
24
+ // Codex sessions never receive a transcript_state (no JSONL), so the
25
+ // overlay stays disabled — selection copy still works normally.
26
+
27
+ import { copyToClipboard, showToast } from './utils.js';
28
+ import { getWs } from './ws-ref.js';
29
+
30
+ // Minimum probe length. Short enough to fit between markdown
31
+ // decorations (a probe like "tui-grab.js" lives inside both sides:
32
+ // the matchKey wraps it in backticks, the rendered TUI strips them,
33
+ // but the 11-char content run is contiguous in both). Long enough
34
+ // to be reasonably unique across messages — at 14 chars random
35
+ // noise basically can't collide.
36
+ var MIN_MATCH_LEN = 14;
37
+
38
+ // Cap on the probe length per offset. We deliberately stay short so
39
+ // each probe lives inside one content run between decorations and
40
+ // won't fail just because matchKey has a backtick or "**" at its
41
+ // boundary. Multi-offset windowing covers the rest of the block.
42
+ var PROBE_LEN = 24;
43
+
44
+ // Debounce mousemove → match work. Same row hovered twice in a row
45
+ // short-circuits without re-computing, but this debounce also avoids
46
+ // burning CPU while the cursor flies across the terminal on its way
47
+ // to somewhere else.
48
+ var HOVER_DEBOUNCE_MS = 60;
49
+
50
+ // Per-localId cache so flipping between TUI sessions doesn't need a
51
+ // fresh round-trip. Replaced wholesale on every tui_transcript_state.
52
+ var indexBySession = Object.create(null);
53
+
54
+ var active = null; // { xterm, containerEl, localId, overlayEl, hoverHandler, ... }
55
+
56
+ // One-time stylesheet injection. The overlay's entry animation lives
57
+ // in CSS (a fade-in + slight zoom-out with a quickly-fading glow,
58
+ // plus a diagonal "shine" band that sweeps once across the box) so
59
+ // the showOverlayForBlock path just toggles classes and the GPU
60
+ // does the rest.
61
+ var stylesInjected = false;
62
+ function ensureStyles() {
63
+ if (stylesInjected || typeof document === "undefined" || !document.head) return;
64
+ stylesInjected = true;
65
+ var style = document.createElement("style");
66
+ style.textContent = [
67
+ "@keyframes tui-grab-enter {",
68
+ " 0% { opacity: 0; transform: scale(1.035);",
69
+ " box-shadow: 0 0 18px 8px rgba(120,170,255,0.55), 0 0 4px 2px rgba(255,255,255,0.45) inset; }",
70
+ " 55% { opacity: 1; transform: scale(1);",
71
+ " box-shadow: 0 0 18px 3px rgba(120,170,255,0.35), 0 0 0 0 rgba(255,255,255,0) inset; }",
72
+ " 100% { box-shadow: 0 0 0 0 rgba(120,170,255,0); }",
73
+ "}",
74
+ ".tui-grab-overlay.tui-grab-entering {",
75
+ " animation: tui-grab-enter 480ms cubic-bezier(.18,.74,.34,1);",
76
+ " transform-origin: center;",
77
+ "}",
78
+ "@keyframes tui-grab-shine {",
79
+ " 0% { transform: translateX(-70%); opacity: 0; }",
80
+ " 18% { opacity: 0.6; }",
81
+ " 100% { transform: translateX(170%); opacity: 0; }",
82
+ "}",
83
+ ".tui-grab-shine {",
84
+ " position: absolute; inset: 0; pointer-events: none; border-radius: inherit;",
85
+ " background: linear-gradient(105deg,",
86
+ " transparent 35%, rgba(200,225,255,0.55) 50%, transparent 65%);",
87
+ " animation: tui-grab-shine 650ms cubic-bezier(.18,.74,.34,1);",
88
+ " will-change: transform, opacity;",
89
+ "}",
90
+ ].join("\n");
91
+ document.head.appendChild(style);
92
+ }
93
+
94
+ function clearActive() {
95
+ if (!active) return;
96
+ if (active.hoverHandler) {
97
+ active.containerEl.removeEventListener("mousemove", active.hoverHandler);
98
+ active.containerEl.removeEventListener("mouseleave", active.leaveHandler);
99
+ active.containerEl.removeEventListener("click", active.clickHandler, true);
100
+ }
101
+ if (active.wheelHandler) {
102
+ active.containerEl.removeEventListener("wheel", active.wheelHandler);
103
+ }
104
+ if (active.pageScrollHandler) {
105
+ window.removeEventListener("scroll", active.pageScrollHandler, true);
106
+ }
107
+ if (active.scrollDisposable && typeof active.scrollDisposable.dispose === "function") {
108
+ try { active.scrollDisposable.dispose(); } catch (e) {}
109
+ }
110
+ if (active.overlayEl && active.overlayEl.parentNode) {
111
+ active.overlayEl.parentNode.removeChild(active.overlayEl);
112
+ }
113
+ if (active.debounceTimer) clearTimeout(active.debounceTimer);
114
+ active = null;
115
+ }
116
+
117
+ // Build the overlay: a single rectangle covering the matched
118
+ // message's full buffer-row range, plus a floating "Click to grab"
119
+ // pill anchored to the top edge. The rect spans the terminal width
120
+ // — we don't try to trace each line's actual column extent. Treating
121
+ // the whole assistant block as one cell reads as "this is one
122
+ // thing" much better than a per-row outline, even though the right
123
+ // margin sits in empty cells.
124
+ function buildOverlay(containerEl) {
125
+ ensureStyles();
126
+ var overlay = document.createElement("div");
127
+ overlay.className = "tui-grab-overlay";
128
+ // overflow:hidden keeps the shine band clipped to the overlay's
129
+ // rounded rect. box-shadow renders OUTSIDE the box so the entry
130
+ // glow stays visible regardless.
131
+ overlay.style.cssText =
132
+ "position:absolute;left:0;right:0;pointer-events:none;display:none;" +
133
+ "background:rgba(120,170,255,0.12);" +
134
+ "border:1px solid rgba(120,170,255,0.55);" +
135
+ "border-radius:4px;z-index:5;overflow:hidden;";
136
+ var hint = document.createElement("div");
137
+ hint.className = "tui-grab-hint";
138
+ hint.textContent = "Click to grab";
139
+ hint.style.cssText =
140
+ "position:absolute;right:6px;top:-22px;font-size:11px;" +
141
+ "padding:2px 8px;border-radius:10px;color:#fff;" +
142
+ "background:rgba(40,80,160,0.85);" +
143
+ "font-family:-apple-system,sans-serif;pointer-events:none;" +
144
+ "white-space:nowrap;";
145
+ overlay.appendChild(hint);
146
+ containerEl.appendChild(overlay);
147
+ return overlay;
148
+ }
149
+
150
+ // Cell metrics from the live xterm. Container coordinates only —
151
+ // works the same whether the renderer is WebGL or DOM. We re-measure
152
+ // on every match because font-size / resize can change cell height
153
+ // while a session is open.
154
+ function cellSize(xterm, containerEl) {
155
+ var rect = containerEl.getBoundingClientRect();
156
+ var cols = xterm.cols || 80;
157
+ var rows = xterm.rows || 24;
158
+ return {
159
+ width: rect.width / cols,
160
+ height: rect.height / rows,
161
+ top: rect.top,
162
+ left: rect.left,
163
+ };
164
+ }
165
+
166
+
167
+ // Visual viewport row (0..rows-1) under the mouse, or -1 if outside
168
+ // the rendered grid.
169
+ function viewportRowAt(xterm, containerEl, clientY) {
170
+ var dims = cellSize(xterm, containerEl);
171
+ if (dims.height <= 0) return -1;
172
+ var y = clientY - dims.top;
173
+ if (y < 0) return -1;
174
+ var row = Math.floor(y / dims.height);
175
+ if (row < 0 || row >= xterm.rows) return -1;
176
+ return row;
177
+ }
178
+
179
+ // Translate a viewport row into a buffer-absolute row (covers
180
+ // scrollback). xterm's buffer.active.viewportY is the buffer-row
181
+ // index of the top of the visible viewport.
182
+ function bufferRowFor(xterm, viewportRow) {
183
+ var buf = xterm.buffer && xterm.buffer.active;
184
+ if (!buf) return -1;
185
+ return buf.viewportY + viewportRow;
186
+ }
187
+
188
+ function isBlankBufferRow(buf, row) {
189
+ if (row < 0 || row >= buf.length) return true;
190
+ var line = buf.getLine(row);
191
+ if (!line) return true;
192
+ var text = line.translateToString(true);
193
+ return !text || /^\s*$/.test(text);
194
+ }
195
+
196
+ // Walk up/down from `row` while consecutive rows are non-blank. The
197
+ // returned [start, end] are both inclusive buffer-row indices that
198
+ // bound a single visual paragraph — the same chunk a human would
199
+ // double-click to "select all this."
200
+ function blockBounds(buf, row) {
201
+ if (isBlankBufferRow(buf, row)) return null;
202
+ var start = row;
203
+ while (start > 0 && !isBlankBufferRow(buf, start - 1)) start--;
204
+ var end = row;
205
+ while (end < buf.length - 1 && !isBlankBufferRow(buf, end + 1)) end++;
206
+ return { start: start, end: end };
207
+ }
208
+
209
+ // Reconstruct the block's text. isWrapped tells us when a buffer row
210
+ // continues the previous logical line (xterm broke it for terminal
211
+ // width) — those joins should not introduce a newline. Plain row
212
+ // transitions become newlines.
213
+ function blockText(buf, bounds) {
214
+ var parts = [];
215
+ for (var r = bounds.start; r <= bounds.end; r++) {
216
+ var line = buf.getLine(r);
217
+ if (!line) continue;
218
+ var seg = line.translateToString(true);
219
+ if (line.isWrapped) {
220
+ parts.push(seg);
221
+ } else {
222
+ if (parts.length > 0) parts.push("\n");
223
+ parts.push(seg);
224
+ }
225
+ }
226
+ return parts.join("");
227
+ }
228
+
229
+ // Claude Code TUI decorates assistant messages with bullets / continuation
230
+ // markers that don't exist in the JSONL source. Strip the common ones
231
+ // before whitespace collapse so the substring match against matchKey
232
+ // actually hits.
233
+ //
234
+ // ⏺ = start-of-message bullet
235
+ // ⎿ = tool-result indent marker
236
+ // ❯ ► = compact prompt indicators some themes use
237
+ // > = stale chevron from older claude versions
238
+ var TUI_BULLET_RE = /[⏺⎿❯►•>]+\s*/g;
239
+
240
+ function normalizeForMatch(s) {
241
+ return String(s || "")
242
+ .replace(TUI_BULLET_RE, "")
243
+ .replace(/\s+/g, " ")
244
+ .trim();
245
+ }
246
+
247
+ // Look for ANY window of `text` that appears in `matchKey`. Walking
248
+ // the probe origin across the text in small steps lets us tolerate
249
+ // asymmetric prefixes (numbered list markers, leading bullets, etc.)
250
+ // that one side might keep and the other might strip. As soon as
251
+ // one window hits we know this is the right message.
252
+ var PROBE_STEP = 3;
253
+ function textContainedIn(matchKey, text, minLen) {
254
+ if (!matchKey || !text || text.length < minLen) return false;
255
+ var max = text.length - minLen;
256
+ for (var offset = 0; offset <= max; offset += PROBE_STEP) {
257
+ var probeLen = Math.min(PROBE_LEN, text.length - offset);
258
+ if (probeLen < minLen) break;
259
+ var probe = text.substring(offset, offset + probeLen);
260
+ if (matchKey.indexOf(probe) !== -1) return true;
261
+ // Also try a shorter window at this offset so we still hit when
262
+ // the content run between markdown decorations is exactly minLen
263
+ // long (e.g. a 14-char filename inside backticks).
264
+ if (probeLen > minLen) {
265
+ var shortProbe = text.substring(offset, offset + minLen);
266
+ if (matchKey.indexOf(shortProbe) !== -1) return true;
267
+ }
268
+ }
269
+ return false;
270
+ }
271
+
272
+ // Count how many distinct probe windows from `text` appear in
273
+ // `matchKey`. Used to score messages for findMatchingMessage so we
274
+ // prefer the message that overlaps the *most* with the hovered block,
275
+ // not just the first or last one to share a single coincidence.
276
+ function countProbeHits(matchKey, text, minLen) {
277
+ if (!matchKey || !text || text.length < minLen) return 0;
278
+ var hits = 0;
279
+ var max = text.length - minLen;
280
+ for (var offset = 0; offset <= max; offset += PROBE_STEP) {
281
+ var probeLen = Math.min(PROBE_LEN, text.length - offset);
282
+ if (probeLen < minLen) break;
283
+ if (matchKey.indexOf(text.substring(offset, offset + probeLen)) !== -1) {
284
+ hits++;
285
+ continue;
286
+ }
287
+ if (probeLen > minLen
288
+ && matchKey.indexOf(text.substring(offset, offset + minLen)) !== -1) {
289
+ hits++;
290
+ }
291
+ }
292
+ return hits;
293
+ }
294
+
295
+ // Pick the message that overlaps the most with the hovered block.
296
+ // Two assistant turns that share a short common phrase (a markdown
297
+ // example, a quoted line, etc.) would both register as "containing"
298
+ // the probe if we just used a boolean — and the wrong one might win
299
+ // on iteration order. Scoring by hit count keeps the right message
300
+ // even when there's incidental overlap. Ties go to the later message
301
+ // (later in the array = later in the JSONL = the user's most recent
302
+ // turn, which is usually what they're hovering over).
303
+ function findMatchingMessage(messages, normalized) {
304
+ if (!messages || !normalized || normalized.length < MIN_MATCH_LEN) return null;
305
+ var best = null;
306
+ var bestScore = 0;
307
+ for (var i = 0; i < messages.length; i++) {
308
+ var m = messages[i];
309
+ if (!m || !m.matchKey) continue;
310
+ var score = countProbeHits(m.matchKey, normalized, MIN_MATCH_LEN);
311
+ if (score === 0) continue;
312
+ if (score > bestScore || (score === bestScore && best)) {
313
+ best = m;
314
+ bestScore = score;
315
+ }
316
+ }
317
+ return best;
318
+ }
319
+
320
+ // Claude Code TUI marks tool calls and tool results with very
321
+ // specific visual signatures that never appear inside an assistant
322
+ // text message in the JSONL. Detecting them lets us hard-stop the
323
+ // upward / downward expansion at the right place even when a tool
324
+ // result happens to quote text that's also in the assistant message
325
+ // (e.g. shared file paths, command names, code snippets).
326
+ //
327
+ // ⏺ Bash(...) tool call (bullet + CapitalName + paren)
328
+ // ⎿ Output line tool result connector
329
+ // ───── ╭───╮ etc. box-drawing decorations around tool output
330
+ //
331
+ // The check runs against the *raw* row text (before normalize strips
332
+ // the bullets), so the markers are still visible to the regexes.
333
+ function looksLikeToolBoundary(raw) {
334
+ if (!raw) return false;
335
+ // Only inspect the first line of the paragraph — the tool-call /
336
+ // tool-result markers live there in real Claude Code output.
337
+ // Scanning the whole block for the marker character would catch
338
+ // assistant prose that literally describes the markers (e.g.
339
+ // "tool detection is ⎿ plus ⏺ CapitalWord(, two patterns")
340
+ // and stop expansion in the middle of such a message.
341
+ var firstLine = raw.split("\n")[0];
342
+ // Tool result connector: line begins with ⎿ (possibly indented).
343
+ if (/^\s*⎿/.test(firstLine)) return true;
344
+ // Tool call signature: optional ⏺ bullet, then CapitalWord(. Bash,
345
+ // Edit, Read, Write, Grep, Glob, Task, WebSearch, WebFetch, …
346
+ // all conform. The ^ anchor matters: prose like "I'll use
347
+ // Bash(...) later" must NOT match.
348
+ if (/^\s*[⏺●]?\s*[A-Z][A-Za-z]+\s*\(/.test(firstLine)) return true;
349
+ // (Intentionally no box-drawing detector: assistant messages
350
+ // sometimes contain ASCII diagrams with ┌─┐│ etc., and we don't
351
+ // want to stop expansion in the middle of one.)
352
+ return false;
353
+ }
354
+
355
+ // Does this single paragraph look like it's part of `message`? We
356
+ // reuse the multi-offset window probe so a paragraph that starts with
357
+ // a different decoration than the rest of the message still counts.
358
+ // Tool-call paragraphs return false unconditionally — even when they
359
+ // happen to share text with the assistant message, they aren't part
360
+ // of the JSONL assistant block we're highlighting.
361
+ var MIN_PARA_MATCH = 10;
362
+ function paragraphBelongsToMessage(buf, bounds, message) {
363
+ if (!bounds || !message || !message.matchKey) return false;
364
+ var raw = blockText(buf, bounds);
365
+ if (looksLikeToolBoundary(raw)) return false;
366
+ var text = normalizeForMatch(raw);
367
+ if (!text) return false;
368
+ if (text.length >= MIN_PARA_MATCH) {
369
+ return textContainedIn(message.matchKey, text, MIN_PARA_MATCH);
370
+ }
371
+ // Very short paragraphs only count if they appear verbatim and have
372
+ // at least a couple characters — otherwise "OK" would absorb the
373
+ // world.
374
+ return text.length >= 3 && message.matchKey.indexOf(text) !== -1;
375
+ }
376
+
377
+ // A "too short to match" paragraph — typically a markdown heading
378
+ // that TUI rendered without its ## (so "## 변경" comes through as
379
+ // just "변경", 2 chars). On its own we can't confidently say it
380
+ // belongs to the matched message, but if the paragraph BEYOND it
381
+ // also matches, the short one is clearly a heading inside the same
382
+ // assistant block and shouldn't break the highlight.
383
+ function isShortNeutralParagraph(buf, bounds) {
384
+ if (!bounds) return false;
385
+ var raw = blockText(buf, bounds);
386
+ if (!raw || looksLikeToolBoundary(raw)) return false;
387
+ var text = normalizeForMatch(raw);
388
+ return text.length > 0 && text.length < 3;
389
+ }
390
+
391
+ // Markdown assistant messages routinely contain blank lines between
392
+ // paragraphs. blockBounds() only knows about visual paragraphs, so the
393
+ // initial bounds cover one paragraph at most. Expand it to every
394
+ // adjacent paragraph that's also a substring of the same matchKey, so
395
+ // the overlay covers the whole assistant message a human would call
396
+ // "one block."
397
+ function expandToMessageBounds(buf, initialBounds, message) {
398
+ var start = initialBounds.start;
399
+ var end = initialBounds.end;
400
+
401
+ // Walk up across blank rows looking for prior paragraphs that
402
+ // belong to the same message. A "neutral" short paragraph (e.g.
403
+ // a heading rendered without its ##) doesn't stop us as long as
404
+ // the paragraph BEYOND it also belongs — we then absorb the
405
+ // short one too so the highlight doesn't have a hole in it.
406
+ var cursor = start - 1;
407
+ while (cursor >= 0) {
408
+ while (cursor >= 0 && isBlankBufferRow(buf, cursor)) cursor--;
409
+ if (cursor < 0) break;
410
+ var prevBounds = blockBounds(buf, cursor);
411
+ if (!prevBounds) break;
412
+ if (paragraphBelongsToMessage(buf, prevBounds, message)) {
413
+ start = prevBounds.start;
414
+ cursor = prevBounds.start - 1;
415
+ continue;
416
+ }
417
+ // Try to peek through a short neutral paragraph (heading / spacer).
418
+ if (isShortNeutralParagraph(buf, prevBounds)) {
419
+ var peekCursor = prevBounds.start - 1;
420
+ while (peekCursor >= 0 && isBlankBufferRow(buf, peekCursor)) peekCursor--;
421
+ if (peekCursor >= 0) {
422
+ var peekPrev = blockBounds(buf, peekCursor);
423
+ if (peekPrev && paragraphBelongsToMessage(buf, peekPrev, message)) {
424
+ start = peekPrev.start;
425
+ cursor = peekPrev.start - 1;
426
+ continue;
427
+ }
428
+ }
429
+ }
430
+ break;
431
+ }
432
+
433
+ // Walk down with the same peek-through logic.
434
+ cursor = end + 1;
435
+ while (cursor < buf.length) {
436
+ while (cursor < buf.length && isBlankBufferRow(buf, cursor)) cursor++;
437
+ if (cursor >= buf.length) break;
438
+ var nextBounds = blockBounds(buf, cursor);
439
+ if (!nextBounds) break;
440
+ if (paragraphBelongsToMessage(buf, nextBounds, message)) {
441
+ end = nextBounds.end;
442
+ cursor = nextBounds.end + 1;
443
+ continue;
444
+ }
445
+ if (isShortNeutralParagraph(buf, nextBounds)) {
446
+ var peekDown = nextBounds.end + 1;
447
+ while (peekDown < buf.length && isBlankBufferRow(buf, peekDown)) peekDown++;
448
+ if (peekDown < buf.length) {
449
+ var peekNext = blockBounds(buf, peekDown);
450
+ if (peekNext && paragraphBelongsToMessage(buf, peekNext, message)) {
451
+ end = peekNext.end;
452
+ cursor = peekNext.end + 1;
453
+ continue;
454
+ }
455
+ }
456
+ }
457
+ break;
458
+ }
459
+
460
+ return { start: start, end: end };
461
+ }
462
+
463
+ function hideOverlay() {
464
+ if (!active || !active.overlayEl) return;
465
+ active.overlayEl.style.display = "none";
466
+ active.currentMatch = null;
467
+ active.currentBounds = null;
468
+ }
469
+
470
+ function playEntryAnimation(overlayEl) {
471
+ if (!overlayEl) return;
472
+ // Strip any previous shine band so it doesn't stack on a rapid
473
+ // hover-out-then-hover-back-in cycle.
474
+ var oldShine = overlayEl.querySelector(".tui-grab-shine");
475
+ if (oldShine) oldShine.remove();
476
+ // Restart the CSS keyframe animation by toggling the class off,
477
+ // forcing a reflow, then on again. Just adding the class twice in
478
+ // a row wouldn't restart the animation on the same element.
479
+ overlayEl.classList.remove("tui-grab-entering");
480
+ // Read offsetWidth to flush the style change.
481
+ /* eslint-disable-next-line no-unused-expressions */
482
+ void overlayEl.offsetWidth;
483
+ overlayEl.classList.add("tui-grab-entering");
484
+ // Light-sweep band on top, removed when the animation ends so it
485
+ // doesn't sit around eating layer memory.
486
+ var shine = document.createElement("div");
487
+ shine.className = "tui-grab-shine";
488
+ overlayEl.appendChild(shine);
489
+ shine.addEventListener("animationend", function () {
490
+ if (shine.parentNode) shine.parentNode.removeChild(shine);
491
+ });
492
+ }
493
+
494
+ function showOverlayForBlock(bounds, match) {
495
+ if (!active) return;
496
+ var xterm = active.xterm;
497
+ var containerEl = active.containerEl;
498
+ var dims = cellSize(xterm, containerEl);
499
+ if (dims.height <= 0) return;
500
+ var buf = xterm.buffer && xterm.buffer.active;
501
+ if (!buf) return;
502
+
503
+ // Clip to the visible viewport. The overlay only paints over rows
504
+ // that are currently rendered — anything scrolled out gets dropped
505
+ // until it scrolls back into view.
506
+ var vpStart = bounds.start - buf.viewportY;
507
+ var vpEnd = bounds.end - buf.viewportY;
508
+ if (vpEnd < 0 || vpStart >= xterm.rows) { hideOverlay(); return; }
509
+ if (vpStart < 0) vpStart = 0;
510
+ if (vpEnd >= xterm.rows) vpEnd = xterm.rows - 1;
511
+ // Bleed the rect past the cell edges so the box wraps the top and
512
+ // bottom rows' glyphs cleanly. The top side gets noticeably more
513
+ // padding than the bottom because glyph ascenders ride high in
514
+ // the cell and the visual line above the matched message tends
515
+ // to be a blank gap — extra top air reads better than crowding
516
+ // the first line. Both pads scale with cell height so the look
517
+ // stays consistent across font sizes.
518
+ var topPad = Math.max(6, Math.round(dims.height * 0.5));
519
+ var botPad = Math.max(3, Math.round(dims.height * 0.25));
520
+ var rawTop = vpStart * dims.height - topPad;
521
+ var top = Math.max(-topPad, Math.round(rawTop));
522
+ var height = Math.round((vpEnd - vpStart + 1) * dims.height + topPad + botPad);
523
+ // Detect whether this is a fresh match (different message or first
524
+ // show) versus a re-position triggered by scroll. Only the former
525
+ // gets the entry animation — replaying the shine on every scroll
526
+ // tick would be obnoxious.
527
+ var isNewMatch = active.currentMatch !== match;
528
+ var wasHidden = active.overlayEl.style.display === "none";
529
+ active.overlayEl.style.top = top + "px";
530
+ active.overlayEl.style.height = height + "px";
531
+ active.overlayEl.style.display = "block";
532
+ active.currentMatch = match;
533
+ active.currentBounds = bounds;
534
+ if (isNewMatch || wasHidden) {
535
+ playEntryAnimation(active.overlayEl);
536
+ }
537
+ }
538
+
539
+ // Scroll wipes the overlay. Tracking buffer rows through a scroll
540
+ // works, but the box visibly snaps to a new position and that reads
541
+ // as glitchy more than helpful. Hide and let the next mousemove
542
+ // re-evaluate against wherever the cursor sits on the new viewport
543
+ // — the entry animation makes the re-appearance feel intentional.
544
+ function onXtermScroll() {
545
+ if (!active) return;
546
+ hideOverlay();
547
+ active.lastBufferRow = -1;
548
+ }
549
+
550
+ function runMatch(clientX, clientY) {
551
+ if (!active) return;
552
+ var xterm = active.xterm;
553
+ var containerEl = active.containerEl;
554
+ var idx = indexBySession[active.localId];
555
+ if (!idx || !idx.length) { hideOverlay(); return; }
556
+ var vRow = viewportRowAt(xterm, containerEl, clientY);
557
+ if (vRow < 0) { hideOverlay(); return; }
558
+ var bRow = bufferRowFor(xterm, vRow);
559
+ if (bRow < 0) { hideOverlay(); return; }
560
+ var buf = xterm.buffer && xterm.buffer.active;
561
+ if (!buf) { hideOverlay(); return; }
562
+ // Same row as last time — don't redo the work (the overlay is
563
+ // already correct for this row).
564
+ if (active.lastBufferRow === bRow && active.currentMatch) return;
565
+ active.lastBufferRow = bRow;
566
+ var bounds = blockBounds(buf, bRow);
567
+ if (!bounds) { hideOverlay(); return; }
568
+ var text = blockText(buf, bounds);
569
+ // The hovered paragraph itself might be a tool call or result.
570
+ // Even if its text accidentally shares a long substring with some
571
+ // assistant message, that's not the right thing to highlight —
572
+ // tool I/O isn't part of the JSONL assistant block.
573
+ if (looksLikeToolBoundary(text)) { hideOverlay(); return; }
574
+ var normalized = normalizeForMatch(text);
575
+ if (normalized.length < MIN_MATCH_LEN) { hideOverlay(); return; }
576
+ var match = findMatchingMessage(idx, normalized);
577
+ if (!match) { hideOverlay(); return; }
578
+ // Initial bounds only cover the visual paragraph under the cursor.
579
+ // Pull in adjacent paragraphs that also belong to the matched
580
+ // message so the overlay covers the whole assistant block.
581
+ var expanded = expandToMessageBounds(buf, bounds, match);
582
+ showOverlayForBlock(expanded, match);
583
+ }
584
+
585
+ function flashOverlay() {
586
+ if (!active || !active.overlayEl) return;
587
+ var el = active.overlayEl;
588
+ var prevBg = el.style.background;
589
+ var prevBorder = el.style.borderColor;
590
+ el.style.background = "rgba(120,200,140,0.30)";
591
+ el.style.borderColor = "rgba(80,180,110,0.7)";
592
+ setTimeout(function () {
593
+ if (!el) return;
594
+ el.style.background = prevBg || "rgba(120,170,255,0.12)";
595
+ el.style.borderColor = prevBorder || "rgba(120,170,255,0.55)";
596
+ }, 220);
597
+ }
598
+
599
+ function onClick(e) {
600
+ if (!active || !active.currentMatch) return;
601
+ // Only consume the click when it lands inside the highlighted block.
602
+ // Anything outside falls through to xterm's normal selection.
603
+ var bounds = active.currentBounds;
604
+ var buf = active.xterm.buffer && active.xterm.buffer.active;
605
+ if (!bounds || !buf) return;
606
+ var vRow = viewportRowAt(active.xterm, active.containerEl, e.clientY);
607
+ if (vRow < 0) return;
608
+ var bRow = bufferRowFor(active.xterm, vRow);
609
+ if (bRow < bounds.start || bRow > bounds.end) return;
610
+ e.preventDefault();
611
+ e.stopPropagation();
612
+ var match = active.currentMatch;
613
+ copyToClipboard(match.text).then(function () {
614
+ flashOverlay();
615
+ showToast("Grabbed!", "success");
616
+ }).catch(function () {
617
+ showToast("Copy failed", "error");
618
+ });
619
+ }
620
+
621
+ function requestTranscript(localId) {
622
+ var ws = getWs();
623
+ if (!ws || ws.readyState !== 1) return;
624
+ try {
625
+ ws.send(JSON.stringify({ type: "tui_transcript_request", id: localId }));
626
+ } catch (e) {}
627
+ }
628
+
629
+ export function attachTuiGrab(xterm, containerEl, localId, opts) {
630
+ if (!xterm || !containerEl || !localId) return;
631
+ clearActive();
632
+ // For non-claude vendors (e.g. Codex) we don't even bother — no
633
+ // transcript exists on disk, so there's nothing to match against.
634
+ if (opts && opts.vendor && opts.vendor !== "claude") return;
635
+
636
+ var overlayEl = buildOverlay(containerEl);
637
+ active = {
638
+ xterm: xterm,
639
+ containerEl: containerEl,
640
+ localId: localId,
641
+ overlayEl: overlayEl,
642
+ debounceTimer: null,
643
+ lastBufferRow: -1,
644
+ currentMatch: null,
645
+ currentBounds: null,
646
+ };
647
+
648
+ active.hoverHandler = function (e) {
649
+ if (active.debounceTimer) clearTimeout(active.debounceTimer);
650
+ var x = e.clientX, y = e.clientY;
651
+ active.debounceTimer = setTimeout(function () { runMatch(x, y); }, HOVER_DEBOUNCE_MS);
652
+ };
653
+ active.leaveHandler = function () {
654
+ if (active.debounceTimer) clearTimeout(active.debounceTimer);
655
+ hideOverlay();
656
+ active.lastBufferRow = -1;
657
+ };
658
+ active.clickHandler = onClick;
659
+
660
+ containerEl.addEventListener("mousemove", active.hoverHandler);
661
+ containerEl.addEventListener("mouseleave", active.leaveHandler);
662
+ // Capture phase so we beat xterm's own click handler when the click
663
+ // lands inside the highlighted block; outside the block we don't
664
+ // call preventDefault, so xterm's selection behavior is preserved.
665
+ containerEl.addEventListener("click", active.clickHandler, true);
666
+
667
+ // Hide the overlay the moment the user scrolls in any way. The
668
+ // matched message has moved relative to the viewport and chasing
669
+ // it looks more like a glitch than a feature — let it vanish and
670
+ // re-detect on the next mousemove.
671
+ //
672
+ // We listen on three different signals because none of them
673
+ // cover every scroll path:
674
+ // - xterm.onScroll fires when the buffer's yDisp shifts. Good
675
+ // for programmatic scrolls and most wheel paths, but on macOS
676
+ // trackpad inertia it sometimes doesn't fire reliably for
677
+ // alternate-screen TUIs.
678
+ // - 'wheel' on the container captures any scroll gesture aimed
679
+ // at the terminal area regardless of who ends up scrolling.
680
+ // - 'scroll' on the surrounding page handles the case where the
681
+ // terminal itself isn't scrolling but its container is being
682
+ // scrolled within the chat layout.
683
+ if (xterm && typeof xterm.onScroll === "function") {
684
+ try { active.scrollDisposable = xterm.onScroll(onXtermScroll); }
685
+ catch (e) { active.scrollDisposable = null; }
686
+ }
687
+ active.wheelHandler = onXtermScroll;
688
+ containerEl.addEventListener("wheel", active.wheelHandler, { passive: true });
689
+ active.pageScrollHandler = onXtermScroll;
690
+ window.addEventListener("scroll", active.pageScrollHandler, { passive: true, capture: true });
691
+
692
+ // If we already have a cached index for this session (came from a
693
+ // prior request or a server-pushed update), don't refetch.
694
+ if (!indexBySession[localId]) {
695
+ requestTranscript(localId);
696
+ }
697
+ }
698
+
699
+ export function detachTuiGrab() {
700
+ clearActive();
701
+ }
702
+
703
+ // Called by app-messages when a tui_transcript_state arrives. Full
704
+ // replacement keyed by the session's localId.
705
+ export function handleTuiTranscriptState(msg) {
706
+ if (!msg || !msg.id) return;
707
+ indexBySession[msg.id] = Array.isArray(msg.messages) ? msg.messages : [];
708
+ // If this update is for the currently active session, drop any
709
+ // overlay state — the buffer row that used to map to a message may
710
+ // not anymore (or vice versa). The next mousemove will recompute.
711
+ if (active && active.localId === msg.id) {
712
+ hideOverlay();
713
+ active.lastBufferRow = -1;
714
+ }
715
+ }
716
+
717
+ // Drop the cache entry for a session — call this when a session is
718
+ // deleted so we don't leak old indexes for sessions that no longer
719
+ // exist.
720
+ export function dropTuiTranscript(localId) {
721
+ if (!localId) return;
722
+ delete indexBySession[localId];
723
+ }