cursor-buddy 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1123 @@
1
+ "use client";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useActor } from "@xstate/react";
4
+ import { useStore } from "@nanostores/react";
5
+ import { assign, setup } from "xstate";
6
+ import { atom } from "nanostores";
7
+ import html2canvas from "html2canvas-pro";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { createPortal } from "react-dom";
10
+ //#region src/client/context.ts
11
+ const CursorBuddyContext = createContext(null);
12
+ //#endregion
13
+ //#region src/core/machine.ts
14
+ /**
15
+ * XState machine for the voice interaction flow.
16
+ *
17
+ * States: idle → listening → processing → responding → idle
18
+ *
19
+ * This enforces valid state transitions and provides hooks for
20
+ * actions (start/stop mic, capture screenshot, play TTS, etc.)
21
+ */
22
+ const cursorBuddyMachine = setup({
23
+ types: {
24
+ context: {},
25
+ events: {}
26
+ },
27
+ actions: {
28
+ clearTranscript: assign({ transcript: "" }),
29
+ clearResponse: assign({ response: "" }),
30
+ clearError: assign({ error: null }),
31
+ setTranscript: assign(({ event }) => {
32
+ if (event.type === "TRANSCRIPTION_COMPLETE") return { transcript: event.transcript };
33
+ return {};
34
+ }),
35
+ setResponse: assign(({ event }) => {
36
+ if (event.type === "AI_RESPONSE_COMPLETE") return { response: event.response };
37
+ return {};
38
+ }),
39
+ appendResponseChunk: assign(({ context, event }) => {
40
+ if (event.type === "AI_RESPONSE_CHUNK") return { response: context.response + event.text };
41
+ return {};
42
+ }),
43
+ setError: assign(({ event }) => {
44
+ if (event.type === "ERROR") return { error: event.error };
45
+ return {};
46
+ })
47
+ }
48
+ }).createMachine({
49
+ id: "cursorBuddy",
50
+ initial: "idle",
51
+ context: {
52
+ transcript: "",
53
+ response: "",
54
+ error: null
55
+ },
56
+ states: {
57
+ idle: {
58
+ entry: ["clearError"],
59
+ on: { HOTKEY_PRESSED: {
60
+ target: "listening",
61
+ actions: ["clearTranscript", "clearResponse"]
62
+ } }
63
+ },
64
+ listening: { on: {
65
+ HOTKEY_RELEASED: { target: "processing" },
66
+ CANCEL: { target: "idle" },
67
+ ERROR: {
68
+ target: "idle",
69
+ actions: ["setError"]
70
+ }
71
+ } },
72
+ processing: { on: {
73
+ TRANSCRIPTION_COMPLETE: { actions: ["setTranscript"] },
74
+ AI_RESPONSE_CHUNK: { actions: ["appendResponseChunk"] },
75
+ AI_RESPONSE_COMPLETE: {
76
+ target: "responding",
77
+ actions: ["setResponse"]
78
+ },
79
+ ERROR: {
80
+ target: "idle",
81
+ actions: ["setError"]
82
+ },
83
+ CANCEL: { target: "idle" }
84
+ } },
85
+ responding: { on: {
86
+ TTS_COMPLETE: { target: "idle" },
87
+ POINTING_COMPLETE: {},
88
+ CANCEL: { target: "idle" },
89
+ ERROR: {
90
+ target: "idle",
91
+ actions: ["setError"]
92
+ }
93
+ } }
94
+ }
95
+ });
96
+ //#endregion
97
+ //#region src/core/atoms.ts
98
+ /**
99
+ * Nanostores atoms for reactive values that don't need state machine semantics.
100
+ * These update frequently (e.g., 60fps audio levels) and are framework-agnostic.
101
+ */
102
+ const $audioLevel = atom(0);
103
+ const $cursorPosition = atom({
104
+ x: 0,
105
+ y: 0
106
+ });
107
+ const $buddyPosition = atom({
108
+ x: 0,
109
+ y: 0
110
+ });
111
+ const $buddyRotation = atom(0);
112
+ const $buddyScale = atom(1);
113
+ const $pointingTarget = atom(null);
114
+ const $isEnabled = atom(true);
115
+ const $isSpeaking = atom(false);
116
+ const $conversationHistory = atom([]);
117
+ //#endregion
118
+ //#region src/core/pointing.ts
119
+ /**
120
+ * Parses [POINT:x,y:label] tags from AI responses.
121
+ * Format matches the Swift Clicky app for consistency.
122
+ */
123
+ const POINTING_TAG_REGEX = /\[POINT:(\d+),(\d+):([^\]]+)\]\s*$/;
124
+ /**
125
+ * Extract pointing target from response text.
126
+ * Returns null if no valid POINT tag is found at the end.
127
+ */
128
+ function parsePointingTag(response) {
129
+ const match = response.match(POINTING_TAG_REGEX);
130
+ if (!match) return null;
131
+ return {
132
+ x: parseInt(match[1], 10),
133
+ y: parseInt(match[2], 10),
134
+ label: match[3].trim()
135
+ };
136
+ }
137
+ /**
138
+ * Remove POINT tag from response text for display/TTS.
139
+ */
140
+ function stripPointingTag(response) {
141
+ return response.replace(POINTING_TAG_REGEX, "").trim();
142
+ }
143
+ //#endregion
144
+ //#region src/core/bezier.ts
145
+ /**
146
+ * Bezier flight animation for cursor pointing.
147
+ */
148
+ /**
149
+ * Quadratic bezier curve: B(t) = (1-t)²P₀ + 2(1-t)t·P₁ + t²P₂
150
+ */
151
+ function quadraticBezier(p0, p1, p2, t) {
152
+ const oneMinusT = 1 - t;
153
+ return {
154
+ x: oneMinusT * oneMinusT * p0.x + 2 * oneMinusT * t * p1.x + t * t * p2.x,
155
+ y: oneMinusT * oneMinusT * p0.y + 2 * oneMinusT * t * p1.y + t * t * p2.y
156
+ };
157
+ }
158
+ /**
159
+ * Bezier tangent (derivative): B'(t) = 2(1-t)(P₁-P₀) + 2t(P₂-P₁)
160
+ */
161
+ function bezierTangent(p0, p1, p2, t) {
162
+ const oneMinusT = 1 - t;
163
+ return {
164
+ x: 2 * oneMinusT * (p1.x - p0.x) + 2 * t * (p2.x - p1.x),
165
+ y: 2 * oneMinusT * (p1.y - p0.y) + 2 * t * (p2.y - p1.y)
166
+ };
167
+ }
168
+ /**
169
+ * Ease-in-out cubic for smooth acceleration/deceleration
170
+ */
171
+ function easeInOutCubic(t) {
172
+ return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
173
+ }
174
+ /**
175
+ * Animate cursor along a parabolic bezier arc from start to end.
176
+ * Used when the AI points at a UI element.
177
+ *
178
+ * @param from - Starting position
179
+ * @param to - Target position
180
+ * @param durationMs - Flight duration in milliseconds
181
+ * @param callbacks - Frame and completion callbacks
182
+ * @returns Cancel function to stop the animation
183
+ */
184
+ function animateBezierFlight(from, to, durationMs, callbacks) {
185
+ const startTime = performance.now();
186
+ const distance = Math.hypot(to.x - from.x, to.y - from.y);
187
+ const controlPoint = {
188
+ x: (from.x + to.x) / 2,
189
+ y: Math.min(from.y, to.y) - distance * .2
190
+ };
191
+ let animationFrameId;
192
+ function animate(now) {
193
+ const elapsed = now - startTime;
194
+ const linearProgress = Math.min(elapsed / durationMs, 1);
195
+ const easedProgress = easeInOutCubic(linearProgress);
196
+ const position = quadraticBezier(from, controlPoint, to, easedProgress);
197
+ const tangent = bezierTangent(from, controlPoint, to, easedProgress);
198
+ const rotation = Math.atan2(tangent.y, tangent.x);
199
+ const scale = 1 + Math.sin(linearProgress * Math.PI) * .3;
200
+ callbacks.onFrame(position, rotation, scale);
201
+ if (linearProgress < 1) animationFrameId = requestAnimationFrame(animate);
202
+ else callbacks.onComplete();
203
+ }
204
+ animationFrameId = requestAnimationFrame(animate);
205
+ return () => cancelAnimationFrame(animationFrameId);
206
+ }
207
+ //#endregion
208
+ //#region src/client/utils/audio-worklet.ts
209
+ /**
210
+ * AudioWorklet processor code for voice capture.
211
+ * Inlined as a blob URL to avoid separate file serving requirements.
212
+ */
213
+ const workletCode = `
214
+ class AudioCaptureProcessor extends AudioWorkletProcessor {
215
+ constructor() {
216
+ super()
217
+ this.isRecording = true
218
+ }
219
+
220
+ process(inputs) {
221
+ if (!this.isRecording) return false
222
+
223
+ const input = inputs[0]
224
+ if (input && input.length > 0) {
225
+ const channelData = input[0]
226
+
227
+ // Send audio data to main thread
228
+ this.port.postMessage({
229
+ type: "audio",
230
+ data: new Float32Array(channelData)
231
+ })
232
+
233
+ // Calculate RMS for audio level visualization
234
+ let sum = 0
235
+ for (let i = 0; i < channelData.length; i++) {
236
+ sum += channelData[i] * channelData[i]
237
+ }
238
+ const rms = Math.sqrt(sum / channelData.length)
239
+ this.port.postMessage({ type: "level", rms })
240
+ }
241
+
242
+ return true
243
+ }
244
+ }
245
+
246
+ registerProcessor("audio-capture-processor", AudioCaptureProcessor)
247
+ `;
248
+ let cachedBlobURL = null;
249
+ /**
250
+ * Create a blob URL for the audio worklet processor.
251
+ * Caches the URL to avoid creating multiple blobs.
252
+ */
253
+ function createWorkletBlobURL() {
254
+ if (!cachedBlobURL) {
255
+ const blob = new Blob([workletCode], { type: "application/javascript" });
256
+ cachedBlobURL = URL.createObjectURL(blob);
257
+ }
258
+ return cachedBlobURL;
259
+ }
260
+ //#endregion
261
+ //#region src/client/utils/audio.ts
262
+ /**
263
+ * Audio conversion utilities for voice capture.
264
+ * Converts Float32 audio data to WAV format for server transcription.
265
+ */
266
+ /**
267
+ * Merge multiple Float32Array chunks into a single array
268
+ */
269
+ function mergeAudioChunks(chunks) {
270
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
271
+ const result = new Float32Array(totalLength);
272
+ let offset = 0;
273
+ for (const chunk of chunks) {
274
+ result.set(chunk, offset);
275
+ offset += chunk.length;
276
+ }
277
+ return result;
278
+ }
279
+ /**
280
+ * Convert Float32 audio data to 16-bit PCM
281
+ */
282
+ function floatTo16BitPCM(output, offset, input) {
283
+ for (let i = 0; i < input.length; i++, offset += 2) {
284
+ const sample = Math.max(-1, Math.min(1, input[i]));
285
+ output.setInt16(offset, sample < 0 ? sample * 32768 : sample * 32767, true);
286
+ }
287
+ }
288
+ /**
289
+ * Write a string to a DataView
290
+ */
291
+ function writeString(view, offset, string) {
292
+ for (let i = 0; i < string.length; i++) view.setUint8(offset + i, string.charCodeAt(i));
293
+ }
294
+ /**
295
+ * Encode Float32 audio data as a WAV file
296
+ */
297
+ function encodeWAV(samples, sampleRate) {
298
+ const numChannels = 1;
299
+ const bitsPerSample = 16;
300
+ const bytesPerSample = bitsPerSample / 8;
301
+ const blockAlign = numChannels * bytesPerSample;
302
+ const dataLength = samples.length * bytesPerSample;
303
+ const buffer = new ArrayBuffer(44 + dataLength);
304
+ const view = new DataView(buffer);
305
+ writeString(view, 0, "RIFF");
306
+ view.setUint32(4, 36 + dataLength, true);
307
+ writeString(view, 8, "WAVE");
308
+ writeString(view, 12, "fmt ");
309
+ view.setUint32(16, 16, true);
310
+ view.setUint16(20, 1, true);
311
+ view.setUint16(22, numChannels, true);
312
+ view.setUint32(24, sampleRate, true);
313
+ view.setUint32(28, sampleRate * blockAlign, true);
314
+ view.setUint16(32, blockAlign, true);
315
+ view.setUint16(34, bitsPerSample, true);
316
+ writeString(view, 36, "data");
317
+ view.setUint32(40, dataLength, true);
318
+ floatTo16BitPCM(view, 44, samples);
319
+ return new Blob([buffer], { type: "audio/wav" });
320
+ }
321
+ //#endregion
322
+ //#region src/client/hooks/useVoiceCapture.ts
323
+ const SAMPLE_RATE = 16e3;
324
+ const AUDIO_LEVEL_BOOST = 10.2;
325
+ /**
326
+ * Hook for voice capture using AudioWorkletNode.
327
+ * Updates $audioLevel atom for waveform visualization.
328
+ */
329
+ function useVoiceCapture() {
330
+ const [isRecording, setIsRecording] = useState(false);
331
+ const [error, setError] = useState(null);
332
+ const audioContextRef = useRef(null);
333
+ const workletNodeRef = useRef(null);
334
+ const streamRef = useRef(null);
335
+ const chunksRef = useRef([]);
336
+ return {
337
+ start: useCallback(async () => {
338
+ setError(null);
339
+ chunksRef.current = [];
340
+ try {
341
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: {
342
+ sampleRate: SAMPLE_RATE,
343
+ channelCount: 1,
344
+ echoCancellation: true,
345
+ noiseSuppression: true
346
+ } });
347
+ streamRef.current = stream;
348
+ const audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
349
+ audioContextRef.current = audioContext;
350
+ const workletURL = createWorkletBlobURL();
351
+ await audioContext.audioWorklet.addModule(workletURL);
352
+ const source = audioContext.createMediaStreamSource(stream);
353
+ const workletNode = new AudioWorkletNode(audioContext, "audio-capture-processor");
354
+ workletNodeRef.current = workletNode;
355
+ workletNode.port.onmessage = (event) => {
356
+ const { type, data, rms } = event.data;
357
+ if (type === "audio") chunksRef.current.push(data);
358
+ else if (type === "level") {
359
+ const boostedLevel = Math.min(rms * AUDIO_LEVEL_BOOST, 1);
360
+ $audioLevel.set(boostedLevel);
361
+ }
362
+ };
363
+ source.connect(workletNode);
364
+ setIsRecording(true);
365
+ } catch (err) {
366
+ const captureError = err instanceof Error ? err : /* @__PURE__ */ new Error("Microphone access failed");
367
+ setError(captureError);
368
+ throw captureError;
369
+ }
370
+ }, []),
371
+ stop: useCallback(async () => {
372
+ if (streamRef.current) {
373
+ streamRef.current.getTracks().forEach((track) => track.stop());
374
+ streamRef.current = null;
375
+ }
376
+ if (workletNodeRef.current) {
377
+ workletNodeRef.current.disconnect();
378
+ workletNodeRef.current = null;
379
+ }
380
+ if (audioContextRef.current) {
381
+ await audioContextRef.current.close();
382
+ audioContextRef.current = null;
383
+ }
384
+ $audioLevel.set(0);
385
+ const wavBlob = encodeWAV(mergeAudioChunks(chunksRef.current), SAMPLE_RATE);
386
+ chunksRef.current = [];
387
+ setIsRecording(false);
388
+ return wavBlob;
389
+ }, []),
390
+ isRecording,
391
+ error
392
+ };
393
+ }
394
+ //#endregion
395
+ //#region src/client/utils/screenshot.ts
396
+ const MAX_WIDTH = 1280;
397
+ function getCaptureMetrics() {
398
+ return {
399
+ viewportWidth: window.innerWidth,
400
+ viewportHeight: window.innerHeight
401
+ };
402
+ }
403
+ /**
404
+ * Resize canvas to max width while maintaining aspect ratio
405
+ */
406
+ function resizeCanvas(canvas, maxWidth) {
407
+ if (canvas.width <= maxWidth) return canvas;
408
+ const scale = maxWidth / canvas.width;
409
+ const resized = document.createElement("canvas");
410
+ resized.width = maxWidth;
411
+ resized.height = Math.round(canvas.height * scale);
412
+ const ctx = resized.getContext("2d");
413
+ if (ctx) ctx.drawImage(canvas, 0, 0, resized.width, resized.height);
414
+ return resized;
415
+ }
416
+ /**
417
+ * Create a fallback canvas when screenshot capture fails.
418
+ * Returns a simple gray canvas with an error message.
419
+ */
420
+ function createFallbackCanvas() {
421
+ const canvas = document.createElement("canvas");
422
+ canvas.width = Math.min(window.innerWidth, MAX_WIDTH);
423
+ canvas.height = Math.round(window.innerHeight / window.innerWidth * canvas.width);
424
+ const ctx = canvas.getContext("2d");
425
+ if (ctx) {
426
+ ctx.fillStyle = "#f0f0f0";
427
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
428
+ ctx.fillStyle = "#666";
429
+ ctx.font = "16px sans-serif";
430
+ ctx.textAlign = "center";
431
+ ctx.fillText("Screenshot unavailable", canvas.width / 2, canvas.height / 2);
432
+ }
433
+ return canvas;
434
+ }
435
+ /**
436
+ * Capture a screenshot of the current viewport.
437
+ * Uses html2canvas to render the DOM to a canvas, then exports as JPEG.
438
+ * Falls back to a placeholder if capture fails (e.g., due to unsupported CSS).
439
+ */
440
+ async function captureViewport() {
441
+ const captureMetrics = getCaptureMetrics();
442
+ let canvas;
443
+ try {
444
+ canvas = await html2canvas(document.body, {
445
+ scale: 1,
446
+ useCORS: true,
447
+ logging: false,
448
+ width: captureMetrics.viewportWidth,
449
+ height: captureMetrics.viewportHeight,
450
+ x: window.scrollX,
451
+ y: window.scrollY
452
+ });
453
+ } catch (err) {
454
+ canvas = createFallbackCanvas();
455
+ }
456
+ const resized = resizeCanvas(canvas, MAX_WIDTH);
457
+ return {
458
+ imageData: resized.toDataURL("image/jpeg", .8),
459
+ width: resized.width,
460
+ height: resized.height,
461
+ viewportWidth: captureMetrics.viewportWidth,
462
+ viewportHeight: captureMetrics.viewportHeight
463
+ };
464
+ }
465
+ //#endregion
466
+ //#region src/client/hooks/useScreenCapture.ts
467
+ /**
468
+ * Hook for capturing viewport screenshots.
469
+ */
470
+ function useScreenCapture() {
471
+ const [isCapturing, setIsCapturing] = useState(false);
472
+ const [lastCapture, setLastCapture] = useState(null);
473
+ const [error, setError] = useState(null);
474
+ return {
475
+ capture: useCallback(async () => {
476
+ setIsCapturing(true);
477
+ setError(null);
478
+ try {
479
+ const result = await captureViewport();
480
+ setLastCapture(result);
481
+ return result;
482
+ } catch (err) {
483
+ const captureError = err instanceof Error ? err : /* @__PURE__ */ new Error("Screenshot capture failed");
484
+ setError(captureError);
485
+ throw captureError;
486
+ } finally {
487
+ setIsCapturing(false);
488
+ }
489
+ }, []),
490
+ isCapturing,
491
+ lastCapture,
492
+ error
493
+ };
494
+ }
495
+ //#endregion
496
+ //#region src/client/hooks/useCursorPosition.ts
497
+ /**
498
+ * Hook that tracks mouse cursor position and updates the $cursorPosition atom.
499
+ * Should be used once at the provider level.
500
+ */
501
+ function useCursorPosition() {
502
+ useEffect(() => {
503
+ function handleMouseMove(event) {
504
+ $cursorPosition.set({
505
+ x: event.clientX,
506
+ y: event.clientY
507
+ });
508
+ }
509
+ window.addEventListener("mousemove", handleMouseMove);
510
+ return () => {
511
+ window.removeEventListener("mousemove", handleMouseMove);
512
+ };
513
+ }, []);
514
+ }
515
+ //#endregion
516
+ //#region src/client/styles.css?inline
517
+ var styles_default = "/**\n * Cursor Buddy Styles\n *\n * Customize by overriding CSS variables in your own stylesheet:\n *\n * :root {\n * --cursor-buddy-color-idle: #8b5cf6;\n * }\n */\n\n:root {\n /* Cursor colors by state */\n --cursor-buddy-color-idle: #3b82f6;\n --cursor-buddy-color-listening: #ef4444;\n --cursor-buddy-color-processing: #eab308;\n --cursor-buddy-color-responding: #22c55e;\n --cursor-buddy-cursor-stroke: #ffffff;\n --cursor-buddy-cursor-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\n\n /* Speech bubble */\n --cursor-buddy-bubble-bg: #ffffff;\n --cursor-buddy-bubble-text: #1f2937;\n --cursor-buddy-bubble-radius: 8px;\n --cursor-buddy-bubble-padding: 8px 12px;\n --cursor-buddy-bubble-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n --cursor-buddy-bubble-max-width: 200px;\n --cursor-buddy-bubble-font-size: 14px;\n\n /* Waveform */\n --cursor-buddy-waveform-color: #ef4444;\n --cursor-buddy-waveform-bar-width: 4px;\n --cursor-buddy-waveform-bar-radius: 2px;\n --cursor-buddy-waveform-gap: 3px;\n\n /* Overlay */\n --cursor-buddy-z-index: 2147483647;\n\n /* Animation durations */\n --cursor-buddy-transition-fast: 0.1s;\n --cursor-buddy-transition-normal: 0.2s;\n --cursor-buddy-animation-duration: 0.3s;\n}\n\n/* Overlay container */\n.cursor-buddy-overlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n isolation: isolate;\n z-index: var(--cursor-buddy-z-index);\n}\n\n/* Buddy container (cursor + accessories) */\n.cursor-buddy-container {\n position: absolute;\n transform: translate(-16px, -16px);\n}\n\n/* Cursor SVG */\n.cursor-buddy-cursor {\n transition: transform var(--cursor-buddy-transition-fast) ease-out;\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow));\n}\n\n.cursor-buddy-cursor polygon {\n stroke: var(--cursor-buddy-cursor-stroke);\n stroke-width: 2;\n transition: fill var(--cursor-buddy-transition-normal) ease-out;\n}\n\n.cursor-buddy-cursor--idle polygon {\n fill: var(--cursor-buddy-color-idle);\n}\n\n.cursor-buddy-cursor--listening polygon {\n fill: var(--cursor-buddy-color-listening);\n}\n\n.cursor-buddy-cursor--processing polygon {\n fill: var(--cursor-buddy-color-processing);\n}\n\n.cursor-buddy-cursor--responding polygon {\n fill: var(--cursor-buddy-color-responding);\n}\n\n/* Cursor pulse animation during listening */\n.cursor-buddy-cursor--listening {\n animation: cursor-buddy-pulse 1.5s ease-in-out infinite;\n}\n\n@keyframes cursor-buddy-pulse {\n 0%,\n 100% {\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow));\n }\n 50% {\n filter: drop-shadow(0 0 8px var(--cursor-buddy-color-listening));\n }\n}\n\n/* Processing spinner effect */\n.cursor-buddy-cursor--processing {\n animation: cursor-buddy-spin-subtle 2s linear infinite;\n}\n\n@keyframes cursor-buddy-spin-subtle {\n 0% {\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow)) hue-rotate(0deg);\n }\n 100% {\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow)) hue-rotate(360deg);\n }\n}\n\n/* Speech bubble */\n.cursor-buddy-bubble {\n position: absolute;\n left: 40px;\n top: -8px;\n pointer-events: auto;\n cursor: pointer;\n max-width: var(--cursor-buddy-bubble-max-width);\n padding: var(--cursor-buddy-bubble-padding);\n background-color: var(--cursor-buddy-bubble-bg);\n color: var(--cursor-buddy-bubble-text);\n border-radius: var(--cursor-buddy-bubble-radius);\n box-shadow: var(--cursor-buddy-bubble-shadow);\n font-size: var(--cursor-buddy-bubble-font-size);\n line-height: 1.4;\n width: max-content;\n overflow-wrap: break-word;\n word-break: break-word;\n user-select: none;\n animation: cursor-buddy-fade-in var(--cursor-buddy-animation-duration)\n ease-out;\n}\n\n@keyframes cursor-buddy-fade-in {\n from {\n opacity: 0;\n transform: translateY(-4px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* Waveform container */\n.cursor-buddy-waveform {\n position: absolute;\n left: 40px;\n top: 4px;\n display: flex;\n align-items: center;\n gap: var(--cursor-buddy-waveform-gap);\n height: 24px;\n animation: cursor-buddy-fade-in var(--cursor-buddy-animation-duration)\n ease-out;\n}\n\n/* Waveform bars */\n.cursor-buddy-waveform-bar {\n width: var(--cursor-buddy-waveform-bar-width);\n background-color: var(--cursor-buddy-waveform-color);\n border-radius: var(--cursor-buddy-waveform-bar-radius);\n transition: height 0.05s ease-out;\n}\n\n/* Fade out animation (applied via JS) */\n.cursor-buddy-fade-out {\n animation: cursor-buddy-fade-out var(--cursor-buddy-animation-duration)\n ease-out forwards;\n}\n\n@keyframes cursor-buddy-fade-out {\n from {\n opacity: 1;\n }\n to {\n opacity: 0;\n }\n}\n";
518
+ //#endregion
519
+ //#region src/client/utils/inject-styles.ts
520
+ const STYLE_ID = "cursor-buddy-styles";
521
+ let injected = false;
522
+ /**
523
+ * Inject cursor buddy styles into the document head.
524
+ * Safe to call multiple times - will only inject once.
525
+ * No-op during SSR.
526
+ */
527
+ function injectStyles() {
528
+ if (typeof document === "undefined") return;
529
+ if (injected) return;
530
+ if (document.getElementById(STYLE_ID)) {
531
+ injected = true;
532
+ return;
533
+ }
534
+ const head = document.head || document.getElementsByTagName("head")[0];
535
+ const style = document.createElement("style");
536
+ style.id = STYLE_ID;
537
+ style.textContent = styles_default;
538
+ if (head.firstChild) head.insertBefore(style, head.firstChild);
539
+ else head.appendChild(style);
540
+ injected = true;
541
+ }
542
+ //#endregion
543
+ //#region src/client/CursorBuddyProvider.tsx
544
+ const POINTING_LOCK_TIMEOUT_MS = 1e4;
545
+ function clamp(value, min, max) {
546
+ return Math.min(Math.max(value, min), max);
547
+ }
548
+ function mapPointToViewport(target, screenshot) {
549
+ if (screenshot.width <= 0 || screenshot.height <= 0) return target;
550
+ const scaleX = screenshot.viewportWidth / screenshot.width;
551
+ const scaleY = screenshot.viewportHeight / screenshot.height;
552
+ return {
553
+ ...target,
554
+ x: clamp(Math.round(target.x * scaleX), 0, Math.max(screenshot.viewportWidth - 1, 0)),
555
+ y: clamp(Math.round(target.y * scaleY), 0, Math.max(screenshot.viewportHeight - 1, 0))
556
+ };
557
+ }
558
+ function CursorBuddyProvider({ endpoint, muted = false, onTranscript, onResponse, onPoint, onStateChange, onError, children }) {
559
+ const [snapshot, send] = useActor(cursorBuddyMachine);
560
+ const voiceCapture = useVoiceCapture();
561
+ const screenCapture = useScreenCapture();
562
+ useCursorPosition();
563
+ useEffect(() => {
564
+ injectStyles();
565
+ }, []);
566
+ const audioLevel = useStore($audioLevel);
567
+ const isEnabled = useStore($isEnabled);
568
+ const isSpeaking = useStore($isSpeaking);
569
+ useStore($pointingTarget);
570
+ const cursorPosition = useStore($cursorPosition);
571
+ const [pointerMode, setPointerMode] = useState("follow");
572
+ const audioRef = useRef(null);
573
+ const cancelPointingRef = useRef(null);
574
+ const dismissPointingTimeoutRef = useRef(null);
575
+ const isRecordingRef = useRef(false);
576
+ const state = snapshot.value;
577
+ const transcript = snapshot.context.transcript;
578
+ const response = snapshot.context.response;
579
+ const error = snapshot.context.error;
580
+ const isPointing = pointerMode !== "follow";
581
+ useEffect(() => {
582
+ onStateChange?.(state);
583
+ }, [state, onStateChange]);
584
+ useEffect(() => {
585
+ if (error) onError?.(error);
586
+ }, [error, onError]);
587
+ const clearPointingTimeout = useCallback(() => {
588
+ if (dismissPointingTimeoutRef.current !== null) {
589
+ window.clearTimeout(dismissPointingTimeoutRef.current);
590
+ dismissPointingTimeoutRef.current = null;
591
+ }
592
+ }, []);
593
+ const cancelPointingAnimation = useCallback(() => {
594
+ if (cancelPointingRef.current) {
595
+ cancelPointingRef.current();
596
+ cancelPointingRef.current = null;
597
+ }
598
+ }, []);
599
+ const releasePointingLock = useCallback(() => {
600
+ clearPointingTimeout();
601
+ cancelPointingAnimation();
602
+ setPointerMode("follow");
603
+ $pointingTarget.set(null);
604
+ $buddyPosition.set($cursorPosition.get());
605
+ $buddyRotation.set(0);
606
+ $buddyScale.set(1);
607
+ }, [cancelPointingAnimation, clearPointingTimeout]);
608
+ const schedulePointingRelease = useCallback(() => {
609
+ clearPointingTimeout();
610
+ dismissPointingTimeoutRef.current = window.setTimeout(() => {
611
+ dismissPointingTimeoutRef.current = null;
612
+ releasePointingLock();
613
+ }, POINTING_LOCK_TIMEOUT_MS);
614
+ }, [clearPointingTimeout, releasePointingLock]);
615
+ useEffect(() => {
616
+ if (pointerMode === "follow") {
617
+ $buddyPosition.set(cursorPosition);
618
+ $buddyRotation.set(0);
619
+ $buddyScale.set(1);
620
+ }
621
+ }, [pointerMode, cursorPosition]);
622
+ useEffect(() => {
623
+ return () => {
624
+ clearPointingTimeout();
625
+ cancelPointingAnimation();
626
+ };
627
+ }, [cancelPointingAnimation, clearPointingTimeout]);
628
+ const handlePointing = useCallback((target) => {
629
+ clearPointingTimeout();
630
+ cancelPointingAnimation();
631
+ $pointingTarget.set(target);
632
+ setPointerMode("flying");
633
+ schedulePointingRelease();
634
+ const startPos = $buddyPosition.get();
635
+ const endPos = {
636
+ x: target.x,
637
+ y: target.y
638
+ };
639
+ cancelPointingRef.current = animateBezierFlight(startPos, endPos, 800, {
640
+ onFrame: (position, rotation, scale) => {
641
+ $buddyPosition.set(position);
642
+ $buddyRotation.set(rotation);
643
+ $buddyScale.set(scale);
644
+ },
645
+ onComplete: () => {
646
+ cancelPointingRef.current = null;
647
+ setPointerMode("anchored");
648
+ $buddyPosition.set(endPos);
649
+ $buddyRotation.set(0);
650
+ $buddyScale.set(1);
651
+ send({ type: "POINTING_COMPLETE" });
652
+ }
653
+ });
654
+ }, [
655
+ cancelPointingAnimation,
656
+ clearPointingTimeout,
657
+ schedulePointingRelease,
658
+ send
659
+ ]);
660
+ const startListening = useCallback(async () => {
661
+ if (!isEnabled || isRecordingRef.current) return;
662
+ try {
663
+ releasePointingLock();
664
+ isRecordingRef.current = true;
665
+ send({ type: "HOTKEY_PRESSED" });
666
+ await voiceCapture.start();
667
+ } catch (err) {
668
+ isRecordingRef.current = false;
669
+ send({
670
+ type: "ERROR",
671
+ error: err instanceof Error ? err : /* @__PURE__ */ new Error("Failed to start recording")
672
+ });
673
+ }
674
+ }, [
675
+ isEnabled,
676
+ releasePointingLock,
677
+ send,
678
+ voiceCapture
679
+ ]);
680
+ const stopListening = useCallback(async () => {
681
+ if (!isRecordingRef.current) return;
682
+ isRecordingRef.current = false;
683
+ try {
684
+ send({ type: "HOTKEY_RELEASED" });
685
+ const audioBlob = await voiceCapture.stop();
686
+ const screenshot = await screenCapture.capture();
687
+ const formData = new FormData();
688
+ formData.append("audio", audioBlob, "recording.wav");
689
+ const transcribeResponse = await fetch(`${endpoint}/transcribe`, {
690
+ method: "POST",
691
+ body: formData
692
+ });
693
+ if (!transcribeResponse.ok) throw new Error("Transcription failed");
694
+ const { text: transcriptText } = await transcribeResponse.json();
695
+ send({
696
+ type: "TRANSCRIPTION_COMPLETE",
697
+ transcript: transcriptText
698
+ });
699
+ onTranscript?.(transcriptText);
700
+ const history = $conversationHistory.get();
701
+ const chatResponse = await fetch(`${endpoint}/chat`, {
702
+ method: "POST",
703
+ headers: { "Content-Type": "application/json" },
704
+ body: JSON.stringify({
705
+ screenshot: screenshot.imageData,
706
+ capture: {
707
+ width: screenshot.width,
708
+ height: screenshot.height
709
+ },
710
+ transcript: transcriptText,
711
+ history
712
+ })
713
+ });
714
+ if (!chatResponse.ok) throw new Error("Chat request failed");
715
+ const reader = chatResponse.body?.getReader();
716
+ if (!reader) throw new Error("No response body");
717
+ const decoder = new TextDecoder();
718
+ let fullResponse = "";
719
+ while (true) {
720
+ const { done, value } = await reader.read();
721
+ if (done) break;
722
+ const chunk = decoder.decode(value, { stream: true });
723
+ fullResponse += chunk;
724
+ send({
725
+ type: "AI_RESPONSE_CHUNK",
726
+ text: chunk
727
+ });
728
+ }
729
+ const rawPointTarget = parsePointingTag(fullResponse);
730
+ const pointTarget = rawPointTarget ? mapPointToViewport(rawPointTarget, screenshot) : null;
731
+ const cleanResponse = stripPointingTag(fullResponse);
732
+ send({
733
+ type: "AI_RESPONSE_COMPLETE",
734
+ response: cleanResponse
735
+ });
736
+ onResponse?.(cleanResponse);
737
+ const newHistory = [
738
+ ...history,
739
+ {
740
+ role: "user",
741
+ content: transcriptText
742
+ },
743
+ {
744
+ role: "assistant",
745
+ content: cleanResponse
746
+ }
747
+ ];
748
+ $conversationHistory.set(newHistory);
749
+ if (pointTarget) {
750
+ onPoint?.(pointTarget);
751
+ handlePointing(pointTarget);
752
+ }
753
+ if (!muted && cleanResponse) await playTTS(cleanResponse);
754
+ send({ type: "TTS_COMPLETE" });
755
+ } catch (err) {
756
+ send({
757
+ type: "ERROR",
758
+ error: err instanceof Error ? err : /* @__PURE__ */ new Error("Processing failed")
759
+ });
760
+ }
761
+ }, [
762
+ send,
763
+ voiceCapture,
764
+ screenCapture,
765
+ endpoint,
766
+ muted,
767
+ onTranscript,
768
+ onResponse,
769
+ onPoint,
770
+ handlePointing
771
+ ]);
772
+ const playTTS = useCallback(async (text) => {
773
+ $isSpeaking.set(true);
774
+ try {
775
+ const response = await fetch(`${endpoint}/tts`, {
776
+ method: "POST",
777
+ headers: { "Content-Type": "application/json" },
778
+ body: JSON.stringify({ text })
779
+ });
780
+ if (!response.ok) throw new Error("TTS request failed");
781
+ const audioBlob = await response.blob();
782
+ const audioUrl = URL.createObjectURL(audioBlob);
783
+ const audio = new Audio(audioUrl);
784
+ audioRef.current = audio;
785
+ await new Promise((resolve, reject) => {
786
+ audio.onended = () => {
787
+ URL.revokeObjectURL(audioUrl);
788
+ resolve();
789
+ };
790
+ audio.onerror = () => {
791
+ URL.revokeObjectURL(audioUrl);
792
+ reject(/* @__PURE__ */ new Error("Audio playback failed"));
793
+ };
794
+ audio.play();
795
+ });
796
+ } finally {
797
+ $isSpeaking.set(false);
798
+ audioRef.current = null;
799
+ }
800
+ }, [endpoint]);
801
+ const speak = useCallback(async (text) => {
802
+ if (muted) return;
803
+ await playTTS(text);
804
+ }, [muted, playTTS]);
805
+ const pointAt = useCallback((x, y, label) => {
806
+ handlePointing({
807
+ x,
808
+ y,
809
+ label
810
+ });
811
+ }, [handlePointing]);
812
+ const dismissPointing = useCallback(() => {
813
+ releasePointingLock();
814
+ }, [releasePointingLock]);
815
+ const setEnabled = useCallback((enabled) => {
816
+ $isEnabled.set(enabled);
817
+ }, []);
818
+ const reset = useCallback(() => {
819
+ if (audioRef.current) {
820
+ audioRef.current.pause();
821
+ audioRef.current = null;
822
+ }
823
+ isRecordingRef.current = false;
824
+ $isSpeaking.set(false);
825
+ releasePointingLock();
826
+ send({ type: "CANCEL" });
827
+ }, [releasePointingLock, send]);
828
+ const contextValue = useMemo(() => ({
829
+ state,
830
+ transcript,
831
+ response,
832
+ audioLevel,
833
+ isEnabled,
834
+ isSpeaking,
835
+ isPointing,
836
+ error,
837
+ startListening,
838
+ stopListening,
839
+ setEnabled,
840
+ speak,
841
+ pointAt,
842
+ dismissPointing,
843
+ reset
844
+ }), [
845
+ state,
846
+ transcript,
847
+ response,
848
+ audioLevel,
849
+ isEnabled,
850
+ isSpeaking,
851
+ isPointing,
852
+ error,
853
+ startListening,
854
+ stopListening,
855
+ setEnabled,
856
+ speak,
857
+ pointAt,
858
+ dismissPointing,
859
+ reset
860
+ ]);
861
+ return /* @__PURE__ */ jsx(CursorBuddyContext.Provider, {
862
+ value: contextValue,
863
+ children
864
+ });
865
+ }
866
+ //#endregion
867
+ //#region src/client/hooks/useCursorBuddy.ts
868
+ /**
869
+ * Hook to access cursor buddy state and actions.
870
+ * Must be used within a CursorBuddyProvider.
871
+ */
872
+ function useCursorBuddy() {
873
+ const context = useContext(CursorBuddyContext);
874
+ if (!context) throw new Error("useCursorBuddy must be used within a CursorBuddyProvider");
875
+ return context;
876
+ }
877
+ //#endregion
878
+ //#region src/client/components/Cursor.tsx
879
+ /**
880
+ * Default cursor component - a colored triangle pointer.
881
+ * Color and animations change based on voice state via CSS classes.
882
+ */
883
+ function DefaultCursor({ state, rotation, scale }) {
884
+ return /* @__PURE__ */ jsx("svg", {
885
+ width: "32",
886
+ height: "32",
887
+ viewBox: "0 0 32 32",
888
+ className: `cursor-buddy-cursor ${`cursor-buddy-cursor--${state}`}`,
889
+ style: { transform: `rotate(${rotation}rad) scale(${scale})` },
890
+ children: /* @__PURE__ */ jsx("polygon", { points: "16,4 28,28 16,22 4,28" })
891
+ });
892
+ }
893
+ //#endregion
894
+ //#region src/client/components/SpeechBubble.tsx
895
+ /**
896
+ * Default speech bubble component.
897
+ * Displays pointing label or response text next to the cursor.
898
+ */
899
+ function DefaultSpeechBubble({ text, isVisible, onClick }) {
900
+ if (!isVisible || !text) return null;
901
+ return /* @__PURE__ */ jsx("div", {
902
+ className: "cursor-buddy-bubble",
903
+ onClick,
904
+ onKeyDown: (event) => {
905
+ if (event.key === "Enter" || event.key === " ") {
906
+ event.preventDefault();
907
+ onClick?.();
908
+ }
909
+ },
910
+ role: "button",
911
+ tabIndex: 0,
912
+ children: text
913
+ });
914
+ }
915
+ //#endregion
916
+ //#region src/client/components/Waveform.tsx
917
+ const BAR_COUNT = 5;
918
+ /**
919
+ * Default waveform component.
920
+ * Shows audio level visualization during recording.
921
+ */
922
+ function DefaultWaveform({ audioLevel, isListening }) {
923
+ if (!isListening) return null;
924
+ return /* @__PURE__ */ jsx("div", {
925
+ className: "cursor-buddy-waveform",
926
+ children: Array.from({ length: BAR_COUNT }).map((_, i) => {
927
+ const baseHeight = 4;
928
+ const variance = Math.sin(i / BAR_COUNT * Math.PI) * .5 + .5;
929
+ return /* @__PURE__ */ jsx("div", {
930
+ className: "cursor-buddy-waveform-bar",
931
+ style: { height: `${baseHeight + audioLevel * 16 * variance}px` }
932
+ }, i);
933
+ })
934
+ });
935
+ }
936
+ //#endregion
937
+ //#region src/client/components/Overlay.tsx
938
+ /**
939
+ * Overlay component that renders the cursor, speech bubble, and waveform.
940
+ * Uses React portal to render at the document body level.
941
+ */
942
+ function Overlay({ cursor, speechBubble, waveform, container }) {
943
+ const [isMounted, setIsMounted] = useState(false);
944
+ useEffect(() => setIsMounted(true), []);
945
+ const { state, isPointing, isEnabled, dismissPointing } = useCursorBuddy();
946
+ const buddyPosition = useStore($buddyPosition);
947
+ const buddyRotation = useStore($buddyRotation);
948
+ const buddyScale = useStore($buddyScale);
949
+ const audioLevel = useStore($audioLevel);
950
+ const pointingTarget = useStore($pointingTarget);
951
+ if (!isMounted || !isEnabled) return null;
952
+ const cursorProps = {
953
+ state,
954
+ isPointing,
955
+ rotation: buddyRotation,
956
+ scale: buddyScale
957
+ };
958
+ const speechBubbleProps = {
959
+ text: pointingTarget?.label ?? "",
960
+ isVisible: isPointing && !!pointingTarget,
961
+ onClick: dismissPointing
962
+ };
963
+ const waveformProps = {
964
+ audioLevel,
965
+ isListening: state === "listening"
966
+ };
967
+ const cursorElement = typeof cursor === "function" ? cursor(cursorProps) : cursor ? cursor : /* @__PURE__ */ jsx(DefaultCursor, { ...cursorProps });
968
+ const speechBubbleElement = speechBubble ? speechBubble(speechBubbleProps) : /* @__PURE__ */ jsx(DefaultSpeechBubble, { ...speechBubbleProps });
969
+ const waveformElement = waveform ? waveform(waveformProps) : /* @__PURE__ */ jsx(DefaultWaveform, { ...waveformProps });
970
+ const overlayContent = /* @__PURE__ */ jsx("div", {
971
+ className: "cursor-buddy-overlay",
972
+ "data-cursor-buddy-overlay": true,
973
+ children: /* @__PURE__ */ jsxs("div", {
974
+ className: "cursor-buddy-container",
975
+ style: {
976
+ left: buddyPosition.x,
977
+ top: buddyPosition.y
978
+ },
979
+ children: [
980
+ cursorElement,
981
+ state === "listening" && waveformElement,
982
+ isPointing && speechBubbleElement
983
+ ]
984
+ })
985
+ });
986
+ const portalContainer = container ?? (typeof document !== "undefined" ? document.body : null);
987
+ if (!portalContainer) return null;
988
+ return createPortal(overlayContent, portalContainer);
989
+ }
990
+ //#endregion
991
+ //#region src/client/hooks/useHotkey.ts
992
+ /**
993
+ * Parse a hotkey string like "ctrl+alt" into modifier flags
994
+ */
995
+ function parseHotkey(hotkey) {
996
+ const parts = hotkey.toLowerCase().split("+");
997
+ return {
998
+ ctrl: parts.includes("ctrl") || parts.includes("control"),
999
+ alt: parts.includes("alt") || parts.includes("option"),
1000
+ shift: parts.includes("shift"),
1001
+ meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command")
1002
+ };
1003
+ }
1004
+ /**
1005
+ * Check if a keyboard event matches the required modifiers
1006
+ */
1007
+ function matchesHotkey(event, modifiers) {
1008
+ return event.ctrlKey === modifiers.ctrl && event.altKey === modifiers.alt && event.shiftKey === modifiers.shift && event.metaKey === modifiers.meta;
1009
+ }
1010
+ /**
1011
+ * Hook for detecting push-to-talk hotkey press/release.
1012
+ *
1013
+ * @param hotkey - Hotkey string like "ctrl+alt" or "ctrl+shift"
1014
+ * @param onPress - Called when hotkey is pressed
1015
+ * @param onRelease - Called when hotkey is released
1016
+ * @param enabled - Whether the hotkey listener is active (default: true)
1017
+ */
1018
+ function useHotkey(hotkey, onPress, onRelease, enabled = true) {
1019
+ const isPressedRef = useRef(false);
1020
+ const modifiersRef = useRef(parseHotkey(hotkey));
1021
+ const onPressRef = useRef(onPress);
1022
+ const onReleaseRef = useRef(onRelease);
1023
+ onPressRef.current = onPress;
1024
+ onReleaseRef.current = onRelease;
1025
+ useEffect(() => {
1026
+ modifiersRef.current = parseHotkey(hotkey);
1027
+ }, [hotkey]);
1028
+ useEffect(() => {
1029
+ if (!enabled) {
1030
+ if (isPressedRef.current) {
1031
+ isPressedRef.current = false;
1032
+ onReleaseRef.current();
1033
+ }
1034
+ return;
1035
+ }
1036
+ function handleKeyDown(event) {
1037
+ if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {
1038
+ isPressedRef.current = true;
1039
+ event.preventDefault();
1040
+ onPressRef.current();
1041
+ }
1042
+ }
1043
+ function handleKeyUp(event) {
1044
+ if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {
1045
+ isPressedRef.current = false;
1046
+ onReleaseRef.current();
1047
+ }
1048
+ }
1049
+ function handleBlur() {
1050
+ if (isPressedRef.current) {
1051
+ isPressedRef.current = false;
1052
+ onReleaseRef.current();
1053
+ }
1054
+ }
1055
+ window.addEventListener("keydown", handleKeyDown);
1056
+ window.addEventListener("keyup", handleKeyUp);
1057
+ window.addEventListener("blur", handleBlur);
1058
+ return () => {
1059
+ window.removeEventListener("keydown", handleKeyDown);
1060
+ window.removeEventListener("keyup", handleKeyUp);
1061
+ window.removeEventListener("blur", handleBlur);
1062
+ };
1063
+ }, [enabled]);
1064
+ }
1065
+ //#endregion
1066
+ //#region src/client/CursorBuddy.tsx
1067
+ /**
1068
+ * Internal component that sets up hotkey handling
1069
+ */
1070
+ function CursorBuddyInner({ hotkey = "ctrl+alt", cursor, speechBubble, waveform, container }) {
1071
+ const { startListening, stopListening, isEnabled } = useCursorBuddy();
1072
+ useHotkey(hotkey, startListening, stopListening, isEnabled);
1073
+ return /* @__PURE__ */ jsx(Overlay, {
1074
+ cursor,
1075
+ speechBubble,
1076
+ waveform,
1077
+ container
1078
+ });
1079
+ }
1080
+ /**
1081
+ * Drop-in cursor buddy component.
1082
+ *
1083
+ * Adds an AI-powered cursor companion to your app. Users hold the hotkey
1084
+ * (default: Ctrl+Alt) to speak. The SDK captures a screenshot, transcribes
1085
+ * the speech, sends it to the AI, speaks the response, and can point at
1086
+ * elements on screen.
1087
+ *
1088
+ * @example
1089
+ * ```tsx
1090
+ * import { CursorBuddy } from "cursor-buddy/client"
1091
+ *
1092
+ * function App() {
1093
+ * return (
1094
+ * <>
1095
+ * <YourApp />
1096
+ * <CursorBuddy endpoint="/api/cursor-buddy" />
1097
+ * </>
1098
+ * )
1099
+ * }
1100
+ * ```
1101
+ */
1102
+ function CursorBuddy({ endpoint, hotkey, muted, container, cursor, speechBubble, waveform, onTranscript, onResponse, onPoint, onStateChange, onError }) {
1103
+ return /* @__PURE__ */ jsx(CursorBuddyProvider, {
1104
+ endpoint,
1105
+ muted,
1106
+ onTranscript,
1107
+ onResponse,
1108
+ onPoint,
1109
+ onStateChange,
1110
+ onError,
1111
+ children: /* @__PURE__ */ jsx(CursorBuddyInner, {
1112
+ hotkey,
1113
+ cursor,
1114
+ speechBubble,
1115
+ waveform,
1116
+ container
1117
+ })
1118
+ });
1119
+ }
1120
+ //#endregion
1121
+ export { CursorBuddy, CursorBuddyProvider, useCursorBuddy };
1122
+
1123
+ //# sourceMappingURL=index.mjs.map