@ahmednawaz/crank 0.1.2

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,2295 @@
1
+ /**
2
+ * Crank bookmarklet — uses Vizpatch tool-ui CSS/helpers + text-source classification.
3
+ * Preview (DOM) vs Save (source via SAVE_TEXT_CHANGE) mirrors Vizpatch text dispatch.
4
+ */
5
+ (function () {
6
+ // Allow re-drag / re-inject to pick up companion updates.
7
+ if (typeof window.__crankTeardown === 'function') {
8
+ try {
9
+ window.__crankTeardown();
10
+ } catch (_) {
11
+ /* ignore */
12
+ }
13
+ }
14
+ if (window.__crankActive) {
15
+ window.__crankActive = false;
16
+ }
17
+ window.__crankActive = true;
18
+
19
+ const COMPANION_HTTP =
20
+ window.__CRANK_COMPANION__ ||
21
+ detectCompanionOrigin() ||
22
+ 'http://127.0.0.1:3344';
23
+ const COMPANION_WS = COMPANION_HTTP.replace(/^http/i, 'ws') + '/ws';
24
+ const TEAM_TOKEN_KEY = 'crank:teamToken';
25
+
26
+ function getTeamToken() {
27
+ try {
28
+ return localStorage.getItem(TEAM_TOKEN_KEY) || '';
29
+ } catch (_) {
30
+ return '';
31
+ }
32
+ }
33
+
34
+ function setTeamToken(value) {
35
+ try {
36
+ localStorage.setItem(TEAM_TOKEN_KEY, String(value || '').trim());
37
+ } catch (_) {
38
+ /* ignore */
39
+ }
40
+ }
41
+
42
+ async function ensureTeamToken() {
43
+ const status = await fetch(COMPANION_HTTP + '/api/auth/status').then(function (r) {
44
+ return r.json();
45
+ }).catch(function () {
46
+ return { teamAuthRequired: false };
47
+ });
48
+ if (!status.teamAuthRequired) {
49
+ return true;
50
+ }
51
+ if (getTeamToken()) {
52
+ return true;
53
+ }
54
+ const entered = window.prompt(
55
+ status.hint ||
56
+ 'Enter the Crank team token (ask your team lead):'
57
+ );
58
+ if (!entered) {
59
+ return false;
60
+ }
61
+ const verify = await fetch(COMPANION_HTTP + '/api/auth/verify', {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ token: entered.trim() })
65
+ }).then(function (r) {
66
+ return r.json();
67
+ }).catch(function () {
68
+ return { ok: false };
69
+ });
70
+ if (!verify.ok) {
71
+ window.alert('Invalid Crank team token');
72
+ return false;
73
+ }
74
+ setTeamToken(entered.trim());
75
+ return true;
76
+ }
77
+
78
+ function authHeaders(extra) {
79
+ const h = extra || {};
80
+ const token = getTeamToken();
81
+ if (token) {
82
+ h['X-Crank-Team-Token'] = token;
83
+ }
84
+ return h;
85
+ }
86
+
87
+ function wsUrlWithToken() {
88
+ const token = getTeamToken();
89
+ if (!token) {
90
+ return COMPANION_WS;
91
+ }
92
+ const sep = COMPANION_WS.indexOf('?') >= 0 ? '&' : '?';
93
+ return COMPANION_WS + sep + 'teamToken=' + encodeURIComponent(token);
94
+ }
95
+
96
+ function detectCompanionOrigin() {
97
+ try {
98
+ const scripts = document.querySelectorAll(
99
+ 'script[src*="bookmarklet.js"],script[src*="dev-loader.js"]'
100
+ );
101
+ for (const s of scripts) {
102
+ return new URL(s.src, location.href).origin;
103
+ }
104
+ } catch (_) {}
105
+ return null;
106
+ }
107
+
108
+ function loadScript(src) {
109
+ return new Promise(function (resolve, reject) {
110
+ const existing = document.querySelector('script[src="' + src + '"]');
111
+ if (existing) {
112
+ resolve();
113
+ return;
114
+ }
115
+ const s = document.createElement('script');
116
+ s.src = src;
117
+ s.onload = function () {
118
+ resolve();
119
+ };
120
+ s.onerror = reject;
121
+ document.documentElement.appendChild(s);
122
+ });
123
+ }
124
+
125
+ function loadCss(href) {
126
+ let link = document.querySelector('link[data-crank-ui="1"]');
127
+ if (!link) {
128
+ link = document.createElement('link');
129
+ link.rel = 'stylesheet';
130
+ link.setAttribute('data-crank-ui', '1');
131
+ document.documentElement.appendChild(link);
132
+ }
133
+ link.href = href;
134
+ }
135
+
136
+ let selectedEl = null;
137
+ let selectedNodes = [];
138
+ let sessionId = null;
139
+ let socket = null;
140
+ let selecting = false;
141
+ let originalSnapshot = [];
142
+ let lastVariants = [];
143
+ let activeVariantId = null;
144
+ /** Bumps on each Ask Glean; stale WS progress after variants must not reopen thinking. */
145
+ let gleanEpoch = 0;
146
+ let gleanActiveEpoch = 0;
147
+ const OPTION_COUNT_KEY = 'crank.gleanOptionCount';
148
+ const DEFAULT_GLEAN_OPTION_COUNT = 3;
149
+
150
+ function normalizeRole(role) {
151
+ return String(role || 'text').toLowerCase();
152
+ }
153
+
154
+ function isTextLikeRole(role) {
155
+ const r = normalizeRole(role);
156
+ return r === 'text' || r === 'label' || r === 'body';
157
+ }
158
+
159
+ function hasSemanticStructure(nodes) {
160
+ return (Array.isArray(nodes) ? nodes : []).some(function (n) {
161
+ const r = normalizeRole(n.role);
162
+ return r === 'heading' || r === 'action' || r === 'button';
163
+ });
164
+ }
165
+
166
+ function textLikeNodes(nodes) {
167
+ return (Array.isArray(nodes) ? nodes : []).filter(function (n) {
168
+ return isTextLikeRole(n.role);
169
+ });
170
+ }
171
+
172
+ function unitDisplayLabel(node, index, nodes) {
173
+ const list = Array.isArray(nodes) ? nodes : [];
174
+ const role = normalizeRole(node && node.role);
175
+ if (role === 'heading') return 'Heading';
176
+ if (role === 'action' || role === 'button') return 'CTA';
177
+ if (role === 'label') return 'Label';
178
+ const semantic = hasSemanticStructure(list);
179
+ const texts = textLikeNodes(list);
180
+ if (semantic) {
181
+ const bodyIndex = texts.findIndex(function (n) {
182
+ return n === node;
183
+ });
184
+ if (bodyIndex >= 0) {
185
+ return texts.length > 1 ? 'Body ' + (bodyIndex + 1) : 'Body';
186
+ }
187
+ }
188
+ if (list.length <= 1) return 'Copy 1';
189
+ return 'Copy ' + (index + 1);
190
+ }
191
+
192
+ function assignUnitLabels(nodes) {
193
+ const list = Array.isArray(nodes) ? nodes : [];
194
+ return list.map(function (node, index) {
195
+ return Object.assign({}, node, {
196
+ label: node.label || unitDisplayLabel(node, index, list)
197
+ });
198
+ });
199
+ }
200
+
201
+ function getGleanOptionCount() {
202
+ const sel = panel && panel.querySelector('#vc-option-count');
203
+ const raw = sel ? sel.value : localStorage.getItem(OPTION_COUNT_KEY);
204
+ const n = parseInt(String(raw || DEFAULT_GLEAN_OPTION_COUNT), 10);
205
+ if (!Number.isFinite(n)) return DEFAULT_GLEAN_OPTION_COUNT;
206
+ return Math.min(6, Math.max(2, n));
207
+ }
208
+
209
+ const highlight = document.createElement('div');
210
+ highlight.id = 'crank-highlight';
211
+ highlight.style.cssText =
212
+ 'position:fixed;pointer-events:none;z-index:2147483646;border:2px solid oklch(0.45 0.08 180);background:oklch(0.7 0.06 180 / 0.12);border-radius:3px;transition:all 80ms ease;';
213
+
214
+ const panel = document.createElement('div');
215
+ panel.id = 'vp-shell';
216
+ panel.setAttribute('data-crank-panel', '1');
217
+ panel.style.cssText =
218
+ 'position:fixed;top:16px;right:16px;width:380px;max-height:calc(100vh - 32px);overflow:auto;z-index:2147483647;';
219
+
220
+ function UI() {
221
+ return window.CrankUI || {};
222
+ }
223
+ function TS() {
224
+ return window.CrankTextSource || {};
225
+ }
226
+
227
+ // Short phrase for the *current* Cursor chat — paste once; skill/MCP pulls pending.
228
+ // Cursor has no public API to inject into an open Composer; deeplink opens a *new* chat.
229
+ const AGENT_SHORT_PROMPT = 'Apply the pending Crank change';
230
+ const CURSOR_DEEPLINK_MAX = 7800;
231
+
232
+ function setStatus(text) {
233
+ const el = panel.querySelector('#vc-status');
234
+ if (el) el.textContent = text;
235
+ }
236
+
237
+ function showToast(text, tone) {
238
+ let toast = panel.querySelector('#vc-toast');
239
+ if (!toast) {
240
+ toast = document.createElement('div');
241
+ toast.id = 'vc-toast';
242
+ const status = panel.querySelector('#vc-status');
243
+ if (status && status.parentNode) {
244
+ status.parentNode.insertBefore(toast, status.nextSibling);
245
+ } else {
246
+ panel.appendChild(toast);
247
+ }
248
+ }
249
+ const bg =
250
+ tone === 'error'
251
+ ? 'oklch(0.93 0.04 25)'
252
+ : tone === 'ok'
253
+ ? 'oklch(0.93 0.04 160)'
254
+ : 'oklch(0.95 0.03 180)';
255
+ const border =
256
+ tone === 'error'
257
+ ? 'oklch(0.55 0.14 25)'
258
+ : tone === 'ok'
259
+ ? 'oklch(0.45 0.1 160)'
260
+ : 'oklch(0.45 0.08 180)';
261
+ toast.style.cssText =
262
+ 'display:block;margin:0 0 10px;padding:8px 10px;border:1px solid ' +
263
+ border +
264
+ ';border-radius:8px;background:' +
265
+ bg +
266
+ ';color:var(--foreground,#111);font:12px/1.45 ui-sans-serif,system-ui,sans-serif;';
267
+ toast.textContent = text;
268
+ }
269
+
270
+ function hideToast() {
271
+ const toast = panel.querySelector('#vc-toast');
272
+ if (toast) toast.style.display = 'none';
273
+ }
274
+
275
+ async function copyTextToClipboard(text) {
276
+ const value = String(text || '');
277
+ if (!value) return false;
278
+ try {
279
+ if (navigator.clipboard && navigator.clipboard.writeText) {
280
+ await navigator.clipboard.writeText(value);
281
+ return true;
282
+ }
283
+ } catch (_) {
284
+ /* fall through */
285
+ }
286
+ try {
287
+ const ta = document.createElement('textarea');
288
+ ta.value = value;
289
+ ta.setAttribute('readonly', '');
290
+ ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
291
+ document.body.appendChild(ta);
292
+ ta.focus();
293
+ ta.select();
294
+ const ok = document.execCommand('copy');
295
+ ta.remove();
296
+ return !!ok;
297
+ } catch (_) {
298
+ return false;
299
+ }
300
+ }
301
+
302
+ function buildCursorPromptDeeplink(promptText) {
303
+ const text = String(promptText || '').trim();
304
+ if (!text) return '';
305
+ const base = 'cursor://anysphere.cursor-deeplink/prompt?text=';
306
+ let encoded = encodeURIComponent(text);
307
+ if (base.length + encoded.length <= CURSOR_DEEPLINK_MAX) {
308
+ return base + encoded;
309
+ }
310
+ encoded = encodeURIComponent(AGENT_SHORT_PROMPT);
311
+ if (base.length + encoded.length <= CURSOR_DEEPLINK_MAX) {
312
+ return base + encoded;
313
+ }
314
+ return '';
315
+ }
316
+
317
+ /** Prefill Cursor chat via official prompt deeplink (user must confirm). */
318
+ function openCursorPrompt(promptText) {
319
+ const href = buildCursorPromptDeeplink(promptText);
320
+ if (!href) return false;
321
+ try {
322
+ const a = document.createElement('a');
323
+ a.href = href;
324
+ a.rel = 'noopener';
325
+ a.style.display = 'none';
326
+ document.documentElement.appendChild(a);
327
+ a.click();
328
+ a.remove();
329
+ return true;
330
+ } catch (_) {
331
+ try {
332
+ window.open(href, '_self');
333
+ return true;
334
+ } catch (__) {
335
+ return false;
336
+ }
337
+ }
338
+ }
339
+
340
+ function hideAgentHandoff() {
341
+ const box = panel.querySelector('#vc-agent-handoff');
342
+ if (box) {
343
+ box.style.display = 'none';
344
+ box.innerHTML = '';
345
+ }
346
+ stopAgentRunPoll();
347
+ }
348
+
349
+ let agentRunPollTimer = null;
350
+ let agentStatusPollTimer = null;
351
+
352
+ function stopAgentRunPoll() {
353
+ if (agentRunPollTimer) {
354
+ clearInterval(agentRunPollTimer);
355
+ agentRunPollTimer = null;
356
+ }
357
+ }
358
+
359
+ function stopAgentStatusPoll() {
360
+ if (agentStatusPollTimer) {
361
+ clearInterval(agentStatusPollTimer);
362
+ agentStatusPollTimer = null;
363
+ }
364
+ }
365
+
366
+ function statusLabelForJob(job) {
367
+ const s = String((job && job.status) || '').toLowerCase();
368
+ if (s === 'queued') return 'Queued';
369
+ if (s === 'running') return 'Running';
370
+ if (s === 'done') return 'Done';
371
+ if (s === 'failed') return 'Failed';
372
+ return s || '—';
373
+ }
374
+
375
+ function updateAgentHandoffJobLine(job) {
376
+ const box = panel.querySelector('#vc-agent-handoff');
377
+ if (!box || box.style.display === 'none') return;
378
+ const el = box.querySelector('#vc-agent-job-status');
379
+ if (!el) return;
380
+ const ui = UI();
381
+ const escape = ui.escapeHtml || function (v) {
382
+ return String(v == null ? '' : v);
383
+ };
384
+ if (!job) {
385
+ el.style.display = 'none';
386
+ el.textContent = '';
387
+ return;
388
+ }
389
+ el.style.display = 'block';
390
+ el.innerHTML =
391
+ 'Auto-run: <strong>' +
392
+ escape(statusLabelForJob(job)) +
393
+ '</strong>' +
394
+ (job.error ? ' — ' + escape(job.error) : '');
395
+ }
396
+
397
+ function showAgentHandoffError(message, detail) {
398
+ const box = panel.querySelector('#vc-agent-handoff');
399
+ if (!box) return;
400
+ const ui = UI();
401
+ const escape = ui.escapeHtml || function (v) {
402
+ return String(v == null ? '' : v);
403
+ };
404
+ box.style.display = 'block';
405
+ box.innerHTML =
406
+ '<div class="vp-type-label" style="font-weight:620;margin-bottom:6px;color:oklch(0.45 0.14 25);">Agent queue failed</div>' +
407
+ '<div class="vp-type-support" style="line-height:1.45;color:var(--foreground);">' +
408
+ escape(message || 'Unknown error') +
409
+ (detail
410
+ ? '<br/><span style="opacity:0.85;">' + escape(detail) + '</span>'
411
+ : '') +
412
+ '</div>';
413
+ try {
414
+ box.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
415
+ } catch (_) {
416
+ /* ignore */
417
+ }
418
+ }
419
+
420
+ function showAgentHandoff(task, options) {
421
+ const box = panel.querySelector('#vc-agent-handoff');
422
+ if (!box) return;
423
+ const opts = options || {};
424
+ const ui = UI();
425
+ const escape = ui.escapeHtml || function (v) {
426
+ return String(v == null ? '' : v);
427
+ };
428
+ const btn = ui.vpButtonHtml || function (_v, label, attrs) {
429
+ return (
430
+ '<button type="button" id="' +
431
+ (attrs && attrs.id ? attrs.id : '') +
432
+ '">' +
433
+ label +
434
+ '</button>'
435
+ );
436
+ };
437
+ const prompt = String((task && task.prompt) || '');
438
+ const label = (task && task.variantLabel) || '';
439
+ const changeCount =
440
+ task && typeof task.changeCount === 'number'
441
+ ? task.changeCount
442
+ : Array.isArray(task && task.changes)
443
+ ? task.changes.length
444
+ : 0;
445
+ const apiKeyConfigured = opts.apiKeyConfigured === true;
446
+ const job = opts.job || null;
447
+ box.style.display = 'block';
448
+ box.innerHTML =
449
+ '<div class="vp-type-label" style="font-weight:620;margin-bottom:6px;">Queued for agent' +
450
+ (label ? ' (“' + escape(label) + '”)' : '') +
451
+ (changeCount ? ' · ' + changeCount + ' change' + (changeCount === 1 ? '' : 's') : '') +
452
+ '</div>' +
453
+ '<div class="vp-type-support" style="margin-bottom:8px;line-height:1.45;color:var(--foreground);">' +
454
+ (opts.waitingWatch
455
+ ? 'Waiting for agent… (skill/MCP watch active)'
456
+ : 'Paste in your <strong>current</strong> Cursor chat: <code>' +
457
+ escape(AGENT_SHORT_PROMPT) +
458
+ '</code> — the <code>crank-apply-changes</code> skill pulls pending changes via MCP. Cursor has no public API to inject into an open chat.') +
459
+ (!apiKeyConfigured
460
+ ? '<br/><span style="opacity:0.85;">Optional: set <code>CURSOR_API_KEY</code> for one-click fully automatic apply (separate agent, not your open chat).</span>'
461
+ : '') +
462
+ '</div>' +
463
+ '<div id="vc-agent-job-status" class="vp-type-support" style="display:' +
464
+ (job ? 'block' : 'none') +
465
+ ';margin-bottom:8px;line-height:1.45;color:var(--foreground);">' +
466
+ (job
467
+ ? 'Auto-run: <strong>' +
468
+ escape(statusLabelForJob(job)) +
469
+ '</strong>' +
470
+ (job.error ? ' — ' + escape(job.error) : '')
471
+ : '') +
472
+ '</div>' +
473
+ '<div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;">' +
474
+ (apiKeyConfigured
475
+ ? btn('primary', 'Run fully automatic', { id: 'vc-agent-auto', type: 'button' }, {
476
+ size: 'compact',
477
+ flex: true
478
+ })
479
+ : '') +
480
+ btn('secondary', 'Copy again', { id: 'vc-agent-copy', type: 'button' }, {
481
+ size: 'compact',
482
+ flex: true
483
+ }) +
484
+ btn('ghost', 'Open new chat instead', { id: 'vc-agent-open', type: 'button' }, {
485
+ size: 'compact',
486
+ flex: true
487
+ }) +
488
+ btn('ghost', 'Dismiss', { id: 'vc-agent-dismiss', type: 'button' }, {
489
+ size: 'compact',
490
+ flex: true
491
+ }) +
492
+ '</div>' +
493
+ '<div class="vp-type-label" style="margin-top:10px;font-size:12px;">Full prompt</div>' +
494
+ '<textarea id="vc-agent-prompt" readonly rows="8" style="width:100%;box-sizing:border-box;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--background, #fff);color:var(--foreground);margin-top:6px;max-height:240px;overflow:auto;">' +
495
+ escape(prompt) +
496
+ '</textarea>' +
497
+ '<div style="margin-top:6px;">' +
498
+ btn('ghost', 'Copy full prompt', { id: 'vc-agent-copy-full', type: 'button' }, {
499
+ size: 'compact',
500
+ flex: true
501
+ }) +
502
+ '</div>';
503
+
504
+ const autoBtn = box.querySelector('#vc-agent-auto');
505
+ const openBtn = box.querySelector('#vc-agent-open');
506
+ const copyBtn = box.querySelector('#vc-agent-copy');
507
+ const copyFullBtn = box.querySelector('#vc-agent-copy-full');
508
+ const dismissBtn = box.querySelector('#vc-agent-dismiss');
509
+
510
+ if (autoBtn) {
511
+ autoBtn.addEventListener('click', function (ev) {
512
+ if (ev && ev.preventDefault) ev.preventDefault();
513
+ startFullyAutomaticRun(task);
514
+ });
515
+ }
516
+ if (openBtn) {
517
+ openBtn.addEventListener('click', function (ev) {
518
+ if (ev && ev.preventDefault) ev.preventDefault();
519
+ const ok = openCursorPrompt(prompt || AGENT_SHORT_PROMPT);
520
+ showToast(
521
+ ok
522
+ ? 'Opened a new Cursor chat (deeplink) — confirm if prompted'
523
+ : 'Could not open deeplink — paste “' +
524
+ AGENT_SHORT_PROMPT +
525
+ '” in your current chat instead',
526
+ ok ? 'ok' : 'error'
527
+ );
528
+ });
529
+ }
530
+ if (copyBtn) {
531
+ copyBtn.addEventListener('click', function () {
532
+ copyTextToClipboard(AGENT_SHORT_PROMPT).then(function (ok) {
533
+ showToast(
534
+ ok
535
+ ? 'Copied — paste in your current Cursor chat'
536
+ : 'Copy failed — type: ' + AGENT_SHORT_PROMPT,
537
+ ok ? 'ok' : 'error'
538
+ );
539
+ });
540
+ });
541
+ }
542
+ if (copyFullBtn) {
543
+ copyFullBtn.addEventListener('click', function () {
544
+ copyTextToClipboard(prompt || AGENT_SHORT_PROMPT).then(function (ok) {
545
+ showToast(
546
+ ok ? 'Full prompt copied' : 'Copy failed — select the textarea',
547
+ ok ? 'ok' : 'error'
548
+ );
549
+ });
550
+ });
551
+ }
552
+ if (dismissBtn) {
553
+ dismissBtn.addEventListener('click', function () {
554
+ hideAgentHandoff();
555
+ hideToast();
556
+ });
557
+ }
558
+ try {
559
+ box.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
560
+ } catch (_) {
561
+ /* ignore */
562
+ }
563
+ }
564
+
565
+ async function fetchAgentStatus() {
566
+ try {
567
+ const data = await postJson('/api/agent-status', null, {
568
+ method: 'GET',
569
+ timeoutMs: 8000
570
+ });
571
+ return data || {};
572
+ } catch (_) {
573
+ try {
574
+ const res = await fetch(COMPANION_HTTP + '/api/agent-status', {
575
+ method: 'GET',
576
+ headers: authHeaders()
577
+ });
578
+ return await res.json();
579
+ } catch (__) {
580
+ return {};
581
+ }
582
+ }
583
+ }
584
+
585
+ function pollAgentRun(jobId) {
586
+ stopAgentRunPoll();
587
+ if (!jobId) return;
588
+ agentRunPollTimer = setInterval(async function () {
589
+ try {
590
+ const res = await fetch(
591
+ COMPANION_HTTP + '/api/agent-run/' + encodeURIComponent(jobId),
592
+ { method: 'GET', headers: authHeaders() }
593
+ );
594
+ const data = await res.json();
595
+ const job = data && data.job;
596
+ if (!job) return;
597
+ updateAgentHandoffJobLine(job);
598
+ setStatus('Agent auto-run: ' + statusLabelForJob(job));
599
+ if (job.status === 'done' || job.status === 'failed') {
600
+ stopAgentRunPoll();
601
+ setStatus(
602
+ job.status === 'done'
603
+ ? 'Agent auto-run done'
604
+ : 'Agent auto-run failed: ' + (job.error || 'unknown')
605
+ );
606
+ }
607
+ } catch (_) {
608
+ /* ignore transient poll errors */
609
+ }
610
+ }, 2000);
611
+ }
612
+
613
+ async function startFullyAutomaticRun(task) {
614
+ showToast('Starting fully automatic agent…', 'info');
615
+ setStatus('Starting fully automatic agent…');
616
+ try {
617
+ const body = {
618
+ sessionId: sessionId,
619
+ variantId: (task && task.variantId) || activeVariantId
620
+ };
621
+ const res = await fetch(COMPANION_HTTP + '/api/agent-run', {
622
+ method: 'POST',
623
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
624
+ body: JSON.stringify(body)
625
+ });
626
+ const data = await res.json().catch(function () {
627
+ return {};
628
+ });
629
+ if (!res.ok || data.ok === false) {
630
+ const msg =
631
+ (data && data.error) ||
632
+ 'Set CURSOR_API_KEY to enable one-click Agent apply';
633
+ showToast(msg, 'error');
634
+ setStatus(msg);
635
+ return;
636
+ }
637
+ const job = data.job;
638
+ showAgentHandoff(task || data.task, {
639
+ apiKeyConfigured: true,
640
+ job: job
641
+ });
642
+ if (job && job.id) pollAgentRun(job.id);
643
+ setStatus('Agent auto-run: ' + statusLabelForJob(job));
644
+ } catch (err) {
645
+ const msg =
646
+ err && err.message
647
+ ? String(err.message)
648
+ : 'Fully automatic run failed';
649
+ showToast(msg, 'error');
650
+ setStatus(msg);
651
+ }
652
+ }
653
+
654
+ let lastFinishedAgentTaskId = null;
655
+
656
+ async function finishAgentQueue(task, options) {
657
+ const opts = options || {};
658
+ if (
659
+ task &&
660
+ task.id &&
661
+ task.id === lastFinishedAgentTaskId &&
662
+ !opts.force
663
+ ) {
664
+ return;
665
+ }
666
+ if (task && task.id) lastFinishedAgentTaskId = task.id;
667
+ let apiKeyConfigured = opts.apiKeyConfigured;
668
+ if (apiKeyConfigured == null) {
669
+ const st = await fetchAgentStatus();
670
+ apiKeyConfigured = !!(st && st.apiKeyConfigured);
671
+ }
672
+ // Same-chat primary: queue for MCP pull + clipboard short phrase.
673
+ // Do NOT auto-open cursor:// prompt deeplink (that opens a *new* chat).
674
+ showAgentHandoff(task, {
675
+ apiKeyConfigured: apiKeyConfigured,
676
+ waitingWatch: !!opts.waitingWatch,
677
+ job: opts.job || null
678
+ });
679
+ let copied = false;
680
+ if (!opts.skipCopy) {
681
+ copied = await copyTextToClipboard(AGENT_SHORT_PROMPT);
682
+ }
683
+ const msg =
684
+ 'Queued — paste in your current Cursor chat (or ask: ' +
685
+ AGENT_SHORT_PROMPT +
686
+ ')' +
687
+ (copied ? ' · copied' : '');
688
+ showToast(msg, copied || opts.skipCopy ? 'ok' : 'info');
689
+ setStatus(msg);
690
+ }
691
+
692
+ function cssPath(el) {
693
+ if (!(el instanceof Element)) return '';
694
+ const parts = [];
695
+ let node = el;
696
+ while (node && node.nodeType === 1 && parts.length < 8) {
697
+ let part = node.tagName.toLowerCase();
698
+ if (node.id) {
699
+ part += '#' + CSS.escape(node.id);
700
+ parts.unshift(part);
701
+ break;
702
+ }
703
+ const parent = node.parentElement;
704
+ if (parent) {
705
+ const siblings = Array.from(parent.children).filter(
706
+ (c) => c.tagName === node.tagName
707
+ );
708
+ if (siblings.length > 1) {
709
+ part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
710
+ }
711
+ }
712
+ parts.unshift(part);
713
+ node = parent;
714
+ }
715
+ return parts.join(' > ');
716
+ }
717
+
718
+ function isInlineTag(tag) {
719
+ return /^(span|strong|b|em|i|u|small|mark|code|a|br|wbr|svg|path)$/i.test(tag);
720
+ }
721
+
722
+ function isLeafTextWrapper(el) {
723
+ if (!(el instanceof Element)) return false;
724
+ const tag = el.tagName.toLowerCase();
725
+ if (!/^(div|span|section|article|header|footer|aside|blockquote)$/.test(tag)) {
726
+ return false;
727
+ }
728
+ const kids = Array.from(el.childNodes);
729
+ if (!kids.length) return false;
730
+ return kids.every(function (child) {
731
+ if (child.nodeType === 3) return true;
732
+ if (child.nodeType !== 1) return false;
733
+ return isInlineTag(child.tagName);
734
+ });
735
+ }
736
+
737
+ function isCopyUnitRoot(el) {
738
+ if (!(el instanceof Element)) return false;
739
+ const tag = el.tagName.toLowerCase();
740
+ if (/^(h1|h2|h3|h4|h5|h6|button|label|li|p|figcaption|caption|td|th)$/.test(tag)) {
741
+ return true;
742
+ }
743
+ if (tag === 'a' && el.childNodes.length) return true;
744
+ return isLeafTextWrapper(el);
745
+ }
746
+
747
+ function unitRole(el) {
748
+ const tag = el.tagName.toLowerCase();
749
+ if (/^(h1|h2|h3|h4|h5|h6)$/.test(tag)) return 'heading';
750
+ if (/^(button|a)$/.test(tag)) return 'action';
751
+ if (/^(label|figcaption|caption)$/.test(tag)) return 'label';
752
+ const ariaRole = String(el.getAttribute('role') || '').toLowerCase();
753
+ if (ariaRole === 'heading') return 'heading';
754
+ if (ariaRole === 'button' || ariaRole === 'link') return 'action';
755
+ const cls = String(el.className || '').toLowerCase();
756
+ if (/\b(heading|headline|title|hero-title|card-title)\b/.test(cls)) {
757
+ return 'heading';
758
+ }
759
+ if (/\b(cta|btn|button|action-link)\b/.test(cls)) return 'action';
760
+ return 'text';
761
+ }
762
+
763
+ /**
764
+ * Within one copy unit, keep the full sentence together but chip any
765
+ * dynamic/bound fragments (DB/prop values) so they are visible, not separate fields.
766
+ */
767
+ function analyzeUnitParts(el, classify) {
768
+ const parts = [];
769
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
770
+ let textNode;
771
+ while ((textNode = walker.nextNode())) {
772
+ const raw = String(textNode.nodeValue || '').replace(/\s+/g, ' ');
773
+ if (!raw.trim()) continue;
774
+ const owner =
775
+ textNode.parentElement instanceof Element ? textNode.parentElement : el;
776
+ const source = classify(owner, raw.trim());
777
+ const type = source.type || 'static-label';
778
+ parts.push({
779
+ text: raw,
780
+ type: type,
781
+ editable: source.editable !== false
782
+ });
783
+ }
784
+ if (!parts.length) {
785
+ const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
786
+ if (text) {
787
+ const source = classify(el, text);
788
+ parts.push({
789
+ text: text,
790
+ type: source.type || 'static-label',
791
+ editable: source.editable !== false
792
+ });
793
+ }
794
+ }
795
+ return parts;
796
+ }
797
+
798
+ function summarizeUnitSource(parts, classify, el, fullText) {
799
+ const types = {};
800
+ parts.forEach(function (p) {
801
+ types[p.type] = (types[p.type] || 0) + 1;
802
+ });
803
+ const hasDynamic = !!types['dynamic-data'];
804
+ const hasBound = !!types['bound-unknown'];
805
+ const hasStatic = !!types['static-label'] || !!types['unknown'];
806
+
807
+ if (hasDynamic && (hasStatic || hasBound)) {
808
+ return {
809
+ type: 'mixed',
810
+ editable: parts.some(function (p) {
811
+ return p.editable;
812
+ }),
813
+ chips: ['Mixed'].concat(hasDynamic ? ['Dynamic'] : []).concat(hasBound ? ['Bound'] : [])
814
+ };
815
+ }
816
+ if (hasDynamic && !hasStatic && !hasBound) {
817
+ return { type: 'dynamic-data', editable: false, chips: ['Dynamic'] };
818
+ }
819
+ if (hasBound && !hasStatic) {
820
+ return { type: 'bound-unknown', editable: false, chips: ['Bound'] };
821
+ }
822
+ const whole = classify(el, fullText);
823
+ return {
824
+ type: whole.type || 'static-label',
825
+ editable: whole.editable !== false,
826
+ chips: [
827
+ whole.type === 'dynamic-data'
828
+ ? 'Dynamic'
829
+ : whole.type === 'bound-unknown'
830
+ ? 'Bound'
831
+ : 'Static'
832
+ ]
833
+ };
834
+ }
835
+
836
+ function buildUnit(el, classify, pathIndex) {
837
+ const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
838
+ if (!text) return null;
839
+ const parts = analyzeUnitParts(el, classify);
840
+ const source = summarizeUnitSource(parts, classify, el, text);
841
+ const path = String(pathIndex);
842
+ return {
843
+ id: 'copy-' + path,
844
+ path: path,
845
+ text: text,
846
+ previousText: text,
847
+ originalText: text,
848
+ role: unitRole(el),
849
+ tagName: el.tagName.toLowerCase(),
850
+ selector: cssPath(el),
851
+ parts: parts,
852
+ textSource: {
853
+ type: source.type,
854
+ editable: source.editable,
855
+ chips: source.chips
856
+ },
857
+ editable: source.editable
858
+ };
859
+ }
860
+
861
+ /**
862
+ * Merge consecutive sibling body wrappers under the same parent into one field
863
+ * so a sentence split across nested div/spans is not exploded into N fields.
864
+ */
865
+ function mergeSiblingBodyUnits(units) {
866
+ if (units.length < 2) return units;
867
+ const merged = [];
868
+ let i = 0;
869
+ while (i < units.length) {
870
+ const cur = units[i];
871
+ if (cur.role !== 'text') {
872
+ merged.push(cur);
873
+ i += 1;
874
+ continue;
875
+ }
876
+ let j = i + 1;
877
+ const group = [cur];
878
+ while (j < units.length && units[j].role === 'text') {
879
+ const a = document.querySelector(group[group.length - 1].selector);
880
+ const b = document.querySelector(units[j].selector);
881
+ if (
882
+ a &&
883
+ b &&
884
+ a.parentElement &&
885
+ a.parentElement === b.parentElement &&
886
+ isLeafTextWrapper(a) &&
887
+ isLeafTextWrapper(b)
888
+ ) {
889
+ group.push(units[j]);
890
+ j += 1;
891
+ } else {
892
+ break;
893
+ }
894
+ }
895
+ if (group.length === 1) {
896
+ merged.push(cur);
897
+ } else {
898
+ const parent = document.querySelector(group[0].selector)?.parentElement;
899
+ if (parent) {
900
+ const classify = TS().classifyTextSource || function () {
901
+ return { type: 'static-label', editable: true };
902
+ };
903
+ const unit = buildUnit(parent, classify, merged.length);
904
+ if (unit) merged.push(unit);
905
+ else merged.push(cur);
906
+ } else {
907
+ merged.push(cur);
908
+ }
909
+ }
910
+ i = j;
911
+ }
912
+ return merged.map(function (u, idx) {
913
+ const path = String(idx);
914
+ return Object.assign({}, u, { path: path, id: u.id || 'copy-' + path });
915
+ });
916
+ }
917
+
918
+ /**
919
+ * Coalesce copy into meaningful units (id, optional role, text, selector).
920
+ * Do not split a sentence across text nodes — keep one field per block;
921
+ * chip dynamic/bound fragments inside the unit instead.
922
+ */
923
+ function extractTextNodes(root) {
924
+ const classify = TS().classifyTextSource || function () {
925
+ return { type: 'static-label', editable: true };
926
+ };
927
+ const units = [];
928
+ const seen = new Set();
929
+
930
+ function pushUnit(el) {
931
+ if (!el || seen.has(el)) return;
932
+ const unit = buildUnit(el, classify, units.length);
933
+ if (!unit) return;
934
+ seen.add(el);
935
+ units.push(unit);
936
+ }
937
+
938
+ function walk(el) {
939
+ if (!(el instanceof Element)) return;
940
+ if (panel.contains(el) || el.id === 'crank-highlight') return;
941
+ if (isCopyUnitRoot(el)) {
942
+ pushUnit(el);
943
+ return; // do not split children into separate units
944
+ }
945
+ Array.from(el.children).forEach(walk);
946
+ }
947
+
948
+ walk(root);
949
+
950
+ // Fallback: whole selection as one body if nothing matched
951
+ if (!units.length) {
952
+ const unit = buildUnit(root, classify, 0);
953
+ if (unit) units.push(unit);
954
+ }
955
+
956
+ return assignUnitLabels(mergeSiblingBodyUnits(units));
957
+ }
958
+
959
+ function resolveUnitElement(root, node) {
960
+ if (!root || !node) return null;
961
+ if (node.selector) {
962
+ try {
963
+ const found = document.querySelector(node.selector);
964
+ if (found && root.contains(found) && found !== root) return found;
965
+ } catch (_) {}
966
+ // Absolute cssPath may include ancestors; try the leaf selector inside root.
967
+ try {
968
+ const leaf = String(node.selector)
969
+ .split('>')
970
+ .pop()
971
+ .trim();
972
+ if (leaf) {
973
+ const candidates = root.querySelectorAll(leaf);
974
+ if (candidates.length === 1 && candidates[0] !== root) {
975
+ return candidates[0];
976
+ }
977
+ }
978
+ } catch (_) {}
979
+ }
980
+ return null;
981
+ }
982
+
983
+ function applyNodesToDom(root, nodes) {
984
+ if (!root || !Array.isArray(nodes)) return 0;
985
+ // Resolve all targets before mutating — never fall back to wiping `root`
986
+ // (that applied one string to the whole selection).
987
+ const resolved = nodes.map(function (node) {
988
+ return { node: node, el: resolveUnitElement(root, node) };
989
+ });
990
+
991
+ // Path-index fallback against live copy units when selectors miss.
992
+ const needPath = resolved.some(function (r) {
993
+ return !r.el;
994
+ });
995
+ let byPath = null;
996
+ if (needPath) {
997
+ byPath = new Map();
998
+ extractTextNodes(root).forEach(function (u) {
999
+ try {
1000
+ const el = document.querySelector(u.selector);
1001
+ if (el && root.contains(el) && el !== root) {
1002
+ byPath.set(String(u.path), el);
1003
+ }
1004
+ } catch (_) {}
1005
+ });
1006
+ }
1007
+
1008
+ let applied = 0;
1009
+ for (let i = 0; i < resolved.length; i++) {
1010
+ const node = resolved[i].node;
1011
+ let el = resolved[i].el;
1012
+ if (!el && byPath && node.path != null) {
1013
+ el = byPath.get(String(node.path)) || null;
1014
+ }
1015
+ if (!el) continue;
1016
+ const next = String(node.text || '');
1017
+ if (
1018
+ String(el.textContent || '')
1019
+ .replace(/\s+/g, ' ')
1020
+ .trim() === next.replace(/\s+/g, ' ').trim()
1021
+ ) {
1022
+ continue;
1023
+ }
1024
+ el.textContent = next;
1025
+ applied += 1;
1026
+ }
1027
+ return applied;
1028
+ }
1029
+
1030
+ function restoreOriginal() {
1031
+ if (selectedEl && originalSnapshot.length) {
1032
+ applyNodesToDom(selectedEl, originalSnapshot);
1033
+ }
1034
+ }
1035
+
1036
+ function connectWs() {
1037
+ try {
1038
+ socket = new WebSocket(wsUrlWithToken());
1039
+ socket.addEventListener('open', function () {
1040
+ setStatus('Connected to companion');
1041
+ });
1042
+ socket.addEventListener('message', function (ev) {
1043
+ let msg;
1044
+ try {
1045
+ msg = JSON.parse(ev.data);
1046
+ } catch (_) {
1047
+ return;
1048
+ }
1049
+ if (msg.type === 'VARIANTS_READY' && msg.session) {
1050
+ sessionId = msg.session.id;
1051
+ gleanActiveEpoch = 0;
1052
+ hideThinking();
1053
+ renderVariants(msg.session.variants || []);
1054
+ setStatus('Glean returned ' + (msg.session.variants || []).length + ' options');
1055
+ const gleanBtn = panel.querySelector('#vc-glean');
1056
+ if (gleanBtn) gleanBtn.disabled = !sessionId;
1057
+ }
1058
+ if (msg.type === 'GLEAN_PROGRESS') {
1059
+ // Ignore progress once variants are on screen (or from a prior request).
1060
+ if (!gleanActiveEpoch) return;
1061
+ if (msg.sessionId && sessionId && msg.sessionId !== sessionId) return;
1062
+ showThinking(msg.progress || {});
1063
+ }
1064
+ if (msg.type === 'GLEAN_ERROR') {
1065
+ gleanActiveEpoch = 0;
1066
+ hideThinking();
1067
+ setStatus('Glean error: ' + (msg.error || 'unknown'));
1068
+ const gleanBtn = panel.querySelector('#vc-glean');
1069
+ if (gleanBtn) gleanBtn.disabled = !sessionId;
1070
+ }
1071
+ if (msg.type === 'APPLY_VARIANT' && selectedEl) {
1072
+ applyNodesToDom(selectedEl, msg.nodes || []);
1073
+ if (msg.variantId) activeVariantId = msg.variantId;
1074
+ markActiveVariantCard(activeVariantId);
1075
+ if (msg.preview) {
1076
+ setStatus('Previewed in DOM (not saved)');
1077
+ } else if (msg.persistResult) {
1078
+ setStatus(formatPersistStatus(msg.persistResult, msg.variantId));
1079
+ } else {
1080
+ setStatus('Applied variant in DOM');
1081
+ }
1082
+ }
1083
+ if (msg.type === 'APPLY_RESULT') {
1084
+ if (msg.persistResult) {
1085
+ setStatus(formatPersistStatus(msg.persistResult, msg.variantId));
1086
+ } else if (msg.preview) {
1087
+ setStatus('Previewed in DOM (not saved)');
1088
+ }
1089
+ }
1090
+ if (msg.type === 'PREVIEW_TEXT' && selectedEl) {
1091
+ applyNodesToDom(selectedEl, msg.nodes || []);
1092
+ }
1093
+ if (msg.type === 'DISCARD_TEXT_PREVIEW') {
1094
+ restoreOriginal();
1095
+ setStatus('Preview discarded');
1096
+ }
1097
+ if (msg.type === 'SAVE_RESULT') {
1098
+ const p = msg.payload || {};
1099
+ setStatus(
1100
+ p.success
1101
+ ? 'Saved to source' + (p.file ? ': ' + p.file : '')
1102
+ : 'Save failed: ' + (p.error || 'unknown')
1103
+ );
1104
+ }
1105
+ if (msg.type === 'AGENT_PROMPT_QUEUED') {
1106
+ const t = msg.task || {};
1107
+ if (t.prompt) {
1108
+ // HTTP path already finished handoff + clipboard; WS only refreshes UI.
1109
+ finishAgentQueue(t, {
1110
+ fromWs: true,
1111
+ skipCopy: true
1112
+ });
1113
+ } else {
1114
+ const wsMsg =
1115
+ 'Queued — paste in your current Cursor chat (or ask: ' +
1116
+ AGENT_SHORT_PROMPT +
1117
+ ')';
1118
+ showToast(wsMsg, 'ok');
1119
+ setStatus(wsMsg);
1120
+ }
1121
+ }
1122
+ if (msg.type === 'ERROR') {
1123
+ setStatus('Error: ' + (msg.error || 'unknown'));
1124
+ }
1125
+ });
1126
+ socket.addEventListener('close', function () {
1127
+ setStatus(
1128
+ 'Companion disconnected — retrying… (start with: npx crank)'
1129
+ );
1130
+ setTimeout(connectWs, 1500);
1131
+ });
1132
+ socket.addEventListener('error', function () {
1133
+ setStatus(
1134
+ 'Companion not running — start with npx crank / open :3344'
1135
+ );
1136
+ });
1137
+ } catch (err) {
1138
+ setStatus(
1139
+ 'Companion not running — start with npx crank / open :3344'
1140
+ );
1141
+ }
1142
+ }
1143
+
1144
+ function sendWs(message) {
1145
+ if (socket && socket.readyState === WebSocket.OPEN) {
1146
+ socket.send(JSON.stringify(message));
1147
+ return true;
1148
+ }
1149
+ return false;
1150
+ }
1151
+
1152
+ // Short default for selection/preview/save. Glean /api/variants needs 3–5 min
1153
+ // (Robot Content Designer ~90–140s) — callers must pass timeoutMs explicitly.
1154
+ const GLEAN_VARIANTS_TIMEOUT_MS = 300000;
1155
+
1156
+ async function postJson(path, body, options) {
1157
+ const opts = options || {};
1158
+ const timeoutMs = opts.timeoutMs != null ? opts.timeoutMs : 20000;
1159
+ const controller =
1160
+ typeof AbortController !== 'undefined' ? new AbortController() : null;
1161
+ let timer = null;
1162
+ if (controller && timeoutMs > 0) {
1163
+ timer = setTimeout(function () {
1164
+ try {
1165
+ controller.abort();
1166
+ } catch (_) {
1167
+ /* ignore */
1168
+ }
1169
+ }, timeoutMs);
1170
+ }
1171
+ let res;
1172
+ try {
1173
+ res = await fetch(COMPANION_HTTP + path, {
1174
+ method: opts.method || 'POST',
1175
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
1176
+ body: opts.method === 'GET' ? undefined : JSON.stringify(body),
1177
+ signal: controller ? controller.signal : undefined
1178
+ });
1179
+ } catch (err) {
1180
+ if (timer) clearTimeout(timer);
1181
+ if (err && err.name === 'AbortError') {
1182
+ throw new Error('Request timed out after ' + timeoutMs + 'ms');
1183
+ }
1184
+ const m = (err && err.message) || String(err || '');
1185
+ if (
1186
+ /Failed to fetch|NetworkError|Load failed|Network request failed|fetch failed/i.test(
1187
+ m
1188
+ )
1189
+ ) {
1190
+ throw new Error(
1191
+ 'Companion not running — start with npx crank / open :3344'
1192
+ );
1193
+ }
1194
+ throw err;
1195
+ }
1196
+ if (timer) clearTimeout(timer);
1197
+ let data = {};
1198
+ try {
1199
+ data = await res.json();
1200
+ } catch (_) {
1201
+ data = {};
1202
+ }
1203
+ if (!res.ok) {
1204
+ if (res.status === 401 && (data.code === 'TEAM_TOKEN_REQUIRED' || data.code === 'TEAM_TOKEN_INVALID')) {
1205
+ setTeamToken('');
1206
+ const ok = await ensureTeamToken();
1207
+ if (ok) {
1208
+ return postJson(path, body, options);
1209
+ }
1210
+ }
1211
+ const err = new Error(data.error || 'HTTP ' + res.status);
1212
+ err.status = res.status;
1213
+ err.data = data;
1214
+ throw err;
1215
+ }
1216
+ return data;
1217
+ }
1218
+
1219
+ function formatPersistStatus(persistResult, variantId) {
1220
+ if (!persistResult) {
1221
+ return 'Applied in DOM';
1222
+ }
1223
+ const bits = ['Applied in DOM'];
1224
+ if (persistResult.summary) bits.push(persistResult.summary);
1225
+ const locked = (persistResult.results || []).filter(function (r) {
1226
+ return r.status === 'skipped' && r.reason === 'locked';
1227
+ });
1228
+ const failed = (persistResult.results || []).filter(function (r) {
1229
+ return r.status === 'failed';
1230
+ });
1231
+ if (locked.length) {
1232
+ bits.push(
1233
+ locked.length +
1234
+ ' locked/dynamic field(s) not written — use Agent or edit source'
1235
+ );
1236
+ }
1237
+ if (failed.length) {
1238
+ bits.push(
1239
+ 'Save failed: ' + (failed[0].message || 'could not write source')
1240
+ );
1241
+ }
1242
+ if (variantId && !persistResult.persisted && !failed.length && locked.length) {
1243
+ bits.push('(DOM only)');
1244
+ }
1245
+ return bits.join(' · ');
1246
+ }
1247
+
1248
+ function roleLabel(role, node, index, nodes) {
1249
+ if (node && node.label) return node.label;
1250
+ if (node && nodes) return unitDisplayLabel(node, index, nodes);
1251
+ const r = normalizeRole(role);
1252
+ if (r === 'heading') return 'Heading';
1253
+ if (r === 'action' || r === 'button') return 'CTA';
1254
+ if (r === 'label') return 'Label';
1255
+ return 'Copy';
1256
+ }
1257
+
1258
+ function sourceChips(textSource) {
1259
+ if (textSource && Array.isArray(textSource.chips) && textSource.chips.length) {
1260
+ return textSource.chips;
1261
+ }
1262
+ const type = textSource && textSource.type;
1263
+ if (type === 'dynamic-data') return ['Dynamic'];
1264
+ if (type === 'bound-unknown') return ['Bound'];
1265
+ if (type === 'mixed') return ['Mixed'];
1266
+ return ['Static'];
1267
+ }
1268
+
1269
+ function buildPanelShell() {
1270
+ const ui = UI();
1271
+ const btn = ui.vpButtonHtml || function (v, label, attrs) {
1272
+ return '<button type="button" id="' + (attrs && attrs.id || '') + '">' + label + '</button>';
1273
+ };
1274
+ const group = ui.vpButtonGroupHtml || function (html) {
1275
+ return '<div style="display:flex;gap:8px;flex-wrap:wrap;">' + html + '</div>';
1276
+ };
1277
+
1278
+ panel.innerHTML =
1279
+ '<div class="vp-panel-block" style="padding:var(--vp-panel-padding, 1rem);">' +
1280
+ '<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;">' +
1281
+ '<strong style="font-size:var(--vp-font-size-body,14px);font-weight:var(--vp-font-weight-title,600);">Crank</strong>' +
1282
+ btn('ghost', '×', { id: 'vc-close' }, { icon: true, size: 'compact' }) +
1283
+ '</div>' +
1284
+ '<p id="vc-status" class="vp-muted vp-type-support" style="margin:0 0 10px;">Loading UI…</p>' +
1285
+ '<div id="vc-toast" style="display:none;"></div>' +
1286
+ '<div id="vc-glean-options" style="margin:0 0 10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
1287
+ '<label for="vc-option-count" class="vp-type-label" style="display:flex;align-items:center;gap:6px;cursor:help;" title="Number of new rewritten alternatives (original draft is not shown). Fewer may reduce wait slightly; most latency is still agent runtime (~1–2 min).">' +
1288
+ 'New alternatives' +
1289
+ '<span class="vp-muted vp-type-micro" aria-hidden="true">(?)</span>' +
1290
+ '</label>' +
1291
+ '<select id="vc-option-count" class="vp-type-support" style="min-width:4.5rem;padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:var(--foreground);">' +
1292
+ [2, 3, 4, 5, 6]
1293
+ .map(function (n) {
1294
+ return (
1295
+ '<option value="' +
1296
+ n +
1297
+ '"' +
1298
+ (n === DEFAULT_GLEAN_OPTION_COUNT ? ' selected' : '') +
1299
+ '>' +
1300
+ n +
1301
+ '</option>'
1302
+ );
1303
+ })
1304
+ .join('') +
1305
+ '</select>' +
1306
+ '</div>' +
1307
+ group(
1308
+ btn('primary', 'Select', { id: 'vc-select' }, { size: 'compact', flex: true }) +
1309
+ btn('secondary', 'Ask Glean', { id: 'vc-glean', disabled: true }, { size: 'compact', flex: true }),
1310
+ { stretch: true }
1311
+ ) +
1312
+ '<div id="vc-edit" style="margin-top:12px;"></div>' +
1313
+ '<div id="vc-thinking" class="vp-muted vp-type-support" style="display:none;margin-top:10px;padding:10px;border:1px solid var(--border);border-radius:8px;white-space:pre-wrap;line-height:1.5;font-size:12px;background:oklch(0.97 0.01 180);max-height:none;overflow:visible;"></div>' +
1314
+ '<div id="vc-actions" style="margin-top:10px;">' +
1315
+ group(
1316
+ btn('secondary', 'Preview', { id: 'vc-preview', disabled: true }, { size: 'compact', flex: true }) +
1317
+ btn('ghost', 'Discard', { id: 'vc-discard', disabled: true }, { size: 'compact', flex: true }) +
1318
+ btn('primary', 'Save', { id: 'vc-save', disabled: true }, { size: 'compact', flex: true }),
1319
+ { stretch: true }
1320
+ ) +
1321
+ '</div>' +
1322
+ '<div id="vc-variants" style="margin-top:12px;"></div>' +
1323
+ '<div id="vc-agent-handoff" style="display:none;margin:12px 0 0;padding:10px;border:1px solid var(--border);border-radius:8px;background:oklch(0.97 0.01 180);"></div>' +
1324
+ '</div>';
1325
+
1326
+ document.documentElement.appendChild(panel);
1327
+ const optionCountSel = panel.querySelector('#vc-option-count');
1328
+ if (optionCountSel) {
1329
+ try {
1330
+ const saved = localStorage.getItem(OPTION_COUNT_KEY);
1331
+ if (saved && [2, 3, 4, 5, 6].indexOf(parseInt(saved, 10)) >= 0) {
1332
+ optionCountSel.value = String(parseInt(saved, 10));
1333
+ }
1334
+ } catch (_) {}
1335
+ optionCountSel.addEventListener('change', function () {
1336
+ try {
1337
+ localStorage.setItem(OPTION_COUNT_KEY, optionCountSel.value);
1338
+ } catch (_) {}
1339
+ });
1340
+ }
1341
+ panel.querySelector('#vc-close').addEventListener('click', teardown);
1342
+ panel.querySelector('#vc-select').addEventListener('click', startSelect);
1343
+ panel.querySelector('#vc-glean').addEventListener('click', requestGlean);
1344
+ panel.querySelector('#vc-preview').addEventListener('click', previewEdits);
1345
+ panel.querySelector('#vc-discard').addEventListener('click', discardEdits);
1346
+ panel.querySelector('#vc-save').addEventListener('click', saveEdits);
1347
+ // Event delegation so Agent/Preview/Apply keep working after re-renders.
1348
+ const variantsBox = panel.querySelector('#vc-variants');
1349
+ if (variantsBox && !variantsBox.__crankDelegated) {
1350
+ variantsBox.__crankDelegated = true;
1351
+ variantsBox.addEventListener('click', function (ev) {
1352
+ const target = ev.target && ev.target.closest
1353
+ ? ev.target.closest(
1354
+ '[data-variant-agent],[data-variant-preview],[data-variant-apply]'
1355
+ )
1356
+ : null;
1357
+ if (!target || !variantsBox.contains(target)) return;
1358
+ if (ev.preventDefault) ev.preventDefault();
1359
+ if (ev.stopPropagation) ev.stopPropagation();
1360
+ const agentId = target.getAttribute('data-variant-agent');
1361
+ if (agentId) {
1362
+ queueAgentVariant(agentId);
1363
+ return;
1364
+ }
1365
+ const previewId = target.getAttribute('data-variant-preview');
1366
+ if (previewId) {
1367
+ applyVariant(previewId, { syncEditors: false, persist: false });
1368
+ return;
1369
+ }
1370
+ const applyId = target.getAttribute('data-variant-apply');
1371
+ if (applyId) {
1372
+ applyVariant(applyId, { syncEditors: true, persist: true });
1373
+ }
1374
+ });
1375
+ }
1376
+ }
1377
+
1378
+ function renderNodes() {
1379
+ const ui = UI();
1380
+ const editEl = panel.querySelector('#vc-edit');
1381
+ if (!editEl) return;
1382
+
1383
+ if (!selectedNodes.length) {
1384
+ editEl.innerHTML = '';
1385
+ setEditButtons(false);
1386
+ return;
1387
+ }
1388
+
1389
+ const field = ui.vpFieldHtml || function (label, control) {
1390
+ return '<div><div>' + label + '</div>' + control + '</div>';
1391
+ };
1392
+ const area = ui.vpTextareaHtml || function (attrs, content) {
1393
+ return '<textarea id="' + (attrs.id || '') + '"' + (attrs.disabled ? ' disabled' : '') + '>' + (content || '') + '</textarea>';
1394
+ };
1395
+ const badge = ui.vpCapabilityBadgeHtml || function (_mode, text) {
1396
+ return '<span class="vp-type-micro">' + text + '</span>';
1397
+ };
1398
+ const escape = ui.escapeHtml || function (v) {
1399
+ return String(v || '');
1400
+ };
1401
+
1402
+ editEl.innerHTML = selectedNodes
1403
+ .map(function (n, idx) {
1404
+ const label = roleLabel(n.role, n, idx, selectedNodes);
1405
+ const chips = sourceChips(n.textSource);
1406
+ const mode = n.editable ? 'editable' : 'read-only';
1407
+ const meta =
1408
+ chips
1409
+ .map(function (chip) {
1410
+ return badge(mode, chip);
1411
+ })
1412
+ .join(' ') +
1413
+ (n.editable
1414
+ ? ''
1415
+ : '<span class="vp-muted vp-type-micro"> Locked</span>');
1416
+
1417
+ const dynamicHints = (n.parts || [])
1418
+ .filter(function (p) {
1419
+ return p.type === 'dynamic-data' || p.type === 'bound-unknown';
1420
+ })
1421
+ .map(function (p) {
1422
+ return (
1423
+ '<span class="vp-type-micro" style="display:inline-block;margin:2px 4px 0 0;padding:1px 6px;border:1px dashed var(--border);border-radius:999px;">' +
1424
+ escape(String(p.text || '').trim()) +
1425
+ '</span>'
1426
+ );
1427
+ })
1428
+ .join('');
1429
+
1430
+ if (!n.editable) {
1431
+ return (
1432
+ '<div class="vp-field" style="margin-bottom:10px;">' +
1433
+ '<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:4px;">' +
1434
+ '<span class="vp-type-label">' +
1435
+ escape(label) +
1436
+ '</span>' +
1437
+ meta +
1438
+ '</div>' +
1439
+ '<div class="vp-type-support" style="padding:8px;border:1px dashed var(--border);border-radius:8px;opacity:0.85;">' +
1440
+ escape(n.text) +
1441
+ '</div>' +
1442
+ (dynamicHints
1443
+ ? '<div style="margin-top:4px;">' + dynamicHints + '</div>'
1444
+ : '') +
1445
+ '</div>'
1446
+ );
1447
+ }
1448
+
1449
+ return (
1450
+ field(
1451
+ label,
1452
+ area(
1453
+ { id: 'vc-edit-' + n.path, rows: '2', 'data-path': n.path },
1454
+ n.text,
1455
+ { size: 'compact' }
1456
+ ),
1457
+ { size: 'compact', metaHtml: meta }
1458
+ ) +
1459
+ (dynamicHints
1460
+ ? '<div class="vp-muted vp-type-micro" style="margin:-4px 0 10px;">Vars: ' +
1461
+ dynamicHints +
1462
+ '</div>'
1463
+ : '')
1464
+ );
1465
+ })
1466
+ .join('');
1467
+
1468
+ const anyEditable = selectedNodes.some(function (n) {
1469
+ return n.editable;
1470
+ });
1471
+ setEditButtons(anyEditable);
1472
+ }
1473
+
1474
+ function setEditButtons(enabled) {
1475
+ ['vc-preview', 'vc-discard', 'vc-save', 'vc-glean'].forEach(function (id) {
1476
+ const el = panel.querySelector('#' + id);
1477
+ if (el) el.disabled = !enabled && id !== 'vc-glean' ? true : !sessionId && id === 'vc-glean' ? true : !enabled && id !== 'vc-glean';
1478
+ });
1479
+ const glean = panel.querySelector('#vc-glean');
1480
+ if (glean) glean.disabled = !sessionId;
1481
+ const preview = panel.querySelector('#vc-preview');
1482
+ const discard = panel.querySelector('#vc-discard');
1483
+ const save = panel.querySelector('#vc-save');
1484
+ if (preview) preview.disabled = !enabled;
1485
+ if (discard) discard.disabled = !enabled;
1486
+ if (save) save.disabled = !enabled;
1487
+ }
1488
+
1489
+ function readEditedNodes() {
1490
+ return selectedNodes.map(function (n) {
1491
+ const input = panel.querySelector('#vc-edit-' + n.path);
1492
+ if (input && n.editable) {
1493
+ return Object.assign({}, n, { text: input.value });
1494
+ }
1495
+ return n;
1496
+ });
1497
+ }
1498
+
1499
+ function previewEdits() {
1500
+ const nodes = readEditedNodes();
1501
+ if (selectedEl) applyNodesToDom(selectedEl, nodes);
1502
+ selectedNodes = nodes;
1503
+ sendWs({
1504
+ type: 'PREVIEW_TEXT_CHANGE',
1505
+ sessionId: sessionId,
1506
+ payload: { sessionId: sessionId, nodes: nodes }
1507
+ });
1508
+ setStatus('DOM preview applied (not saved to source yet)');
1509
+ }
1510
+
1511
+ function discardEdits() {
1512
+ restoreOriginal();
1513
+ selectedNodes = originalSnapshot.map(function (n) {
1514
+ return Object.assign({}, n);
1515
+ });
1516
+ renderNodes();
1517
+ sendWs({ type: 'DISCARD_TEXT_PREVIEW', sessionId: sessionId });
1518
+ setStatus('Discarded — restored original DOM text');
1519
+ }
1520
+
1521
+ function pathHintsFromLocation() {
1522
+ const hints = [];
1523
+ try {
1524
+ const pathname = String(location.pathname || '/');
1525
+ if (pathname && pathname !== '/') {
1526
+ hints.push(pathname);
1527
+ hints.push(pathname.replace(/^\//, ''));
1528
+ const base = pathname.split('/').pop();
1529
+ if (base) hints.push(base);
1530
+ } else {
1531
+ hints.push('/index.html', 'index.html');
1532
+ }
1533
+ } catch (_) {}
1534
+ return hints;
1535
+ }
1536
+
1537
+ async function saveEdits() {
1538
+ const nodes = readEditedNodes();
1539
+ previewEdits();
1540
+ setStatus('Saving editable strings to source…');
1541
+ const selector = selectedEl ? cssPath(selectedEl) : '';
1542
+ let saved = 0;
1543
+ let failed = 0;
1544
+ for (const node of nodes) {
1545
+ if (!node.editable) continue;
1546
+ if (String(node.text) === String(node.previousText || originalSnapshot.find(function (o) { return o.path === node.path; })?.text)) {
1547
+ continue;
1548
+ }
1549
+ const oldValue =
1550
+ (originalSnapshot.find(function (o) {
1551
+ return o.path === node.path;
1552
+ }) || {}).text || node.previousText || node.originalText;
1553
+ const payload = {
1554
+ selectorText: node.selector || selector,
1555
+ value: node.text,
1556
+ oldValue: oldValue,
1557
+ sourceUrl: String(document.baseURI || location.href),
1558
+ origin: String(location.origin || ''),
1559
+ pageUrl: String(location.href || ''),
1560
+ pathHints: pathHintsFromLocation()
1561
+ };
1562
+ const sent = sendWs({ type: 'SAVE_TEXT_CHANGE', payload: payload });
1563
+ if (!sent) {
1564
+ const data = await postJson('/api/save-text', payload);
1565
+ if (data.ok || data.success) saved += 1;
1566
+ else failed += 1;
1567
+ } else {
1568
+ saved += 1;
1569
+ }
1570
+ }
1571
+ setStatus(
1572
+ failed
1573
+ ? 'Save finished with errors (see status / companion logs)'
1574
+ : 'Save dispatched for ' + saved + ' string(s)'
1575
+ );
1576
+ }
1577
+
1578
+ function variantFieldRows(variant) {
1579
+ const fields = variant && variant.fields ? variant.fields : null;
1580
+ const rows = [];
1581
+ const sessionLabels = (selectedNodes || []).map(function (n, idx) {
1582
+ return roleLabel(n.role, n, idx, selectedNodes);
1583
+ });
1584
+ if (
1585
+ fields &&
1586
+ (fields.heading ||
1587
+ fields.body ||
1588
+ fields.cta ||
1589
+ (Array.isArray(fields.bodies) && fields.bodies.length))
1590
+ ) {
1591
+ if (fields.heading) {
1592
+ rows.push({ label: sessionLabels[0] || 'Heading', text: fields.heading });
1593
+ }
1594
+ if (Array.isArray(fields.bodies) && fields.bodies.length) {
1595
+ fields.bodies.forEach(function (t, i) {
1596
+ if (String(t || '').trim()) {
1597
+ rows.push({
1598
+ label: sessionLabels[rows.length] || 'Body ' + (i + 1),
1599
+ text: t
1600
+ });
1601
+ }
1602
+ });
1603
+ } else if (fields.body) {
1604
+ rows.push({
1605
+ label: sessionLabels[rows.length] || 'Body',
1606
+ text: fields.body
1607
+ });
1608
+ }
1609
+ if (fields.cta) {
1610
+ rows.push({
1611
+ label: sessionLabels[sessionLabels.length - 1] || 'CTA',
1612
+ text: fields.cta
1613
+ });
1614
+ }
1615
+ return rows;
1616
+ }
1617
+ (variant.nodes || []).forEach(function (n, idx) {
1618
+ const text = String(n && n.text != null ? n.text : '').trim();
1619
+ if (!text) return;
1620
+ rows.push({
1621
+ label: roleLabel(n.role, n, idx, variant.nodes),
1622
+ text: text
1623
+ });
1624
+ });
1625
+ return rows;
1626
+ }
1627
+
1628
+ function markActiveVariantCard(variantId) {
1629
+ const box = panel.querySelector('#vc-variants');
1630
+ if (!box) return;
1631
+ box.querySelectorAll('[data-variant-card]').forEach(function (card) {
1632
+ const active = card.getAttribute('data-variant-card') === String(variantId || '');
1633
+ card.style.borderColor = active
1634
+ ? 'color-mix(in oklch, var(--ring, #888) 45%, var(--border))'
1635
+ : '';
1636
+ card.style.boxShadow = active
1637
+ ? '0 0 0 1px color-mix(in oklch, var(--ring, #888) 25%, transparent)'
1638
+ : '';
1639
+ });
1640
+ }
1641
+
1642
+ function syncEditorsFromVariant(variant) {
1643
+ if (!variant || !Array.isArray(variant.nodes) || !variant.nodes.length) return;
1644
+ const byPath = {};
1645
+ const byId = {};
1646
+ variant.nodes.forEach(function (n) {
1647
+ byPath[String(n.path)] = n;
1648
+ if (n.id) byId[String(n.id)] = n;
1649
+ });
1650
+ selectedNodes = selectedNodes.map(function (n) {
1651
+ const next = (n.id && byId[String(n.id)]) || byPath[String(n.path)];
1652
+ if (!next) return n;
1653
+ return Object.assign({}, n, { text: next.text });
1654
+ });
1655
+ // If selection was empty of matching paths, fall back to variant nodes.
1656
+ if (!selectedNodes.length) {
1657
+ selectedNodes = variant.nodes.map(function (n) {
1658
+ return Object.assign({}, n);
1659
+ });
1660
+ }
1661
+ renderNodes();
1662
+ setEditButtons(true);
1663
+ }
1664
+
1665
+ function renderVariantCard(variant, ui) {
1666
+ const escape = ui.escapeHtml || function (v) {
1667
+ return String(v == null ? '' : v);
1668
+ };
1669
+ const escapeAttr = ui.escapeAttr || escape;
1670
+ const btn = ui.vpButtonHtml || function (_v, label, attrs) {
1671
+ const a = attrs || {};
1672
+ let attrHtml = ' type="button"';
1673
+ Object.keys(a).forEach(function (key) {
1674
+ if (key === 'type' || a[key] == null || a[key] === false) return;
1675
+ if (a[key] === true) {
1676
+ attrHtml += ' ' + key;
1677
+ return;
1678
+ }
1679
+ attrHtml += ' ' + key + '="' + escapeAttr(String(a[key])) + '"';
1680
+ });
1681
+ return '<button' + attrHtml + '>' + label + '</button>';
1682
+ };
1683
+ const group = ui.vpButtonGroupHtml || function (html) {
1684
+ return '<div style="display:flex;gap:8px;flex-wrap:wrap;">' + html + '</div>';
1685
+ };
1686
+ const badge = ui.vpCapabilityBadgeHtml || function (_mode, text) {
1687
+ return '<span class="vp-chip">' + escape(text) + '</span>';
1688
+ };
1689
+ const title = variant.label || variant.title || variant.id || 'Option';
1690
+ const rows = variantFieldRows(variant);
1691
+ let bodyHtml = '';
1692
+
1693
+ if (rows.length) {
1694
+ bodyHtml = rows
1695
+ .map(function (row) {
1696
+ return (
1697
+ '<div class="vc-variant-field" style="margin-top:8px;">' +
1698
+ '<div class="vp-muted vp-type-micro" style="margin-bottom:2px;">' +
1699
+ escape(row.label || 'Copy') +
1700
+ '</div>' +
1701
+ '<div class="vp-type-body" style="white-space:pre-wrap;word-break:break-word;color:var(--foreground);">' +
1702
+ escape(row.text) +
1703
+ '</div>' +
1704
+ '</div>'
1705
+ );
1706
+ })
1707
+ .join('');
1708
+ } else if (variant.rawText) {
1709
+ bodyHtml =
1710
+ '<div class="vp-type-support" style="margin-top:8px;white-space:pre-wrap;word-break:break-word;line-height:1.4;color:var(--foreground);">' +
1711
+ escape(String(variant.rawText)) +
1712
+ '</div>';
1713
+ } else if (variant.summary) {
1714
+ bodyHtml =
1715
+ '<div class="vp-muted vp-type-support" style="margin-top:8px;white-space:pre-wrap;word-break:break-word;">' +
1716
+ escape(variant.summary) +
1717
+ '</div>';
1718
+ }
1719
+
1720
+ const recommended = !!variant.recommended;
1721
+ return (
1722
+ '<article class="vp-panel-block vc-variant-card' +
1723
+ (recommended ? ' is-recommended' : '') +
1724
+ '" data-variant-card="' +
1725
+ escape(variant.id) +
1726
+ '" style="padding:10px 12px;margin-bottom:8px;">' +
1727
+ '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;">' +
1728
+ '<div class="vp-type-label" style="font-weight:var(--vp-font-weight-title,620);color:var(--foreground);line-height:var(--vp-line-height-label,1.4);">' +
1729
+ escape(title) +
1730
+ '</div>' +
1731
+ (recommended ? badge('preview', 'Recommended') : '') +
1732
+ '</div>' +
1733
+ bodyHtml +
1734
+ '<div style="margin-top:10px;">' +
1735
+ group(
1736
+ btn(
1737
+ 'secondary',
1738
+ 'Preview',
1739
+ { 'data-variant-preview': variant.id, type: 'button' },
1740
+ { size: 'compact', flex: true }
1741
+ ) +
1742
+ btn(
1743
+ 'primary',
1744
+ 'Apply',
1745
+ { 'data-variant-apply': variant.id, type: 'button' },
1746
+ { size: 'compact', flex: true }
1747
+ ) +
1748
+ btn(
1749
+ 'secondary',
1750
+ 'Agent',
1751
+ { 'data-variant-agent': variant.id, type: 'button' },
1752
+ { size: 'compact', flex: true }
1753
+ ),
1754
+ { stretch: true }
1755
+ ) +
1756
+ '</div>' +
1757
+ '</article>'
1758
+ );
1759
+ }
1760
+
1761
+ function renderVariants(variants) {
1762
+ const box = panel.querySelector('#vc-variants');
1763
+ const ui = UI();
1764
+ const escape = ui.escapeHtml || String;
1765
+ lastVariants = Array.isArray(variants) ? variants : [];
1766
+ if (!lastVariants.length) {
1767
+ box.innerHTML = '';
1768
+ return;
1769
+ }
1770
+ const titleHtml = ui.vpSectionTitleHtml
1771
+ ? ui.vpSectionTitleHtml('New alternatives')
1772
+ : '<div class="vp-section-title"><span>' + escape('New alternatives') + '</span></div>';
1773
+ box.innerHTML =
1774
+ '<div style="margin-bottom:8px;">' +
1775
+ titleHtml +
1776
+ '</div>' +
1777
+ lastVariants.map(function (v) {
1778
+ return renderVariantCard(v, ui);
1779
+ }).join('');
1780
+
1781
+ markActiveVariantCard(activeVariantId);
1782
+ }
1783
+
1784
+ async function queueAgentVariant(variantId) {
1785
+ if (!sessionId) {
1786
+ const msg = 'Select a region and Ask Glean before using Agent';
1787
+ showToast(msg, 'error');
1788
+ setStatus(msg);
1789
+ return;
1790
+ }
1791
+ if (!variantId) {
1792
+ const msg = 'Agent: missing variant id';
1793
+ showToast(msg, 'error');
1794
+ setStatus(msg);
1795
+ return;
1796
+ }
1797
+ const local = lastVariants.find(function (v) {
1798
+ return v && v.id === variantId;
1799
+ });
1800
+ activeVariantId = variantId;
1801
+ markActiveVariantCard(variantId);
1802
+ showToast('Queueing for Cursor agent…', 'info');
1803
+ setStatus('Queueing for Cursor agent…');
1804
+
1805
+ // Prefer HTTP so we always get the executable prompt for clipboard + handoff UI.
1806
+ // (WS-only fire-and-forget looked like “nothing happened” when Cursor chat stayed quiet.)
1807
+ try {
1808
+ const data = await postJson(
1809
+ '/api/agent-prompt',
1810
+ {
1811
+ sessionId: sessionId,
1812
+ variantId: variantId,
1813
+ source: 'panel'
1814
+ },
1815
+ { timeoutMs: 15000 }
1816
+ );
1817
+ const task = (data && data.task) || {};
1818
+ if (!task.prompt) {
1819
+ throw new Error(
1820
+ (data && data.error) || 'Companion returned no agent prompt'
1821
+ );
1822
+ }
1823
+ if (!task.variantLabel && local && local.label) {
1824
+ task.variantLabel = local.label;
1825
+ }
1826
+ await finishAgentQueue(task, { force: true });
1827
+ } catch (err) {
1828
+ const raw = err && err.message ? String(err.message) : 'unknown';
1829
+ const down =
1830
+ /Companion not running|Failed to fetch|NetworkError|Load failed/i.test(
1831
+ raw
1832
+ );
1833
+ const msg = down
1834
+ ? 'Companion not running — start with npx crank / open :3344'
1835
+ : 'Agent queue failed: ' + raw;
1836
+ showAgentHandoffError(
1837
+ msg,
1838
+ down
1839
+ ? 'Expected companion at ' + COMPANION_HTTP
1840
+ : 'Is the companion running at ' + COMPANION_HTTP + '?'
1841
+ );
1842
+ showToast(msg, 'error');
1843
+ setStatus(msg);
1844
+ }
1845
+ }
1846
+
1847
+ async function applyVariant(variantId, options) {
1848
+ if (!sessionId) return;
1849
+ const opts = options || {};
1850
+ const persist = opts.persist === true;
1851
+ const local = lastVariants.find(function (v) {
1852
+ return v && v.id === variantId;
1853
+ });
1854
+ setStatus(persist ? 'Applying (DOM + source)…' : 'Previewing…');
1855
+ activeVariantId = variantId;
1856
+ markActiveVariantCard(variantId);
1857
+
1858
+ if (persist) {
1859
+ if (
1860
+ sendWs({
1861
+ type: 'APPLY_VARIANT',
1862
+ sessionId: sessionId,
1863
+ variantId: variantId,
1864
+ persist: true
1865
+ })
1866
+ ) {
1867
+ if (opts.syncEditors && local) syncEditorsFromVariant(local);
1868
+ return;
1869
+ }
1870
+ try {
1871
+ const data = await postJson('/api/apply', {
1872
+ sessionId: sessionId,
1873
+ variantId: variantId,
1874
+ persist: true
1875
+ });
1876
+ if (data.ok && selectedEl) {
1877
+ const variant = data.variant || local;
1878
+ applyNodesToDom(selectedEl, (variant && variant.nodes) || []);
1879
+ if (opts.syncEditors && variant) syncEditorsFromVariant(variant);
1880
+ setStatus(
1881
+ formatPersistStatus(
1882
+ data.persistResult,
1883
+ (variant && variant.label) || variantId
1884
+ )
1885
+ );
1886
+ } else {
1887
+ setStatus('Apply error: ' + (data.error || 'failed'));
1888
+ }
1889
+ } catch (err) {
1890
+ setStatus('Apply error: ' + (err.message || 'failed'));
1891
+ }
1892
+ return;
1893
+ }
1894
+
1895
+ // Preview — DOM only
1896
+ if (
1897
+ sendWs({
1898
+ type: 'PREVIEW_VARIANT',
1899
+ sessionId: sessionId,
1900
+ variantId: variantId
1901
+ })
1902
+ ) {
1903
+ return;
1904
+ }
1905
+ try {
1906
+ const data = await postJson('/api/preview-variant', {
1907
+ sessionId: sessionId,
1908
+ variantId: variantId
1909
+ });
1910
+ if (data.ok && selectedEl) {
1911
+ const variant = data.variant || local;
1912
+ applyNodesToDom(selectedEl, (variant && variant.nodes) || []);
1913
+ setStatus(
1914
+ 'Previewed "' +
1915
+ ((variant && variant.label) || variantId) +
1916
+ '" in DOM (not saved)'
1917
+ );
1918
+ } else {
1919
+ setStatus('Preview error: ' + (data.error || 'failed'));
1920
+ }
1921
+ } catch (err) {
1922
+ // Fallback: local DOM only if companion route missing
1923
+ if (local && selectedEl) {
1924
+ applyNodesToDom(selectedEl, local.nodes || []);
1925
+ setStatus(
1926
+ 'Previewed "' +
1927
+ (local.label || variantId) +
1928
+ '" in DOM (local; companion preview unavailable)'
1929
+ );
1930
+ } else {
1931
+ setStatus('Preview error: ' + (err.message || 'failed'));
1932
+ }
1933
+ }
1934
+ }
1935
+
1936
+ let thinkingLivePartial = '';
1937
+ let lastThinkingProgress = null;
1938
+ let thinkingStartedAt = 0;
1939
+ let thinkingUiTimer = null;
1940
+
1941
+ function thinkingElapsedLine() {
1942
+ if (!thinkingStartedAt) return 'Elapsed 0s';
1943
+ const secs = Math.round((Date.now() - thinkingStartedAt) / 1000);
1944
+ return 'Elapsed ' + secs + 's';
1945
+ }
1946
+
1947
+ /** Last N lines of stream text for paragraph-style thinking display. */
1948
+ function streamingBodyLines(partial, maxLines) {
1949
+ maxLines = maxLines || 3;
1950
+ const raw = String(partial || '').trim();
1951
+ if (!raw) return [];
1952
+
1953
+ let lines = raw.split(/\n/).map(function (l) {
1954
+ return l.trim();
1955
+ }).filter(Boolean);
1956
+ if (lines.length > maxLines) {
1957
+ return lines.slice(-maxLines);
1958
+ }
1959
+ if (lines.length >= 2) return lines;
1960
+
1961
+ const compact = raw.replace(/\s+/g, ' ').trim();
1962
+ const tail = compact.length > 320 ? '…' + compact.slice(-317) : compact;
1963
+ const width = 88;
1964
+ const wrapped = [];
1965
+ for (let i = tail.length; i > 0 && wrapped.length < maxLines; ) {
1966
+ const start = Math.max(0, i - width);
1967
+ const piece = tail.slice(start, i).trim();
1968
+ if (piece) wrapped.unshift(piece);
1969
+ i = start;
1970
+ if (start === 0) break;
1971
+ }
1972
+ return wrapped.slice(-maxLines);
1973
+ }
1974
+
1975
+ function resolveLatestThought(progress) {
1976
+ if (!progress) return 'Waiting on Glean…';
1977
+
1978
+ const elapsedSec = thinkingStartedAt
1979
+ ? Math.round((Date.now() - thinkingStartedAt) / 1000)
1980
+ : 0;
1981
+ const stage = progress.stage ? String(progress.stage) : '';
1982
+ const genericConnected = 'Connected — agent running…';
1983
+
1984
+ if (
1985
+ progress.latestThought &&
1986
+ progress.latestThought !== genericConnected &&
1987
+ !/^Streaming agent output/i.test(String(progress.latestThought))
1988
+ ) {
1989
+ return String(progress.latestThought);
1990
+ }
1991
+
1992
+ if (progress.streamChars > 0 && !(progress.live && progress.partial)) {
1993
+ return 'Generating copy… (' + progress.streamChars + ' chars received)';
1994
+ }
1995
+
1996
+ if (stage === 'start') {
1997
+ return progress.latestThought || progress.text || 'Sending to Glean…';
1998
+ }
1999
+
2000
+ // Silent stream / wait — Glean often sends no tokens for ~1 min; rotate by elapsed.
2001
+ if (stage === 'stream_wait' || stage === 'waiting' || progress.text === genericConnected) {
2002
+ if (elapsedSec < 15) {
2003
+ return progress.text || 'Connecting to live agent stream…';
2004
+ }
2005
+ if (elapsedSec < 35) return 'Agent is analyzing your copy…';
2006
+ if (elapsedSec < 60) return 'Agent is drafting alternatives…';
2007
+ if (elapsedSec < 90) return 'Agent is formatting options…';
2008
+ return 'Agent is finishing the response…';
2009
+ }
2010
+
2011
+ if (progress.text) return String(progress.text);
2012
+ return 'Waiting on Glean…';
2013
+ }
2014
+
2015
+ function renderThinking() {
2016
+ const el = panel.querySelector('#vc-thinking');
2017
+ if (!el || !gleanActiveEpoch) return;
2018
+ const progress = lastThinkingProgress || {};
2019
+ el.style.display = 'block';
2020
+
2021
+ if (progress.live && progress.partial) {
2022
+ thinkingLivePartial = String(progress.partial);
2023
+ }
2024
+
2025
+ const elapsed = thinkingElapsedLine();
2026
+ const displayLines = [];
2027
+ const partial = progress.partial || thinkingLivePartial;
2028
+
2029
+ if (progress.live && partial) {
2030
+ const body = streamingBodyLines(partial, 3);
2031
+ if (body.length) {
2032
+ for (let i = 0; i < body.length; i++) displayLines.push(body[i]);
2033
+ } else {
2034
+ displayLines.push(resolveLatestThought(progress));
2035
+ }
2036
+ } else {
2037
+ displayLines.push(resolveLatestThought(progress));
2038
+ if (progress.detail && String(progress.detail).trim()) {
2039
+ displayLines.push(String(progress.detail).trim());
2040
+ }
2041
+ }
2042
+
2043
+ displayLines.push(elapsed);
2044
+
2045
+ el.style.whiteSpace = 'pre-wrap';
2046
+ el.style.overflow = 'hidden';
2047
+ el.style.textOverflow = '';
2048
+ el.style.maxHeight = 'min(6.5em, 96px)';
2049
+ el.style.lineHeight = '1.45';
2050
+ el.style.overflowY = 'auto';
2051
+ el.style.overflowX = 'hidden';
2052
+ el.style.fontFamily = progress.live && partial
2053
+ ? 'ui-monospace,SFMono-Regular,Menlo,monospace'
2054
+ : '';
2055
+ el.textContent = displayLines.join('\n');
2056
+ el.scrollTop = el.scrollHeight;
2057
+
2058
+ const stage = progress.stage ? String(progress.stage) : '';
2059
+ const statusPreview = displayLines[0] || '';
2060
+ if (stage === 'stub' || stage === 'done' || stage === 'fallback') {
2061
+ setStatus(statusPreview);
2062
+ } else if (stage === 'start') {
2063
+ setStatus('Sending to Glean…');
2064
+ } else if (progress.live) {
2065
+ setStatus('Glean is streaming…');
2066
+ } else {
2067
+ setStatus('Waiting on Glean…');
2068
+ }
2069
+ }
2070
+
2071
+ function showThinking(progress) {
2072
+ lastThinkingProgress = progress || {};
2073
+ renderThinking();
2074
+ }
2075
+
2076
+ function startThinkingClock() {
2077
+ thinkingStartedAt = Date.now();
2078
+ lastThinkingProgress = null;
2079
+ thinkingLivePartial = '';
2080
+ if (thinkingUiTimer) clearInterval(thinkingUiTimer);
2081
+ thinkingUiTimer = setInterval(function () {
2082
+ if (!gleanActiveEpoch) return;
2083
+ renderThinking();
2084
+ }, 1000);
2085
+ }
2086
+
2087
+ function stopThinkingClock() {
2088
+ if (thinkingUiTimer) {
2089
+ clearInterval(thinkingUiTimer);
2090
+ thinkingUiTimer = null;
2091
+ }
2092
+ thinkingStartedAt = 0;
2093
+ lastThinkingProgress = null;
2094
+ thinkingLivePartial = '';
2095
+ }
2096
+
2097
+ function hideThinking() {
2098
+ const el = panel.querySelector('#vc-thinking');
2099
+ if (el) {
2100
+ el.style.display = 'none';
2101
+ el.textContent = '';
2102
+ el.style.maxHeight = '';
2103
+ el.style.overflowY = '';
2104
+ el.style.overflowX = '';
2105
+ el.style.fontFamily = '';
2106
+ }
2107
+ stopThinkingClock();
2108
+ }
2109
+
2110
+ async function requestGlean() {
2111
+ if (!sessionId) return;
2112
+ if (gleanActiveEpoch) {
2113
+ setStatus('Glean request already in progress…');
2114
+ return;
2115
+ }
2116
+ const epoch = ++gleanEpoch;
2117
+ gleanActiveEpoch = epoch;
2118
+ setStatus('Waiting on Glean…');
2119
+ startThinkingClock();
2120
+ showThinking({
2121
+ stage: 'stream_wait',
2122
+ text: 'Connecting to live agent stream…',
2123
+ detail: 'Robot Content Designer · usually 1–2 min',
2124
+ live: false
2125
+ });
2126
+ panel.querySelector('#vc-glean').disabled = true;
2127
+ let progressPoll = null;
2128
+ const pollProgress = async function () {
2129
+ if (gleanActiveEpoch !== epoch || !sessionId) return;
2130
+ try {
2131
+ const resp = await fetch(
2132
+ COMPANION_HTTP + '/api/glean-progress?sessionId=' + encodeURIComponent(sessionId),
2133
+ { cache: 'no-store', headers: authHeaders() }
2134
+ );
2135
+ const data = await resp.json();
2136
+ if (data.ok && data.progress && gleanActiveEpoch === epoch) {
2137
+ showThinking(data.progress);
2138
+ }
2139
+ } catch (_) {}
2140
+ };
2141
+ progressPoll = setInterval(pollProgress, 1000);
2142
+ pollProgress();
2143
+ // HTTP await is source of truth; WS + poll carry live GLEAN_PROGRESS.
2144
+ // Do not also send REQUEST_VARIANTS (would double-call Glean).
2145
+ try {
2146
+ const data = await postJson(
2147
+ '/api/variants',
2148
+ {
2149
+ sessionId: sessionId,
2150
+ optionCount: getGleanOptionCount(),
2151
+ options: { optionCount: getGleanOptionCount() }
2152
+ },
2153
+ { timeoutMs: GLEAN_VARIANTS_TIMEOUT_MS }
2154
+ );
2155
+ if (gleanActiveEpoch !== epoch) return;
2156
+ if (!data.ok) throw new Error(data.error || 'Glean failed');
2157
+ gleanActiveEpoch = 0;
2158
+ hideThinking();
2159
+ const variants = data.variants || (data.session && data.session.variants) || [];
2160
+ renderVariants(variants);
2161
+ setStatus('Glean returned ' + variants.length + ' options');
2162
+ } catch (err) {
2163
+ if (gleanActiveEpoch !== epoch) return;
2164
+ gleanActiveEpoch = 0;
2165
+ hideThinking();
2166
+ setStatus('Glean error: ' + err.message);
2167
+ } finally {
2168
+ if (progressPoll) clearInterval(progressPoll);
2169
+ if (gleanActiveEpoch === epoch) gleanActiveEpoch = 0;
2170
+ panel.querySelector('#vc-glean').disabled = !sessionId;
2171
+ }
2172
+ }
2173
+
2174
+ function onMove(ev) {
2175
+ if (!selecting) return;
2176
+ const el = document.elementFromPoint(ev.clientX, ev.clientY);
2177
+ if (!el || panel.contains(el) || el === highlight) return;
2178
+ const target = el.closest('div') || el;
2179
+ const r = target.getBoundingClientRect();
2180
+ highlight.style.left = r.left + 'px';
2181
+ highlight.style.top = r.top + 'px';
2182
+ highlight.style.width = r.width + 'px';
2183
+ highlight.style.height = r.height + 'px';
2184
+ }
2185
+
2186
+ async function onClick(ev) {
2187
+ if (!selecting) return;
2188
+ ev.preventDefault();
2189
+ ev.stopPropagation();
2190
+ const el = document.elementFromPoint(ev.clientX, ev.clientY);
2191
+ if (!el || panel.contains(el)) return;
2192
+ const target = el.closest('div') || el;
2193
+ stopSelect();
2194
+ selectedEl = target;
2195
+ selectedNodes = extractTextNodes(target);
2196
+ originalSnapshot = selectedNodes.map(function (n) {
2197
+ return Object.assign({}, n, {
2198
+ text: n.text,
2199
+ previousText: n.text,
2200
+ originalText: n.originalText != null ? n.originalText : n.text
2201
+ });
2202
+ });
2203
+ selectedNodes = selectedNodes.map(function (n) {
2204
+ return Object.assign({}, n, {
2205
+ originalText: n.originalText != null ? n.originalText : n.text
2206
+ });
2207
+ });
2208
+ renderNodes();
2209
+ setStatus('Selected ' + selectedNodes.length + ' copy field(s)');
2210
+ const payload = {
2211
+ origin: location.origin,
2212
+ pageUrl: location.href,
2213
+ selector: cssPath(target),
2214
+ tagName: target.tagName.toLowerCase(),
2215
+ nodes: selectedNodes,
2216
+ combinedText: selectedNodes.map(function (n) {
2217
+ return n.text;
2218
+ }).join('\n'),
2219
+ meta: {
2220
+ pathHints: pathHintsFromLocation()
2221
+ }
2222
+ };
2223
+ try {
2224
+ if (sendWs({ type: 'SET_SELECTION', payload: payload })) {
2225
+ /* ack sets session via SELECTION — also POST for reliability */
2226
+ }
2227
+ const data = await postJson('/api/selection', payload);
2228
+ if (data.ok) {
2229
+ sessionId = data.session.id;
2230
+ panel.querySelector('#vc-glean').disabled = false;
2231
+ setStatus('Selection ready — preview/edit or Ask Glean');
2232
+ }
2233
+ } catch (err) {
2234
+ setStatus('Selection error: ' + err.message);
2235
+ }
2236
+ }
2237
+
2238
+ function onKey(ev) {
2239
+ if (ev.key === 'Escape' && selecting) stopSelect();
2240
+ }
2241
+
2242
+ function startSelect() {
2243
+ selecting = true;
2244
+ document.documentElement.appendChild(highlight);
2245
+ document.addEventListener('mousemove', onMove, true);
2246
+ document.addEventListener('click', onClick, true);
2247
+ document.addEventListener('keydown', onKey, true);
2248
+ setStatus('Hover a div, click to select. Esc cancels.');
2249
+ }
2250
+
2251
+ function stopSelect() {
2252
+ selecting = false;
2253
+ highlight.remove();
2254
+ document.removeEventListener('mousemove', onMove, true);
2255
+ document.removeEventListener('click', onClick, true);
2256
+ document.removeEventListener('keydown', onKey, true);
2257
+ }
2258
+
2259
+ function teardown() {
2260
+ stopSelect();
2261
+ restoreOriginal();
2262
+ if (socket) socket.close();
2263
+ panel.remove();
2264
+ highlight.remove();
2265
+ window.__crankActive = false;
2266
+ window.__crankTeardown = null;
2267
+ }
2268
+
2269
+ window.__crankTeardown = teardown;
2270
+
2271
+ async function boot() {
2272
+ loadCss(COMPANION_HTTP + '/ui/vizpatch-ui.css?v=' + Date.now());
2273
+ await loadScript(COMPANION_HTTP + '/ui/crank-ui-helpers.js?v=' + Date.now());
2274
+ await loadScript(COMPANION_HTTP + '/ui/text-source.js?v=' + Date.now());
2275
+ buildPanelShell();
2276
+ const authed = await ensureTeamToken();
2277
+ if (!authed) {
2278
+ setStatus('Crank team token required — reload after you have it');
2279
+ return;
2280
+ }
2281
+ connectWs();
2282
+ setStatus('Ready — companion ' + COMPANION_HTTP);
2283
+ }
2284
+
2285
+ boot().catch(function (err) {
2286
+ console.error('[Crank] boot failed', err);
2287
+ if (panel && panel.parentNode) {
2288
+ panel.innerHTML =
2289
+ '<div class="vp-panel-block" style="padding:var(--vp-panel-padding, 1rem);">' +
2290
+ '<div class="vp-banner vp-banner--error vp-type-support">Crank failed to load UI: ' +
2291
+ String(err && err.message ? err.message : err) +
2292
+ '</div></div>';
2293
+ }
2294
+ });
2295
+ })();