cursor-buddy 0.0.1 → 0.0.3

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,358 @@
1
+ "use client";
2
+ import { a as $buddyScale, i as $buddyRotation, n as $audioLevel, o as $cursorPosition, r as $buddyPosition, s as $pointingTarget, t as CursorBuddyClient } from "../client-DAa4L2fE.mjs";
3
+ import { useStore } from "@nanostores/react";
4
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { createPortal } from "react-dom";
7
+ //#region src/react/styles.css?inline
8
+ 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, -4px);\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";
9
+ //#endregion
10
+ //#region src/react/utils/inject-styles.ts
11
+ const STYLE_ID = "cursor-buddy-styles";
12
+ let injected = false;
13
+ /**
14
+ * Inject cursor buddy styles into the document head.
15
+ * Safe to call multiple times - will only inject once.
16
+ * No-op during SSR.
17
+ */
18
+ function injectStyles() {
19
+ if (typeof document === "undefined") return;
20
+ if (injected) return;
21
+ if (document.getElementById(STYLE_ID)) {
22
+ injected = true;
23
+ return;
24
+ }
25
+ const head = document.head || document.getElementsByTagName("head")[0];
26
+ const style = document.createElement("style");
27
+ style.id = STYLE_ID;
28
+ style.textContent = styles_default;
29
+ if (head.firstChild) head.insertBefore(style, head.firstChild);
30
+ else head.appendChild(style);
31
+ injected = true;
32
+ }
33
+ //#endregion
34
+ //#region src/react/provider.tsx
35
+ const CursorBuddyContext = createContext(null);
36
+ /**
37
+ * Provider for cursor buddy. Creates and manages the client instance.
38
+ */
39
+ function CursorBuddyProvider({ endpoint, children, onTranscript, onResponse, onPoint, onStateChange, onError }) {
40
+ const [client] = useState(() => new CursorBuddyClient(endpoint, {
41
+ onTranscript,
42
+ onResponse,
43
+ onPoint,
44
+ onStateChange,
45
+ onError
46
+ }));
47
+ useEffect(() => {
48
+ injectStyles();
49
+ }, []);
50
+ useEffect(() => {
51
+ function handleMouseMove(event) {
52
+ $cursorPosition.set({
53
+ x: event.clientX,
54
+ y: event.clientY
55
+ });
56
+ client.updateCursorPosition();
57
+ }
58
+ window.addEventListener("mousemove", handleMouseMove);
59
+ return () => window.removeEventListener("mousemove", handleMouseMove);
60
+ }, [client]);
61
+ return /* @__PURE__ */ jsx(CursorBuddyContext.Provider, {
62
+ value: client,
63
+ children
64
+ });
65
+ }
66
+ /**
67
+ * Get the cursor buddy client from context.
68
+ * @internal
69
+ */
70
+ function useClient() {
71
+ const client = useContext(CursorBuddyContext);
72
+ if (!client) throw new Error("useCursorBuddy must be used within CursorBuddyProvider");
73
+ return client;
74
+ }
75
+ //#endregion
76
+ //#region src/react/hooks.ts
77
+ /**
78
+ * Hook to access cursor buddy state and actions.
79
+ */
80
+ function useCursorBuddy() {
81
+ const client = useClient();
82
+ const subscribe = useCallback((listener) => client.subscribe(listener), [client]);
83
+ const getSnapshot = useCallback(() => client.getSnapshot(), [client]);
84
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
85
+ const audioLevel = useStore($audioLevel);
86
+ return {
87
+ ...snapshot,
88
+ audioLevel,
89
+ startListening: useCallback(() => client.startListening(), [client]),
90
+ stopListening: useCallback(() => client.stopListening(), [client]),
91
+ setEnabled: useCallback((enabled) => client.setEnabled(enabled), [client]),
92
+ pointAt: useCallback((x, y, label) => client.pointAt(x, y, label), [client]),
93
+ dismissPointing: useCallback(() => client.dismissPointing(), [client]),
94
+ reset: useCallback(() => client.reset(), [client])
95
+ };
96
+ }
97
+ //#endregion
98
+ //#region src/react/use-hotkey.ts
99
+ /**
100
+ * Parse a hotkey string like "ctrl+alt" into modifier flags
101
+ */
102
+ function parseHotkey(hotkey) {
103
+ const parts = hotkey.toLowerCase().split("+");
104
+ return {
105
+ ctrl: parts.includes("ctrl") || parts.includes("control"),
106
+ alt: parts.includes("alt") || parts.includes("option"),
107
+ shift: parts.includes("shift"),
108
+ meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command")
109
+ };
110
+ }
111
+ /**
112
+ * Check if a keyboard event matches the required modifiers
113
+ */
114
+ function matchesHotkey(event, modifiers) {
115
+ return event.ctrlKey === modifiers.ctrl && event.altKey === modifiers.alt && event.shiftKey === modifiers.shift && event.metaKey === modifiers.meta;
116
+ }
117
+ /**
118
+ * Hook for detecting push-to-talk hotkey press/release.
119
+ *
120
+ * @param hotkey - Hotkey string like "ctrl+alt" or "ctrl+shift"
121
+ * @param onPress - Called when hotkey is pressed
122
+ * @param onRelease - Called when hotkey is released
123
+ * @param enabled - Whether the hotkey listener is active (default: true)
124
+ */
125
+ function useHotkey(hotkey, onPress, onRelease, enabled = true) {
126
+ const isPressedRef = useRef(false);
127
+ const modifiersRef = useRef(parseHotkey(hotkey));
128
+ const onPressRef = useRef(onPress);
129
+ const onReleaseRef = useRef(onRelease);
130
+ onPressRef.current = onPress;
131
+ onReleaseRef.current = onRelease;
132
+ useEffect(() => {
133
+ modifiersRef.current = parseHotkey(hotkey);
134
+ }, [hotkey]);
135
+ useEffect(() => {
136
+ if (!enabled) {
137
+ if (isPressedRef.current) {
138
+ isPressedRef.current = false;
139
+ onReleaseRef.current();
140
+ }
141
+ return;
142
+ }
143
+ function handleKeyDown(event) {
144
+ if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {
145
+ isPressedRef.current = true;
146
+ event.preventDefault();
147
+ onPressRef.current();
148
+ }
149
+ }
150
+ function handleKeyUp(event) {
151
+ if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {
152
+ isPressedRef.current = false;
153
+ onReleaseRef.current();
154
+ }
155
+ }
156
+ function handleBlur() {
157
+ if (isPressedRef.current) {
158
+ isPressedRef.current = false;
159
+ onReleaseRef.current();
160
+ }
161
+ }
162
+ window.addEventListener("keydown", handleKeyDown);
163
+ window.addEventListener("keyup", handleKeyUp);
164
+ window.addEventListener("blur", handleBlur);
165
+ return () => {
166
+ window.removeEventListener("keydown", handleKeyDown);
167
+ window.removeEventListener("keyup", handleKeyUp);
168
+ window.removeEventListener("blur", handleBlur);
169
+ };
170
+ }, [enabled]);
171
+ }
172
+ //#endregion
173
+ //#region src/react/components/Cursor.tsx
174
+ const BASE_ROTATION = -Math.PI / 6;
175
+ /**
176
+ * Default cursor component - a colored triangle pointer.
177
+ * Color and animations change based on voice state via CSS classes.
178
+ */
179
+ function DefaultCursor({ state, rotation, scale }) {
180
+ return /* @__PURE__ */ jsx("svg", {
181
+ width: "32",
182
+ height: "32",
183
+ viewBox: "0 0 32 32",
184
+ className: `cursor-buddy-cursor ${`cursor-buddy-cursor--${state}`}`,
185
+ style: {
186
+ transform: `rotate(${BASE_ROTATION + rotation}rad) scale(${scale})`,
187
+ transformOrigin: "16px 4px"
188
+ },
189
+ children: /* @__PURE__ */ jsx("polygon", { points: "16,4 28,28 16,22 4,28" })
190
+ });
191
+ }
192
+ //#endregion
193
+ //#region src/react/components/SpeechBubble.tsx
194
+ /**
195
+ * Default speech bubble component.
196
+ * Displays pointing label or response text next to the cursor.
197
+ */
198
+ function DefaultSpeechBubble({ text, isVisible, onClick }) {
199
+ if (!isVisible || !text) return null;
200
+ return /* @__PURE__ */ jsx("div", {
201
+ className: "cursor-buddy-bubble",
202
+ onClick,
203
+ onKeyDown: (event) => {
204
+ if (event.key === "Enter" || event.key === " ") {
205
+ event.preventDefault();
206
+ onClick?.();
207
+ }
208
+ },
209
+ role: "button",
210
+ tabIndex: 0,
211
+ children: text
212
+ });
213
+ }
214
+ //#endregion
215
+ //#region src/react/components/Waveform.tsx
216
+ const EMPTY_BARS = Array.from({ length: 12 }, () => 0);
217
+ /**
218
+ * Default waveform component.
219
+ * Shows audio level visualization during recording.
220
+ */
221
+ function DefaultWaveform({ audioLevel, isListening }) {
222
+ const [bars, setBars] = useState(EMPTY_BARS);
223
+ useEffect(() => {
224
+ if (!isListening) {
225
+ setBars(EMPTY_BARS);
226
+ return;
227
+ }
228
+ setBars((previousBars) => {
229
+ const nextBars = previousBars.slice(1);
230
+ nextBars.push(audioLevel);
231
+ return nextBars;
232
+ });
233
+ }, [audioLevel, isListening]);
234
+ if (!isListening) return null;
235
+ return /* @__PURE__ */ jsx("div", {
236
+ className: "cursor-buddy-waveform",
237
+ children: bars.map((level) => Math.pow(level, .65)).map((level, i) => {
238
+ const baseHeight = 4;
239
+ const variance = .75 + (i + 1) % 3 * .12;
240
+ return /* @__PURE__ */ jsx("div", {
241
+ className: "cursor-buddy-waveform-bar",
242
+ style: { height: `${baseHeight + level * 20 * variance}px` }
243
+ }, i);
244
+ })
245
+ });
246
+ }
247
+ //#endregion
248
+ //#region src/react/components/Overlay.tsx
249
+ /**
250
+ * Overlay component that renders the cursor, speech bubble, and waveform.
251
+ * Uses React portal to render at the document body level.
252
+ */
253
+ function Overlay({ cursor, speechBubble, waveform, container }) {
254
+ const [isMounted, setIsMounted] = useState(false);
255
+ useEffect(() => setIsMounted(true), []);
256
+ const { state, isPointing, isEnabled, dismissPointing } = useCursorBuddy();
257
+ const buddyPosition = useStore($buddyPosition);
258
+ const buddyRotation = useStore($buddyRotation);
259
+ const buddyScale = useStore($buddyScale);
260
+ const audioLevel = useStore($audioLevel);
261
+ const pointingTarget = useStore($pointingTarget);
262
+ if (!isMounted || !isEnabled) return null;
263
+ const cursorProps = {
264
+ state,
265
+ isPointing,
266
+ rotation: buddyRotation,
267
+ scale: buddyScale
268
+ };
269
+ const speechBubbleProps = {
270
+ text: pointingTarget?.label ?? "",
271
+ isVisible: isPointing && !!pointingTarget,
272
+ onClick: dismissPointing
273
+ };
274
+ const waveformProps = {
275
+ audioLevel,
276
+ isListening: state === "listening"
277
+ };
278
+ const cursorElement = typeof cursor === "function" ? cursor(cursorProps) : cursor ? cursor : /* @__PURE__ */ jsx(DefaultCursor, { ...cursorProps });
279
+ const speechBubbleElement = speechBubble ? speechBubble(speechBubbleProps) : /* @__PURE__ */ jsx(DefaultSpeechBubble, { ...speechBubbleProps });
280
+ const waveformElement = waveform ? waveform(waveformProps) : /* @__PURE__ */ jsx(DefaultWaveform, { ...waveformProps });
281
+ const overlayContent = /* @__PURE__ */ jsx("div", {
282
+ className: "cursor-buddy-overlay",
283
+ "data-cursor-buddy-overlay": true,
284
+ children: /* @__PURE__ */ jsxs("div", {
285
+ className: "cursor-buddy-container",
286
+ style: {
287
+ left: buddyPosition.x,
288
+ top: buddyPosition.y
289
+ },
290
+ children: [
291
+ cursorElement,
292
+ state === "listening" && waveformElement,
293
+ isPointing && speechBubbleElement
294
+ ]
295
+ })
296
+ });
297
+ const portalContainer = container ?? (typeof document !== "undefined" ? document.body : null);
298
+ if (!portalContainer) return null;
299
+ return createPortal(overlayContent, portalContainer);
300
+ }
301
+ //#endregion
302
+ //#region src/react/components/CursorBuddy.tsx
303
+ /**
304
+ * Internal component that sets up hotkey handling
305
+ */
306
+ function CursorBuddyInner({ hotkey = "ctrl+alt", cursor, speechBubble, waveform, container }) {
307
+ const { startListening, stopListening, isEnabled } = useCursorBuddy();
308
+ useHotkey(hotkey, startListening, stopListening, isEnabled);
309
+ return /* @__PURE__ */ jsx(Overlay, {
310
+ cursor,
311
+ speechBubble,
312
+ waveform,
313
+ container
314
+ });
315
+ }
316
+ /**
317
+ * Drop-in cursor buddy component.
318
+ *
319
+ * Adds an AI-powered cursor companion to your app. Users hold the hotkey
320
+ * (default: Ctrl+Alt) to speak. The SDK captures a screenshot, transcribes
321
+ * the speech, sends it to the AI, speaks the response, and can point at
322
+ * elements on screen.
323
+ *
324
+ * @example
325
+ * ```tsx
326
+ * import { CursorBuddy } from "cursor-buddy/react"
327
+ *
328
+ * function App() {
329
+ * return (
330
+ * <>
331
+ * <YourApp />
332
+ * <CursorBuddy endpoint="/api/cursor-buddy" />
333
+ * </>
334
+ * )
335
+ * }
336
+ * ```
337
+ */
338
+ function CursorBuddy({ endpoint, hotkey, container, cursor, speechBubble, waveform, onTranscript, onResponse, onPoint, onStateChange, onError }) {
339
+ return /* @__PURE__ */ jsx(CursorBuddyProvider, {
340
+ endpoint,
341
+ onTranscript,
342
+ onResponse,
343
+ onPoint,
344
+ onStateChange,
345
+ onError,
346
+ children: /* @__PURE__ */ jsx(CursorBuddyInner, {
347
+ hotkey,
348
+ cursor,
349
+ speechBubble,
350
+ waveform,
351
+ container
352
+ })
353
+ });
354
+ }
355
+ //#endregion
356
+ export { CursorBuddy, CursorBuddyProvider, useCursorBuddy };
357
+
358
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["styles"],"sources":["../../src/react/styles.css?inline","../../src/react/utils/inject-styles.ts","../../src/react/provider.tsx","../../src/react/hooks.ts","../../src/react/use-hotkey.ts","../../src/react/components/Cursor.tsx","../../src/react/components/SpeechBubble.tsx","../../src/react/components/Waveform.tsx","../../src/react/components/Overlay.tsx","../../src/react/components/CursorBuddy.tsx"],"sourcesContent":["export 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, -4px);\\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\";","// Import CSS as string - need to configure bundler for this\nimport styles from \"../styles.css?inline\"\n\nconst STYLE_ID = \"cursor-buddy-styles\"\n\nlet injected = false\n\n/**\n * Inject cursor buddy styles into the document head.\n * Safe to call multiple times - will only inject once.\n * No-op during SSR.\n */\nexport function injectStyles(): void {\n // Skip on server\n if (typeof document === \"undefined\") return\n\n // Skip if already injected\n if (injected) return\n\n // Check if style tag already exists (e.g., from a previous mount)\n if (document.getElementById(STYLE_ID)) {\n injected = true\n return\n }\n\n const head = document.head || document.getElementsByTagName(\"head\")[0]\n const style = document.createElement(\"style\")\n style.id = STYLE_ID\n style.textContent = styles\n\n // Insert at the beginning so user styles can override\n if (head.firstChild) {\n head.insertBefore(style, head.firstChild)\n } else {\n head.appendChild(style)\n }\n\n injected = true\n}\n","\"use client\"\n\nimport { createContext, useContext, useEffect, useState } from \"react\"\nimport { $cursorPosition } from \"../core/atoms\"\nimport { CursorBuddyClient } from \"../core/client\"\nimport type { CursorBuddyClientOptions } from \"../core/types\"\nimport { injectStyles } from \"./utils/inject-styles\"\n\nconst CursorBuddyContext = createContext<CursorBuddyClient | null>(null)\n\nexport interface CursorBuddyProviderProps extends CursorBuddyClientOptions {\n /** API endpoint for cursor buddy server */\n endpoint: string\n /** Children */\n children: React.ReactNode\n}\n\n/**\n * Provider for cursor buddy. Creates and manages the client instance.\n */\nexport function CursorBuddyProvider({\n endpoint,\n children,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n}: CursorBuddyProviderProps) {\n const [client] = useState(\n () =>\n new CursorBuddyClient(endpoint, {\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n }),\n )\n\n // Inject styles on mount\n useEffect(() => {\n injectStyles()\n }, [])\n\n // Track cursor position\n useEffect(() => {\n function handleMouseMove(event: MouseEvent) {\n $cursorPosition.set({ x: event.clientX, y: event.clientY })\n client.updateCursorPosition()\n }\n\n window.addEventListener(\"mousemove\", handleMouseMove)\n return () => window.removeEventListener(\"mousemove\", handleMouseMove)\n }, [client])\n\n return (\n <CursorBuddyContext.Provider value={client}>\n {children}\n </CursorBuddyContext.Provider>\n )\n}\n\n/**\n * Get the cursor buddy client from context.\n * @internal\n */\nexport function useClient(): CursorBuddyClient {\n const client = useContext(CursorBuddyContext)\n if (!client) {\n throw new Error(\"useCursorBuddy must be used within CursorBuddyProvider\")\n }\n return client\n}\n","\"use client\"\n\nimport { useStore } from \"@nanostores/react\"\nimport { useCallback, useSyncExternalStore } from \"react\"\nimport { $audioLevel } from \"../core/atoms\"\nimport type { VoiceState } from \"../core/types\"\nimport { useClient } from \"./provider\"\n\nexport interface UseCursorBuddyReturn {\n /** Current voice state */\n state: VoiceState\n /** Latest transcribed user speech */\n transcript: string\n /** Latest AI response (stripped of POINT tags) */\n response: string\n /** Current audio level (0-1) */\n audioLevel: number\n /** Whether the buddy is enabled */\n isEnabled: boolean\n /** Whether currently engaged with a pointing target */\n isPointing: boolean\n /** Current error (null if none) */\n error: Error | null\n\n /** Start listening (called automatically by hotkey) */\n startListening: () => void\n /** Stop listening and process (called automatically by hotkey release) */\n stopListening: () => void\n /** Enable or disable the buddy */\n setEnabled: (enabled: boolean) => void\n /** Manually point at coordinates */\n pointAt: (x: number, y: number, label: string) => void\n /** Dismiss the current pointing target */\n dismissPointing: () => void\n /** Reset to idle state */\n reset: () => void\n}\n\n/**\n * Hook to access cursor buddy state and actions.\n */\nexport function useCursorBuddy(): UseCursorBuddyReturn {\n const client = useClient()\n\n const subscribe = useCallback(\n (listener: () => void) => client.subscribe(listener),\n [client],\n )\n const getSnapshot = useCallback(() => client.getSnapshot(), [client])\n\n const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)\n\n const audioLevel = useStore($audioLevel)\n\n return {\n ...snapshot,\n audioLevel,\n startListening: useCallback(() => client.startListening(), [client]),\n stopListening: useCallback(() => client.stopListening(), [client]),\n setEnabled: useCallback(\n (enabled: boolean) => client.setEnabled(enabled),\n [client],\n ),\n pointAt: useCallback(\n (x: number, y: number, label: string) => client.pointAt(x, y, label),\n [client],\n ),\n dismissPointing: useCallback(() => client.dismissPointing(), [client]),\n reset: useCallback(() => client.reset(), [client]),\n }\n}\n","\"use client\"\n\nimport { useEffect, useRef } from \"react\"\n\ninterface HotkeyModifiers {\n ctrl: boolean\n alt: boolean\n shift: boolean\n meta: boolean\n}\n\n/**\n * Parse a hotkey string like \"ctrl+alt\" into modifier flags\n */\nfunction parseHotkey(hotkey: string): HotkeyModifiers {\n const parts = hotkey.toLowerCase().split(\"+\")\n return {\n ctrl: parts.includes(\"ctrl\") || parts.includes(\"control\"),\n alt: parts.includes(\"alt\") || parts.includes(\"option\"),\n shift: parts.includes(\"shift\"),\n meta:\n parts.includes(\"meta\") ||\n parts.includes(\"cmd\") ||\n parts.includes(\"command\"),\n }\n}\n\n/**\n * Check if a keyboard event matches the required modifiers\n */\nfunction matchesHotkey(\n event: KeyboardEvent,\n modifiers: HotkeyModifiers,\n): boolean {\n return (\n event.ctrlKey === modifiers.ctrl &&\n event.altKey === modifiers.alt &&\n event.shiftKey === modifiers.shift &&\n event.metaKey === modifiers.meta\n )\n}\n\n/**\n * Hook for detecting push-to-talk hotkey press/release.\n *\n * @param hotkey - Hotkey string like \"ctrl+alt\" or \"ctrl+shift\"\n * @param onPress - Called when hotkey is pressed\n * @param onRelease - Called when hotkey is released\n * @param enabled - Whether the hotkey listener is active (default: true)\n */\nexport function useHotkey(\n hotkey: string,\n onPress: () => void,\n onRelease: () => void,\n enabled: boolean = true,\n): void {\n const isPressedRef = useRef(false)\n const modifiersRef = useRef<HotkeyModifiers>(parseHotkey(hotkey))\n\n // Use refs for callbacks to avoid stale closures in event handlers\n const onPressRef = useRef(onPress)\n const onReleaseRef = useRef(onRelease)\n onPressRef.current = onPress\n onReleaseRef.current = onRelease\n\n // Update modifiers when hotkey changes\n useEffect(() => {\n modifiersRef.current = parseHotkey(hotkey)\n }, [hotkey])\n\n useEffect(() => {\n if (!enabled) {\n // If disabled while pressed, trigger release\n if (isPressedRef.current) {\n isPressedRef.current = false\n onReleaseRef.current()\n }\n return\n }\n\n function handleKeyDown(event: KeyboardEvent) {\n if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {\n isPressedRef.current = true\n event.preventDefault()\n onPressRef.current()\n }\n }\n\n function handleKeyUp(event: KeyboardEvent) {\n // Release when any required modifier is released\n if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {\n isPressedRef.current = false\n onReleaseRef.current()\n }\n }\n\n function handleBlur() {\n // Release if window loses focus while hotkey is pressed\n if (isPressedRef.current) {\n isPressedRef.current = false\n onReleaseRef.current()\n }\n }\n\n window.addEventListener(\"keydown\", handleKeyDown)\n window.addEventListener(\"keyup\", handleKeyUp)\n window.addEventListener(\"blur\", handleBlur)\n\n return () => {\n window.removeEventListener(\"keydown\", handleKeyDown)\n window.removeEventListener(\"keyup\", handleKeyUp)\n window.removeEventListener(\"blur\", handleBlur)\n }\n }, [enabled])\n}\n","import type { CursorRenderProps } from \"../../core/types\"\n\n// -30 degrees ≈ -0.52 radians (standard cursor tilt)\nconst BASE_ROTATION = -Math.PI / 6\n\n/**\n * Default cursor component - a colored triangle pointer.\n * Color and animations change based on voice state via CSS classes.\n */\nexport function DefaultCursor({ state, rotation, scale }: CursorRenderProps) {\n const stateClass = `cursor-buddy-cursor--${state}`\n\n return (\n <svg\n width=\"32\"\n height=\"32\"\n viewBox=\"0 0 32 32\"\n className={`cursor-buddy-cursor ${stateClass}`}\n style={{\n transform: `rotate(${BASE_ROTATION + rotation}rad) scale(${scale})`,\n transformOrigin: \"16px 4px\",\n }}\n >\n <polygon points=\"16,4 28,28 16,22 4,28\" />\n </svg>\n )\n}\n","import type { SpeechBubbleRenderProps } from \"../../core/types\"\n\n/**\n * Default speech bubble component.\n * Displays pointing label or response text next to the cursor.\n */\nexport function DefaultSpeechBubble({\n text,\n isVisible,\n onClick,\n}: SpeechBubbleRenderProps) {\n if (!isVisible || !text) return null\n\n return (\n <div\n className=\"cursor-buddy-bubble\"\n onClick={onClick}\n onKeyDown={(event) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault()\n onClick?.()\n }\n }}\n role=\"button\"\n tabIndex={0}\n >\n {text}\n </div>\n )\n}\n","import { useEffect, useState } from \"react\"\nimport type { WaveformRenderProps } from \"../../core/types\"\n\nconst BAR_COUNT = 12\nconst EMPTY_BARS = Array.from({ length: BAR_COUNT }, () => 0)\n\n/**\n * Default waveform component.\n * Shows audio level visualization during recording.\n */\nexport function DefaultWaveform({\n audioLevel,\n isListening,\n}: WaveformRenderProps) {\n const [bars, setBars] = useState<number[]>(EMPTY_BARS)\n\n useEffect(() => {\n if (!isListening) {\n setBars(EMPTY_BARS)\n return\n }\n\n setBars((previousBars) => {\n const nextBars = previousBars.slice(1)\n nextBars.push(audioLevel)\n return nextBars\n })\n }, [audioLevel, isListening])\n\n if (!isListening) return null\n\n const displayBars = bars.map((level) => Math.pow(level, 0.65))\n\n return (\n <div className=\"cursor-buddy-waveform\">\n {displayBars.map((level, i) => {\n const baseHeight = 4\n const variance = 0.75 + ((i + 1) % 3) * 0.12\n const height = baseHeight + level * 20 * variance\n\n return (\n <div\n key={i}\n className=\"cursor-buddy-waveform-bar\"\n style={{ height: `${height}px` }}\n />\n )\n })}\n </div>\n )\n}\n","\"use client\"\n\nimport { useStore } from \"@nanostores/react\"\nimport { useEffect, useState } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport {\n $audioLevel,\n $buddyPosition,\n $buddyRotation,\n $buddyScale,\n $pointingTarget,\n} from \"../../core/atoms\"\nimport type {\n CursorRenderProps,\n SpeechBubbleRenderProps,\n WaveformRenderProps,\n} from \"../../core/types\"\nimport { useCursorBuddy } from \"../hooks\"\nimport { DefaultCursor } from \"./Cursor\"\nimport { DefaultSpeechBubble } from \"./SpeechBubble\"\nimport { DefaultWaveform } from \"./Waveform\"\n\nexport interface OverlayProps {\n /** Custom cursor renderer */\n cursor?: React.ReactNode | ((props: CursorRenderProps) => React.ReactNode)\n /** Custom speech bubble renderer */\n speechBubble?: (props: SpeechBubbleRenderProps) => React.ReactNode\n /** Custom waveform renderer */\n waveform?: (props: WaveformRenderProps) => React.ReactNode\n /** Container element for portal (defaults to document.body) */\n container?: HTMLElement | null\n}\n\n/**\n * Overlay component that renders the cursor, speech bubble, and waveform.\n * Uses React portal to render at the document body level.\n */\nexport function Overlay({\n cursor,\n speechBubble,\n waveform,\n container,\n}: OverlayProps) {\n // Only render after mount to avoid hydration mismatch\n const [isMounted, setIsMounted] = useState(false)\n useEffect(() => setIsMounted(true), [])\n\n const { state, isPointing, isEnabled, dismissPointing } = useCursorBuddy()\n\n const buddyPosition = useStore($buddyPosition)\n const buddyRotation = useStore($buddyRotation)\n const buddyScale = useStore($buddyScale)\n const audioLevel = useStore($audioLevel)\n const pointingTarget = useStore($pointingTarget)\n\n // Don't render on server or when disabled\n if (!isMounted || !isEnabled) return null\n\n const cursorProps: CursorRenderProps = {\n state,\n isPointing,\n rotation: buddyRotation,\n scale: buddyScale,\n }\n\n const speechBubbleProps: SpeechBubbleRenderProps = {\n text: pointingTarget?.label ?? \"\",\n isVisible: isPointing && !!pointingTarget,\n onClick: dismissPointing,\n }\n\n const waveformProps: WaveformRenderProps = {\n audioLevel,\n isListening: state === \"listening\",\n }\n\n // Render cursor element\n const cursorElement =\n typeof cursor === \"function\" ? (\n cursor(cursorProps)\n ) : cursor ? (\n cursor\n ) : (\n <DefaultCursor {...cursorProps} />\n )\n\n // Render speech bubble element\n const speechBubbleElement = speechBubble ? (\n speechBubble(speechBubbleProps)\n ) : (\n <DefaultSpeechBubble {...speechBubbleProps} />\n )\n\n // Render waveform element\n const waveformElement = waveform ? (\n waveform(waveformProps)\n ) : (\n <DefaultWaveform {...waveformProps} />\n )\n\n const overlayContent = (\n <div className=\"cursor-buddy-overlay\" data-cursor-buddy-overlay>\n <div\n className=\"cursor-buddy-container\"\n style={{\n left: buddyPosition.x,\n top: buddyPosition.y,\n }}\n >\n {cursorElement}\n {state === \"listening\" && waveformElement}\n {isPointing && speechBubbleElement}\n </div>\n </div>\n )\n\n const portalContainer =\n container ?? (typeof document !== \"undefined\" ? document.body : null)\n\n if (!portalContainer) return null\n\n return createPortal(overlayContent, portalContainer)\n}\n","\"use client\"\n\nimport type { PointingTarget, VoiceState } from \"../../core/types\"\nimport { useCursorBuddy } from \"../hooks\"\nimport { CursorBuddyProvider } from \"../provider\"\nimport { useHotkey } from \"../use-hotkey\"\nimport { Overlay, type OverlayProps } from \"./Overlay\"\n\nexport interface CursorBuddyProps\n extends Pick<OverlayProps, \"cursor\" | \"speechBubble\" | \"waveform\"> {\n /** API endpoint for cursor buddy server */\n endpoint: string\n /** Hotkey for push-to-talk (default: \"ctrl+alt\") */\n hotkey?: string\n /** Container element for portal (defaults to document.body) */\n container?: HTMLElement | null\n /** Callback when transcript is ready */\n onTranscript?: (text: string) => void\n /** Callback when AI responds */\n onResponse?: (text: string) => void\n /** Callback when pointing at element */\n onPoint?: (target: PointingTarget) => void\n /** Callback when state changes */\n onStateChange?: (state: VoiceState) => void\n /** Callback when error occurs */\n onError?: (error: Error) => void\n}\n\n/**\n * Internal component that sets up hotkey handling\n */\nfunction CursorBuddyInner({\n hotkey = \"ctrl+alt\",\n cursor,\n speechBubble,\n waveform,\n container,\n}: Pick<\n CursorBuddyProps,\n \"hotkey\" | \"cursor\" | \"speechBubble\" | \"waveform\" | \"container\"\n>) {\n const { startListening, stopListening, isEnabled } = useCursorBuddy()\n\n // Set up hotkey\n useHotkey(hotkey, startListening, stopListening, isEnabled)\n\n return (\n <Overlay\n cursor={cursor}\n speechBubble={speechBubble}\n waveform={waveform}\n container={container}\n />\n )\n}\n\n/**\n * Drop-in cursor buddy component.\n *\n * Adds an AI-powered cursor companion to your app. Users hold the hotkey\n * (default: Ctrl+Alt) to speak. The SDK captures a screenshot, transcribes\n * the speech, sends it to the AI, speaks the response, and can point at\n * elements on screen.\n *\n * @example\n * ```tsx\n * import { CursorBuddy } from \"cursor-buddy/react\"\n *\n * function App() {\n * return (\n * <>\n * <YourApp />\n * <CursorBuddy endpoint=\"/api/cursor-buddy\" />\n * </>\n * )\n * }\n * ```\n */\nexport function CursorBuddy({\n endpoint,\n hotkey,\n container,\n cursor,\n speechBubble,\n waveform,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n}: CursorBuddyProps) {\n return (\n <CursorBuddyProvider\n endpoint={endpoint}\n onTranscript={onTranscript}\n onResponse={onResponse}\n onPoint={onPoint}\n onStateChange={onStateChange}\n onError={onError}\n >\n <CursorBuddyInner\n hotkey={hotkey}\n cursor={cursor}\n speechBubble={speechBubble}\n waveform={waveform}\n container={container}\n />\n </CursorBuddyProvider>\n )\n}\n"],"mappings":";;;;;;;;;;ACKA,MAAI,WAAW;;;;;;;AASb,SAAI,eAAO;AAGX,KAAI,OAAA,aAAU,YAAA;AAGd,KAAI,SAAS;AACX,KAAA,SAAW,eAAA,SAAA,EAAA;AACX,aAAA;;;CAIF,MAAM,OAAA,SAAQ,QAAS,SAAc,qBAAQ,OAAA,CAAA;CAC7C,MAAM,QAAK,SAAA,cAAA,QAAA;AACX,OAAM,KAAA;AAGN,OAAI,cACF;KAEA,KAAA,WAAK,MAAY,aAAM,OAAA,KAAA,WAAA;KAGzB,MAAA,YAAW,MAAA;;;;;;;;;SCRJ,oBAAU,EAAA,UAET,UAAA,cAA4B,YAAA,SAAA,eAAA,WAAA;OAC9B,CAAA,UAAA,eAAA,IAAA,kBAAA,UAAA;EACA;EACA;EACA;EACA;EACD;EAIL,CAAA,CAAA;AACE,iBAAc;gBACV;IAGN,EAAA,CAAA;iBACW;EACP,SAAA,gBAAoB,OAAA;mBAAW,IAAA;IAAS,GAAG,MAAM;IAAS,GAAC,MAAA;IAC3D,CAAA;;;AAIF,SAAA,iBAAoB,aAAA,gBAAiC;eAC3C,OAAA,oBAAA,aAAA,gBAAA;IAEZ,CAAA,OACE,CAAA;QAAoC,oBAAA,mBAAA,UAAA;EACjC,OAAA;EAC2B;;;;;;;SAS1B,YAAS;CACf,MAAK,SACH,WAAU,mBAAM;AAElB,KAAA,CAAA,OAAO,OAAA,IAAA,MAAA,yDAAA;;;;;;;;SC9BD,iBAAS;CAEf,MAAM,SAAA,WAAY;CAIlB,MAAM,YAAA,aAAc,aAAkB,OAAO,UAAgB,SAAQ,EAAA,CAAA,OAAA,CAAA;CAErE,MAAM,cAAW,kBAAqB,OAAA,aAAW,EAAA,CAAA,OAAa,CAAA;CAE9D,MAAM,WAAA,qBAAsB,WAAY,aAAA,YAAA;CAExC,MAAA,aAAO,SAAA,YAAA;QACF;EACH,GAAA;EACA;EACA,gBAAe,kBAAkB,OAAO,gBAAiB,EAAC,CAAA,OAAQ,CAAA;EAClE,eAAY,kBACT,OAAqB,eAAkB,EAAA,CAAA,OACxC,CAAC;EAEH,YAAS,aACK,YAAW,OAAkB,WAAe,QAAM,EAAM,CAAA,OACnE,CAAA;EAEH,SAAA,aAAiB,GAAA,GAAA,UAAkB,OAAO,QAAA,GAAA,GAAiB,MAAG,EAAA,CAAA,OAAQ,CAAA;EACtE,iBAAO,kBAAyB,OAAU,iBAAQ,EAAA,CAAA,OAAA,CAAA;EACnD,OAAA,kBAAA,OAAA,OAAA,EAAA,CAAA,OAAA,CAAA;;;;;;;;SCtDK,YAAQ,QAAO;CACrB,MAAA,QAAO,OAAA,aAAA,CAAA,MAAA,IAAA;QACC;EACN,MAAK,MAAM,SAAS,OAAM,IAAI,MAAM,SAAS,UAAS;EACtD,KAAA,MAAO,SAAM,MAAS,IAAQ,MAAA,SAAA,SAAA;EAC9B,OACE,MAAM,SAAS,QAAO;EAGzB,MAAA,MAAA,SAAA,OAAA,IAAA,MAAA,SAAA,MAAA,IAAA,MAAA,SAAA,UAAA;;;;;;AAUD,SACE,cAAM,OAAY,WAAU;;;;;;;;;;;SAqBxB,UAAA,QAAe,SAAa,WAAA,UAAA,MAAA;CAClC,MAAM,eAAe,OAAwB,MAAA;CAG7C,MAAM,eAAa,OAAO,YAAQ,OAAA,CAAA;CAClC,MAAM,aAAA,OAAe,QAAO;CAC5B,MAAA,eAAqB,OAAA,UAAA;AACrB,YAAA,UAAa;AAGb,cAAA,UAAgB;AACd,iBAAa;eACH,UAAA,YAAA,OAAA;IAEZ,CAAA,OAAA,CAAA;AACE,iBAAc;AAEZ,MAAA,CAAI,SAAA;AACF,OAAA,aAAa,SAAU;AACvB,iBAAa,UAAS;;;;;EAMxB,SAAI,cAAc,OAAO;AACvB,OAAA,cAAa,OAAU,aAAA,QAAA,IAAA,CAAA,aAAA,SAAA;AACvB,iBAAM,UAAgB;AACtB,UAAA,gBAAoB;;;;EAMtB,SAAI,YAAa,OAAA;AACf,OAAA,aAAa,WAAU,CAAA,cAAA,OAAA,aAAA,QAAA,EAAA;AACvB,iBAAa,UAAS;;;;EAMxB,SAAI,aAAa;AACf,OAAA,aAAa,SAAU;AACvB,iBAAa,UAAS;;;;AAK1B,SAAO,iBAAiB,WAAS,cAAY;AAC7C,SAAO,iBAAiB,SAAQ,YAAW;AAE3C,SAAA,iBAAa,QAAA,WAAA;AACX,eAAO;AACP,UAAO,oBAAoB,WAAS,cAAY;AAChD,UAAO,oBAAoB,SAAQ,YAAW;;;;;;;;;;;;ACnGlD,SACE,cAAA,EAAA,OAAC,UAAD,SAAA;QACQ,oBAAA,OAAA;EACN,OAAA;EACA,QAAA;EACA,SAAA;EACA,WAAO,uBAAA,wBAAA;SACL;GACA,WAAA,UAAiB,gBAAA,SAAA,aAAA,MAAA;GAClB,iBAAA;;EAGG,UAAA,oBAAA,WAAA,EAAA,QAAA,yBAAA,CAAA;;;;;;;;;ACbR,SAAK,oBAAoB,EAAA,MAAO,WAAA,WAAA;AAEhC,KAAA,CAAA,aACE,CAAA,KAAA,QAAC;QACW,oBAAA,OAAA;EACD,WAAA;EACT;EACE,YAAU,UAAQ;AAChB,OAAA,MAAM,QAAA,WAAgB,MAAA,QAAA,KAAA;AACtB,UAAA,gBAAW;;;;EAIf,MAAA;YAEC;EACG,UAAA;;;;;;;;;;SCbD,gBAAiB,EAAA,YAAmB,eAAW;CAEtD,MAAA,CAAA,MAAA,WAAgB,SAAA,WAAA;AACd,iBAAK;AACH,MAAA,CAAA,aAAQ;AACR,WAAA,WAAA;;;WAIM,iBAAW;GACjB,MAAA,WAAc,aAAW,MAAA,EAAA;AACzB,YAAO,KAAA,WAAA;UACP;IACD;IAEH,CAAI,YAAC,YAAoB,CAAA;AAIzB,KAAA,CAAA,YACE,QAAA;QAAe,oBAAA,OAAA;aAHG;YAKR,KAAA,KAAa,UAAA,KAAA,IAAA,OAAA,IAAA,CAAA,CAAA,KAAA,OAAA,MAAA;GACnB,MAAM,aAAW;GAGjB,MAAA,WACE,OAAA,IAAC,KAAA,IAAD;UAEY,oBAAA,OAAA;IACV,WAAS;IACT,OAAA,EAAA,QAAA,GAAA,aAAA,QAAA,KAAA,SAAA,KAAA;IAEJ,EAAA,EAAA;IACE;;;;;;;;;SCJD,QAAA,EAAW,QAAA,cAAgB,UAAe,aAAA;CACjD,MAAA,CAAA,WAAgB,gBAAkB,SAAK,MAAA;AAEvC,iBAAe,aAAY,KAAA,EAAA,EAAW,CAAA;CAEtC,MAAM,EAAA,OAAA,YAAgB,WAAS,oBAAe,gBAAA;CAC9C,MAAM,gBAAgB,SAAS,eAAe;CAC9C,MAAM,gBAAa,SAAS,eAAY;CACxC,MAAM,aAAa,SAAS,YAAY;CACxC,MAAM,aAAA,SAAiB,YAAS;CAGhC,MAAK,iBAAc,SAAW,gBAAO;AAErC,KAAA,CAAA,aAAM,CAAA,UAAiC,QAAA;OACrC,cAAA;EACA;EACA;EACA,UAAO;EACR,OAAA;EAED;OACE,oBAAsB;EACtB,MAAA,gBAAW,SAAgB;EAC3B,WAAS,cAAA,CAAA,CAAA;EACV,SAAA;EAED;OACE,gBAAA;EACA;EACD,aAAA,UAAA;EAGD;CAUA,MAAM,gBAAA,OAAsB,WAAA,aAC1B,OAAa,YAAA,GAAkB,SAE/B,SAAC,oBAAA,eAAwB,EAAA,GAAA,aAAqB,CAAA;CAIhD,MAAM,sBAAkB,eACtB,aAAS,kBAET,GAAC,oBAAA,qBAAqC,EAAA,GAAA,mBAAA,CAAA;CAGxC,MAAM,kBACJ,WAAA,SAAC,cAAD,GAAA,oBAAA,iBAAA,EAAA,GAAA,eAAA,CAAA;OAAK,iBAAU,oBAAA,OAAA;EAAuB,WAAA;+BACpC;YACY,qBAAA,OAAA;GACV,WAAO;UACC;IACN,MAAK,cAAc;IACpB,KAAA,cAAA;;aAEA;IACA;IACA,UAAA,eAAc;IACX,cAAA;;GACF,CAAA;EAGR,CAAA;CAGA,MAAK,kBAAiB,cAAO,OAAA,aAAA,cAAA,SAAA,OAAA;AAE7B,KAAA,CAAA,gBAAoB,QAAA;;;;;;;;SChFZ,iBAAgB,EAAA,SAAA,YAAe,QAAc,cAAgB,UAAA,aAAA;CAGrE,MAAA,EAAA,gBAAkB,eAAgB,cAAe,gBAAU;AAE3D,WACE,QAAA,gBAAC,eAAD,UAAA;QACU,oBAAA,SAAA;EACM;EACJ;EACC;EACX;;;;;;;;;;;;;;;;;;;;;;;;;AAuCJ,SACE,YAAA,EAAA,UAAC,QAAA,WAAD,QAAA,cAAA,UAAA,cAAA,YAAA,SAAA,eAAA,WAAA;QACY,oBAAA,qBAAA;EACI;EACF;EACH;EACM;EACN;;YAGC,oBAAA,kBAAA;GACA;GACM;GACJ;GACC;GACX;GACkB,CAAA"}
@@ -1,4 +1,4 @@
1
- import { t as CursorBuddyHandler } from "../../types-B2GUdTzP.mjs";
1
+ import { t as CursorBuddyHandler } from "../../types-L97cq8UK.mjs";
2
2
 
