@cairnvibe/sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@ exports.executeToolStep = executeToolStep;
10
10
  exports.executeVerbResponse = executeVerbResponse;
11
11
  const core_1 = require("@cairnvibe/core");
12
12
  const element_ladder_1 = require("./element-ladder");
13
+ const cursor_overlay_1 = require("./cursor-overlay");
13
14
  const webmcp_client_1 = require("./webmcp-client");
14
15
  // wait_for's own real, bounded retry budget — longer than
15
16
  // findElementWithRetry's own default (2 attempts, 300ms apart, ~300ms
@@ -78,12 +79,17 @@ function dispatchVerb(verb, route, options) {
78
79
  return;
79
80
  }
80
81
  (0, element_ladder_1.highlightElement)(el);
81
- // "open" means make the thing actually appear (a menu, a modal, a
82
- // panel) highlighting alone doesn't do that; a real click does.
83
- if (verb.verb === "open")
84
- el.click();
85
- if (verb.text)
86
- options.onExplain(verb.text);
82
+ // The cursor visibly arrives before "open"'s real click fires the
83
+ // whole point is a user watching sees where it's about to click
84
+ // BEFORE the menu/modal/panel actually opens, not simultaneously.
85
+ void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
86
+ // "open" means make the thing actually appear (a menu, a modal, a
87
+ // panel) — highlighting alone doesn't do that; a real click does.
88
+ if (verb.verb === "open")
89
+ el.click();
90
+ if (verb.text)
91
+ options.onExplain(verb.text);
92
+ });
87
93
  return;
88
94
  }
89
95
  case "navigate": {
@@ -142,9 +148,11 @@ function dispatchVerb(verb, route, options) {
142
148
  // on a different page) — never fired in addition to a real
143
149
  // click, so the action can't run twice.
144
150
  (0, element_ladder_1.highlightElement)(el);
145
- el.click();
146
- if (verb.text)
147
- options.onExplain(verb.text);
151
+ void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
152
+ el.click();
153
+ if (verb.text)
154
+ options.onExplain(verb.text);
155
+ });
148
156
  return;
149
157
  }
150
158
  if (verb.target)
@@ -189,14 +197,16 @@ function dispatchVerb(verb, route, options) {
189
197
  return;
190
198
  }
191
199
  (0, element_ladder_1.highlightElement)(el);
192
- el.click();
193
- // Real, live-found race this closes — see waitForDomSettle's own doc
194
- // comment: a click can trigger an async re-render (a cart count
195
- // updating, a filtered list refreshing) that hasn't happened yet the
196
- // instant .click() returns. A subsequent read step in the same turn
197
- // needs the SETTLED result, not whatever was on screen a moment ago.
198
- void (0, element_ladder_1.waitForDomSettle)().then(() => {
199
- options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
200
+ void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
201
+ el.click();
202
+ // Real, live-found race this closes see waitForDomSettle's own
203
+ // doc comment: a click can trigger an async re-render (a cart count
204
+ // updating, a filtered list refreshing) that hasn't happened yet the
205
+ // instant .click() returns. A subsequent read step in the same turn
206
+ // needs the SETTLED result, not whatever was on screen a moment ago.
207
+ void (0, element_ladder_1.waitForDomSettle)().then(() => {
208
+ options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
209
+ });
200
210
  });
201
211
  return;
202
212
  }
@@ -204,23 +214,30 @@ function dispatchVerb(verb, route, options) {
204
214
  if (verb.text)
205
215
  options.onExplain(verb.text);
206
216
  const el = (0, element_ladder_1.findElement)(verb.target, options.liveElements);
207
- if (!el || !(0, element_ladder_1.fillElement)(el, verb.value)) {
217
+ if (!el) {
208
218
  (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
209
- options.onToolStep?.({
210
- verb: "fill",
211
- target: verb.target,
212
- ok: false,
213
- observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
214
- });
219
+ options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "Could not find that element on the page." });
215
220
  return;
216
221
  }
217
222
  (0, element_ladder_1.highlightElement)(el);
218
- // See the click case's own comment the exact real bug found live:
219
- // typing into a search box, then reading the still-unfiltered
220
- // results a moment later and reporting a match the real, since-
221
- // filtered page never actually showed.
222
- void (0, element_ladder_1.waitForDomSettle)().then(() => {
223
- options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
223
+ // fillElement itself is both the "is this a real form field" check
224
+ // AND the commit (it sets the value the instant it returns true) —
225
+ // deliberately called from inside this .then(), not the synchronous
226
+ // miss-check above, so the cursor is genuinely seen arriving BEFORE
227
+ // any text appears, not simultaneously with it.
228
+ void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
229
+ if (!(0, element_ladder_1.fillElement)(el, verb.value)) {
230
+ (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
231
+ options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "That element isn't a real form field — can't type into it." });
232
+ return;
233
+ }
234
+ // See the click case's own comment — the exact real bug found live:
235
+ // typing into a search box, then reading the still-unfiltered
236
+ // results a moment later and reporting a match the real, since-
237
+ // filtered page never actually showed.
238
+ void (0, element_ladder_1.waitForDomSettle)().then(() => {
239
+ options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
240
+ });
224
241
  });
