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.
- package/lib/daemon.js +1 -1
- package/lib/project-sessions.js +38 -0
- package/lib/public/index.html +3 -0
- package/lib/public/modules/app-messages.js +8 -1
- package/lib/public/modules/app-notifications.js +91 -2
- package/lib/public/modules/header-tui-font.js +1 -0
- package/lib/public/modules/session-tui-view.js +10 -1
- package/lib/public/modules/sidebar-projects.js +11 -3
- package/lib/public/modules/terminal-prefs.js +51 -4
- package/lib/public/modules/tui-grab.js +723 -0
- package/lib/server-global-ws.js +189 -0
- package/lib/server-settings.js +2 -2
- package/lib/server.js +40 -7
- package/lib/tui-transcript-index.js +125 -0
- package/lib/users-preferences.js +1 -1
- package/lib/ws-schema.js +2 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|