cursor-buddy 0.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +422 -0
- package/dist/client-Ba6rv-du.d.mts +460 -0
- package/dist/client-Ba6rv-du.d.mts.map +1 -0
- package/dist/client-D-LeEdoH.mjs +2254 -0
- package/dist/client-D-LeEdoH.mjs.map +1 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +3 -0
- package/dist/point-tool-DtHgq6gQ.mjs +54 -0
- package/dist/point-tool-DtHgq6gQ.mjs.map +1 -0
- package/dist/point-tool-kIviMn1q.d.mts +46 -0
- package/dist/point-tool-kIviMn1q.d.mts.map +1 -0
- package/dist/react/index.d.mts +142 -0
- package/dist/react/index.d.mts.map +1 -0
- package/dist/react/index.mjs +574 -0
- package/dist/react/index.mjs.map +1 -0
- package/dist/server/adapters/next.d.mts +22 -0
- package/dist/server/adapters/next.d.mts.map +1 -0
- package/dist/server/adapters/next.mjs +24 -0
- package/dist/server/adapters/next.mjs.map +1 -0
- package/dist/server/index.d.mts +31 -0
- package/dist/server/index.d.mts.map +1 -0
- package/dist/server/index.mjs +278 -0
- package/dist/server/index.mjs.map +1 -0
- package/dist/types-COQKMo5C.d.mts +44 -0
- package/dist/types-COQKMo5C.d.mts.map +1 -0
- package/package.json +108 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { a as $buddyScale, i as $buddyRotation, n as $audioLevel, o as $cursorPosition, r as $buddyPosition, s as $pointingTarget, t as CursorBuddyClient } from "../client-D-LeEdoH.mjs";
|
|
3
|
+
import { useStore } from "@nanostores/react";
|
|
4
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { createPortal } from "react-dom";
|
|
7
|
+
//#region src/react/styles.css?inline
|
|
8
|
+
var styles_default = "/**\n * Cursor Buddy Styles\n *\n * Customize by overriding CSS variables in your own stylesheet:\n *\n * :root {\n * --cursor-buddy-color-idle: #8b5cf6;\n * }\n */\n\n:root {\n /* Cursor colors by state */\n --cursor-buddy-color-idle: #3b82f6;\n --cursor-buddy-color-listening: #ef4444;\n --cursor-buddy-color-processing: #eab308;\n --cursor-buddy-color-responding: #22c55e;\n --cursor-buddy-cursor-stroke: #ffffff;\n --cursor-buddy-cursor-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\n\n /* Speech bubble */\n --cursor-buddy-bubble-bg: #ffffff;\n --cursor-buddy-bubble-text: #1f2937;\n --cursor-buddy-bubble-radius: 8px;\n --cursor-buddy-bubble-padding: 8px 12px;\n --cursor-buddy-bubble-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n --cursor-buddy-bubble-max-width: 200px;\n --cursor-buddy-bubble-font-size: 14px;\n\n /* Waveform */\n --cursor-buddy-waveform-color: #ef4444;\n --cursor-buddy-waveform-bar-width: 4px;\n --cursor-buddy-waveform-bar-radius: 2px;\n --cursor-buddy-waveform-gap: 3px;\n\n /* Overlay */\n --cursor-buddy-z-index: 2147483647;\n\n /* Animation durations */\n --cursor-buddy-transition-fast: 0.1s;\n --cursor-buddy-transition-normal: 0.2s;\n --cursor-buddy-animation-duration: 0.3s;\n}\n\n/* Overlay container */\n.cursor-buddy-overlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n isolation: isolate;\n z-index: var(--cursor-buddy-z-index);\n}\n\n/* Buddy container (cursor + accessories)\n 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
|
+
//#endregion
|
|
10
|
+
//#region src/react/utils/inject-styles.ts
|
|
11
|
+
const STYLE_ID = "cursor-buddy-styles";
|
|
12
|
+
let injected = false;
|
|
13
|
+
/**
|
|
14
|
+
* Inject cursor buddy styles into the document head.
|
|
15
|
+
* Safe to call multiple times - will only inject once.
|
|
16
|
+
* No-op during SSR.
|
|
17
|
+
*/
|
|
18
|
+
function injectStyles() {
|
|
19
|
+
if (typeof document === "undefined") return;
|
|
20
|
+
if (injected) return;
|
|
21
|
+
if (document.getElementById(STYLE_ID)) {
|
|
22
|
+
injected = true;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const head = document.head || document.getElementsByTagName("head")[0];
|
|
26
|
+
const style = document.createElement("style");
|
|
27
|
+
style.id = STYLE_ID;
|
|
28
|
+
style.textContent = styles_default;
|
|
29
|
+
if (head.firstChild) head.insertBefore(style, head.firstChild);
|
|
30
|
+
else head.appendChild(style);
|
|
31
|
+
injected = true;
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/react/provider.tsx
|
|
35
|
+
const CursorBuddyContext = createContext(null);
|
|
36
|
+
/**
|
|
37
|
+
* Provider for cursor buddy. Creates and manages the client instance.
|
|
38
|
+
*/
|
|
39
|
+
function CursorBuddyProvider({ endpoint, transcription, speech, children, onTranscript, onResponse, onPoint, onStateChange, onError }) {
|
|
40
|
+
const [client] = useState(() => new CursorBuddyClient(endpoint, {
|
|
41
|
+
onTranscript,
|
|
42
|
+
onResponse,
|
|
43
|
+
onPoint,
|
|
44
|
+
onStateChange,
|
|
45
|
+
onError,
|
|
46
|
+
speech,
|
|
47
|
+
transcription
|
|
48
|
+
}));
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
injectStyles();
|
|
51
|
+
}, []);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
function handleMouseMove(event) {
|
|
54
|
+
$cursorPosition.set({
|
|
55
|
+
x: event.clientX,
|
|
56
|
+
y: event.clientY
|
|
57
|
+
});
|
|
58
|
+
client.updateCursorPosition();
|
|
59
|
+
}
|
|
60
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
61
|
+
return () => window.removeEventListener("mousemove", handleMouseMove);
|
|
62
|
+
}, [client]);
|
|
63
|
+
return /* @__PURE__ */ jsx(CursorBuddyContext.Provider, {
|
|
64
|
+
value: client,
|
|
65
|
+
children
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get the cursor buddy client from context.
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
function useClient() {
|
|
73
|
+
const client = useContext(CursorBuddyContext);
|
|
74
|
+
if (!client) throw new Error("useCursorBuddy must be used within CursorBuddyProvider");
|
|
75
|
+
return client;
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/react/hooks.ts
|
|
79
|
+
/**
|
|
80
|
+
* Hook to access cursor buddy state and actions.
|
|
81
|
+
*/
|
|
82
|
+
function useCursorBuddy() {
|
|
83
|
+
const client = useClient();
|
|
84
|
+
const subscribe = useCallback((listener) => client.subscribe(listener), [client]);
|
|
85
|
+
const getSnapshot = useCallback(() => client.getSnapshot(), [client]);
|
|
86
|
+
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
87
|
+
const audioLevel = useStore($audioLevel);
|
|
88
|
+
return {
|
|
89
|
+
...snapshot,
|
|
90
|
+
audioLevel,
|
|
91
|
+
startListening: useCallback(() => client.startListening(), [client]),
|
|
92
|
+
stopListening: useCallback(() => client.stopListening(), [client]),
|
|
93
|
+
setEnabled: useCallback((enabled) => client.setEnabled(enabled), [client]),
|
|
94
|
+
pointAt: useCallback((x, y, label) => client.pointAt(x, y, label), [client]),
|
|
95
|
+
dismissPointing: useCallback(() => client.dismissPointing(), [client]),
|
|
96
|
+
reset: useCallback(() => client.reset(), [client])
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
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));
|
|
115
|
+
/**
|
|
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
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
function parseHotkey(hotkey) {
|
|
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;
|
|
179
|
+
return {
|
|
180
|
+
key,
|
|
181
|
+
...modifiers,
|
|
182
|
+
isModifierOnly
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/core/hotkeys/matcher.ts
|
|
187
|
+
/**
|
|
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
|
|
196
|
+
*/
|
|
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();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
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.
|
|
206
|
+
*
|
|
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"
|
|
313
|
+
* @param onPress - Called when hotkey is pressed
|
|
314
|
+
* @param onRelease - Called when hotkey is released
|
|
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
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
function useHotkey(hotkey, onPress, onRelease, enabled = true) {
|
|
330
|
+
const parsedHotkeyRef = useRef(parseHotkey(hotkey));
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
parsedHotkeyRef.current = parseHotkey(hotkey);
|
|
333
|
+
}, [hotkey]);
|
|
334
|
+
const onPressRef = useRef(onPress);
|
|
335
|
+
const onReleaseRef = useRef(onRelease);
|
|
336
|
+
onPressRef.current = onPress;
|
|
337
|
+
onReleaseRef.current = onRelease;
|
|
338
|
+
const controllerRef = useRef(null);
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
controllerRef.current = createHotkeyController(parsedHotkeyRef.current, {
|
|
341
|
+
onPress: () => onPressRef.current(),
|
|
342
|
+
onRelease: () => onReleaseRef.current(),
|
|
343
|
+
enabled
|
|
344
|
+
});
|
|
345
|
+
return () => {
|
|
346
|
+
controllerRef.current?.destroy();
|
|
347
|
+
controllerRef.current = null;
|
|
348
|
+
};
|
|
349
|
+
}, []);
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
controllerRef.current?.setEnabled(enabled);
|
|
352
|
+
}, [enabled]);
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/react/components/Cursor.tsx
|
|
356
|
+
const BASE_ROTATION = -Math.PI / 6;
|
|
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
|
+
/**
|
|
382
|
+
* Default cursor component - a colored triangle pointer.
|
|
383
|
+
* Color and animations change based on voice state via CSS classes.
|
|
384
|
+
*/
|
|
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}`,
|
|
390
|
+
style: {
|
|
391
|
+
transform: `rotate(${BASE_ROTATION + rotation}rad) scale(${scale})`,
|
|
392
|
+
transformOrigin: "8px 2px",
|
|
393
|
+
display: "flex",
|
|
394
|
+
alignItems: "center",
|
|
395
|
+
gap: "4px"
|
|
396
|
+
},
|
|
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" })]
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/react/components/SpeechBubble.tsx
|
|
407
|
+
/**
|
|
408
|
+
* Default speech bubble component.
|
|
409
|
+
* Displays pointing label or response text next to the cursor.
|
|
410
|
+
*/
|
|
411
|
+
function DefaultSpeechBubble({ text, isVisible, onClick }) {
|
|
412
|
+
if (!isVisible || !text) return null;
|
|
413
|
+
return /* @__PURE__ */ jsx("div", {
|
|
414
|
+
className: "cursor-buddy-bubble",
|
|
415
|
+
onClick,
|
|
416
|
+
onKeyDown: (event) => {
|
|
417
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
418
|
+
event.preventDefault();
|
|
419
|
+
onClick?.();
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
role: "button",
|
|
423
|
+
tabIndex: 0,
|
|
424
|
+
children: text
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/react/components/Waveform.tsx
|
|
429
|
+
const EMPTY_BARS = Array.from({ length: 12 }, () => 0);
|
|
430
|
+
/**
|
|
431
|
+
* Default waveform component.
|
|
432
|
+
* Shows audio level visualization during recording.
|
|
433
|
+
*/
|
|
434
|
+
function DefaultWaveform({ audioLevel, isListening }) {
|
|
435
|
+
const [bars, setBars] = useState(EMPTY_BARS);
|
|
436
|
+
useEffect(() => {
|
|
437
|
+
if (!isListening) {
|
|
438
|
+
setBars(EMPTY_BARS);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
setBars((previousBars) => {
|
|
442
|
+
const nextBars = previousBars.slice(1);
|
|
443
|
+
nextBars.push(audioLevel);
|
|
444
|
+
return nextBars;
|
|
445
|
+
});
|
|
446
|
+
}, [audioLevel, isListening]);
|
|
447
|
+
if (!isListening) return null;
|
|
448
|
+
return /* @__PURE__ */ jsx("div", {
|
|
449
|
+
className: "cursor-buddy-waveform",
|
|
450
|
+
children: bars.map((level) => Math.pow(level, .65)).map((level, i) => {
|
|
451
|
+
const baseHeight = 4;
|
|
452
|
+
const variance = .75 + (i + 1) % 3 * .12;
|
|
453
|
+
return /* @__PURE__ */ jsx("div", {
|
|
454
|
+
className: "cursor-buddy-waveform-bar",
|
|
455
|
+
style: { height: `${baseHeight + level * 20 * variance}px` }
|
|
456
|
+
}, i);
|
|
457
|
+
})
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region src/react/components/Overlay.tsx
|
|
462
|
+
/**
|
|
463
|
+
* Overlay component that renders the cursor, speech bubble, and waveform.
|
|
464
|
+
* Uses React portal to render at the document body level.
|
|
465
|
+
*/
|
|
466
|
+
function Overlay({ cursor, speechBubble, waveform, container }) {
|
|
467
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
468
|
+
useEffect(() => setIsMounted(true), []);
|
|
469
|
+
const { state, isPointing, isEnabled, dismissPointing } = useCursorBuddy();
|
|
470
|
+
const buddyPosition = useStore($buddyPosition);
|
|
471
|
+
const buddyRotation = useStore($buddyRotation);
|
|
472
|
+
const buddyScale = useStore($buddyScale);
|
|
473
|
+
const audioLevel = useStore($audioLevel);
|
|
474
|
+
const pointingTarget = useStore($pointingTarget);
|
|
475
|
+
if (!isMounted || !isEnabled) return null;
|
|
476
|
+
const cursorProps = {
|
|
477
|
+
state,
|
|
478
|
+
isPointing,
|
|
479
|
+
rotation: buddyRotation,
|
|
480
|
+
scale: buddyScale
|
|
481
|
+
};
|
|
482
|
+
const speechBubbleProps = {
|
|
483
|
+
text: pointingTarget?.label ?? "",
|
|
484
|
+
isVisible: isPointing && !!pointingTarget,
|
|
485
|
+
onClick: dismissPointing
|
|
486
|
+
};
|
|
487
|
+
const waveformProps = {
|
|
488
|
+
audioLevel,
|
|
489
|
+
isListening: state === "listening"
|
|
490
|
+
};
|
|
491
|
+
const cursorElement = typeof cursor === "function" ? cursor(cursorProps) : cursor ? cursor : /* @__PURE__ */ jsx(DefaultCursor, { ...cursorProps });
|
|
492
|
+
const speechBubbleElement = speechBubble ? speechBubble(speechBubbleProps) : /* @__PURE__ */ jsx(DefaultSpeechBubble, { ...speechBubbleProps });
|
|
493
|
+
const waveformElement = waveform ? waveform(waveformProps) : /* @__PURE__ */ jsx(DefaultWaveform, { ...waveformProps });
|
|
494
|
+
const overlayContent = /* @__PURE__ */ jsx("div", {
|
|
495
|
+
className: "cursor-buddy-overlay",
|
|
496
|
+
"data-cursor-buddy-overlay": true,
|
|
497
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
498
|
+
className: "cursor-buddy-container",
|
|
499
|
+
style: {
|
|
500
|
+
left: buddyPosition.x,
|
|
501
|
+
top: buddyPosition.y
|
|
502
|
+
},
|
|
503
|
+
children: [
|
|
504
|
+
cursorElement,
|
|
505
|
+
state === "listening" && waveformElement,
|
|
506
|
+
isPointing && speechBubbleElement
|
|
507
|
+
]
|
|
508
|
+
})
|
|
509
|
+
});
|
|
510
|
+
const portalContainer = container ?? (typeof document !== "undefined" ? document.body : null);
|
|
511
|
+
if (!portalContainer) return null;
|
|
512
|
+
return createPortal(overlayContent, portalContainer);
|
|
513
|
+
}
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region src/react/components/CursorBuddy.tsx
|
|
516
|
+
/**
|
|
517
|
+
* Internal component that sets up hotkey handling
|
|
518
|
+
*/
|
|
519
|
+
function CursorBuddyInner({ hotkey = "ctrl+alt", cursor, speechBubble, waveform, container }) {
|
|
520
|
+
const { startListening, stopListening, isEnabled } = useCursorBuddy();
|
|
521
|
+
useHotkey(hotkey, startListening, stopListening, isEnabled);
|
|
522
|
+
return /* @__PURE__ */ jsx(Overlay, {
|
|
523
|
+
cursor,
|
|
524
|
+
speechBubble,
|
|
525
|
+
waveform,
|
|
526
|
+
container
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Drop-in cursor buddy component.
|
|
531
|
+
*
|
|
532
|
+
* Adds an AI-powered cursor companion to your app. Users hold the hotkey
|
|
533
|
+
* (default: Ctrl+Alt) to speak. The SDK captures a screenshot, transcribes
|
|
534
|
+
* speech in the browser or on the server based on the configured mode, sends
|
|
535
|
+
* it to the AI, speaks the response in the browser or on the server based on
|
|
536
|
+
* the configured mode, and can point at elements on screen.
|
|
537
|
+
*
|
|
538
|
+
* @example
|
|
539
|
+
* ```tsx
|
|
540
|
+
* import { CursorBuddy } from "cursor-buddy/react"
|
|
541
|
+
*
|
|
542
|
+
* function App() {
|
|
543
|
+
* return (
|
|
544
|
+
* <>
|
|
545
|
+
* <YourApp />
|
|
546
|
+
* <CursorBuddy endpoint="/api/cursor-buddy" />
|
|
547
|
+
* </>
|
|
548
|
+
* )
|
|
549
|
+
* }
|
|
550
|
+
* ```
|
|
551
|
+
*/
|
|
552
|
+
function CursorBuddy({ endpoint, hotkey, container, speech, transcription, cursor, speechBubble, waveform, onTranscript, onResponse, onPoint, onStateChange, onError }) {
|
|
553
|
+
return /* @__PURE__ */ jsx(CursorBuddyProvider, {
|
|
554
|
+
endpoint,
|
|
555
|
+
speech,
|
|
556
|
+
transcription,
|
|
557
|
+
onTranscript,
|
|
558
|
+
onResponse,
|
|
559
|
+
onPoint,
|
|
560
|
+
onStateChange,
|
|
561
|
+
onError,
|
|
562
|
+
children: /* @__PURE__ */ jsx(CursorBuddyInner, {
|
|
563
|
+
hotkey,
|
|
564
|
+
cursor,
|
|
565
|
+
speechBubble,
|
|
566
|
+
waveform,
|
|
567
|
+
container
|
|
568
|
+
})
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
export { CursorBuddy, CursorBuddyProvider, useCursorBuddy };
|
|
573
|
+
|
|
574
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["styles"],"sources":["../../src/react/styles.css?inline","../../src/react/utils/inject-styles.ts","../../src/react/provider.tsx","../../src/react/hooks.ts","../../src/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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"}
|