225
242
  return;
226
243
  }
@@ -233,7 +250,12 @@ function dispatchVerb(verb, route, options) {
233
250
  options.onToolStep?.({ verb: "read", target: verb.target, ok: false, observation: "Could not find that element on the page." });
234
251
  return;
235
252
  }
236
- options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: (0, element_ladder_1.readElement)(el) });
253
+ // No mutation here, but the cursor still visits what's being read —
254
+ // real visual proof of what the agent is actually looking at, not
255
+ // just a claim in the eventual reported observation.
256
+ void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
257
+ options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: (0, element_ladder_1.readElement)(el) });
258
+ });
237
259
  return;
238
260
  }
239
261
  case "call_tool": {
@@ -255,12 +277,19 @@ function dispatchVerb(verb, route, options) {
255
277
  return;
256
278
  }
257
279
  (0, element_ladder_1.highlightElement)(from);
258
- (0, element_ladder_1.dragElement)(from, to);
259
- // Same real re-render race as click/fill — a drop can trigger an
260
- // async re-render (a canvas connection line, a reordered list) that
261
- // hasn't settled the instant the pointer sequence finishes.
262
- void (0, element_ladder_1.waitForDomSettle)().then(() => {
263
- options.onToolStep?.({ verb: "drag", target: verb.target, ok: true, observation: `Dragged it to ${verb.to}.` });
280
+ void (0, cursor_overlay_1.moveCursorTo)(from).then(() => {
281
+ (0, element_ladder_1.dragElement)(from, to);
282
+ // The cursor also glides to the drop point fire-and-forget, not
283
+ // awaited, since it's purely a visual echo of a drag that already
284
+ // happened via real pointer events; nothing downstream depends on
285
+ // it finishing.
286
+ void (0, cursor_overlay_1.moveCursorTo)(to);
287
+ // Same real re-render race as click/fill — a drop can trigger an
288
+ // async re-render (a canvas connection line, a reordered list) that
289
+ // hasn't settled the instant the pointer sequence finishes.
290
+ void (0, element_ladder_1.waitForDomSettle)().then(() => {
291
+ options.onToolStep?.({ verb: "drag", target: verb.target, ok: true, observation: `Dragged it to ${verb.to}.` });
292
+ });
264
293
  });
265
294
  return;
266
295
  }
@@ -268,19 +297,24 @@ function dispatchVerb(verb, route, options) {
268
297
  if (verb.text)
269
298
  options.onExplain(verb.text);
270
299
  const el = (0, element_ladder_1.findElement)(verb.target, options.liveElements);
271
- if (!el || !(0, element_ladder_1.selectOption)(el, verb.value)) {
300
+ if (!el) {
272
301
  (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
273
- options.onToolStep?.({
274
- verb: "select",
275
- target: verb.target,
276
- ok: false,
277
- observation: el ? `Could not find an option matching "${verb.value}".` : "Could not find that element on the page.",
278
- });
302
+ options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: "Could not find that element on the page." });
279
303
  return;
280
304
  }
281
305
  (0, element_ladder_1.highlightElement)(el);
282
- void (0, element_ladder_1.waitForDomSettle)().then(() => {
283
- options.onToolStep?.({ verb: "select", target: verb.target, ok: true, observation: `Selected "${verb.value}".` });
306
+ // Same reasoning as fill's own comment — selectOption is both the
307
+ // "does a matching option exist" check and the commit, so it's called
308
+ // from inside this .then(), after the cursor genuinely arrives.
309
+ void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
310
+ if (!(0, element_ladder_1.selectOption)(el, verb.value)) {
311
+ (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
312
+ options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: `Could not find an option matching "${verb.value}".` });
313
+ return;
314
+ }
315
+ void (0, element_ladder_1.waitForDomSettle)().then(() => {
316
+ options.onToolStep?.({ verb: "select", target: verb.target, ok: true, observation: `Selected "${verb.value}".` });
317
+ });
284
318
  });
285
319
  return;
286
320
  }
@@ -294,10 +328,22 @@ function dispatchVerb(verb, route, options) {
294
328
  options.onToolStep?.({ verb: "key", target: verb.target, ok: false, observation: "Could not find that element on the page." });
295
329
  return;
296
330
  }
297
- (0, element_ladder_1.pressKey)(el, verb.key);
298
- void (0, element_ladder_1.waitForDomSettle)().then(() => {
299
- options.onToolStep?.({ verb: "key", target: verb.target, ok: true, observation: `Pressed ${verb.key}.` });
300
- });
331
+ const afterMove = () => {
332
+ (0, element_ladder_1.pressKey)(el, verb.key);
333
+ void (0, element_ladder_1.waitForDomSettle)().then(() => {
334
+ options.onToolStep?.({ verb: "key", target: verb.target, ok: true, observation: `Pressed ${verb.key}.` });
335
+ });
336
+ };
337
+ // With no explicit target ("whatever's currently focused"), there's
338
+ // nothing sensible for the cursor to glide to — call straight through
339
+ // instead of routing through a promise callback for no reason, so
340
+ // this path stays exactly as synchronous as it always was.
341
+ if (verb.target) {
342
+ void (0, cursor_overlay_1.moveCursorTo)(el).then(afterMove);
343
+ }
344
+ else {
345
+ afterMove();
346
+ }
301
347
  return;
302
348
  }