3
3
  //#region src/server/adapters/next.d.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { n as CursorBuddyHandlerConfig, t as CursorBuddyHandler } from "../types-B2GUdTzP.mjs";
1
+ import { n as CursorBuddyHandlerConfig, t as CursorBuddyHandler } from "../types-L97cq8UK.mjs";
2
2
 
3
3
  //#region src/server/handler.d.ts
4
4
  /**
@@ -28,7 +28,7 @@ declare function createCursorBuddyHandler(config: CursorBuddyHandlerConfig): Cur
28
28
  * Default system prompt for the cursor buddy AI.
29
29
  * Instructs the model on how to respond conversationally and use POINT tags.
30
30
  */
31
- declare const DEFAULT_SYSTEM_PROMPT = "You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally \u2014 your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response:\n\n[POINT:x,y:label]\n\nWhere:\n- x,y are coordinates in screenshot image pixels (top-left origin)\n- label is a brief description shown in a speech bubble\n\nExample: \"The submit button is right here. [POINT:450,320:Submit button]\"\n\nGuidelines:\n- Only point when it genuinely helps (showing a specific button, field, or element)\n- Use natural descriptions (\"this button\", \"over here\", \"right there\")\n- If the screenshot image size is provided in text, always point in that screenshot image pixel space.\n- Coordinates should be the CENTER of the element you're pointing at\n- Keep labels short (2-4 words)\n\n## Response Style\n\n- Be concise \u2014 aim for 1-3 sentences\n- Sound natural when spoken aloud\n- Avoid technical jargon unless the user is technical\n- If you can't see something clearly, say so\n- Never mention that you're looking at a \"screenshot\" \u2014 say \"I can see...\" or \"Looking at your screen...\"\n";
31
+ declare const DEFAULT_SYSTEM_PROMPT = "You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally \u2014 your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response.\n\n### Interactive Elements (Preferred)\nThe screenshot has numbered markers on interactive elements (buttons, links, inputs, etc.). Use the marker number to point at these:\n\n[POINT:marker_number:label]\n\nExample: \"Click this button right here. [POINT:5:Submit]\"\n\nThis is the most accurate pointing method \u2014 always prefer it when pointing at interactive elements.\n\n### Anywhere Else (Fallback)\nFor non-interactive content (text, images, areas without markers), use pixel coordinates:\n\n[POINT:x,y:label]\n\nWhere x,y are coordinates in screenshot image pixels (top-left origin).\n\nExample: \"The error message is shown here. [POINT:450,320:Error text]\"\n\n### Guidelines\n- Prefer marker-based pointing when the element has a visible number\n- Only use coordinates when pointing at unmarked content\n- Only point when it genuinely helps\n- Use natural descriptions (\"this button\", \"over here\", \"right there\")\n- Coordinates should be the CENTER of the element you're pointing at\n- Keep labels short (2-4 words)\n\n## Response Style\n\n- Be concise \u2014 aim for 1-3 sentences\n- Sound natural when spoken aloud\n- Avoid technical jargon unless the user is technical\n- If you can't see something clearly, say so\n- Never mention that you're looking at a \"screenshot\" \u2014 say \"I can see...\" or \"Looking at your screen...\"\n";
32
32
  //#endregion
