cursor-buddy 0.0.10 → 0.0.11
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/README.md +196 -22
- package/dist/{client-CliXcNch.mjs → client-D7kFGsuH.mjs} +634 -300
- package/dist/client-D7kFGsuH.mjs.map +1 -0
- package/dist/client-DoqSfCbo.d.mts +82 -0
- package/dist/client-DoqSfCbo.d.mts.map +1 -0
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +1 -1
- package/dist/{point-tool-l3FewgM9.d.mts → point-tool-B_s8op--.d.mts} +3 -9
- package/dist/point-tool-B_s8op--.d.mts.map +1 -0
- package/dist/point-tool-DZJmhD8e.mjs.map +1 -1
- package/dist/react/index.d.mts +83 -6
- package/dist/react/index.d.mts.map +1 -1
- package/dist/react/index.mjs +268 -13
- package/dist/react/index.mjs.map +1 -1
- package/dist/server/adapters/next.d.mts +1 -1
- package/dist/server/index.d.mts +3 -3
- package/dist/server/index.mjs +84 -28
- package/dist/server/index.mjs.map +1 -1
- package/dist/{client-sjVVGYPU.d.mts → types-BU0Gegg2.d.mts} +123 -180
- package/dist/types-BU0Gegg2.d.mts.map +1 -0
- package/dist/{types-BJfkApb_.d.mts → types-ClkvIgAm.d.mts} +1 -1
- package/dist/{types-BJfkApb_.d.mts.map → types-ClkvIgAm.d.mts.map} +1 -1
- package/package.json +3 -2
- package/dist/client-CliXcNch.mjs.map +0 -1
- package/dist/client-sjVVGYPU.d.mts.map +0 -1
- package/dist/point-tool-l3FewgM9.d.mts.map +0 -1
package/dist/react/index.mjs.map
CHANGED
|
@@ -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/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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"}
|
|
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/core/hotkeys/approval-shortcuts.ts","../../src/react/use-hotkey.ts","../../src/react/use-approval-shortcuts.ts","../../src/react/components/Cursor.tsx","../../src/react/components/SpeechBubble.tsx","../../src/react/components/ToolBubble.tsx","../../src/react/components/ToolBubbleStack.tsx","../../src/react/components/Waveform.tsx","../../src/react/components/Overlay.tsx","../../src/react/components/CursorBuddy.tsx"],"sourcesContent":["export default \":root{--cursor-buddy-color-idle:#3b82f6;--cursor-buddy-color-listening:#ef4444;--cursor-buddy-color-processing:#eab308;--cursor-buddy-color-responding:#22c55e;--cursor-buddy-cursor-stroke:#fff;--cursor-buddy-cursor-shadow:0 2px 4px #0000004d;--cursor-buddy-bubble-bg:#fff;--cursor-buddy-bubble-text:#1f2937;--cursor-buddy-bubble-radius:8px;--cursor-buddy-bubble-padding:8px 12px;--cursor-buddy-bubble-shadow:0 4px 12px #00000026;--cursor-buddy-bubble-max-width:200px;--cursor-buddy-bubble-font-size:14px;--cursor-buddy-waveform-color:#ef4444;--cursor-buddy-waveform-bar-width:4px;--cursor-buddy-waveform-bar-radius:2px;--cursor-buddy-waveform-gap:3px;--cursor-buddy-z-index:2147480000;--cursor-buddy-transition-fast:.1s;--cursor-buddy-transition-normal:.2s;--cursor-buddy-animation-duration:.3s}.cursor-buddy-overlay{pointer-events:none;isolation:isolate;z-index:var(--cursor-buddy-z-index);position:fixed;inset:0}.cursor-buddy-container{position:absolute;transform:translate(8px,4px)}.cursor-buddy-cursor{transition:transform var(--cursor-buddy-transition-fast) ease-out;filter:drop-shadow(var(--cursor-buddy-cursor-shadow))}.cursor-buddy-cursor polygon{stroke:var(--cursor-buddy-cursor-stroke);stroke-width:2px;transition:fill var(--cursor-buddy-transition-normal) ease-out}.cursor-buddy-cursor--idle polygon{fill:var(--cursor-buddy-color-idle)}.cursor-buddy-cursor--listening polygon{fill:var(--cursor-buddy-color-listening)}.cursor-buddy-cursor--processing polygon{fill:var(--cursor-buddy-color-processing)}.cursor-buddy-cursor--responding polygon{fill:var(--cursor-buddy-color-responding)}.cursor-buddy-cursor__spinner{color:var(--cursor-buddy-color-processing)}.cursor-buddy-cursor--listening{animation:1.5s ease-in-out infinite cursor-buddy-pulse}@keyframes cursor-buddy-pulse{0%,to{filter:drop-shadow(var(--cursor-buddy-cursor-shadow))}50%{filter:drop-shadow(0 0 8px var(--cursor-buddy-color-listening))}}.cursor-buddy-cursor--processing{animation:2s linear infinite cursor-buddy-spin-subtle}@keyframes cursor-buddy-spin-subtle{0%{filter:drop-shadow(var(--cursor-buddy-cursor-shadow)) hue-rotate(0deg)}to{filter:drop-shadow(var(--cursor-buddy-cursor-shadow)) hue-rotate(360deg)}}.cursor-buddy-bubble{pointer-events:auto;cursor:pointer;max-width:var(--cursor-buddy-bubble-max-width);padding:var(--cursor-buddy-bubble-padding);background-color:var(--cursor-buddy-bubble-bg);color:var(--cursor-buddy-bubble-text);border-radius:var(--cursor-buddy-bubble-radius);box-shadow:var(--cursor-buddy-bubble-shadow);font-size:var(--cursor-buddy-bubble-font-size);overflow-wrap:break-word;word-break:break-word;user-select:none;width:max-content;animation:cursor-buddy-fade-in var(--cursor-buddy-animation-duration) ease-out;line-height:1.4;position:absolute;top:-8px;left:24px}@keyframes cursor-buddy-fade-in{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.cursor-buddy-waveform{align-items:center;gap:var(--cursor-buddy-waveform-gap);height:24px;animation:cursor-buddy-fade-in var(--cursor-buddy-animation-duration) ease-out;display:flex;position:absolute;top:2px;left:24px}.cursor-buddy-waveform-bar{width:var(--cursor-buddy-waveform-bar-width);background-color:var(--cursor-buddy-waveform-color);border-radius:var(--cursor-buddy-waveform-bar-radius);transition:height 50ms ease-out}.cursor-buddy-fade-out{animation:cursor-buddy-fade-out var(--cursor-buddy-animation-duration) ease-out forwards}@keyframes cursor-buddy-fade-out{0%{opacity:1}to{opacity:0}}:root{--cursor-buddy-tool-bg:#fff;--cursor-buddy-tool-text:#1f2937;--cursor-buddy-tool-border:#e5e7eb;--cursor-buddy-tool-radius:8px;--cursor-buddy-tool-padding:8px 12px;--cursor-buddy-tool-shadow:0 4px 12px #00000026;--cursor-buddy-tool-gap:8px;--cursor-buddy-tool-pending:#3b82f6;--cursor-buddy-tool-approval:#f59e0b;--cursor-buddy-tool-success:#22c55e;--cursor-buddy-tool-error:#ef4444;--cursor-buddy-tool-denied:#6b7280;--cursor-buddy-tool-btn-approve-bg:#22c55e;--cursor-buddy-tool-btn-approve-text:#fff;--cursor-buddy-tool-btn-deny-bg:#ef4444;--cursor-buddy-tool-btn-deny-text:#fff}.cursor-buddy-tool-stack{gap:var(--cursor-buddy-tool-gap);pointer-events:auto;flex-direction:column;display:flex;position:absolute;top:32px;left:24px}.cursor-buddy-tool-stack__item{animation:cursor-buddy-fade-in var(--cursor-buddy-animation-duration) ease-out}.cursor-buddy-tool-bubble{padding:var(--cursor-buddy-tool-padding);background-color:var(--cursor-buddy-tool-bg);color:var(--cursor-buddy-tool-text);border-radius:var(--cursor-buddy-tool-radius);box-shadow:var(--cursor-buddy-tool-shadow);white-space:nowrap;user-select:none;border-left:3px solid var(--cursor-buddy-tool-pending);align-items:center;gap:8px;font-size:13px;line-height:1.4;display:flex}.cursor-buddy-tool-bubble--pending,.cursor-buddy-tool-bubble--approved{border-left-color:var(--cursor-buddy-tool-pending)}.cursor-buddy-tool-bubble--awaiting_approval{border-left-color:var(--cursor-buddy-tool-approval)}.cursor-buddy-tool-bubble--completed{border-left-color:var(--cursor-buddy-tool-success)}.cursor-buddy-tool-bubble--failed{border-left-color:var(--cursor-buddy-tool-error)}.cursor-buddy-tool-bubble--denied{border-left-color:var(--cursor-buddy-tool-denied)}.cursor-buddy-tool-bubble__content{align-items:center;gap:6px;display:flex}.cursor-buddy-tool-bubble__label{font-weight:500}.cursor-buddy-tool-icon{justify-content:center;align-items:center;width:16px;height:16px;font-size:12px;font-weight:700;display:inline-flex}.cursor-buddy-tool-icon--spinner{border:2px solid var(--cursor-buddy-tool-pending);border-top-color:#0000;border-radius:50%;width:14px;height:14px;animation:.8s linear infinite cursor-buddy-tool-spin}@keyframes cursor-buddy-tool-spin{to{transform:rotate(360deg)}}.cursor-buddy-tool-icon--check{color:var(--cursor-buddy-tool-success)}.cursor-buddy-tool-icon--error{color:var(--cursor-buddy-tool-error)}.cursor-buddy-tool-icon--denied{color:var(--cursor-buddy-tool-denied)}.cursor-buddy-tool-icon--question{color:var(--cursor-buddy-tool-approval)}.cursor-buddy-tool-bubble__actions{gap:4px;margin-left:4px;display:flex}.cursor-buddy-tool-button{cursor:pointer;border:none;border-radius:4px;padding:4px 10px;font-size:12px;font-weight:500;transition:opacity .15s}.cursor-buddy-tool-button:hover{opacity:.9}.cursor-buddy-tool-button:active{opacity:.8}.cursor-buddy-tool-button--approve{background-color:var(--cursor-buddy-tool-btn-approve-bg);color:var(--cursor-buddy-tool-btn-approve-text)}.cursor-buddy-tool-button--deny{background-color:var(--cursor-buddy-tool-btn-deny-bg);color:var(--cursor-buddy-tool-btn-deny-text)}.cursor-buddy-tool-bubble__dismiss{color:#9ca3af;cursor:pointer;background-color:#0000;border:none;border-radius:50%;justify-content:center;align-items:center;width:18px;height:18px;margin-left:auto;padding:0;font-size:12px;transition:color .15s,background-color .15s;display:flex}.cursor-buddy-tool-bubble__dismiss:hover{color:#6b7280;background-color:#f3f4f6}\";","// 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 toolDisplay,\n children,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n onToolCall,\n onToolResult,\n}: CursorBuddyProviderProps) {\n const [client] = useState(\n () =>\n new CursorBuddyClient(endpoint, {\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n onToolCall,\n onToolResult,\n speech,\n transcription,\n toolDisplay,\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 { ToolCallState } from \"../core/tools\"\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 */\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 // Tool state\n /** All tool calls in current turn */\n toolCalls: ToolCallState[]\n /** Visible, non-expired tool calls */\n activeToolCalls: ToolCallState[]\n /** Tool awaiting user approval, or null */\n pendingApproval: ToolCallState | null\n\n // Actions\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 // Tool actions\n /** Approve a pending tool call */\n approveToolCall: (id: string) => void\n /** Deny a pending tool call */\n denyToolCall: (id: string) => void\n /** Dismiss a tool call bubble manually */\n dismissToolCall: (id: string) => 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 // Tool actions\n approveToolCall: useCallback(\n (id: string) => client.approveToolCall(id),\n [client],\n ),\n denyToolCall: useCallback(\n (id: string) => client.denyToolCall(id),\n [client],\n ),\n dismissToolCall: useCallback(\n (id: string) => client.dismissToolCall(id),\n [client],\n ),\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","/**\n * Approval shortcut keys\n */\nconst APPROVE_KEYS = [\"y\", \"enter\"]\nconst DENY_KEYS = [\"n\", \"escape\"]\n\nexport interface ApprovalShortcutController {\n /** Whether the controller is currently enabled */\n readonly isEnabled: boolean\n /** Enable or disable the controller */\n setEnabled(enabled: boolean): void\n /** Clean up event listeners */\n destroy(): void\n}\n\nexport interface ApprovalShortcutOptions {\n /** Called when user presses Y or Enter */\n onApprove: () => void\n /** Called when user presses N or Escape */\n onDeny: () => void\n /** Whether shortcuts are active (default: true) */\n enabled?: boolean\n}\n\n/**\n * Create a controller for approval keyboard shortcuts.\n *\n * Listens for:\n * - Y or Enter → onApprove\n * - N or Escape → onDeny\n *\n * @example\n * ```ts\n * const controller = createApprovalShortcutController({\n * onApprove: () => approveToolCall(id),\n * onDeny: () => denyToolCall(id),\n * enabled: pendingApproval !== null,\n * })\n *\n * // Later, cleanup\n * controller.destroy()\n * ```\n */\nexport function createApprovalShortcutController(\n options: ApprovalShortcutOptions,\n): ApprovalShortcutController {\n let enabled = options.enabled ?? true\n const { onApprove, onDeny } = options\n\n function handleKeyDown(event: KeyboardEvent) {\n if (!enabled) return\n\n // Ignore if user is typing in an input\n const target = event.target as HTMLElement\n if (\n target.tagName === \"INPUT\" ||\n target.tagName === \"TEXTAREA\" ||\n target.isContentEditable\n ) {\n return\n }\n\n const key = event.key.toLowerCase()\n\n if (APPROVE_KEYS.includes(key)) {\n event.preventDefault()\n onApprove()\n } else if (DENY_KEYS.includes(key)) {\n event.preventDefault()\n onDeny()\n }\n }\n\n // Attach listener\n window.addEventListener(\"keydown\", handleKeyDown)\n\n return {\n get isEnabled() {\n return enabled\n },\n\n setEnabled(newEnabled: boolean) {\n enabled = newEnabled\n },\n\n destroy() {\n window.removeEventListener(\"keydown\", handleKeyDown)\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","\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport {\n type ApprovalShortcutController,\n createApprovalShortcutController,\n} from \"../core/hotkeys\"\n\n/**\n * Hook for approval keyboard shortcuts.\n *\n * When enabled, listens for:\n * - Y or Enter → onApprove\n * - N or Escape → onDeny\n *\n * Automatically enables when `enabled` is true.\n * Ignores keypresses when focus is in an input/textarea.\n *\n * @param enabled - Whether shortcuts should be active\n * @param onApprove - Called when user presses Y or Enter\n * @param onDeny - Called when user presses N or Escape\n *\n * @example\n * ```tsx\n * useApprovalShortcuts(\n * pendingApproval !== null,\n * () => approveToolCall(pendingApproval.id),\n * () => denyToolCall(pendingApproval.id)\n * )\n * ```\n */\nexport function useApprovalShortcuts(\n enabled: boolean,\n onApprove: () => void,\n onDeny: () => void,\n): void {\n const onApproveRef = useRef(onApprove)\n const onDenyRef = useRef(onDeny)\n onApproveRef.current = onApprove\n onDenyRef.current = onDeny\n\n const controllerRef = useRef<ApprovalShortcutController | null>(null)\n\n useEffect(() => {\n controllerRef.current = createApprovalShortcutController({\n onApprove: () => onApproveRef.current(),\n onDeny: () => onDenyRef.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","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","\"use client\"\n\nimport type { ToolBubbleRenderProps, ToolCallStatus } from \"../../core/tools\"\n\nexport interface ToolBubbleProps extends ToolBubbleRenderProps {\n /** Custom render function (from toolDisplay config) */\n customRender?: (props: ToolBubbleRenderProps) => React.ReactNode\n}\n\n/**\n * Status icons for tool call states\n */\nfunction StatusIcon({ status }: { status: ToolCallStatus }) {\n switch (status) {\n case \"pending\":\n case \"approved\":\n return (\n <span\n className=\"cursor-buddy-tool-icon cursor-buddy-tool-icon--spinner\"\n aria-label=\"Loading\"\n />\n )\n case \"awaiting_approval\":\n return (\n <span\n className=\"cursor-buddy-tool-icon cursor-buddy-tool-icon--question\"\n aria-label=\"Needs approval\"\n >\n ?\n </span>\n )\n case \"completed\":\n return (\n <span\n className=\"cursor-buddy-tool-icon cursor-buddy-tool-icon--check\"\n aria-label=\"Completed\"\n >\n ✓\n </span>\n )\n case \"denied\":\n return (\n <span\n className=\"cursor-buddy-tool-icon cursor-buddy-tool-icon--denied\"\n aria-label=\"Denied\"\n >\n ✕\n </span>\n )\n case \"failed\":\n return (\n <span\n className=\"cursor-buddy-tool-icon cursor-buddy-tool-icon--error\"\n aria-label=\"Failed\"\n >\n !\n </span>\n )\n default:\n return null\n }\n}\n\n/**\n * Default tool bubble component.\n * Displays tool call status with optional approve/deny buttons.\n */\nexport function ToolBubble({\n toolName,\n args,\n status,\n label,\n result,\n error,\n approve,\n deny,\n dismiss,\n customRender,\n}: ToolBubbleProps) {\n // Use custom render if provided\n if (customRender) {\n return (\n <>\n {customRender({\n toolName,\n args,\n status,\n label,\n result,\n error,\n approve,\n deny,\n dismiss,\n })}\n </>\n )\n }\n\n const needsApproval = status === \"awaiting_approval\"\n const isTerminal =\n status === \"completed\" || status === \"denied\" || status === \"failed\"\n\n return (\n <div\n className={`cursor-buddy-tool-bubble cursor-buddy-tool-bubble--${status}`}\n role=\"status\"\n aria-live=\"polite\"\n >\n <div className=\"cursor-buddy-tool-bubble__content\">\n <StatusIcon status={status} />\n <span className=\"cursor-buddy-tool-bubble__label\">{label}</span>\n </div>\n\n {needsApproval && approve && deny && (\n <div className=\"cursor-buddy-tool-bubble__actions\">\n <button\n type=\"button\"\n className=\"cursor-buddy-tool-button cursor-buddy-tool-button--approve\"\n onClick={approve}\n aria-label={`Approve ${toolName}`}\n >\n Yes (Y)\n </button>\n <button\n type=\"button\"\n className=\"cursor-buddy-tool-button cursor-buddy-tool-button--deny\"\n onClick={deny}\n aria-label={`Deny ${toolName}`}\n >\n No (Esc)\n </button>\n </div>\n )}\n\n {isTerminal && (\n <button\n type=\"button\"\n className=\"cursor-buddy-tool-bubble__dismiss\"\n onClick={dismiss}\n aria-label=\"Dismiss\"\n >\n ✕\n </button>\n )}\n </div>\n )\n}\n","\"use client\"\n\nimport type {\n ToolBubbleRenderProps,\n ToolCallState,\n ToolDisplayConfig,\n} from \"../../core/tools\"\nimport { ToolBubble } from \"./ToolBubble\"\n\nexport interface ToolBubbleStackProps {\n /** Active tool calls to display */\n toolCalls: ToolCallState[]\n /** Tool display configuration */\n toolDisplay?: ToolDisplayConfig\n /** Approve a tool call */\n onApprove: (id: string) => void\n /** Deny a tool call */\n onDeny: (id: string) => void\n /** Dismiss a tool call bubble */\n onDismiss: (id: string) => void\n /** Custom render function for all bubbles (overrides per-tool config) */\n renderToolBubble?: (props: ToolBubbleRenderProps) => React.ReactNode\n}\n\n/**\n * Stack of tool bubbles displayed near the cursor.\n * Renders all active tool calls in a vertical column.\n */\nexport function ToolBubbleStack({\n toolCalls,\n toolDisplay,\n onApprove,\n onDeny,\n onDismiss,\n renderToolBubble,\n}: ToolBubbleStackProps) {\n if (toolCalls.length === 0) return null\n\n return (\n <div className=\"cursor-buddy-tool-stack\" role=\"region\" aria-label=\"Tool calls\">\n {toolCalls.map((toolCall) => {\n const toolConfig = toolDisplay?.[toolCall.toolName] ?? toolDisplay?.[\"*\"]\n\n // Skip hidden tools\n if (toolConfig?.mode === \"hidden\") return null\n\n const needsApproval = toolCall.status === \"awaiting_approval\"\n\n const bubbleProps: ToolBubbleRenderProps = {\n toolName: toolCall.toolName,\n args: toolCall.args,\n status: toolCall.status,\n label: toolCall.label,\n result: toolCall.result,\n error: toolCall.error,\n approve: needsApproval ? () => onApprove(toolCall.id) : undefined,\n deny: needsApproval ? () => onDeny(toolCall.id) : undefined,\n dismiss: () => onDismiss(toolCall.id),\n }\n\n // Use global render override if provided\n if (renderToolBubble) {\n return (\n <div key={toolCall.id} className=\"cursor-buddy-tool-stack__item\">\n {renderToolBubble(bubbleProps)}\n </div>\n )\n }\n\n return (\n <div key={toolCall.id} className=\"cursor-buddy-tool-stack__item\">\n <ToolBubble {...bubbleProps} customRender={toolConfig?.render} />\n </div>\n )\n })}\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 ToolBubbleRenderProps,\n ToolDisplayConfig,\n} from \"../../core/tools\"\nimport type {\n CursorRenderProps,\n SpeechBubbleRenderProps,\n WaveformRenderProps,\n} from \"../../core/types\"\nimport { useCursorBuddy } from \"../hooks\"\nimport { useApprovalShortcuts } from \"../use-approval-shortcuts\"\nimport { DefaultCursor } from \"./Cursor\"\nimport { DefaultSpeechBubble } from \"./SpeechBubble\"\nimport { ToolBubbleStack } from \"./ToolBubbleStack\"\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 /** Tool display configuration */\n toolDisplay?: ToolDisplayConfig\n /** Custom tool bubble renderer (overrides per-tool config) */\n renderToolBubble?: (props: ToolBubbleRenderProps) => 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, waveform, and tool bubbles.\n * Uses React portal to render at the document body level.\n */\nexport function Overlay({\n cursor,\n speechBubble,\n waveform,\n toolDisplay,\n renderToolBubble,\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 {\n state,\n isPointing,\n isEnabled,\n dismissPointing,\n activeToolCalls,\n pendingApproval,\n approveToolCall,\n denyToolCall,\n dismissToolCall,\n } = useCursorBuddy()\n\n // Keyboard shortcuts for approval (Y/Enter = approve, N/Escape = deny)\n useApprovalShortcuts(\n pendingApproval !== null,\n () => pendingApproval && approveToolCall(pendingApproval.id),\n () => pendingApproval && denyToolCall(pendingApproval.id),\n )\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 <ToolBubbleStack\n toolCalls={activeToolCalls}\n toolDisplay={toolDisplay}\n onApprove={approveToolCall}\n onDeny={denyToolCall}\n onDismiss={dismissToolCall}\n renderToolBubble={renderToolBubble}\n />\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 ToolCallEvent,\n ToolDisplayConfig,\n ToolResultEvent,\n} from \"../../core/tools\"\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<\n OverlayProps,\n \"cursor\" | \"speechBubble\" | \"waveform\" | \"toolDisplay\" | \"renderToolBubble\"\n > {\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 /** Callback when a tool is called */\n onToolCall?: (event: ToolCallEvent) => void\n /** Callback when a tool completes */\n onToolResult?: (event: ToolResultEvent) => 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 toolDisplay,\n renderToolBubble,\n container,\n}: Pick<\n CursorBuddyProps,\n | \"hotkey\"\n | \"cursor\"\n | \"speechBubble\"\n | \"waveform\"\n | \"toolDisplay\"\n | \"renderToolBubble\"\n | \"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 toolDisplay={toolDisplay}\n renderToolBubble={renderToolBubble}\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 toolDisplay,\n renderToolBubble,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n onToolCall,\n onToolResult,\n}: CursorBuddyProps) {\n return (\n <CursorBuddyProvider\n endpoint={endpoint}\n speech={speech}\n transcription={transcription}\n toolDisplay={toolDisplay}\n onTranscript={onTranscript}\n onResponse={onResponse}\n onPoint={onPoint}\n onStateChange={onStateChange}\n onError={onError}\n onToolCall={onToolCall}\n onToolResult={onToolResult}\n >\n <CursorBuddyInner\n hotkey={hotkey}\n cursor={cursor}\n speechBubble={speechBubble}\n waveform={waveform}\n toolDisplay={toolDisplay}\n renderToolBubble={renderToolBubble}\n container={container}\n />\n </CursorBuddyProvider>\n )\n}\n"],"mappings":"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"}
|
package/dist/server/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as pointTool, t as PointToolInput } from "../point-tool-
|
|
2
|
-
import { n as CursorBuddyHandlerConfig, t as CursorBuddyHandler } from "../types-
|
|
1
|
+
import { n as pointTool, t as PointToolInput } from "../point-tool-B_s8op--.mjs";
|
|
2
|
+
import { n as CursorBuddyHandlerConfig, t as CursorBuddyHandler } from "../types-ClkvIgAm.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/server/handler.d.ts
|
|
5
5
|
/**
|
|
@@ -25,7 +25,7 @@ import { n as CursorBuddyHandlerConfig, t as CursorBuddyHandler } from "../types
|
|
|
25
25
|
declare function createCursorBuddyHandler(config: CursorBuddyHandlerConfig): CursorBuddyHandler;
|
|
26
26
|
//#endregion
|
|
27
27
|
//#region src/server/system-prompt.d.ts
|
|
28
|
-
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 the user's current screen and hear what they say. Respond conversationally. Your response will be spoken aloud with text-to-speech, so keep it natural, concise, and easy to follow.\n\n## Core behavior\n\n- Speak like a helpful companion, not a robot\n- Keep most responses to 1-3 short sentences\n- Focus on what is visible right now on the user's screen\n- If something is unclear or not visible, say that plainly\n- Do not mention screenshots, overlays, internal helper data, or the DOM snapshot to the user\n- Never describe the internal element IDs to the user - they are for your reference only\n\n## Visual Context: DOM Snapshot\n\nYou receive a screenshot of the user's viewport along with a DOM snapshot that lists visible elements in a compact, hierarchical format. The DOM snapshot looks like this:\n\n```\n# viewport 1440x900\n@1 nav \"Sidebar\"\n @2 link \"Projects\" [x=24 y=96 w=96 h=28]\n @3 link \"Tasks\" [x=24 y=132 w=72 h=28]\n@4 main\n @5 heading \"Q2 Roadmap\"\n @6 textbox \"Search tasks\" [x=320 y=120 w=280 h=36]\n @7 button \"Filter\" [x=612 y=120 w=84 h=36] [expanded=false]\n @8 checkbox \"Selected\" [checked=false] [x=340 y=220 w=16 h=16]\n```\n\n**How to read the DOM snapshot:**\n- Each element starts with `@X` where X is its unique ID\n- The element's role follows (button, link, textbox, heading, nav, main, etc.)\n- Text content is in quotes after the role\n- `[x=... y=... w=... h=...]` shows the element's position and size for your reference\n- `[key=value]` brackets show element state (checked, expanded, disabled, etc.)\n- Indentation shows parent-child relationships\n\n**The DOM snapshot is invisible to the user.** It helps you understand the page structure and identify specific elements to point at. Never mention it to the user.\n\n## The point tool\n\nYou have a `point` tool that can visually indicate an element on the user's screen.\n\nUse the `point` tool when the user is asking you to identify, locate, indicate, highlight, or show a specific visible target on screen.\n\nCommon cases where you should use `point`:\n- the user asks where something is\n- the user asks what to click\n- the user says things like \"show me\", \"point to it\", \"where is it\", \"which one\", \"what should I click\", or \"highlight that\"\n\nDo not use the `point` tool when spoken guidance alone is enough and the user is not asking you to identify a specific on-screen target.\n\nExamples where spoken guidance alone may be enough:\n- explaining what a page does\n- answering a general question about what is on screen\n- giving brief next-step advice that does not depend on locating a specific element\n\nIf using the `point` tool:\n- first give the spoken response\n- then call the tool\n- call it at most once per response\n- point only at the most relevant target\n- never replace the tool call with plain text like \"(point here)\" or \"I'm pointing at it now\"\n\nIf the user asks where something is on screen, what to click, or asks you to point something out, you should usually use the point tool rather than only describing it in words.\nDo not say things like \"I can point to it if you want\" when the user already asked where it is. In that case, answer briefly and use the point tool.\n\n## How to point using the point tool\n\nThe point tool accepts an `elementId` parameter which is the numeric ID from the DOM snapshot (the number after `@`).\n\n**Example:** To point at the \"Filter\" button from the example above (which is `@7`):\n```\nelementId: 7\nlabel: \"Filter button\"\n```\n\n**Steps:**\n1. Find the element in the DOM snapshot by reading its text/role\n2. Note its `@X` ID\n3. Call the point tool with that numeric ID (just the number, without the @ symbol)\n4. Provide a brief, natural label describing what you're pointing at\n\nThe element's position is resolved in real-time when the cursor moves, so it will point accurately even if the page has changed slightly.\n\n## What to say\n\nWhen the user asks you to point something out:\n- briefly answer in a natural spoken way\n- then use the tool if the request is about locating or indicating something on screen\n\nGood spoken style:\n- \"Click this button right here.\"\n- \"The error message is over here.\"\n- \"This is the field you want.\"\n- \"That setting is in this section.\"\n\nAvoid:\n- mentioning element IDs (like \"@5\" or \"element 12\")\n- mentioning internal tools\n- describing internal reasoning\n- saying you are looking at a screenshot\n\n## If the target is not clear\n\nIf you cannot confidently find the requested thing on screen:\n- say you cannot see it clearly or cannot find it\n- do not point at a random or uncertain target\n\n## Priority\n\nYour first priority is being helpful and correct.\nYour second priority is using the `point` tool whenever the user is asking you to visually identify a specific thing on screen.\n";
|
|
28
|
+
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 the user's current screen and hear what they say. Respond conversationally. Your response will be spoken aloud with text-to-speech, so keep it natural, concise, and easy to follow.\n\n## Core behavior\n\n- Speak like a helpful companion, not a robot\n- Keep most responses to 1-3 short sentences\n- Focus on what is visible right now on the user's screen\n- If something is unclear or not visible, say that plainly\n- Do not mention screenshots, overlays, internal helper data, or the DOM snapshot to the user\n- Never describe the internal element IDs to the user - they are for your reference only\n- Spell out abbreviations for TTS clarity: say \"Doctor Smith\" not \"Dr. Smith\", \"for example\" not \"e.g.\"\n\n## Visual Context: DOM Snapshot\n\nYou receive a screenshot of the user's viewport along with a DOM snapshot that lists visible elements in a compact, hierarchical format. The DOM snapshot looks like this:\n\n```\n# viewport 1440x900\n@1 nav \"Sidebar\"\n @2 link \"Projects\" [x=24 y=96 w=96 h=28]\n @3 link \"Tasks\" [x=24 y=132 w=72 h=28]\n@4 main\n @5 heading \"Q2 Roadmap\"\n @6 textbox \"Search tasks\" [x=320 y=120 w=280 h=36]\n @7 button \"Filter\" [x=612 y=120 w=84 h=36] [expanded=false]\n @8 checkbox \"Selected\" [checked=false] [x=340 y=220 w=16 h=16]\n```\n\n**How to read the DOM snapshot:**\n- Each element starts with `@X` where X is its unique ID\n- The element's role follows (button, link, textbox, heading, nav, main, etc.)\n- Text content is in quotes after the role\n- `[x=... y=... w=... h=...]` shows the element's position and size for your reference\n- `[key=value]` brackets show element state (checked, expanded, disabled, etc.)\n- Indentation shows parent-child relationships\n\n**The DOM snapshot is invisible to the user.** It helps you understand the page structure and identify specific elements to point at. Never mention it to the user.\n\n## The point tool\n\nYou have a `point` tool that can visually indicate an element on the user's screen.\n\nUse the `point` tool when the user is asking you to identify, locate, indicate, highlight, or show a specific visible target on screen.\n\nCommon cases where you should use `point`:\n- the user asks where something is\n- the user asks what to click\n- the user says things like \"show me\", \"point to it\", \"where is it\", \"which one\", \"what should I click\", or \"highlight that\"\n\nDo not use the `point` tool when spoken guidance alone is enough and the user is not asking you to identify a specific on-screen target.\n\nExamples where spoken guidance alone may be enough:\n- explaining what a page does\n- answering a general question about what is on screen\n- giving brief next-step advice that does not depend on locating a specific element\n\nIf using the `point` tool:\n- first give the spoken response\n- then call the tool\n- call it at most once per response\n- point only at the most relevant target\n- never replace the tool call with plain text like \"(point here)\" or \"I'm pointing at it now\"\n\nIf the user asks where something is on screen, what to click, or asks you to point something out, you should usually use the point tool rather than only describing it in words.\nDo not say things like \"I can point to it if you want\" when the user already asked where it is. In that case, answer briefly and use the point tool.\n\n## How to point using the point tool\n\nThe point tool accepts an `elementId` parameter which is the numeric ID from the DOM snapshot (the number after `@`).\n\n**Example:** To point at the \"Filter\" button from the example above (which is `@7`):\n```\nelementId: 7\nlabel: \"Filter button\"\n```\n\n**Steps:**\n1. Find the element in the DOM snapshot by reading its text/role\n2. Note its `@X` ID\n3. Call the point tool with that numeric ID (just the number, without the @ symbol)\n4. Provide a brief, natural label describing what you're pointing at\n\nThe element's position is resolved in real-time when the cursor moves, so it will point accurately even if the page has changed slightly.\n\n## What to say\n\nWhen the user asks you to point something out:\n- briefly answer in a natural spoken way\n- then use the tool if the request is about locating or indicating something on screen\n\nGood spoken style:\n- \"Click this button right here.\"\n- \"The error message is over here.\"\n- \"This is the field you want.\"\n- \"That setting is in this section.\"\n\nAvoid:\n- mentioning element IDs (like \"@5\" or \"element 12\")\n- mentioning internal tools\n- describing internal reasoning\n- saying you are looking at a screenshot\n\n## If the target is not clear\n\nIf you cannot confidently find the requested thing on screen:\n- say you cannot see it clearly or cannot find it\n- do not point at a random or uncertain target\n\n## Using tools\n\nWhen you use tools like web search:\n- Do NOT include URLs, links, or source citations in your spoken response\n- Summarize findings in natural speech without mentioning sources\n- Remember: your response is spoken aloud via TTS, so links and URLs are useless to the listener\n- If the user explicitly asks for sources, tell them you can show them on screen instead\n\n## Priority\n\nYour first priority is being helpful and correct.\nYour second priority is using the `point` tool whenever the user is asking you to visually identify a specific thing on screen.\n";
|
|
29
29
|
//#endregion
|
|
30
30
|
export { type CursorBuddyHandler, type CursorBuddyHandlerConfig, DEFAULT_SYSTEM_PROMPT, type PointToolInput, createCursorBuddyHandler, pointTool };
|
|
31
31
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/server/index.mjs
CHANGED
|
@@ -13,6 +13,7 @@ You can see the user's current screen and hear what they say. Respond conversati
|
|
|
13
13
|
- If something is unclear or not visible, say that plainly
|
|
14
14
|
- Do not mention screenshots, overlays, internal helper data, or the DOM snapshot to the user
|
|
15
15
|
- Never describe the internal element IDs to the user - they are for your reference only
|
|
16
|
+
- Spell out abbreviations for TTS clarity: say "Doctor Smith" not "Dr. Smith", "for example" not "e.g."
|
|
16
17
|
|
|
17
18
|
## Visual Context: DOM Snapshot
|
|
18
19
|
|
|
@@ -110,6 +111,14 @@ If you cannot confidently find the requested thing on screen:
|
|
|
110
111
|
- say you cannot see it clearly or cannot find it
|
|
111
112
|
- do not point at a random or uncertain target
|
|
112
113
|
|
|
114
|
+
## Using tools
|
|
115
|
+
|
|
116
|
+
When you use tools like web search:
|
|
117
|
+
- Do NOT include URLs, links, or source citations in your spoken response
|
|
118
|
+
- Summarize findings in natural speech without mentioning sources
|
|
119
|
+
- Remember: your response is spoken aloud via TTS, so links and URLs are useless to the listener
|
|
120
|
+
- If the user explicitly asks for sources, tell them you can show them on screen instead
|
|
121
|
+
|
|
113
122
|
## Priority
|
|
114
123
|
|
|
115
124
|
Your first priority is being helpful and correct.
|
|
@@ -118,37 +127,84 @@ Your second priority is using the \`point\` tool whenever the user is asking you
|
|
|
118
127
|
//#endregion
|
|
119
128
|
//#region src/server/routes/chat.ts
|
|
120
129
|
/**
|
|
121
|
-
*
|
|
130
|
+
* Build the visual context string from capture metadata and DOM snapshot.
|
|
131
|
+
*/
|
|
132
|
+
function buildCaptureContext(capture, domSnapshot) {
|
|
133
|
+
const parts = [];
|
|
134
|
+
if (capture) parts.push(`Screenshot size: ${capture.width}x${capture.height} pixels.`);
|
|
135
|
+
if (domSnapshot) parts.push("", "Visible page structure (each element has @X ID for pointing):", domSnapshot);
|
|
136
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Check if a message is a tool approval response.
|
|
140
|
+
*/
|
|
141
|
+
function isToolApprovalContent(content) {
|
|
142
|
+
return Array.isArray(content) && content.length > 0 && content[0].type === "tool-approval-response";
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Convert client messages to AI SDK CoreMessage format.
|
|
146
|
+
* Attaches screenshot and DOM snapshot to the appropriate message.
|
|
147
|
+
*/
|
|
148
|
+
function buildAIMessages(clientMessages, screenshot, capture, domSnapshot, maxHistory) {
|
|
149
|
+
const maxMessages = maxHistory * 2;
|
|
150
|
+
const trimmedMessages = clientMessages.slice(-maxMessages);
|
|
151
|
+
const aiMessages = [];
|
|
152
|
+
const captureContext = buildCaptureContext(capture, domSnapshot);
|
|
153
|
+
let lastUserMessageIndex = -1;
|
|
154
|
+
for (let i = trimmedMessages.length - 1; i >= 0; i--) {
|
|
155
|
+
const msg = trimmedMessages[i];
|
|
156
|
+
if (msg.role === "user" && typeof msg.content === "string") {
|
|
157
|
+
lastUserMessageIndex = i;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (let i = 0; i < trimmedMessages.length; i++) {
|
|
162
|
+
const msg = trimmedMessages[i];
|
|
163
|
+
if (msg.role === "user") {
|
|
164
|
+
if (typeof msg.content === "string") if (i === lastUserMessageIndex && screenshot !== void 0) {
|
|
165
|
+
const contentParts = [];
|
|
166
|
+
if (captureContext) contentParts.push({
|
|
167
|
+
type: "text",
|
|
168
|
+
text: captureContext
|
|
169
|
+
});
|
|
170
|
+
contentParts.push({
|
|
171
|
+
type: "image",
|
|
172
|
+
image: screenshot
|
|
173
|
+
});
|
|
174
|
+
contentParts.push({
|
|
175
|
+
type: "text",
|
|
176
|
+
text: msg.content
|
|
177
|
+
});
|
|
178
|
+
aiMessages.push({
|
|
179
|
+
role: "user",
|
|
180
|
+
content: contentParts
|
|
181
|
+
});
|
|
182
|
+
} else aiMessages.push({
|
|
183
|
+
role: "user",
|
|
184
|
+
content: msg.content
|
|
185
|
+
});
|
|
186
|
+
} else if (msg.role === "assistant") aiMessages.push({
|
|
187
|
+
role: "assistant",
|
|
188
|
+
content: typeof msg.content === "string" ? msg.content : ""
|
|
189
|
+
});
|
|
190
|
+
else if (msg.role === "tool" && isToolApprovalContent(msg.content)) aiMessages.push({
|
|
191
|
+
role: "tool",
|
|
192
|
+
content: msg.content.map((approval) => ({
|
|
193
|
+
type: "tool-approval-response",
|
|
194
|
+
approvalId: approval.approvalId,
|
|
195
|
+
approved: approval.approved
|
|
196
|
+
}))
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return aiMessages;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Handle chat requests: messages + screenshot → AI SSE stream
|
|
122
203
|
*/
|
|
123
204
|
async function handleChat(request, config) {
|
|
124
|
-
const {
|
|
205
|
+
const { messages: clientMessages, screenshot, capture, domSnapshot } = await request.json();
|
|
125
206
|
const systemPrompt = typeof config.system === "function" ? config.system({ defaultPrompt: DEFAULT_SYSTEM_PROMPT }) : config.system ?? DEFAULT_SYSTEM_PROMPT;
|
|
126
|
-
const
|
|
127
|
-
const trimmedHistory = history.slice(-maxMessages);
|
|
128
|
-
const captureContextParts = [];
|
|
129
|
-
if (capture) captureContextParts.push(`Screenshot size: ${capture.width}x${capture.height} pixels.`);
|
|
130
|
-
if (domSnapshot) captureContextParts.push("", "Visible page structure (each element has @X ID for pointing):", domSnapshot);
|
|
131
|
-
const captureContext = captureContextParts.length > 0 ? captureContextParts.join("\n") : null;
|
|
132
|
-
const messages = [...trimmedHistory.map((msg) => ({
|
|
133
|
-
role: msg.role,
|
|
134
|
-
content: msg.content
|
|
135
|
-
})), {
|
|
136
|
-
role: "user",
|
|
137
|
-
content: [
|
|
138
|
-
...captureContext ? [{
|
|
139
|
-
type: "text",
|
|
140
|
-
text: captureContext
|
|
141
|
-
}] : [],
|
|
142
|
-
{
|
|
143
|
-
type: "image",
|
|
144
|
-
image: screenshot
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
type: "text",
|
|
148
|
-
text: transcript
|
|
149
|
-
}
|
|
150
|
-
]
|
|
151
|
-
}];
|
|
207
|
+
const messages = buildAIMessages(clientMessages, screenshot, capture, domSnapshot, config.maxHistory ?? 10);
|
|
152
208
|
const tools = {
|
|
153
209
|
point: pointTool,
|
|
154
210
|
...config.tools
|
|
@@ -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":["export const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see the user's current screen and hear what they say. Respond conversationally. Your response will be spoken aloud with text-to-speech, so keep it natural, concise, and easy to follow.\n\n## Core behavior\n\n- Speak like a helpful companion, not a robot\n- Keep most responses to 1-3 short sentences\n- Focus on what is visible right now on the user's screen\n- If something is unclear or not visible, say that plainly\n- Do not mention screenshots, overlays, internal helper data, or the DOM snapshot to the user\n- Never describe the internal element IDs to the user - they are for your reference only\n\n## Visual Context: DOM Snapshot\n\nYou receive a screenshot of the user's viewport along with a DOM snapshot that lists visible elements in a compact, hierarchical format. The DOM snapshot looks like this:\n\n\\`\\`\\`\n# viewport 1440x900\n@1 nav \"Sidebar\"\n @2 link \"Projects\" [x=24 y=96 w=96 h=28]\n @3 link \"Tasks\" [x=24 y=132 w=72 h=28]\n@4 main\n @5 heading \"Q2 Roadmap\"\n @6 textbox \"Search tasks\" [x=320 y=120 w=280 h=36]\n @7 button \"Filter\" [x=612 y=120 w=84 h=36] [expanded=false]\n @8 checkbox \"Selected\" [checked=false] [x=340 y=220 w=16 h=16]\n\\`\\`\\`\n\n**How to read the DOM snapshot:**\n- Each element starts with \\`@X\\` where X is its unique ID\n- The element's role follows (button, link, textbox, heading, nav, main, etc.)\n- Text content is in quotes after the role\n- \\`[x=... y=... w=... h=...]\\` shows the element's position and size for your reference\n- \\`[key=value]\\` brackets show element state (checked, expanded, disabled, etc.)\n- Indentation shows parent-child relationships\n\n**The DOM snapshot is invisible to the user.** It helps you understand the page structure and identify specific elements to point at. Never mention it to the user.\n\n## The point tool\n\nYou have a \\`point\\` tool that can visually indicate an element on the user's screen.\n\nUse the \\`point\\` tool when the user is asking you to identify, locate, indicate, highlight, or show a specific visible target on screen.\n\nCommon cases where you should use \\`point\\`:\n- the user asks where something is\n- the user asks what to click\n- the user says things like \"show me\", \"point to it\", \"where is it\", \"which one\", \"what should I click\", or \"highlight that\"\n\nDo not use the \\`point\\` tool when spoken guidance alone is enough and the user is not asking you to identify a specific on-screen target.\n\nExamples where spoken guidance alone may be enough:\n- explaining what a page does\n- answering a general question about what is on screen\n- giving brief next-step advice that does not depend on locating a specific element\n\nIf using the \\`point\\` tool:\n- first give the spoken response\n- then call the tool\n- call it at most once per response\n- point only at the most relevant target\n- never replace the tool call with plain text like \"(point here)\" or \"I'm pointing at it now\"\n\nIf the user asks where something is on screen, what to click, or asks you to point something out, you should usually use the point tool rather than only describing it in words.\nDo not say things like \"I can point to it if you want\" when the user already asked where it is. In that case, answer briefly and use the point tool.\n\n## How to point using the point tool\n\nThe point tool accepts an \\`elementId\\` parameter which is the numeric ID from the DOM snapshot (the number after \\`@\\`).\n\n**Example:** To point at the \"Filter\" button from the example above (which is \\`@7\\`):\n\\`\\`\\`\nelementId: 7\nlabel: \"Filter button\"\n\\`\\`\\`\n\n**Steps:**\n1. Find the element in the DOM snapshot by reading its text/role\n2. Note its \\`@X\\` ID\n3. Call the point tool with that numeric ID (just the number, without the @ symbol)\n4. Provide a brief, natural label describing what you're pointing at\n\nThe element's position is resolved in real-time when the cursor moves, so it will point accurately even if the page has changed slightly.\n\n## What to say\n\nWhen the user asks you to point something out:\n- briefly answer in a natural spoken way\n- then use the tool if the request is about locating or indicating something on screen\n\nGood spoken style:\n- \"Click this button right here.\"\n- \"The error message is over here.\"\n- \"This is the field you want.\"\n- \"That setting is in this section.\"\n\nAvoid:\n- mentioning element IDs (like \"@5\" or \"element 12\")\n- mentioning internal tools\n- describing internal reasoning\n- saying you are looking at a screenshot\n\n## If the target is not clear\n\nIf you cannot confidently find the requested thing on screen:\n- say you cannot see it clearly or cannot find it\n- do not point at a random or uncertain target\n\n## Priority\n\nYour first priority is being helpful and correct.\nYour second priority is using the \\`point\\` tool whenever the user is asking you to visually identify a specific thing on screen.\n`\n","import { type StopCondition, stepCountIs, streamText } from \"ai\"\nimport { pointTool } from \"../../shared/point-tool\"\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, domSnapshot } = 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 DOM snapshot\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 (domSnapshot) {\n captureContextParts.push(\n \"\",\n \"Visible page structure (each element has @X ID for pointing):\",\n domSnapshot,\n )\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 { type: \"image\" as const, image: screenshot },\n { type: \"text\" as const, text: transcript },\n ],\n },\n ]\n\n const tools = {\n point: pointTool,\n ...config.tools,\n }\n\n const mustContinueUntilText: StopCondition<typeof tools> = ({ steps }) => {\n const lastStep = steps.at(-1)\n if (!lastStep) return false\n\n const stepText =\n typeof lastStep.text === \"string\" ? lastStep.text.trim() : \"\"\n const hadToolResults =\n Array.isArray(lastStep.toolResults) && lastStep.toolResults.length > 0\n\n // Stop only after we have actual assistant text.\n // If the step was tool-only, continue the loop.\n if (stepText.length > 0) return true\n if (hadToolResults) return false\n\n return false\n }\n\n const result = streamText({\n model: config.model,\n system: systemPrompt,\n providerOptions: config?.modelProviderMetadata,\n messages,\n tools,\n\n // Allow a follow-up step after tool use instead of the default single step.\n stopWhen: [mustContinueUntilText, stepCountIs(3)],\n\n prepareStep: async ({ stepNumber, steps }) => {\n // Normal first pass: let the model speak and optionally point.\n if (stepNumber === 0) {\n return {}\n }\n\n const previousStep = steps.at(-1)\n\n const prevText =\n typeof previousStep?.text === \"string\" ? previousStep.text.trim() : \"\"\n\n const usedPoint =\n previousStep?.toolCalls?.some((call) => call.toolName === \"point\") ??\n false\n\n // If the previous step pointed but did not speak, force the next step\n // to be text-only by removing the point tool.\n if (usedPoint && prevText.length === 0) {\n const toolNames = Object.keys(tools) as Array<keyof typeof tools>\n\n return {\n activeTools: toolNames.filter((name) => name !== \"point\"),\n }\n }\n\n return {}\n },\n })\n\n return result.toUIMessageStreamResponse()\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 voice: config?.speechVoice,\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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"}
|
|
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":["export const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see the user's current screen and hear what they say. Respond conversationally. Your response will be spoken aloud with text-to-speech, so keep it natural, concise, and easy to follow.\n\n## Core behavior\n\n- Speak like a helpful companion, not a robot\n- Keep most responses to 1-3 short sentences\n- Focus on what is visible right now on the user's screen\n- If something is unclear or not visible, say that plainly\n- Do not mention screenshots, overlays, internal helper data, or the DOM snapshot to the user\n- Never describe the internal element IDs to the user - they are for your reference only\n- Spell out abbreviations for TTS clarity: say \"Doctor Smith\" not \"Dr. Smith\", \"for example\" not \"e.g.\"\n\n## Visual Context: DOM Snapshot\n\nYou receive a screenshot of the user's viewport along with a DOM snapshot that lists visible elements in a compact, hierarchical format. The DOM snapshot looks like this:\n\n\\`\\`\\`\n# viewport 1440x900\n@1 nav \"Sidebar\"\n @2 link \"Projects\" [x=24 y=96 w=96 h=28]\n @3 link \"Tasks\" [x=24 y=132 w=72 h=28]\n@4 main\n @5 heading \"Q2 Roadmap\"\n @6 textbox \"Search tasks\" [x=320 y=120 w=280 h=36]\n @7 button \"Filter\" [x=612 y=120 w=84 h=36] [expanded=false]\n @8 checkbox \"Selected\" [checked=false] [x=340 y=220 w=16 h=16]\n\\`\\`\\`\n\n**How to read the DOM snapshot:**\n- Each element starts with \\`@X\\` where X is its unique ID\n- The element's role follows (button, link, textbox, heading, nav, main, etc.)\n- Text content is in quotes after the role\n- \\`[x=... y=... w=... h=...]\\` shows the element's position and size for your reference\n- \\`[key=value]\\` brackets show element state (checked, expanded, disabled, etc.)\n- Indentation shows parent-child relationships\n\n**The DOM snapshot is invisible to the user.** It helps you understand the page structure and identify specific elements to point at. Never mention it to the user.\n\n## The point tool\n\nYou have a \\`point\\` tool that can visually indicate an element on the user's screen.\n\nUse the \\`point\\` tool when the user is asking you to identify, locate, indicate, highlight, or show a specific visible target on screen.\n\nCommon cases where you should use \\`point\\`:\n- the user asks where something is\n- the user asks what to click\n- the user says things like \"show me\", \"point to it\", \"where is it\", \"which one\", \"what should I click\", or \"highlight that\"\n\nDo not use the \\`point\\` tool when spoken guidance alone is enough and the user is not asking you to identify a specific on-screen target.\n\nExamples where spoken guidance alone may be enough:\n- explaining what a page does\n- answering a general question about what is on screen\n- giving brief next-step advice that does not depend on locating a specific element\n\nIf using the \\`point\\` tool:\n- first give the spoken response\n- then call the tool\n- call it at most once per response\n- point only at the most relevant target\n- never replace the tool call with plain text like \"(point here)\" or \"I'm pointing at it now\"\n\nIf the user asks where something is on screen, what to click, or asks you to point something out, you should usually use the point tool rather than only describing it in words.\nDo not say things like \"I can point to it if you want\" when the user already asked where it is. In that case, answer briefly and use the point tool.\n\n## How to point using the point tool\n\nThe point tool accepts an \\`elementId\\` parameter which is the numeric ID from the DOM snapshot (the number after \\`@\\`).\n\n**Example:** To point at the \"Filter\" button from the example above (which is \\`@7\\`):\n\\`\\`\\`\nelementId: 7\nlabel: \"Filter button\"\n\\`\\`\\`\n\n**Steps:**\n1. Find the element in the DOM snapshot by reading its text/role\n2. Note its \\`@X\\` ID\n3. Call the point tool with that numeric ID (just the number, without the @ symbol)\n4. Provide a brief, natural label describing what you're pointing at\n\nThe element's position is resolved in real-time when the cursor moves, so it will point accurately even if the page has changed slightly.\n\n## What to say\n\nWhen the user asks you to point something out:\n- briefly answer in a natural spoken way\n- then use the tool if the request is about locating or indicating something on screen\n\nGood spoken style:\n- \"Click this button right here.\"\n- \"The error message is over here.\"\n- \"This is the field you want.\"\n- \"That setting is in this section.\"\n\nAvoid:\n- mentioning element IDs (like \"@5\" or \"element 12\")\n- mentioning internal tools\n- describing internal reasoning\n- saying you are looking at a screenshot\n\n## If the target is not clear\n\nIf you cannot confidently find the requested thing on screen:\n- say you cannot see it clearly or cannot find it\n- do not point at a random or uncertain target\n\n## Using tools\n\nWhen you use tools like web search:\n- Do NOT include URLs, links, or source citations in your spoken response\n- Summarize findings in natural speech without mentioning sources\n- Remember: your response is spoken aloud via TTS, so links and URLs are useless to the listener\n- If the user explicitly asks for sources, tell them you can show them on screen instead\n\n## Priority\n\nYour first priority is being helpful and correct.\nYour second priority is using the \\`point\\` tool whenever the user is asking you to visually identify a specific thing on screen.\n`\n","import type { ModelMessage } from \"ai\"\nimport { type StopCondition, stepCountIs, streamText } from \"ai\"\nimport { pointTool } from \"../../core/tools/point-tool\"\nimport { DEFAULT_SYSTEM_PROMPT } from \"../system-prompt\"\nimport type {\n ChatRequestBody,\n ConversationMessage,\n CursorBuddyHandlerConfig,\n ToolApprovalResponseContent,\n} from \"../types\"\n\n/**\n * Build the visual context string from capture metadata and DOM snapshot.\n */\nfunction buildCaptureContext(\n capture: ChatRequestBody[\"capture\"],\n domSnapshot: string | undefined,\n): string | null {\n const parts: string[] = []\n\n if (capture) {\n parts.push(`Screenshot size: ${capture.width}x${capture.height} pixels.`)\n }\n\n if (domSnapshot) {\n parts.push(\n \"\",\n \"Visible page structure (each element has @X ID for pointing):\",\n domSnapshot,\n )\n }\n\n return parts.length > 0 ? parts.join(\"\\n\") : null\n}\n\n/**\n * Check if a message is a tool approval response.\n */\nfunction isToolApprovalContent(\n content: ConversationMessage[\"content\"],\n): content is ToolApprovalResponseContent[] {\n return (\n Array.isArray(content) &&\n content.length > 0 &&\n content[0].type === \"tool-approval-response\"\n )\n}\n\n/**\n * Convert client messages to AI SDK CoreMessage format.\n * Attaches screenshot and DOM snapshot to the appropriate message.\n */\nfunction buildAIMessages(\n clientMessages: ConversationMessage[],\n screenshot: string | undefined,\n capture: ChatRequestBody[\"capture\"],\n domSnapshot: string | undefined,\n maxHistory: number,\n): ModelMessage[] {\n // Trim to max history (default 10 exchanges = 20 messages)\n const maxMessages = maxHistory * 2\n const trimmedMessages = clientMessages.slice(-maxMessages)\n\n const aiMessages: ModelMessage[] = []\n const captureContext = buildCaptureContext(capture, domSnapshot)\n\n // Find the last user message that should have visual context attached\n // This is the last \"user\" role message with string content (not a tool response)\n let lastUserMessageIndex = -1\n for (let i = trimmedMessages.length - 1; i >= 0; i--) {\n const msg = trimmedMessages[i]\n if (msg.role === \"user\" && typeof msg.content === \"string\") {\n lastUserMessageIndex = i\n break\n }\n }\n\n for (let i = 0; i < trimmedMessages.length; i++) {\n const msg = trimmedMessages[i]\n\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n // Check if this is the message that should have visual context\n const shouldAttachVisuals =\n i === lastUserMessageIndex && screenshot !== undefined\n\n if (shouldAttachVisuals) {\n // Build multimodal content with screenshot\n const contentParts: Array<\n { type: \"text\"; text: string } | { type: \"image\"; image: string }\n > = []\n\n if (captureContext) {\n contentParts.push({ type: \"text\", text: captureContext })\n }\n\n contentParts.push({ type: \"image\", image: screenshot })\n contentParts.push({ type: \"text\", text: msg.content })\n\n aiMessages.push({\n role: \"user\",\n content: contentParts,\n })\n } else {\n // Plain text message\n aiMessages.push({\n role: \"user\",\n content: msg.content,\n })\n }\n }\n // Skip user messages with non-string content (shouldn't happen in normal flow)\n } else if (msg.role === \"assistant\") {\n aiMessages.push({\n role: \"assistant\",\n content: typeof msg.content === \"string\" ? msg.content : \"\",\n })\n } else if (msg.role === \"tool\" && isToolApprovalContent(msg.content)) {\n // Convert tool approval responses to AI SDK format\n // ToolApprovalResponse goes directly in the content array\n aiMessages.push({\n role: \"tool\",\n content: msg.content.map((approval) => ({\n type: \"tool-approval-response\" as const,\n approvalId: approval.approvalId,\n approved: approval.approved,\n })),\n })\n }\n }\n\n return aiMessages\n}\n\n/**\n * Handle chat requests: messages + screenshot → 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 { messages: clientMessages, screenshot, capture, domSnapshot } = 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 // Build AI SDK messages from client messages\n const messages = buildAIMessages(\n clientMessages,\n screenshot,\n capture,\n domSnapshot,\n config.maxHistory ?? 10,\n )\n\n const tools = {\n point: pointTool,\n ...config.tools,\n }\n\n const mustContinueUntilText: StopCondition<typeof tools> = ({ steps }) => {\n const lastStep = steps.at(-1)\n if (!lastStep) return false\n\n const stepText =\n typeof lastStep.text === \"string\" ? lastStep.text.trim() : \"\"\n const hadToolResults =\n Array.isArray(lastStep.toolResults) && lastStep.toolResults.length > 0\n\n // Stop only after we have actual assistant text.\n // If the step was tool-only, continue the loop.\n if (stepText.length > 0) return true\n if (hadToolResults) return false\n\n return false\n }\n\n const result = streamText({\n model: config.model,\n system: systemPrompt,\n providerOptions: config?.modelProviderMetadata,\n messages,\n tools,\n\n // Allow a follow-up step after tool use instead of the default single step.\n stopWhen: [mustContinueUntilText, stepCountIs(3)],\n\n prepareStep: async ({ stepNumber, steps }) => {\n // Normal first pass: let the model speak and optionally point.\n if (stepNumber === 0) {\n return {}\n }\n\n const previousStep = steps.at(-1)\n\n const prevText =\n typeof previousStep?.text === \"string\" ? previousStep.text.trim() : \"\"\n\n const usedPoint =\n previousStep?.toolCalls?.some((call) => call.toolName === \"point\") ??\n false\n\n // If the previous step pointed but did not speak, force the next step\n // to be text-only by removing the point tool.\n if (usedPoint && prevText.length === 0) {\n const toolNames = Object.keys(tools) as Array<keyof typeof tools>\n\n return {\n activeTools: toolNames.filter((name) => name !== \"point\"),\n }\n }\n\n return {}\n },\n })\n\n return result.toUIMessageStreamResponse()\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 voice: config?.speechVoice,\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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"}
|