303
349
  case "scroll": {
@@ -314,8 +360,10 @@ function dispatchVerb(verb, route, options) {
314
360
  // exactly the real repositioning this verb exists for; the glow
315
361
  // also gives the user a visible cue of where the agent just moved.
316
362
  (0, element_ladder_1.highlightElement)(el);
317
- void (0, element_ladder_1.waitForDomSettle)().then(() => {
318
- options.onToolStep?.({ verb: "scroll", target: verb.target, ok: true, observation: "Scrolled it into view." });
363
+ void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
364
+ void (0, element_ladder_1.waitForDomSettle)().then(() => {
365
+ options.onToolStep?.({ verb: "scroll", target: verb.target, ok: true, observation: "Scrolled it into view." });
366
+ });
319
367
  });
320
368
  return;
321
369
  }
@@ -328,7 +376,9 @@ function dispatchVerb(verb, route, options) {
328
376
  options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: false, observation: "It never appeared." });
329
377
  return;
330
378
  }
331
- options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: true, observation: "It appeared." });
379
+ void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
380
+ options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: true, observation: "It appeared." });
381
+ });
332
382
  });
333
383
  return;
334
384
  }
@@ -379,6 +429,7 @@ async function executeOneBatchAction(action, route, options) {
379
429
  return { ok: false, observation: "Could not find that element on the page." };
380
430
  }
381
431
  (0, element_ladder_1.highlightElement)(el);
432
+ await (0, cursor_overlay_1.moveCursorTo)(el);
382
433
  el.click();
383
434
  // Same real race as the single-step case (see waitForDomSettle's own
384
435
  // doc comment) — arguably MORE likely here, since a batch's next
@@ -388,14 +439,19 @@ async function executeOneBatchAction(action, route, options) {
388
439
  }