33
33
  export { type CursorBuddyHandler, type CursorBuddyHandlerConfig, DEFAULT_SYSTEM_PROMPT, createCursorBuddyHandler };
34
34
  //# sourceMappingURL=index.d.mts.map
@@ -10,20 +10,31 @@ You can see screenshots of the user's viewport and hear their voice. Respond con
10
10
 
11
11
  ## Pointing at Elements
12
12
 
13
- When you want to direct the user's attention to something on screen, add a pointing tag at the END of your response:
13
+ When you want to direct the user's attention to something on screen, add a pointing tag at the END of your response.
14
+
15
+ ### Interactive Elements (Preferred)
16
+ The screenshot has numbered markers on interactive elements (buttons, links, inputs, etc.). Use the marker number to point at these:
17
+
18
+ [POINT:marker_number:label]
19
+
20
+ Example: "Click this button right here. [POINT:5:Submit]"
21
+
22
+ This is the most accurate pointing method — always prefer it when pointing at interactive elements.
23
+
24
+ ### Anywhere Else (Fallback)
25
+ For non-interactive content (text, images, areas without markers), use pixel coordinates:
14
26
 
15
27
  [POINT:x,y:label]
16
28
 
17
- Where:
18
- - x,y are coordinates in screenshot image pixels (top-left origin)
19
- - label is a brief description shown in a speech bubble
29
+ Where x,y are coordinates in screenshot image pixels (top-left origin).
20
30
 
