cursor-buddy 0.0.4 → 0.0.5

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.
@@ -5,7 +5,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, us
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  import { createPortal } from "react-dom";
7
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";
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 Positioned at the $buddyPosition coordinates (cursor center).\n CSS transform shifts the cursor to the right of the mouse pointer\n to avoid overlapping with the system cursor.\n Customize this offset via CSS to change cursor positioning.\n*/\n.cursor-buddy-container {\n position: absolute;\n transform: translate(8px, 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/* Processing spinner */\n.cursor-buddy-cursor__spinner {\n color: var(--cursor-buddy-color-processing);\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: 24px;\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: 24px;\n top: 2px;\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
9
  //#endregion
10
10
  //#region src/react/utils/inject-styles.ts
11
11
  const STYLE_ID = "cursor-buddy-styles";
@@ -97,98 +97,309 @@ function useCursorBuddy() {
97
97
  };
98
98
  }
99
99
  //#endregion
100
- //#region src/react/use-hotkey.ts
100
+ //#region src/core/hotkeys/parser.ts
101
+ /**
102
+ * Modifier aliases mapping to canonical names.
103
+ */
104
+ const MODIFIER_ALIASES = {
105
+ ctrl: "ctrl",
106
+ control: "ctrl",
107
+ alt: "alt",
108
+ option: "alt",
109
+ shift: "shift",
110
+ meta: "meta",
111
+ cmd: "meta",
112
+ command: "meta"
113
+ };
114
+ new Set(Object.keys(MODIFIER_ALIASES));
101
115
  /**
102
- * Parse a hotkey string like "ctrl+alt" into modifier flags
116
+ * Normalize a key name to lowercase for consistent comparison.
117
+ */
118
+ function normalizeKey(key) {
119
+ const lower = key.toLowerCase();
120
+ if (lower === "esc") return "escape";
121
+ if (lower === "del") return "delete";
122
+ if (lower === "space") return " ";
123
+ if (lower === "spacebar") return " ";
124
+ return lower;
125
+ }
126
+ /**
127
+ * Parse a hotkey string into a ParsedHotkey object.
128
+ *
129
+ * Supports:
130
+ * - Modifier-only: "ctrl+alt", "cmd", "shift"
131
+ * - Modifier+key: "ctrl+k", "cmd+shift+a", "alt+f4"
132
+ * - Key-only: "escape", "f1", "a"
133
+ *
134
+ * @param hotkey - The hotkey string to parse (e.g., 'ctrl+k', 'cmd+shift', 'escape')
135
+ * @returns A ParsedHotkey object
136
+ *
137
+ * @example
138
+ * ```ts
139
+ * parseHotkey('ctrl+k')
140
+ * // { key: 'k', ctrl: true, shift: false, alt: false, meta: false, isModifierOnly: false }
141
+ *
142
+ * parseHotkey('cmd+shift')
143
+ * // { key: null, ctrl: false, shift: true, alt: false, meta: true, isModifierOnly: true }
144
+ *
145
+ * parseHotkey('escape')
146
+ * // { key: 'escape', ctrl: false, shift: false, alt: false, meta: false, isModifierOnly: false }
147
+ * ```
103
148
  */
104
149
  function parseHotkey(hotkey) {
105
- const parts = hotkey.toLowerCase().split("+");
150
+ const parts = hotkey.toLowerCase().split("+").map((p) => p.trim());
151
+ const modifiers = {
152
+ ctrl: false,
153
+ alt: false,
154
+ shift: false,
155
+ meta: false
156
+ };
157
+ let key = null;
158
+ for (let i = 0; i < parts.length; i++) {
159
+ const part = parts[i];
160
+ if (!part) continue;
161
+ const canonicalModifier = MODIFIER_ALIASES[part];
162
+ if (canonicalModifier) switch (canonicalModifier) {
163
+ case "ctrl":
164
+ modifiers.ctrl = true;
165
+ break;
166
+ case "alt":
167
+ modifiers.alt = true;
168
+ break;
169
+ case "shift":
170
+ modifiers.shift = true;
171
+ break;
172
+ case "meta":
173
+ modifiers.meta = true;
174
+ break;
175
+ }
176
+ else key = key ? `${key}+${normalizeKey(part)}` : normalizeKey(part);
177
+ }
178
+ const isModifierOnly = key === null;
106
179
  return {
107
- ctrl: parts.includes("ctrl") || parts.includes("control"),
108
- alt: parts.includes("alt") || parts.includes("option"),
109
- shift: parts.includes("shift"),
110
- meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command")
180
+ key,
181
+ ...modifiers,
182
+ isModifierOnly
111
183
  };
112
184
  }
185
+ //#endregion
186
+ //#region src/core/hotkeys/matcher.ts
113
187
  /**
114
- * Check if a keyboard event matches the required modifiers
188
+ * Check if a keyboard event matches a parsed hotkey.
189
+ *
190
+ * For modifier-only hotkeys: matches when all required modifiers are pressed.
191
+ * For modifier+key hotkeys: matches when the specific key is pressed with required modifiers.
192
+ *
193
+ * @param event - The keyboard event
194
+ * @param hotkey - The parsed hotkey to match against
195
+ * @returns True if the event matches the hotkey
115
196
  */
116
- function matchesHotkey(event, modifiers) {
117
- return event.ctrlKey === modifiers.ctrl && event.altKey === modifiers.alt && event.shiftKey === modifiers.shift && event.metaKey === modifiers.meta;
197
+ function matchesHotkey(event, hotkey) {
198
+ if (!(event.ctrlKey === hotkey.ctrl && event.altKey === hotkey.alt && event.shiftKey === hotkey.shift && event.metaKey === hotkey.meta)) return false;
199
+ if (hotkey.isModifierOnly) return true;
200
+ return event.key.toLowerCase() === hotkey.key?.toLowerCase();
118
201
  }
119
202
  /**
120
- * Hook for detecting push-to-talk hotkey press/release.
203
+ * Check if a keyboard event should trigger a release for a modifier-only hotkey.
204
+ *
205
+ * For modifier-only hotkeys, we release when ANY of the required modifiers is released.
121
206
  *
122
- * @param hotkey - Hotkey string like "ctrl+alt" or "ctrl+shift"
207
+ * @param event - The keyboard event
208
+ * @param hotkey - The parsed hotkey
209
+ * @returns True if the hotkey should be released
210
+ */
211
+ function shouldReleaseModifierOnlyHotkey(event, hotkey) {
212
+ if (!hotkey.isModifierOnly) return false;
213
+ if (hotkey.ctrl && !event.ctrlKey) return true;
214
+ if (hotkey.alt && !event.altKey) return true;
215
+ if (hotkey.shift && !event.shiftKey) return true;
216
+ if (hotkey.meta && !event.metaKey) return true;
217
+ return false;
218
+ }
219
+ //#endregion
220
+ //#region src/core/hotkeys/controller.ts
221
+ /**
222
+ * Create a hotkey controller that manages press/release state.
223
+ *
224
+ * This is framework-agnostic and can be used with React, Vue, Svelte, etc.
225
+ *
226
+ * @param hotkey - The parsed hotkey to listen for
227
+ * @param options - Controller options including callbacks
228
+ * @returns A HotkeyController instance
229
+ *
230
+ * @example
231
+ * ```ts
232
+ * const controller = createHotkeyController(
233
+ * parseHotkey('ctrl+k'),
234
+ * {
235
+ * onPress: () => console.log('pressed'),
236
+ * onRelease: () => console.log('released'),
237
+ * enabled: true,
238
+ * }
239
+ * )
240
+ *
241
+ * // Later, cleanup
242
+ * controller.destroy()
243
+ * ```
244
+ */
245
+ function createHotkeyController(hotkey, options) {
246
+ let isPressed = false;
247
+ let enabled = options.enabled ?? true;
248
+ const { onPress, onRelease } = options;
249
+ function handleKeyDown(event) {
250
+ if (!enabled) return;
251
+ if (matchesHotkey(event, hotkey)) {
252
+ if (!isPressed) {
253
+ isPressed = true;
254
+ event.preventDefault();
255
+ onPress();
256
+ }
257
+ }
258
+ }
259
+ function handleKeyUp(event) {
260
+ if (!isPressed) return;
261
+ if (hotkey.isModifierOnly) {
262
+ if (shouldReleaseModifierOnlyHotkey(event, hotkey)) {
263
+ isPressed = false;
264
+ onRelease();
265
+ }
266
+ } else if (event.key.toLowerCase() === hotkey.key?.toLowerCase()) {
267
+ isPressed = false;
268
+ onRelease();
269
+ }
270
+ }
271
+ function handleBlur() {
272
+ if (isPressed) {
273
+ isPressed = false;
274
+ onRelease();
275
+ }
276
+ }
277
+ window.addEventListener("keydown", handleKeyDown);
278
+ window.addEventListener("keyup", handleKeyUp);
279
+ window.addEventListener("blur", handleBlur);
280
+ return {
281
+ get isPressed() {
282
+ return isPressed;
283
+ },
284
+ setEnabled(newEnabled) {
285
+ enabled = newEnabled;
286
+ if (!enabled && isPressed) {
287
+ isPressed = false;
288
+ onRelease();
289
+ }
290
+ },
291
+ destroy() {
292
+ if (isPressed) {
293
+ isPressed = false;
294
+ onRelease();
295
+ }
296
+ window.removeEventListener("keydown", handleKeyDown);
297
+ window.removeEventListener("keyup", handleKeyUp);
298
+ window.removeEventListener("blur", handleBlur);
299
+ }
300
+ };
301
+ }
302
+ //#endregion
303
+ //#region src/react/use-hotkey.ts
304
+ /**
305
+ * Hook for detecting hotkey press/release.
306
+ *
307
+ * Supports:
308
+ * - Modifier-only hotkeys: "ctrl+alt", "cmd", "shift" (for push-to-talk)
309
+ * - Modifier+key hotkeys: "ctrl+k", "cmd+shift+a", "alt+f4"
310
+ * - Key-only hotkeys: "escape", "f1", "a"
311
+ *
312
+ * @param hotkey - Hotkey string like "ctrl+k" or "ctrl+alt"
123
313
  * @param onPress - Called when hotkey is pressed
124
314
  * @param onRelease - Called when hotkey is released
125
315
  * @param enabled - Whether the hotkey listener is active (default: true)
316
+ *
317
+ * @example
318
+ * ```tsx
319
+ * // Push-to-talk with modifier-only
320
+ * useHotkey('ctrl+alt', () => startRecording(), () => stopRecording())
321
+ *
322
+ * // Quick action with modifier+key
323
+ * useHotkey('ctrl+k', () => openCommandPalette(), () => {})
324
+ *
325
+ * // Escape to close
326
+ * useHotkey('escape', () => closeModal(), () => {})
327
+ * ```
126
328
  */
127
329
  function useHotkey(hotkey, onPress, onRelease, enabled = true) {
128
- const isPressedRef = useRef(false);
129
- const modifiersRef = useRef(parseHotkey(hotkey));
330
+ const parsedHotkeyRef = useRef(parseHotkey(hotkey));
331
+ useEffect(() => {
332
+ parsedHotkeyRef.current = parseHotkey(hotkey);
333
+ }, [hotkey]);
130
334
  const onPressRef = useRef(onPress);
131
335
  const onReleaseRef = useRef(onRelease);
132
336
  onPressRef.current = onPress;
133
337
  onReleaseRef.current = onRelease;
338
+ const controllerRef = useRef(null);
134
339
  useEffect(() => {
135
- modifiersRef.current = parseHotkey(hotkey);
136
- }, [hotkey]);
137
- useEffect(() => {
138
- if (!enabled) {
139
- if (isPressedRef.current) {
140
- isPressedRef.current = false;
141
- onReleaseRef.current();
142
- }
143
- return;
144
- }
145
- function handleKeyDown(event) {
146
- if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {
147
- isPressedRef.current = true;
148
- event.preventDefault();
149
- onPressRef.current();
150
- }
151
- }
152
- function handleKeyUp(event) {
153
- if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {
154
- isPressedRef.current = false;
155
- onReleaseRef.current();
156
- }
157
- }
158
- function handleBlur() {
159
- if (isPressedRef.current) {
160
- isPressedRef.current = false;
161
- onReleaseRef.current();
162
- }
163
- }
164
- window.addEventListener("keydown", handleKeyDown);
165
- window.addEventListener("keyup", handleKeyUp);
166
- window.addEventListener("blur", handleBlur);
340
+ controllerRef.current = createHotkeyController(parsedHotkeyRef.current, {
341
+ onPress: () => onPressRef.current(),
342
+ onRelease: () => onReleaseRef.current(),
343
+ enabled
344
+ });
167
345
  return () => {
168
- window.removeEventListener("keydown", handleKeyDown);
169
- window.removeEventListener("keyup", handleKeyUp);
170
- window.removeEventListener("blur", handleBlur);
346
+ controllerRef.current?.destroy();
347
+ controllerRef.current = null;
171
348
  };
349
+ }, []);
350
+ useEffect(() => {
351
+ controllerRef.current?.setEnabled(enabled);
172
352
  }, [enabled]);
173
353
  }
174
354
  //#endregion
175
355
  //#region src/react/components/Cursor.tsx
176
356
  const BASE_ROTATION = -Math.PI / 6;
177
357
  /**
358
+ * Spinner component for processing state.
359
+ * A simple ring spinner using SVG animateTransform.
360
+ */
361
+ function ProcessingSpinner({ className }) {
362
+ return /* @__PURE__ */ jsx("svg", {
363
+ className,
364
+ xmlns: "http://www.w3.org/2000/svg",
365
+ width: "12",
366
+ height: "12",
367
+ viewBox: "0 0 24 24",
368
+ children: /* @__PURE__ */ jsx("path", {
369
+ fill: "currentColor",
370
+ d: "M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z",
371
+ children: /* @__PURE__ */ jsx("animateTransform", {
372
+ attributeName: "transform",
373
+ dur: "0.75s",
374
+ repeatCount: "indefinite",
375
+ type: "rotate",
376
+ values: "0 12 12;360 12 12"
377
+ })
378
+ })
379
+ });
380
+ }
381
+ /**
178
382
  * Default cursor component - a colored triangle pointer.
179
383
  * Color and animations change based on voice state via CSS classes.
180
384
  */
181
- function DefaultCursor({ state, rotation, scale }) {
182
- return /* @__PURE__ */ jsx("svg", {
183
- width: "32",
184
- height: "32",
185
- viewBox: "0 0 32 32",
186
- className: `cursor-buddy-cursor ${`cursor-buddy-cursor--${state}`}`,
385
+ function DefaultCursor({ state, rotation, scale, isPointing }) {
386
+ const stateClass = `cursor-buddy-cursor--${state}`;
387
+ const showSpinner = state === "processing" && !isPointing;
388
+ return /* @__PURE__ */ jsxs("div", {
389
+ className: `cursor-buddy-cursor ${stateClass}`,
187
390
  style: {
188
391
  transform: `rotate(${BASE_ROTATION + rotation}rad) scale(${scale})`,
189
- transformOrigin: "16px 4px"
392
+ transformOrigin: "8px 2px",
393
+ display: "flex",
394
+ alignItems: "center",
395
+ gap: "4px"
190
396
  },
191
- children: /* @__PURE__ */ jsx("polygon", { points: "16,4 28,28 16,22 4,28" })
397
+ children: [/* @__PURE__ */ jsx("svg", {
398
+ width: "16",
399
+ height: "16",
400
+ viewBox: "0 0 16 16",
401
+ children: /* @__PURE__ */ jsx("polygon", { points: "8,2 14,14 8,11 2,14" })
402
+ }), showSpinner && /* @__PURE__ */ jsx(ProcessingSpinner, { className: "cursor-buddy-cursor__spinner" })]
192
403
  });
193
404
  }
194
405
  //#endregion
@@ -1 +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 {\n CursorBuddyClientOptions,\n CursorBuddySpeechConfig,\n CursorBuddyTranscriptionConfig,\n} 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 /** Transcription configuration */\n transcription?: CursorBuddyTranscriptionConfig\n /** Speech configuration */\n speech?: CursorBuddySpeechConfig\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 transcription,\n speech,\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 speech,\n transcription,\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 /** In-progress transcript while browser transcription is listening */\n liveTranscript: string\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 {\n CursorBuddySpeechConfig,\n CursorBuddyTranscriptionConfig,\n PointingTarget,\n VoiceState,\n} 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 /** Transcription configuration */\n transcription?: CursorBuddyTranscriptionConfig\n /** Speech configuration */\n speech?: CursorBuddySpeechConfig\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 * speech in the browser or on the server based on the configured mode, sends\n * it to the AI, speaks the response in the browser or on the server based on\n * the configured mode, and can point at 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 speech,\n transcription,\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 speech={speech}\n transcription={transcription}\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":"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"}
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/core/hotkeys/parser.ts","../../src/core/hotkeys/matcher.ts","../../src/core/hotkeys/controller.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 Positioned at the $buddyPosition coordinates (cursor center).\\n CSS transform shifts the cursor to the right of the mouse pointer\\n to avoid overlapping with the system cursor.\\n Customize this offset via CSS to change cursor positioning.\\n*/\\n.cursor-buddy-container {\\n position: absolute;\\n transform: translate(8px, 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/* Processing spinner */\\n.cursor-buddy-cursor__spinner {\\n color: var(--cursor-buddy-color-processing);\\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: 24px;\\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: 24px;\\n top: 2px;\\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 {\n CursorBuddyClientOptions,\n CursorBuddySpeechConfig,\n CursorBuddyTranscriptionConfig,\n} 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 /** Transcription configuration */\n transcription?: CursorBuddyTranscriptionConfig\n /** Speech configuration */\n speech?: CursorBuddySpeechConfig\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 transcription,\n speech,\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 speech,\n transcription,\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 /** In-progress transcript while browser transcription is listening */\n liveTranscript: string\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","import type { ParsedHotkey } from \"./types\"\n\n/**\n * Modifier aliases mapping to canonical names.\n */\nconst MODIFIER_ALIASES: Record<string, string> = {\n // Control variants\n ctrl: \"ctrl\",\n control: \"ctrl\",\n // Alt variants\n alt: \"alt\",\n option: \"alt\",\n // Shift variants\n shift: \"shift\",\n // Meta variants\n meta: \"meta\",\n cmd: \"meta\",\n command: \"meta\",\n}\n\n/**\n * Set of all valid modifier names (including aliases).\n */\nconst VALID_MODIFIERS = new Set(Object.keys(MODIFIER_ALIASES))\n\n/**\n * Check if a key is a modifier.\n */\nfunction isModifierKey(key: string): boolean {\n return VALID_MODIFIERS.has(key.toLowerCase())\n}\n\n/**\n * Normalize a key name to lowercase for consistent comparison.\n */\nfunction normalizeKey(key: string): string {\n // Handle special cases\n const lower = key.toLowerCase()\n\n // Normalize common key variations\n if (lower === \"esc\") return \"escape\"\n if (lower === \"del\") return \"delete\"\n if (lower === \"space\") return \" \"\n if (lower === \"spacebar\") return \" \"\n\n return lower\n}\n\n/**\n * Parse a hotkey string into a ParsedHotkey object.\n *\n * Supports:\n * - Modifier-only: \"ctrl+alt\", \"cmd\", \"shift\"\n * - Modifier+key: \"ctrl+k\", \"cmd+shift+a\", \"alt+f4\"\n * - Key-only: \"escape\", \"f1\", \"a\"\n *\n * @param hotkey - The hotkey string to parse (e.g., 'ctrl+k', 'cmd+shift', 'escape')\n * @returns A ParsedHotkey object\n *\n * @example\n * ```ts\n * parseHotkey('ctrl+k')\n * // { key: 'k', ctrl: true, shift: false, alt: false, meta: false, isModifierOnly: false }\n *\n * parseHotkey('cmd+shift')\n * // { key: null, ctrl: false, shift: true, alt: false, meta: true, isModifierOnly: true }\n *\n * parseHotkey('escape')\n * // { key: 'escape', ctrl: false, shift: false, alt: false, meta: false, isModifierOnly: false }\n * ```\n */\nexport function parseHotkey(hotkey: string): ParsedHotkey {\n const parts = hotkey\n .toLowerCase()\n .split(\"+\")\n .map((p) => p.trim())\n\n const modifiers = {\n ctrl: false,\n alt: false,\n shift: false,\n meta: false,\n }\n\n let key: string | null = null\n\n for (let i = 0; i < parts.length; i++) {\n const part = parts[i]\n if (!part) continue\n\n const canonicalModifier = MODIFIER_ALIASES[part]\n\n if (canonicalModifier) {\n // This part is a modifier\n switch (canonicalModifier) {\n case \"ctrl\":\n modifiers.ctrl = true\n break\n case \"alt\":\n modifiers.alt = true\n break\n case \"shift\":\n modifiers.shift = true\n break\n case \"meta\":\n modifiers.meta = true\n break\n }\n } else {\n // This part is the key\n // If we've already found a key, combine them (e.g., for \"ctrl+plus\")\n key = key ? `${key}+${normalizeKey(part)}` : normalizeKey(part)\n }\n }\n\n const isModifierOnly = key === null\n\n return {\n key,\n ...modifiers,\n isModifierOnly,\n }\n}\n\n/**\n * Convert a KeyboardEvent to a ParsedHotkey representation.\n *\n * @param event - The keyboard event\n * @returns A ParsedHotkey representing the current key state\n */\nexport function parseKeyboardEvent(event: KeyboardEvent): ParsedHotkey {\n const key = normalizeKey(event.key)\n\n // Check if the key itself is a modifier being pressed\n const isKeyAModifier = isModifierKey(key)\n\n return {\n key: isKeyAModifier ? null : key,\n ctrl: event.ctrlKey,\n alt: event.altKey,\n shift: event.shiftKey,\n meta: event.metaKey,\n isModifierOnly: isKeyAModifier,\n }\n}\n\n/**\n * Format a ParsedHotkey back to a string representation.\n *\n * @param parsed - The parsed hotkey\n * @returns A formatted string like \"ctrl+k\" or \"cmd+shift\"\n */\nexport function formatHotkey(parsed: ParsedHotkey): string {\n const parts: string[] = []\n\n if (parsed.ctrl) parts.push(\"ctrl\")\n if (parsed.alt) parts.push(\"alt\")\n if (parsed.shift) parts.push(\"shift\")\n if (parsed.meta) parts.push(\"meta\")\n\n if (parsed.key) {\n parts.push(parsed.key)\n }\n\n return parts.join(\"+\")\n}\n","import type { ParsedHotkey } from \"./types\"\n\n/**\n * Check if a keyboard event matches a parsed hotkey.\n *\n * For modifier-only hotkeys: matches when all required modifiers are pressed.\n * For modifier+key hotkeys: matches when the specific key is pressed with required modifiers.\n *\n * @param event - The keyboard event\n * @param hotkey - The parsed hotkey to match against\n * @returns True if the event matches the hotkey\n */\nexport function matchesHotkey(\n event: KeyboardEvent,\n hotkey: ParsedHotkey,\n): boolean {\n // First check if modifiers match\n const modifiersMatch =\n event.ctrlKey === hotkey.ctrl &&\n event.altKey === hotkey.alt &&\n event.shiftKey === hotkey.shift &&\n event.metaKey === hotkey.meta\n\n if (!modifiersMatch) {\n return false\n }\n\n if (hotkey.isModifierOnly) {\n // For modifier-only hotkeys, we just need the modifiers to match\n // The key itself doesn't matter (we're listening for modifier keydown/keyup)\n return true\n }\n\n // For hotkeys with a specific key, check if the event key matches\n // We normalize to lowercase for comparison\n const eventKey = event.key.toLowerCase()\n const expectedKey = hotkey.key?.toLowerCase()\n\n return eventKey === expectedKey\n}\n\n/**\n * Check if a keyboard event should trigger a release for a modifier-only hotkey.\n *\n * For modifier-only hotkeys, we release when ANY of the required modifiers is released.\n *\n * @param event - The keyboard event\n * @param hotkey - The parsed hotkey\n * @returns True if the hotkey should be released\n */\nexport function shouldReleaseModifierOnlyHotkey(\n event: KeyboardEvent,\n hotkey: ParsedHotkey,\n): boolean {\n if (!hotkey.isModifierOnly) {\n return false\n }\n\n // Check if any required modifier was released\n if (hotkey.ctrl && !event.ctrlKey) return true\n if (hotkey.alt && !event.altKey) return true\n if (hotkey.shift && !event.shiftKey) return true\n if (hotkey.meta && !event.metaKey) return true\n\n return false\n}\n\n/**\n * Check if the event represents a modifier key being released.\n *\n * @param event - The keyboard event\n * @returns True if a modifier key was released\n */\nexport function isModifierReleased(event: KeyboardEvent): boolean {\n const key = event.key.toLowerCase()\n\n // Check if the released key is a modifier\n const isCtrl = key === \"control\" || key === \"ctrl\"\n const isAlt = key === \"alt\"\n const isShift = key === \"shift\"\n const isMeta =\n key === \"meta\" || key === \"command\" || key === \"cmd\" || key === \"os\"\n\n return isCtrl || isAlt || isShift || isMeta\n}\n","import type {\n HotkeyController,\n HotkeyControllerOptions,\n ParsedHotkey,\n} from \"./types\"\nimport { matchesHotkey, shouldReleaseModifierOnlyHotkey } from \"./matcher\"\n\n/**\n * Create a hotkey controller that manages press/release state.\n *\n * This is framework-agnostic and can be used with React, Vue, Svelte, etc.\n *\n * @param hotkey - The parsed hotkey to listen for\n * @param options - Controller options including callbacks\n * @returns A HotkeyController instance\n *\n * @example\n * ```ts\n * const controller = createHotkeyController(\n * parseHotkey('ctrl+k'),\n * {\n * onPress: () => console.log('pressed'),\n * onRelease: () => console.log('released'),\n * enabled: true,\n * }\n * )\n *\n * // Later, cleanup\n * controller.destroy()\n * ```\n */\nexport function createHotkeyController(\n hotkey: ParsedHotkey,\n options: HotkeyControllerOptions,\n): HotkeyController {\n let isPressed = false\n let enabled = options.enabled ?? true\n\n const { onPress, onRelease } = options\n\n function handleKeyDown(event: KeyboardEvent) {\n if (!enabled) return\n\n // Check if this is a match\n if (matchesHotkey(event, hotkey)) {\n if (!isPressed) {\n isPressed = true\n event.preventDefault()\n onPress()\n }\n }\n }\n\n function handleKeyUp(event: KeyboardEvent) {\n if (!isPressed) return\n\n if (hotkey.isModifierOnly) {\n // For modifier-only hotkeys, release when any required modifier is released\n if (shouldReleaseModifierOnlyHotkey(event, hotkey)) {\n isPressed = false\n onRelease()\n }\n } else {\n // For hotkeys with a specific key, release when that key is released\n const eventKey = event.key.toLowerCase()\n const expectedKey = hotkey.key?.toLowerCase()\n\n if (eventKey === expectedKey) {\n isPressed = false\n onRelease()\n }\n }\n }\n\n function handleBlur() {\n // Release if window loses focus while hotkey is pressed\n if (isPressed) {\n isPressed = false\n onRelease()\n }\n }\n\n // Attach listeners\n window.addEventListener(\"keydown\", handleKeyDown)\n window.addEventListener(\"keyup\", handleKeyUp)\n window.addEventListener(\"blur\", handleBlur)\n\n return {\n get isPressed() {\n return isPressed\n },\n\n setEnabled(newEnabled: boolean) {\n enabled = newEnabled\n\n // If disabling while pressed, trigger release\n if (!enabled && isPressed) {\n isPressed = false\n onRelease()\n }\n },\n\n destroy() {\n // If pressed when destroyed, release first\n if (isPressed) {\n isPressed = false\n onRelease()\n }\n\n window.removeEventListener(\"keydown\", handleKeyDown)\n window.removeEventListener(\"keyup\", handleKeyUp)\n window.removeEventListener(\"blur\", handleBlur)\n },\n }\n}\n","\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport {\n createHotkeyController,\n type HotkeyController,\n type ParsedHotkey,\n parseHotkey,\n} from \"../core/hotkeys\"\n\n/**\n * Hook for detecting hotkey press/release.\n *\n * Supports:\n * - Modifier-only hotkeys: \"ctrl+alt\", \"cmd\", \"shift\" (for push-to-talk)\n * - Modifier+key hotkeys: \"ctrl+k\", \"cmd+shift+a\", \"alt+f4\"\n * - Key-only hotkeys: \"escape\", \"f1\", \"a\"\n *\n * @param hotkey - Hotkey string like \"ctrl+k\" or \"ctrl+alt\"\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 *\n * @example\n * ```tsx\n * // Push-to-talk with modifier-only\n * useHotkey('ctrl+alt', () => startRecording(), () => stopRecording())\n *\n * // Quick action with modifier+key\n * useHotkey('ctrl+k', () => openCommandPalette(), () => {})\n *\n * // Escape to close\n * useHotkey('escape', () => closeModal(), () => {})\n * ```\n */\nexport function useHotkey(\n hotkey: string,\n onPress: () => void,\n onRelease: () => void,\n enabled: boolean = true,\n): void {\n const parsedHotkeyRef = useRef<ParsedHotkey>(parseHotkey(hotkey))\n\n useEffect(() => {\n parsedHotkeyRef.current = parseHotkey(hotkey)\n }, [hotkey])\n\n const onPressRef = useRef(onPress)\n const onReleaseRef = useRef(onRelease)\n onPressRef.current = onPress\n onReleaseRef.current = onRelease\n\n const controllerRef = useRef<HotkeyController | null>(null)\n\n useEffect(() => {\n controllerRef.current = createHotkeyController(parsedHotkeyRef.current, {\n onPress: () => onPressRef.current(),\n onRelease: () => onReleaseRef.current(),\n enabled,\n })\n\n return () => {\n controllerRef.current?.destroy()\n controllerRef.current = null\n }\n }, [])\n\n useEffect(() => {\n controllerRef.current?.setEnabled(enabled)\n }, [enabled])\n}\n\n// Re-export types for convenience\nexport type { ParsedHotkey } from \"../core/hotkeys\"\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 * Spinner component for processing state.\n * A simple ring spinner using SVG animateTransform.\n */\nfunction ProcessingSpinner({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"12\"\n height=\"12\"\n viewBox=\"0 0 24 24\"\n >\n <path\n fill=\"currentColor\"\n d=\"M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z\"\n >\n <animateTransform\n attributeName=\"transform\"\n dur=\"0.75s\"\n repeatCount=\"indefinite\"\n type=\"rotate\"\n values=\"0 12 12;360 12 12\"\n />\n </path>\n </svg>\n )\n}\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({\n state,\n rotation,\n scale,\n isPointing,\n}: CursorRenderProps) {\n const stateClass = `cursor-buddy-cursor--${state}`\n const showSpinner = state === \"processing\" && !isPointing\n\n return (\n <div\n className={`cursor-buddy-cursor ${stateClass}`}\n style={{\n transform: `rotate(${BASE_ROTATION + rotation}rad) scale(${scale})`,\n transformOrigin: \"8px 2px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: \"4px\",\n }}\n >\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\n <polygon points=\"8,2 14,14 8,11 2,14\" />\n </svg>\n {showSpinner && (\n <ProcessingSpinner className=\"cursor-buddy-cursor__spinner\" />\n )}\n </div>\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 {\n CursorBuddySpeechConfig,\n CursorBuddyTranscriptionConfig,\n PointingTarget,\n VoiceState,\n} 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 /** Transcription configuration */\n transcription?: CursorBuddyTranscriptionConfig\n /** Speech configuration */\n speech?: CursorBuddySpeechConfig\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 * speech in the browser or on the server based on the configured mode, sends\n * it to the AI, speaks the response in the browser or on the server based on\n * the configured mode, and can point at 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 speech,\n transcription,\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 speech={speech}\n transcription={transcription}\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":"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"}
@@ -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. Only ONE pointing tag is allowed per 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";
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. Only ONE pointing tag is allowed per response.\n\n### Interactive Elements (Preferred)\nInteractive elements (buttons, links, inputs, etc.) have invisible reference markers. 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- NEVER mention the numbered markers or annotations to the user \u2014 these are invisible helpers for you only\n- Only point when it genuinely helps answer the user's specific question or request\n- Do NOT point at elements just because they have markers \u2014 point only when relevant to the conversation\n- Prefer marker-based pointing when the element has a marker and pointing is appropriate\n- Only use coordinates when pointing at unmarked content\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- Never mention the numbered markers or annotations you see on elements\n";
32
32
  //#endregion
33
33
  export { type CursorBuddyHandler, type CursorBuddyHandlerConfig, DEFAULT_SYSTEM_PROMPT, createCursorBuddyHandler };
34
34
  //# sourceMappingURL=index.d.mts.map
@@ -13,7 +13,7 @@ You can see screenshots of the user's viewport and hear their voice. Respond con
13
13
  When you want to direct the user's attention to something on screen, add a pointing tag at the END of your response. Only ONE pointing tag is allowed per response.
14
14
 
15
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:
16
+ Interactive elements (buttons, links, inputs, etc.) have invisible reference markers. Use the marker number to point at these:
17
17
 
18
18
  [POINT:marker_number:label]
19
19
 
@@ -31,9 +31,11 @@ Where x,y are coordinates in screenshot image pixels (top-left origin).
31
31
  Example: "The error message is shown here. [POINT:450,320:Error text]"
32
32
 
33
33
  ### Guidelines
34
- - Prefer marker-based pointing when the element has a visible number
34
+ - NEVER mention the numbered markers or annotations to the user these are invisible helpers for you only
35
+ - Only point when it genuinely helps answer the user's specific question or request
36
+ - Do NOT point at elements just because they have markers — point only when relevant to the conversation
37
+ - Prefer marker-based pointing when the element has a marker and pointing is appropriate
35
38
  - Only use coordinates when pointing at unmarked content
36
- - Only point when it genuinely helps
37
39
  - Use natural descriptions ("this button", "over here", "right there")
38
40
  - Coordinates should be the CENTER of the element you're pointing at
39
41
  - Keep labels short (2-4 words)
@@ -45,6 +47,7 @@ Example: "The error message is shown here. [POINT:450,320:Error text]"
45
47
  - Avoid technical jargon unless the user is technical
46
48
  - If you can't see something clearly, say so
47
49
  - Never mention that you're looking at a "screenshot" — say "I can see..." or "Looking at your screen..."
50
+ - Never mention the numbered markers or annotations you see on elements
48
51
  `;
49
52
  //#endregion
50
53
  //#region src/server/routes/chat.ts
@@ -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. Only ONE pointing tag is allowed per 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 providerOptions: config?.modelProviderMetadata,\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 if (!config.transcriptionModel) {\n return new Response(\n JSON.stringify({\n error:\n \"Server transcription is not configured. Provide a transcriptionModel or use browser transcription only.\",\n }),\n {\n status: 501,\n headers: { \"Content-Type\": \"application/json\" },\n },\n )\n }\n\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 if (!config.speechModel) {\n return new Response(\n JSON.stringify({\n error:\n \"Server speech is not configured. Provide a speechModel or use browser speech only.\",\n }),\n {\n status: 501,\n headers: { \"Content-Type\": \"application/json\" },\n },\n )\n }\n\n const outputFormat = \"wav\"\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 outputFormat,\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/wav\",\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\"), // optional for browser-only speech\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,SAAS,kBADpC,MAAM,QAAQ,MAAM;CAIlC,MAAM,eACJ,OAAO,OAAO,WAAW,aACrB,OAAO,OAAO,EAAE,eAAe,uBAAuB,CAAC,GACtD,OAAO,UAAU;CAGxB,MAAM,eAAe,OAAO,cAAc,MAAM;CAChD,MAAM,iBAAiB,QAAQ,MAAM,CAAC,YAAY;CAGlD,MAAM,sBAAgC,EAAE;AAExC,KAAI,QACF,qBAAoB,KAClB,oBAAoB,QAAQ,MAAM,GAAG,QAAQ,OAAO,UACrD;AAGH,KAAI,cACF,qBAAoB,KAAK,IAAI,cAAc;CAG7C,MAAM,iBACJ,oBAAoB,SAAS,IAAI,oBAAoB,KAAK,KAAK,GAAG;CAGpE,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;AAUD,QARe,WAAW;EACxB,OAAO,OAAO;EACd,QAAQ;EACR,iBAAiB,QAAQ;EACzB;EACA,OAAO,OAAO;EACf,CAAC,CAEY,sBAAsB;;;;;;;ACvEtC,eAAsB,iBACpB,SACA,QACmB;AACnB,KAAI,CAAC,OAAO,mBACV,QAAO,IAAI,SACT,KAAK,UAAU,EACb,OACE,2GACH,CAAC,EACF;EACE,QAAQ;EACR,SAAS,EAAE,gBAAgB,oBAAoB;EAChD,CACF;CAIH,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;;;;;;;ACtCJ,eAAsB,UACpB,SACA,QACmB;AACnB,KAAI,CAAC,OAAO,YACV,QAAO,IAAI,SACT,KAAK,UAAU,EACb,OACE,sFACH,CAAC,EACF;EACE,QAAQ;EACR,SAAS,EAAE,gBAAgB,oBAAoB;EAChD,CACF;CAGH,MAAM,eAAe;CAErB,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;EACA;EACD,CAAC;CAGF,MAAM,YAAY,IAAI,WAAW,OAAO,MAAM,WAAW;AAEzD,QAAO,IAAI,SAAS,WAAW,EAC7B,SAAS,EACP,gBAAgB,aACjB,EACF,CAAC;;;;;;;;;;;;;;;;;;;;;;;;ACtBJ,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. Only ONE pointing tag is allowed per response.\n\n### Interactive Elements (Preferred)\nInteractive elements (buttons, links, inputs, etc.) have invisible reference markers. 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- NEVER mention the numbered markers or annotations to the user — these are invisible helpers for you only\n- Only point when it genuinely helps answer the user's specific question or request\n- Do NOT point at elements just because they have markers — point only when relevant to the conversation\n- Prefer marker-based pointing when the element has a marker and pointing is appropriate\n- Only use coordinates when pointing at unmarked content\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- Never mention the numbered markers or annotations you see on elements\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 providerOptions: config?.modelProviderMetadata,\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 if (!config.transcriptionModel) {\n return new Response(\n JSON.stringify({\n error:\n \"Server transcription is not configured. Provide a transcriptionModel or use browser transcription only.\",\n }),\n {\n status: 501,\n headers: { \"Content-Type\": \"application/json\" },\n },\n )\n }\n\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 if (!config.speechModel) {\n return new Response(\n JSON.stringify({\n error:\n \"Server speech is not configured. Provide a speechModel or use browser speech only.\",\n }),\n {\n status: 501,\n headers: { \"Content-Type\": \"application/json\" },\n },\n )\n }\n\n const outputFormat = \"wav\"\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 outputFormat,\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/wav\",\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\"), // optional for browser-only speech\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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-buddy",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "AI-powered cursor companion for web apps",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -84,6 +84,7 @@
84
84
  "devDependencies": {
85
85
  "@types/react": "^19.0.8",
86
86
  "@types/react-dom": "^19.2.3",
87
+ "happy-dom": "^20.9.0",
87
88
  "react": "^19.0.0",
88
89
  "react-dom": "^19.0.0",
89
90
  "tsdown": "^0.21.7",