389
440
  case "fill": {
390
441
  const el = await (0, element_ladder_1.findElementWithRetry)(action.target, options.liveElements);
391
- if (!el || !(0, element_ladder_1.fillElement)(el, action.value)) {
442
+ if (!el) {
392
443
  (options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
393
- return {
394
- ok: false,
395
- observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
396
- };
444
+ return { ok: false, observation: "Could not find that element on the page." };
397
445
  }
398
446
  (0, element_ladder_1.highlightElement)(el);
447
+ // See the single-step fill case's own comment — fillElement is both
448
+ // the field-type check and the commit, called after the cursor
449
+ // genuinely arrives rather than in the synchronous miss-check.
450
+ await (0, cursor_overlay_1.moveCursorTo)(el);
451
+ if (!(0, element_ladder_1.fillElement)(el, action.value)) {
452
+ (options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
453
+ return { ok: false, observation: "That element isn't a real form field — can't type into it." };
454
+ }
399
455
  await (0, element_ladder_1.waitForDomSettle)();
400
456
  return { ok: true, observation: `Typed "${action.value}" into it.` };
401
457
  }
@@ -405,6 +461,7 @@ async function executeOneBatchAction(action, route, options) {
405
461
  (options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
406
462
  return { ok: false, observation: "Could not find that element on the page." };
407
463
  }
464
+ await (0, cursor_overlay_1.moveCursorTo)(el);
408
465
  return { ok: true, observation: (0, element_ladder_1.readElement)(el) };
409
466
  }
410
467
  case "call_tool": {
@@ -419,17 +476,24 @@ async function executeOneBatchAction(action, route, options) {
419
476
  return { ok: false, observation: from ? "Could not find the drop destination on the page." : "Could not find that element on the page." };
420
477
  }
421
478
  (0, element_ladder_1.highlightElement)(from);
479
+ await (0, cursor_overlay_1.moveCursorTo)(from);
422
480
  (0, element_ladder_1.dragElement)(from, to);
481
+ void (0, cursor_overlay_1.moveCursorTo)(to); // visual echo of the drop point — not awaited, purely decorative
423
482
  await (0, element_ladder_1.waitForDomSettle)();
424
483
  return { ok: true, observation: `Dragged it to ${action.to}.` };
425
484
  }
426
485
  case "select": {
427
486
  const el = await (0, element_ladder_1.findElementWithRetry)(action.target, options.liveElements);
428
- if (!el || !(0, element_ladder_1.selectOption)(el, action.value)) {
487
+ if (!el) {
429
488
  (options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
430
- return { ok: false, observation: el ? `Could not find an option matching "${action.value}".` : "Could not find that element on the page." };
489
+ return { ok: false, observation: "Could not find that element on the page." };
431
490
  }
432
491
  (0, element_ladder_1.highlightElement)(el);
492
+ await (0, cursor_overlay_1.moveCursorTo)(el);
493
+ if (!(0, element_ladder_1.selectOption)(el, action.value)) {
494
+ (options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
495
+ return { ok: false, observation: `Could not find an option matching "${action.value}".` };
496
+ }
433
497
  await (0, element_ladder_1.waitForDomSettle)();
434
498
  return { ok: true, observation: `Selected "${action.value}".` };
435
499
  }
@@ -440,6 +504,8 @@ async function executeOneBatchAction(action, route, options) {
440
504
  (options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
441
505
  return { ok: false, observation: "Could not find that element on the page." };
442
506
  }
507
+ if (action.target)
508
+ await (0, cursor_overlay_1.moveCursorTo)(el);
443
509
  (0, element_ladder_1.pressKey)(el, action.key);
444
510
  await (0, element_ladder_1.waitForDomSettle)();
445
511
  return { ok: true, observation: `Pressed ${action.key}.` };
@@ -451,6 +517,7 @@ async function executeOneBatchAction(action, route, options) {
451
517
  return { ok: false, observation: "Could not find that element on the page." };
452
518
  }
453
519
  (0, element_ladder_1.highlightElement)(el);
520
+ await (0, cursor_overlay_1.moveCursorTo)(el);
454
521
  await (0, element_ladder_1.waitForDomSettle)();
455
522
  return { ok: true, observation: "Scrolled it into view." };
456
523
  }
@@ -460,6 +527,7 @@ async function executeOneBatchAction(action, route, options) {
460
527
  (options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
461
528
  return { ok: false, observation: "It never appeared." };
462
529
  }
530
+ await (0, cursor_overlay_1.moveCursorTo)(el);
463
531
  return { ok: true, observation: "It appeared." };
464
532
  }
465
533
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnvibe/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "In-app AI copilot — <Copilot/> for React/Next.js, <cairn-widget> for any framework — plus the server handlers and realtime voice relay behind them.",
5
5
  "license": "MIT",
6
6
  "publishConfig": { "access": "public" },
@@ -0,0 +1,130 @@
1
+ // A visible, animated synthetic cursor that glides to whatever element the
2
+ // agent is about to act on, and genuinely arrives — before anything actually
3
+ // happens on screen — rather than a click just occurring with no visible
4
+ // lead-up. Real, watchable proof of what the agent resolved, the same way
5
+ // watching a person's own mouse move tells you where they're about to click
6
+ // before it happens; matches this SDK's own "verified, not trusted"
7
+ // discipline in a form a user can literally see, not just read.
8
+ //
9
+ // Purely additive and deliberately decoupled from highlightElement
10
+ // (element-ladder.ts) — that function's own scroll+glow behavior is
11
+ // unchanged and still called by every site that used it before. This module
12
+ // only adds the moving cursor itself; a caller awaits moveCursorTo(el)
13
+ // before firing the real action so the cursor is seen arriving first, never
14
+ // after the fact — see verb-executor.ts's own call sites for the exact
15
+ // sequencing.
16
+
17
+ const CURSOR_ID = "cairn-cursor";
18
+ const MOVE_MS = 550;
19
+ const ARRIVE_PAUSE_MS = 160;
20
+ // The CSS side already disables the cursor's transition/animation under
21
+ // prefers-reduced-motion (see #cairn-cursor in the injected <style> block),
22
+ // which makes it jump instead of glide — but without this, the real delay
23
+ // before the action fires would stay the full ~710ms even though there's
24
+ // nothing left to watch. Mirrors the visual change with a real timing one.
25
+ const REDUCED_MOVE_MS = 60;
26
+ const REDUCED_ARRIVE_PAUSE_MS = 40;
27
+
28
+ function prefersReducedMotion(): boolean {
29
+ return (
30
+ typeof window !== "undefined" &&
31
+ typeof window.matchMedia === "function" &&
32
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
33
+ );
34
+ }
35
+
36
+ // Module-scope, not per-call — the whole point is a SINGLE cursor that
37
+ // glides from wherever it last was, the way a real mouse never teleports
38
+ // between two unrelated screen positions.
39
+ let lastX: number | null = null;
40
+ let lastY: number | null = null;
41
+
42
+ function ensureCursorEl(): HTMLElement | null {
43
+ if (typeof document === "undefined" || !document.body) return null;
44
+ let el = document.getElementById(CURSOR_ID);
45
+ if (el) return el;
46
+ el = document.createElement("div");
47
+ el.id = CURSOR_ID;
48
+ el.setAttribute("aria-hidden", "true");
49
+ // A simple filled pointer shape — matches the widget's own ember accent,
50
+ // with a thin dark stroke so it reads clearly on light AND dark pages
51
+ // (the host app's own background is never something this SDK controls).
52
+ el.innerHTML =
53
+ '<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">' +
54
+ '<path d="M2 1.5 L2 18.2 L6.3 14.4 L9.1 20.6 L11.7 19.4 L8.9 13.3 L14.6 13.1 Z" fill="#E07A3F" stroke="#1B1815" stroke-width="1.1" stroke-linejoin="round"/>' +
55
+ "</svg>";
56
+ el.style.cssText =
57
+ "position:fixed;left:0;top:0;z-index:2147483001;pointer-events:none;opacity:0;transition:opacity 180ms ease;will-change:transform;filter:drop-shadow(0 3px 6px rgba(0,0,0,0.35));";
58
+ document.body.appendChild(el);
59
+ return el;
60
+ }
61
+
62
+ /**
63
+ * Animates the synthetic cursor to `el`'s center and resolves once it has
64
+ * genuinely arrived (plus a brief real hover pause) — callers await this
65
+ * BEFORE performing the real action, so the cursor is seen gliding there
66
+ * first. Deliberately timer-driven (`window.setTimeout`), not
67
+ * `transitionend`/`Element.animate().finished`-driven — this repo's test
68
+ * environment is plain Node, not a real browser (see waitForDomSettle's own
69
+ * doc comment for the same discipline), and a fixed, known duration is what
70
+ * makes this testable with fake timers instead of needing real animation-
71
+ * completion events that a headless/no-DOM environment may never fire.
72
+ *
73
+ * SSR/no-DOM safe — same defensive guard `waitForDomSettle` already uses —
74
+ * so a caller never needs its own environment check before calling this.
75
+ */
76
+ export function moveCursorTo(el: HTMLElement): Promise<void> {
77
+ if (typeof document === "undefined" || typeof window === "undefined" || typeof el.getBoundingClientRect !== "function") {
78
+ return Promise.resolve();
79
+ }
80
+ const cursor = ensureCursorEl();
81
+ if (!cursor) return Promise.resolve();
82
+
83
+ const rect = el.getBoundingClientRect();
84
+ const x = rect.left + rect.width / 2;
85
+ const y = rect.top + rect.height / 2;
86
+
87
+ if (lastX === null || lastY === null) {
88
+ // The very first move of the session starts from the widget's own
89
+ // corner (bottom-right, where the FAB lives) instead of materializing
90
+ // at (0,0) — reads as "coming from Cairn," not appearing from nowhere.
91
+ lastX = window.innerWidth - 40;
92
+ lastY = window.innerHeight - 40;
93
+ cursor.style.transform = `translate(${lastX}px, ${lastY}px)`;
94
+ }
95
+
96
+ const reduced = prefersReducedMotion();
97
+ const moveMs = reduced ? REDUCED_MOVE_MS : MOVE_MS;
98
+ const arrivePauseMs = reduced ? REDUCED_ARRIVE_PAUSE_MS : ARRIVE_PAUSE_MS;
99
+
100
+ cursor.style.transition = `transform ${moveMs}ms cubic-bezier(.4,0,.2,1), opacity 180ms ease`;
101
+ cursor.style.opacity = "1";
102
+ // Forces a style flush so the browser animates FROM the current position
103
+ // TO the new one instead of jumping straight there — reading a layout
104
+ // property is the standard, harmless way to force this without a real
105
+ // animation API (which, per this function's own doc comment, this
106
+ // deliberately avoids depending on for its completion signal anyway).
107
+ void cursor.offsetHeight;
108
+ cursor.style.transform = `translate(${x}px, ${y}px)`;
109
+ lastX = x;
110
+ lastY = y;
111
+
112
+ return new Promise((resolve) => {
113
+ window.setTimeout(() => {
114
+ cursor.classList.add("cairn-cursor-hover");
115
+ window.setTimeout(() => {
116
+ cursor.classList.remove("cairn-cursor-hover");
117
+ resolve();
118
+ }, arrivePauseMs);
119
+ }, moveMs);
120
+ });
121
+ }
122
+
123
+ /** Fades the synthetic cursor out — called once the widget itself closes or
124
+ * unmounts, so it doesn't sit visible on screen after the conversation
125
+ * ends. Safe to call even if the cursor was never created. */
126
+ export function hideCursor(): void {
127
+ if (typeof document === "undefined") return;
128
+ const el = document.getElementById(CURSOR_ID);
129
+ if (el) el.style.opacity = "0";
130
+ }
package/src/index.tsx CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { classifyUiPattern, deriveStructureSignals, isTerminalVerb, safeParseVerbResponse, type CriticVerdict, type HistoryTurn as HistoryEntry, type Plan, type ProgressLedger, type Task, type TourStep, type VerbResponse } from "@cairnvibe/core";
20
20
  import { driveAgentLoop, looksMultiStep } from "./agent-loop";
21
21
  import { collectVisible } from "./context-collector";
22
+ import { hideCursor } from "./cursor-overlay";
22
23
  import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
23
24
  import { createLiveElementRegistry } from "./runtime-scan";
24
25
  import { discoverWebMcpTools } from "./webmcp-client";
@@ -132,6 +133,12 @@ export function Copilot({
132
133
  // loadPersistedConversation's own doc comment) had showing a moment ago,
133
134
  // so a real reload never again looks like the conversation simply ended.
134
135
  const [open, setOpen] = useState(false);
136
+ // Fades the synthetic cursor out once the panel closes (or the widget
137
+ // itself unmounts) instead of leaving it sitting visible on the page.
138
+ useEffect(() => {
139
+ if (!open) hideCursor();
140
+ return () => hideCursor();
141
+ }, [open]);
135
142
  // Collapsed by default so the panel only ever shows the current exchange
136
143
  // — the full archived transcript (built up over a long conversation)
137
144
  // stays out of the way behind an explicit toggle instead of always being
@@ -2160,9 +2167,13 @@ const COPILOT_STYLES = `
2160
2167
  0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
2161
2168
  70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
2162
2169
  }
2163
- @keyframes cairn-pulse-indigo {
2164
- 0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); }
2165
- 70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); }
2170
+ @keyframes cairn-pulse-ember {
2171
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.4); }
2172
+ 70% { box-shadow: 0 0 0 10px rgba(224, 122, 63, 0); }
2173
+ }
2174
+ @keyframes cairn-cursor-arrive {
2175
+ 0% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.55); }
2176
+ 100% { box-shadow: 0 0 0 9px rgba(224, 122, 63, 0); }
2166
2177
  }
2167
2178
  @keyframes cairn-spin {
2168
2179
  from { transform: rotate(0deg); }
@@ -2178,7 +2189,7 @@ const COPILOT_STYLES = `
2178
2189
  }
2179
2190
  @keyframes cairn-word-sweep {
2180
2191
  0% { opacity: 0.35; text-shadow: none; }
2181
- 35% { opacity: 1; color: #4f46e5; text-shadow: 0 0 10px rgba(99, 102, 241, 0.45); }
2192
+ 35% { opacity: 1; color: #E07A3F; text-shadow: 0 0 10px rgba(224, 122, 63, 0.45); }
2182
2193
  100% { opacity: 1; color: inherit; text-shadow: none; }
2183
2194
  }
2184
2195
  @keyframes cairn-thinking-bounce {
@@ -2186,16 +2197,19 @@ const COPILOT_STYLES = `
2186
2197
  40% { opacity: 0.9; transform: translateY(-3px); }
2187
2198
  }
2188
2199
  .cairn-glow {
2189
- animation: cairn-pulse-indigo 1.1s ease-out 2;
2190
- outline: 2px solid #6366f1;
2200
+ animation: cairn-pulse-ember 1.1s ease-out 2;
2201
+ outline: 2px solid #E07A3F;
2191
2202
  outline-offset: 3px;
2192
2203
  border-radius: 8px;
2193
2204
  }
2205
+ .cairn-cursor-hover {
2206
+ animation: cairn-cursor-arrive 0.3s ease-out;
2207
+ }
2194
2208
  .cairn-spin {
2195
2209
  animation: cairn-spin 0.8s linear infinite;
2196
2210
  }
2197
2211
  @media (prefers-reduced-motion: reduce) {
2198
- .cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot {
2212
+ .cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot, #cairn-cursor {
2199
2213
  animation: none !important;
2200
2214
  transition: none !important;
2201
2215
  }
@@ -42,6 +42,7 @@ import {
42
42
  resolveCritic,
43
43
  resolvePlan,
44
44
  resolveVerb,
45
+ KeyRotator,
45
46
  type CapabilityTier,
46
47
  type CreateCopilotHandlerOptions,
47
48
  } from "./server";
@@ -175,11 +176,23 @@ export { seedHistoryFromMemory, formatRememberedFacts };
175
176
  export function createRealtimeServer(options: CreateRealtimeServerOptions): http.Server {
176
177
  const registeredActions = options.registeredActions ?? [];
177
178
  const capability = options.capability ?? "act";
178
- const llm = createVerbLLM(options);
179
+ // One shared rotator across all three LLM roles — see
180
+ // CreateCopilotHandlerOptions.keyRotator's own doc comment for the real
181
+ // gap this closes (a key one role confirmed dead used to stay invisible
182
+ // to the other two, which kept rediscovering it fresh on every call).
183
+ // Only built for groq — anthropic's createXLLM calls ignore keyRotator
184
+ // entirely, so building one for it would be dead work. Respects a
185
+ // caller-supplied options.keyRotator (e.g. shared with the typed/HTTP
186
+ // transport in the same process) instead of always building a fresh one.
187
+ const sharedOptions: CreateRealtimeServerOptions =
188
+ options.provider === "groq" && !options.keyRotator
189
+ ? { ...options, keyRotator: options.apiKeys ? new KeyRotator(options.apiKeys) : options.apiKey ? new KeyRotator([options.apiKey]) : KeyRotator.fromEnvList(process.env.GROQ_API_KEYS) ?? undefined }
190
+ : options;
191
+ const llm = createVerbLLM(sharedOptions);
179
192
  // Phase 3 steps 2-3 — real, separately-configured Planner/Critic LLMs.
180
193
  // See finalizeTurn's own doc comment for how they're actually used.
181
- const planLLM = createPlanLLM(options);
182
- const criticLLM = createCriticLLM(options);
194
+ const planLLM = createPlanLLM(sharedOptions);
195
+ const criticLLM = createCriticLLM(sharedOptions);
183
196
  // "text" is optional on highlight/open/navigate/do in the base prompt —
184
197
  // fine for the typed/HTTP path, which always has a visible answer area,
185
198
  // but silence reads as broken in a live voice conversation (the client