21
- Example: "The submit button is right here. [POINT:450,320:Submit button]"
31
+ Example: "The error message is shown here. [POINT:450,320:Error text]"
22
32
 
23
- Guidelines:
24
- - Only point when it genuinely helps (showing a specific button, field, or element)
33
+ ### Guidelines
34
+ - Prefer marker-based pointing when the element has a visible number
35
+ - Only use coordinates when pointing at unmarked content
36
+ - Only point when it genuinely helps
25
37
  - Use natural descriptions ("this button", "over here", "right there")
26
- - If the screenshot image size is provided in text, always point in that screenshot image pixel space.
27
38
  - Coordinates should be the CENTER of the element you're pointing at
28
39
  - Keep labels short (2-4 words)
29
40
 
@@ -41,12 +52,14 @@ Guidelines:
41
52
  * Handle chat requests: screenshot + transcript → AI SSE stream
42
53
  */
43
54
  async function handleChat(request, config) {
44
- const { screenshot, transcript, history, capture } = await request.json();
55
+ const { screenshot, transcript, history, capture, markerContext } = await request.json();
45
56
  const systemPrompt = typeof config.system === "function" ? config.system({ defaultPrompt: DEFAULT_SYSTEM_PROMPT }) : config.system ?? DEFAULT_SYSTEM_PROMPT;
46
57
  const maxMessages = (config.maxHistory ?? 10) * 2;
47
58
  const trimmedHistory = history.slice(-maxMessages);
48
- const captureContext = capture ? `The screenshot image size is ${capture.width}x${capture.height} pixels.
49
- If you include a [POINT:x,y:label] tag, x and y MUST use that screenshot image pixel space.` : null;
59
+ const captureContextParts = [];
60
+ if (capture) captureContextParts.push(`Screenshot size: ${capture.width}x${capture.height} pixels.`);
61
+ if (markerContext) captureContextParts.push("", markerContext);
62
+ const captureContext = captureContextParts.length > 0 ? captureContextParts.join("\n") : null;
50
63
  const messages = [...trimmedHistory.map((msg) => ({
51
64
  role: msg.role,
52
65
  content: msg.content
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["transcribe","generateSpeech"],"sources":["../../src/server/system-prompt.ts","../../src/server/routes/chat.ts","../../src/server/routes/transcribe.ts","../../src/server/routes/tts.ts","../../src/server/handler.ts"],"sourcesContent":["/**\n * Default system prompt for the cursor buddy AI.\n * Instructs the model on how to respond conversationally and use POINT tags.\n */\nexport const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally — your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response:\n\n[POINT:x,y:label]\n\nWhere:\n- x,y are coordinates in screenshot image pixels (top-left origin)\n- label is a brief description shown in a speech bubble\n\nExample: \"The submit button is right here. [POINT:450,320:Submit button]\"\n\nGuidelines:\n- Only point when it genuinely helps (showing a specific button, field, or element)\n- Use natural descriptions (\"this button\", \"over here\", \"right there\")\n- If the screenshot image size is provided in text, always point in that screenshot image pixel space.\n- Coordinates should be the CENTER of the element you're pointing at\n- Keep labels short (2-4 words)\n\n## Response Style\n\n- Be concise — aim for 1-3 sentences\n- Sound natural when spoken aloud\n- Avoid technical jargon unless the user is technical\n- If you can't see something clearly, say so\n- Never mention that you're looking at a \"screenshot\" — say \"I can see...\" or \"Looking at your screen...\"\n`\n","import { streamText } from \"ai\"\nimport type { CursorBuddyHandlerConfig, ChatRequestBody } from \"../types\"\nimport { DEFAULT_SYSTEM_PROMPT } from \"../system-prompt\"\n\n/**\n * Handle chat requests: screenshot + transcript → AI SSE stream\n */\nexport async function handleChat(\n request: Request,\n config: CursorBuddyHandlerConfig\n): Promise<Response> {\n const body = (await request.json()) as ChatRequestBody\n const { screenshot, transcript, history, capture } = body\n\n // Resolve system prompt (string or function)\n const systemPrompt =\n typeof config.system === \"function\"\n ? config.system({ defaultPrompt: DEFAULT_SYSTEM_PROMPT })\n : config.system ?? DEFAULT_SYSTEM_PROMPT\n\n // Trim history to maxHistory (default 10 exchanges = 20 messages)\n const maxMessages = (config.maxHistory ?? 10) * 2\n const trimmedHistory = history.slice(-maxMessages)\n\n const captureContext = capture\n ? `The screenshot image size is ${capture.width}x${capture.height} pixels.\nIf you include a [POINT:x,y:label] tag, x and y MUST use that screenshot image pixel space.`\n : null\n\n // Build messages array with vision content\n const messages = [\n ...trimmedHistory.map((msg) => ({\n role: msg.role as \"user\" | \"assistant\",\n content: msg.content,\n })),\n {\n role: \"user\" as const,\n content: [\n ...(captureContext\n ? [\n {\n type: \"text\" as const,\n text: captureContext,\n },\n ]\n : []),\n {\n type: \"image\" as const,\n image: screenshot,\n },\n {\n type: \"text\" as const,\n text: transcript,\n },\n ],\n },\n ]\n\n const result = streamText({\n model: config.model,\n system: systemPrompt,\n messages,\n tools: config.tools,\n })\n\n return result.toTextStreamResponse()\n}\n","import { experimental_transcribe as transcribe } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TranscribeResponse } from \"../types\"\n\n/**\n * Handle transcription requests: audio file → text\n */\nexport async function handleTranscribe(\n request: Request,\n config: CursorBuddyHandlerConfig\n): Promise<Response> {\n const formData = await request.formData()\n const audioFile = formData.get(\"audio\")\n\n if (!audioFile || !(audioFile instanceof File)) {\n return new Response(JSON.stringify({ error: \"No audio file provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const audioBuffer = await audioFile.arrayBuffer()\n\n const result = await transcribe({\n model: config.transcriptionModel,\n audio: new Uint8Array(audioBuffer),\n })\n\n const response: TranscribeResponse = { text: result.text }\n\n return new Response(JSON.stringify(response), {\n headers: { \"Content-Type\": \"application/json\" },\n })\n}\n","import { experimental_generateSpeech as generateSpeech } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TTSRequestBody } from \"../types\"\n\n/**\n * Handle TTS requests: text → audio\n */\nexport async function handleTTS(\n request: Request,\n config: CursorBuddyHandlerConfig\n): Promise<Response> {\n const body = (await request.json()) as TTSRequestBody\n const { text } = body\n\n if (!text) {\n return new Response(JSON.stringify({ error: \"No text provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const result = await generateSpeech({\n model: config.speechModel,\n text,\n })\n\n // Create a new ArrayBuffer copy to satisfy TypeScript's strict typing\n const audioData = new Uint8Array(result.audio.uint8Array)\n\n return new Response(audioData, {\n headers: {\n \"Content-Type\": \"audio/mpeg\",\n },\n })\n}\n","import type { CursorBuddyHandlerConfig, CursorBuddyHandler } from \"./types\"\nimport { handleChat } from \"./routes/chat\"\nimport { handleTranscribe } from \"./routes/transcribe\"\nimport { handleTTS } from \"./routes/tts\"\n\n/**\n * Create a cursor buddy request handler.\n *\n * The handler responds to three routes based on the last path segment:\n * - /chat - Screenshot + transcript → AI SSE stream\n * - /transcribe - Audio → text\n * - /tts - Text → audio\n *\n * @example\n * ```ts\n * import { createCursorBuddyHandler } from \"cursor-buddy/server\"\n * import { openai } from \"@ai-sdk/openai\"\n *\n * const cursorBuddy = createCursorBuddyHandler({\n * model: openai(\"gpt-4o\"),\n * speechModel: openai.speech(\"tts-1\"),\n * transcriptionModel: openai.transcription(\"whisper-1\"),\n * })\n * ```\n */\nexport function createCursorBuddyHandler(\n config: CursorBuddyHandlerConfig\n): CursorBuddyHandler {\n const handler = async (request: Request): Promise<Response> => {\n const url = new URL(request.url)\n const pathSegments = url.pathname.split(\"/\").filter(Boolean)\n const route = pathSegments[pathSegments.length - 1]\n\n switch (route) {\n case \"chat\":\n return handleChat(request, config)\n\n case \"transcribe\":\n return handleTranscribe(request, config)\n\n case \"tts\":\n return handleTTS(request, config)\n\n default:\n return new Response(\n JSON.stringify({\n error: \"Not found\",\n availableRoutes: [\"/chat\", \"/transcribe\", \"/tts\"],\n }),\n {\n status: 404,\n headers: { \"Content-Type\": \"application/json\" },\n }\n )\n }\n }\n\n return { handler, config }\n}\n"],"mappings":";;;;;;AAIA,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACGrC,eAAsB,WACpB,SACA,QACmB;CAEnB,MAAM,EAAE,YAAY,YAAY,SAAS,YAD3B,MAAM,QAAQ,MAAM;CAIlC,MAAM,eACJ,OAAO,OAAO,WAAW,aACrB,OAAO,OAAO,EAAE,eAAe,uBAAuB,CAAC,GACvD,OAAO,UAAU;CAGvB,MAAM,eAAe,OAAO,cAAc,MAAM;CAChD,MAAM,iBAAiB,QAAQ,MAAM,CAAC,YAAY;CAElD,MAAM,iBAAiB,UACnB,gCAAgC,QAAQ,MAAM,GAAG,QAAQ,OAAO;+FAEhE;CAGJ,MAAM,WAAW,CACf,GAAG,eAAe,KAAK,SAAS;EAC9B,MAAM,IAAI;EACV,SAAS,IAAI;EACd,EAAE,EACH;EACE,MAAM;EACN,SAAS;GACP,GAAI,iBACA,CACE;IACE,MAAM;IACN,MAAM;IACP,CACF,GACD,EAAE;GACN;IACE,MAAM;IACN,OAAO;IACR;GACD;IACE,MAAM;IACN,MAAM;IACP;GACF;EACF,CACF;AASD,QAPe,WAAW;EACxB,OAAO,OAAO;EACd,QAAQ;EACR;EACA,OAAO,OAAO;EACf,CAAC,CAEY,sBAAsB;;;;;;;AC3DtC,eAAsB,iBACpB,SACA,QACmB;CAEnB,MAAM,aADW,MAAM,QAAQ,UAAU,EACd,IAAI,QAAQ;AAEvC,KAAI,CAAC,aAAa,EAAE,qBAAqB,MACvC,QAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,EAAE;EACvE,QAAQ;EACR,SAAS,EAAE,gBAAgB,oBAAoB;EAChD,CAAC;CAGJ,MAAM,cAAc,MAAM,UAAU,aAAa;CAOjD,MAAM,WAA+B,EAAE,OALxB,MAAMA,wBAAW;EAC9B,OAAO,OAAO;EACd,OAAO,IAAI,WAAW,YAAY;EACnC,CAAC,EAEkD,MAAM;AAE1D,QAAO,IAAI,SAAS,KAAK,UAAU,SAAS,EAAE,EAC5C,SAAS,EAAE,gBAAgB,oBAAoB,EAChD,CAAC;;;;;;;ACzBJ,eAAsB,UACpB,SACA,QACmB;CAEnB,MAAM,EAAE,SADM,MAAM,QAAQ,MAAM;AAGlC,KAAI,CAAC,KACH,QAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,oBAAoB,CAAC,EAAE;EACjE,QAAQ;EACR,SAAS,EAAE,gBAAgB,oBAAoB;EAChD,CAAC;CAGJ,MAAM,SAAS,MAAMC,4BAAe;EAClC,OAAO,OAAO;EACd;EACD,CAAC;CAGF,MAAM,YAAY,IAAI,WAAW,OAAO,MAAM,WAAW;AAEzD,QAAO,IAAI,SAAS,WAAW,EAC7B,SAAS,EACP,gBAAgB,cACjB,EACF,CAAC;;;;;;;;;;;;;;;;;;;;;;;;ACPJ,SAAgB,yBACd,QACoB;CACpB,MAAM,UAAU,OAAO,YAAwC;EAE7D,MAAM,eADM,IAAI,IAAI,QAAQ,IAAI,CACP,SAAS,MAAM,IAAI,CAAC,OAAO,QAAQ;AAG5D,UAFc,aAAa,aAAa,SAAS,IAEjD;GACE,KAAK,OACH,QAAO,WAAW,SAAS,OAAO;GAEpC,KAAK,aACH,QAAO,iBAAiB,SAAS,OAAO;GAE1C,KAAK,MACH,QAAO,UAAU,SAAS,OAAO;GAEnC,QACE,QAAO,IAAI,SACT,KAAK,UAAU;IACb,OAAO;IACP,iBAAiB;KAAC;KAAS;KAAe;KAAO;IAClD,CAAC,EACF;IACE,QAAQ;IACR,SAAS,EAAE,gBAAgB,oBAAoB;IAChD,CACF;;;AAIP,QAAO;EAAE;EAAS;EAAQ"}
1
+ {"version":3,"file":"index.mjs","names":["transcribe","generateSpeech"],"sources":["../../src/server/system-prompt.ts","../../src/server/routes/chat.ts","../../src/server/routes/transcribe.ts","../../src/server/routes/tts.ts","../../src/server/handler.ts"],"sourcesContent":["/**\n * Default system prompt for the cursor buddy AI.\n * Instructs the model on how to respond conversationally and use POINT tags.\n */\nexport const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally — your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response.\n\n### Interactive Elements (Preferred)\nThe screenshot has numbered markers on interactive elements (buttons, links, inputs, etc.). Use the marker number to point at these:\n\n[POINT:marker_number:label]\n\nExample: \"Click this button right here. [POINT:5:Submit]\"\n\nThis is the most accurate pointing method — always prefer it when pointing at interactive elements.\n\n### Anywhere Else (Fallback)\nFor non-interactive content (text, images, areas without markers), use pixel coordinates:\n\n[POINT:x,y:label]\n\nWhere x,y are coordinates in screenshot image pixels (top-left origin).\n\nExample: \"The error message is shown here. [POINT:450,320:Error text]\"\n\n### Guidelines\n- Prefer marker-based pointing when the element has a visible number\n- Only use coordinates when pointing at unmarked content\n- Only point when it genuinely helps\n- Use natural descriptions (\"this button\", \"over here\", \"right there\")\n- Coordinates should be the CENTER of the element you're pointing at\n- Keep labels short (2-4 words)\n\n## Response Style\n\n- Be concise — aim for 1-3 sentences\n- Sound natural when spoken aloud\n- Avoid technical jargon unless the user is technical\n- If you can't see something clearly, say so\n- Never mention that you're looking at a \"screenshot\" — say \"I can see...\" or \"Looking at your screen...\"\n`\n","import { streamText } from \"ai\"\nimport { DEFAULT_SYSTEM_PROMPT } from \"../system-prompt\"\nimport type { ChatRequestBody, CursorBuddyHandlerConfig } from \"../types\"\n\n/**\n * Handle chat requests: screenshot + transcript → AI SSE stream\n */\nexport async function handleChat(\n request: Request,\n config: CursorBuddyHandlerConfig,\n): Promise<Response> {\n const body = (await request.json()) as ChatRequestBody\n const { screenshot, transcript, history, capture, markerContext } = body\n\n // Resolve system prompt (string or function)\n const systemPrompt =\n typeof config.system === \"function\"\n ? config.system({ defaultPrompt: DEFAULT_SYSTEM_PROMPT })\n : (config.system ?? DEFAULT_SYSTEM_PROMPT)\n\n // Trim history to maxHistory (default 10 exchanges = 20 messages)\n const maxMessages = (config.maxHistory ?? 10) * 2\n const trimmedHistory = history.slice(-maxMessages)\n\n // Build capture context with marker information\n const captureContextParts: string[] = []\n\n if (capture) {\n captureContextParts.push(\n `Screenshot size: ${capture.width}x${capture.height} pixels.`,\n )\n }\n\n if (markerContext) {\n captureContextParts.push(\"\", markerContext)\n }\n\n const captureContext =\n captureContextParts.length > 0 ? captureContextParts.join(\"\\n\") : null\n\n // Build messages array with vision content\n const messages = [\n ...trimmedHistory.map((msg) => ({\n role: msg.role as \"user\" | \"assistant\",\n content: msg.content,\n })),\n {\n role: \"user\" as const,\n content: [\n ...(captureContext\n ? [\n {\n type: \"text\" as const,\n text: captureContext,\n },\n ]\n : []),\n {\n type: \"image\" as const,\n image: screenshot,\n },\n {\n type: \"text\" as const,\n text: transcript,\n },\n ],\n },\n ]\n\n const result = streamText({\n model: config.model,\n system: systemPrompt,\n messages,\n tools: config.tools,\n })\n\n return result.toTextStreamResponse()\n}\n","import { experimental_transcribe as transcribe } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TranscribeResponse } from \"../types\"\n\n/**\n * Handle transcription requests: audio file → text\n */\nexport async function handleTranscribe(\n request: Request,\n config: CursorBuddyHandlerConfig,\n): Promise<Response> {\n const formData = await request.formData()\n const audioFile = formData.get(\"audio\")\n\n if (!audioFile || !(audioFile instanceof File)) {\n return new Response(JSON.stringify({ error: \"No audio file provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const audioBuffer = await audioFile.arrayBuffer()\n\n const result = await transcribe({\n model: config.transcriptionModel,\n audio: new Uint8Array(audioBuffer),\n })\n\n const response: TranscribeResponse = { text: result.text }\n\n return new Response(JSON.stringify(response), {\n headers: { \"Content-Type\": \"application/json\" },\n })\n}\n","import { experimental_generateSpeech as generateSpeech } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TTSRequestBody } from \"../types\"\n\n/**\n * Handle TTS requests: text → audio\n */\nexport async function handleTTS(\n request: Request,\n config: CursorBuddyHandlerConfig,\n): Promise<Response> {\n const body = (await request.json()) as TTSRequestBody\n const { text } = body\n\n if (!text) {\n return new Response(JSON.stringify({ error: \"No text provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const result = await generateSpeech({\n model: config.speechModel,\n text,\n })\n\n // Create a new ArrayBuffer copy to satisfy TypeScript's strict typing\n const audioData = new Uint8Array(result.audio.uint8Array)\n\n return new Response(audioData, {\n headers: {\n \"Content-Type\": \"audio/mpeg\",\n },\n })\n}\n","import { handleChat } from \"./routes/chat\"\nimport { handleTranscribe } from \"./routes/transcribe\"\nimport { handleTTS } from \"./routes/tts\"\nimport type { CursorBuddyHandler, CursorBuddyHandlerConfig } from \"./types\"\n\n/**\n * Create a cursor buddy request handler.\n *\n * The handler responds to three routes based on the last path segment:\n * - /chat - Screenshot + transcript → AI SSE stream\n * - /transcribe - Audio → text\n * - /tts - Text → audio\n *\n * @example\n * ```ts\n * import { createCursorBuddyHandler } from \"cursor-buddy/server\"\n * import { openai } from \"@ai-sdk/openai\"\n *\n * const cursorBuddy = createCursorBuddyHandler({\n * model: openai(\"gpt-4o\"),\n * speechModel: openai.speech(\"tts-1\"),\n * transcriptionModel: openai.transcription(\"whisper-1\"),\n * })\n * ```\n */\nexport function createCursorBuddyHandler(\n config: CursorBuddyHandlerConfig,\n): CursorBuddyHandler {\n const handler = async (request: Request): Promise<Response> => {\n const url = new URL(request.url)\n const pathSegments = url.pathname.split(\"/\").filter(Boolean)\n const route = pathSegments[pathSegments.length - 1]\n\n switch (route) {\n case \"chat\":\n return handleChat(request, config)\n\n case \"transcribe\":\n return handleTranscribe(request, config)\n\n case \"tts\":\n return handleTTS(request, config)\n\n default:\n return new Response(\n JSON.stringify({\n error: \"Not found\",\n availableRoutes: [\"/chat\", \"/transcribe\", \"/tts\"],\n }),\n {\n status: 404,\n headers: { \"Content-Type\": \"application/json\" },\n },\n )\n }\n }\n\n return { handler, config }\n}\n"],"mappings":"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"}
@@ -34,4 +34,4 @@ interface CursorBuddyHandler {
34
34
  }
35
35
  //#endregion
36
36
  export { CursorBuddyHandlerConfig as n, CursorBuddyHandler as t };
37
- //# sourceMappingURL=types-B2GUdTzP.d.mts.map
37
+ //# sourceMappingURL=types-L97cq8UK.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types-B2GUdTzP.d.mts","names":[],"sources":["../src/server/types.ts"],"mappings":";;;;;AAKA;UAAiB,wBAAA;;EAEf,KAAA,EAAO,aAAA;EAGM;EAAb,WAAA,EAAa,WAAA;EAYU;EATvB,kBAAA,EAAoB,kBAAA;EASN;;;;EAHd,MAAA,cAAoB,GAAA;IAAO,aAAA;EAAA;EANP;EASpB,KAAA,GAAQ,MAAA,SAAe,IAAA;EAHI;EAM3B,UAAA;AAAA;;;;UAMe,kBAAA;EANL;EAQV,OAAA,GAAU,OAAA,EAAS,OAAA,KAAY,OAAA,CAAQ,QAAA;EAFN;EAKjC,MAAA,EAAQ,wBAAA;AAAA"}
1
+ {"version":3,"file":"types-L97cq8UK.d.mts","names":[],"sources":["../src/server/types.ts"],"mappings":";;;;;AAKA;UAAiB,wBAAA;;EAEf,KAAA,EAAO,aAAA;EAGM;EAAb,WAAA,EAAa,WAAA;EAYU;EATvB,kBAAA,EAAoB,kBAAA;EASN;;;;EAHd,MAAA,cAAoB,GAAA;IAAO,aAAA;EAAA;EANP;EASpB,KAAA,GAAQ,MAAA,SAAe,IAAA;EAHI;EAM3B,UAAA;AAAA;;;;UAMe,kBAAA;EANL;EAQV,OAAA,GAAU,OAAA,EAAS,OAAA,KAAY,OAAA,CAAQ,QAAA;EAFN;EAKjC,MAAA,EAAQ,wBAAA;AAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-buddy",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "AI-powered cursor companion for web apps",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,8 @@
25
25
  "scripts": {
26
26
  "build": "tsdown",
27
27
  "dev": "tsdown --watch",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
28
30
  "typecheck": "tsc --noEmit",
29
31
  "prepack": "pnpm build",
30
32
  "release:check": "pnpm typecheck && npm pack --dry-run",
@@ -47,9 +49,9 @@
47
49
  "types": "./dist/index.d.mts",
48
50
  "default": "./dist/index.mjs"
49
51
  },
50
- "./client": {
51
- "types": "./dist/client/index.d.mts",
52
- "default": "./dist/client/index.mjs"
52
+ "./react": {
53
+ "types": "./dist/react/index.d.mts",
54
+ "default": "./dist/react/index.mjs"
53
55
  },
54
56
  "./server": {
55
57
  "types": "./dist/server/index.d.mts",
@@ -62,8 +64,8 @@
62
64
  },
63
65
  "typesVersions": {
64
66
  "*": {
65
- "client": [
66
- "./dist/client/index.d.mts"
67
+ "react": [
68
+ "./dist/react/index.d.mts"
67
69
  ],
68
70
  "server": [
69
71
  "./dist/server/index.d.mts"
@@ -75,11 +77,9 @@
75
77
  },
76
78
  "dependencies": {
77
79
  "@nanostores/react": "^1.1.0",
78
- "@xstate/react": "^6.1.0",
79
80
  "ai": "^6.0.158",
80
81
  "html2canvas-pro": "^2.0.2",
81
- "nanostores": "^1.2.0",
82
- "xstate": "^5.19.2"
82
+ "nanostores": "^1.2.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/react": "^19.0.8",
@@ -87,7 +87,8 @@
87
87
  "react": "^19.0.0",
88
88
  "react-dom": "^19.0.0",
89
89
  "tsdown": "^0.21.7",
90
- "typescript": "^5.7.3"
90
+ "typescript": "^5.7.3",
91
+ "vitest": "^3.2.4"
91
92
  },
92
93
  "peerDependencies": {
93
94
  "react": "^18.0.0 || ^19.0.0",