cursor-buddy 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["styles"],"sources":["../../src/client/context.ts","../../src/core/machine.ts","../../src/core/atoms.ts","../../src/core/pointing.ts","../../src/core/bezier.ts","../../src/client/utils/audio-worklet.ts","../../src/client/utils/audio.ts","../../src/client/hooks/useVoiceCapture.ts","../../src/client/utils/screenshot.ts","../../src/client/hooks/useScreenCapture.ts","../../src/client/hooks/useCursorPosition.ts","../../src/client/styles.css?inline","../../src/client/utils/inject-styles.ts","../../src/client/CursorBuddyProvider.tsx","../../src/client/hooks/useCursorBuddy.ts","../../src/client/components/Cursor.tsx","../../src/client/components/SpeechBubble.tsx","../../src/client/components/Waveform.tsx","../../src/client/components/Overlay.tsx","../../src/client/hooks/useHotkey.ts","../../src/client/CursorBuddy.tsx"],"sourcesContent":["import { createContext } from \"react\"\nimport type { VoiceState } from \"../core/types\"\n\nexport interface CursorBuddyContextValue {\n /** Current voice state */\n state: VoiceState\n /** Latest transcribed user speech */\n transcript: string\n /** Latest AI response (stripped of POINT tags) */\n response: string\n /** Current audio level (0-1) */\n audioLevel: number\n /** Whether the buddy is enabled */\n isEnabled: boolean\n /** Whether TTS is currently playing */\n isSpeaking: 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 speak text via TTS */\n speak: (text: string) => Promise<void>\n /** Manually point at coordinates */\n pointAt: (x: number, y: number, label: string) => void\n /** Dismiss the current pointing target and return to follow mode */\n dismissPointing: () => void\n /** Reset to idle state */\n reset: () => void\n}\n\nexport const CursorBuddyContext = createContext<CursorBuddyContextValue | null>(\n null\n)\n","import { setup, assign } from \"xstate\"\nimport type { VoiceMachineContext, VoiceMachineEvent } from \"./types\"\n\n/**\n * XState machine for the voice interaction flow.\n *\n * States: idle → listening → processing → responding → idle\n *\n * This enforces valid state transitions and provides hooks for\n * actions (start/stop mic, capture screenshot, play TTS, etc.)\n */\nexport const cursorBuddyMachine = setup({\n types: {\n context: {} as VoiceMachineContext,\n events: {} as VoiceMachineEvent,\n },\n actions: {\n clearTranscript: assign({ transcript: \"\" }),\n clearResponse: assign({ response: \"\" }),\n clearError: assign({ error: null }),\n setTranscript: assign(({ event }) => {\n if (event.type === \"TRANSCRIPTION_COMPLETE\") {\n return { transcript: event.transcript }\n }\n return {}\n }),\n setResponse: assign(({ event }) => {\n if (event.type === \"AI_RESPONSE_COMPLETE\") {\n return { response: event.response }\n }\n return {}\n }),\n appendResponseChunk: assign(({ context, event }) => {\n if (event.type === \"AI_RESPONSE_CHUNK\") {\n return { response: context.response + event.text }\n }\n return {}\n }),\n setError: assign(({ event }) => {\n if (event.type === \"ERROR\") {\n return { error: event.error }\n }\n return {}\n }),\n },\n}).createMachine({\n id: \"cursorBuddy\",\n initial: \"idle\",\n context: {\n transcript: \"\",\n response: \"\",\n error: null,\n },\n states: {\n idle: {\n entry: [\"clearError\"],\n on: {\n HOTKEY_PRESSED: {\n target: \"listening\",\n actions: [\"clearTranscript\", \"clearResponse\"],\n },\n },\n },\n listening: {\n on: {\n HOTKEY_RELEASED: {\n target: \"processing\",\n },\n CANCEL: {\n target: \"idle\",\n },\n ERROR: {\n target: \"idle\",\n actions: [\"setError\"],\n },\n },\n },\n processing: {\n on: {\n TRANSCRIPTION_COMPLETE: {\n actions: [\"setTranscript\"],\n },\n AI_RESPONSE_CHUNK: {\n actions: [\"appendResponseChunk\"],\n },\n AI_RESPONSE_COMPLETE: {\n target: \"responding\",\n actions: [\"setResponse\"],\n },\n ERROR: {\n target: \"idle\",\n actions: [\"setError\"],\n },\n CANCEL: {\n target: \"idle\",\n },\n },\n },\n responding: {\n on: {\n TTS_COMPLETE: {\n target: \"idle\",\n },\n POINTING_COMPLETE: {\n // Stay in responding until TTS is also complete\n },\n CANCEL: {\n target: \"idle\",\n },\n ERROR: {\n target: \"idle\",\n actions: [\"setError\"],\n },\n },\n },\n },\n})\n\nexport type CursorBuddyMachine = typeof cursorBuddyMachine\n","import { atom } from \"nanostores\"\nimport type { Point, PointingTarget, ConversationMessage } from \"./types\"\n\n/**\n * Nanostores atoms for reactive values that don't need state machine semantics.\n * These update frequently (e.g., 60fps audio levels) and are framework-agnostic.\n */\n\n// Audio level during recording (0-1, updates at ~60fps)\nexport const $audioLevel = atom<number>(0)\n\n// Mouse cursor position (real cursor, not buddy)\nexport const $cursorPosition = atom<Point>({ x: 0, y: 0 })\n\n// Buddy animated position (follows cursor with spring physics, or flies to target)\nexport const $buddyPosition = atom<Point>({ x: 0, y: 0 })\n\n// Buddy rotation in radians (direction of travel during pointing)\nexport const $buddyRotation = atom<number>(0)\n\n// Buddy scale (1.0 normal, up to 1.3 during flight)\nexport const $buddyScale = atom<number>(1)\n\n// Current pointing target parsed from AI response\nexport const $pointingTarget = atom<PointingTarget | null>(null)\n\n// Whether buddy overlay is enabled/visible\nexport const $isEnabled = atom<boolean>(true)\n\n// Whether TTS is currently playing\nexport const $isSpeaking = atom<boolean>(false)\n\n// Conversation history for context\nexport const $conversationHistory = atom<ConversationMessage[]>([])\n","import type { PointingTarget } from \"./types\"\n\n/**\n * Parses [POINT:x,y:label] tags from AI responses.\n * Format matches the Swift Clicky app for consistency.\n */\n\nconst POINTING_TAG_REGEX = /\\[POINT:(\\d+),(\\d+):([^\\]]+)\\]\\s*$/\n\n/**\n * Extract pointing target from response text.\n * Returns null if no valid POINT tag is found at the end.\n */\nexport function parsePointingTag(response: string): PointingTarget | null {\n const match = response.match(POINTING_TAG_REGEX)\n if (!match) return null\n\n return {\n x: parseInt(match[1], 10),\n y: parseInt(match[2], 10),\n label: match[3].trim(),\n }\n}\n\n/**\n * Remove POINT tag from response text for display/TTS.\n */\nexport function stripPointingTag(response: string): string {\n return response.replace(POINTING_TAG_REGEX, \"\").trim()\n}\n","import type { Point } from \"./types\";\n\n/**\n * Bezier flight animation for cursor pointing.\n */\n\n/**\n * Quadratic bezier curve: B(t) = (1-t)²P₀ + 2(1-t)t·P₁ + t²P₂\n */\nfunction quadraticBezier(p0: Point, p1: Point, p2: Point, t: number): Point {\n const oneMinusT = 1 - t;\n return {\n x: oneMinusT * oneMinusT * p0.x + 2 * oneMinusT * t * p1.x + t * t * p2.x,\n y: oneMinusT * oneMinusT * p0.y + 2 * oneMinusT * t * p1.y + t * t * p2.y,\n };\n}\n\n/**\n * Bezier tangent (derivative): B'(t) = 2(1-t)(P₁-P₀) + 2t(P₂-P₁)\n */\nfunction bezierTangent(p0: Point, p1: Point, p2: Point, t: number): Point {\n const oneMinusT = 1 - t;\n return {\n x: 2 * oneMinusT * (p1.x - p0.x) + 2 * t * (p2.x - p1.x),\n y: 2 * oneMinusT * (p1.y - p0.y) + 2 * t * (p2.y - p1.y),\n };\n}\n\n/**\n * Ease-in-out cubic for smooth acceleration/deceleration\n */\nfunction easeInOutCubic(t: number): number {\n return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;\n}\n\nexport interface BezierFlightCallbacks {\n onFrame: (position: Point, rotation: number, scale: number) => void;\n onComplete: () => void;\n}\n\n/**\n * Animate cursor along a parabolic bezier arc from start to end.\n * Used when the AI points at a UI element.\n *\n * @param from - Starting position\n * @param to - Target position\n * @param durationMs - Flight duration in milliseconds\n * @param callbacks - Frame and completion callbacks\n * @returns Cancel function to stop the animation\n */\nexport function animateBezierFlight(\n from: Point,\n to: Point,\n durationMs: number,\n callbacks: BezierFlightCallbacks,\n): () => void {\n const startTime = performance.now();\n const distance = Math.hypot(to.x - from.x, to.y - from.y);\n\n // Control point: offset upward by 20% of distance (creates parabolic arc)\n const controlPoint: Point = {\n x: (from.x + to.x) / 2,\n y: Math.min(from.y, to.y) - distance * 0.2,\n };\n\n let animationFrameId: number;\n\n function animate(now: number) {\n const elapsed = now - startTime;\n const linearProgress = Math.min(elapsed / durationMs, 1);\n const easedProgress = easeInOutCubic(linearProgress);\n\n const position = quadraticBezier(from, controlPoint, to, easedProgress);\n const tangent = bezierTangent(from, controlPoint, to, easedProgress);\n const rotation = Math.atan2(tangent.y, tangent.x);\n\n // Scale pulse: grows to 1.3x at midpoint, returns to 1x\n const scale = 1 + Math.sin(linearProgress * Math.PI) * 0.3;\n\n callbacks.onFrame(position, rotation, scale);\n\n if (linearProgress < 1) {\n animationFrameId = requestAnimationFrame(animate);\n } else {\n callbacks.onComplete();\n }\n }\n\n animationFrameId = requestAnimationFrame(animate);\n\n return () => cancelAnimationFrame(animationFrameId);\n}\n","/**\n * AudioWorklet processor code for voice capture.\n * Inlined as a blob URL to avoid separate file serving requirements.\n */\nconst workletCode = `\nclass AudioCaptureProcessor extends AudioWorkletProcessor {\n constructor() {\n super()\n this.isRecording = true\n }\n\n process(inputs) {\n if (!this.isRecording) return false\n\n const input = inputs[0]\n if (input && input.length > 0) {\n const channelData = input[0]\n\n // Send audio data to main thread\n this.port.postMessage({\n type: \"audio\",\n data: new Float32Array(channelData)\n })\n\n // Calculate RMS for audio level visualization\n let sum = 0\n for (let i = 0; i < channelData.length; i++) {\n sum += channelData[i] * channelData[i]\n }\n const rms = Math.sqrt(sum / channelData.length)\n this.port.postMessage({ type: \"level\", rms })\n }\n\n return true\n }\n}\n\nregisterProcessor(\"audio-capture-processor\", AudioCaptureProcessor)\n`\n\nlet cachedBlobURL: string | null = null\n\n/**\n * Create a blob URL for the audio worklet processor.\n * Caches the URL to avoid creating multiple blobs.\n */\nexport function createWorkletBlobURL(): string {\n if (!cachedBlobURL) {\n const blob = new Blob([workletCode], { type: \"application/javascript\" })\n cachedBlobURL = URL.createObjectURL(blob)\n }\n return cachedBlobURL\n}\n\n/**\n * Clean up the cached worklet blob URL.\n * Call this when the app unmounts if needed.\n */\nexport function revokeWorkletBlobURL(): void {\n if (cachedBlobURL) {\n URL.revokeObjectURL(cachedBlobURL)\n cachedBlobURL = null\n }\n}\n","/**\n * Audio conversion utilities for voice capture.\n * Converts Float32 audio data to WAV format for server transcription.\n */\n\n/**\n * Merge multiple Float32Array chunks into a single array\n */\nexport function mergeAudioChunks(chunks: Float32Array[]): Float32Array {\n const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)\n const result = new Float32Array(totalLength)\n\n let offset = 0\n for (const chunk of chunks) {\n result.set(chunk, offset)\n offset += chunk.length\n }\n\n return result\n}\n\n/**\n * Convert Float32 audio data to 16-bit PCM\n */\nfunction floatTo16BitPCM(\n output: DataView,\n offset: number,\n input: Float32Array\n): void {\n for (let i = 0; i < input.length; i++, offset += 2) {\n const sample = Math.max(-1, Math.min(1, input[i]))\n output.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true)\n }\n}\n\n/**\n * Write a string to a DataView\n */\nfunction writeString(view: DataView, offset: number, string: string): void {\n for (let i = 0; i < string.length; i++) {\n view.setUint8(offset + i, string.charCodeAt(i))\n }\n}\n\n/**\n * Encode Float32 audio data as a WAV file\n */\nexport function encodeWAV(samples: Float32Array, sampleRate: number): Blob {\n const numChannels = 1\n const bitsPerSample = 16\n const bytesPerSample = bitsPerSample / 8\n const blockAlign = numChannels * bytesPerSample\n\n const dataLength = samples.length * bytesPerSample\n const buffer = new ArrayBuffer(44 + dataLength)\n const view = new DataView(buffer)\n\n // RIFF header\n writeString(view, 0, \"RIFF\")\n view.setUint32(4, 36 + dataLength, true)\n writeString(view, 8, \"WAVE\")\n\n // fmt chunk\n writeString(view, 12, \"fmt \")\n view.setUint32(16, 16, true) // chunk size\n view.setUint16(20, 1, true) // audio format (PCM)\n view.setUint16(22, numChannels, true)\n view.setUint32(24, sampleRate, true)\n view.setUint32(28, sampleRate * blockAlign, true) // byte rate\n view.setUint16(32, blockAlign, true)\n view.setUint16(34, bitsPerSample, true)\n\n // data chunk\n writeString(view, 36, \"data\")\n view.setUint32(40, dataLength, true)\n\n floatTo16BitPCM(view, 44, samples)\n\n return new Blob([buffer], { type: \"audio/wav\" })\n}\n","import { useCallback, useRef, useState } from \"react\";\nimport { createWorkletBlobURL } from \"../utils/audio-worklet\";\nimport { mergeAudioChunks, encodeWAV } from \"../utils/audio\";\nimport { $audioLevel } from \"../../core/atoms\";\n\nconst SAMPLE_RATE = 16000;\nconst AUDIO_LEVEL_BOOST = 10.2;\n\nexport interface UseVoiceCaptureReturn {\n /** Start recording audio */\n start: () => Promise<void>;\n /** Stop recording and return WAV blob */\n stop: () => Promise<Blob>;\n /** Whether currently recording */\n isRecording: boolean;\n /** Last error (null if none) */\n error: Error | null;\n}\n\n/**\n * Hook for voice capture using AudioWorkletNode.\n * Updates $audioLevel atom for waveform visualization.\n */\nexport function useVoiceCapture(): UseVoiceCaptureReturn {\n const [isRecording, setIsRecording] = useState(false);\n const [error, setError] = useState<Error | null>(null);\n\n const audioContextRef = useRef<AudioContext | null>(null);\n const workletNodeRef = useRef<AudioWorkletNode | null>(null);\n const streamRef = useRef<MediaStream | null>(null);\n const chunksRef = useRef<Float32Array[]>([]);\n\n const start = useCallback(async () => {\n setError(null);\n chunksRef.current = [];\n\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: {\n sampleRate: SAMPLE_RATE,\n channelCount: 1,\n echoCancellation: true,\n noiseSuppression: true,\n },\n });\n streamRef.current = stream;\n\n const audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });\n audioContextRef.current = audioContext;\n\n // Load worklet from blob URL\n const workletURL = createWorkletBlobURL();\n await audioContext.audioWorklet.addModule(workletURL);\n\n const source = audioContext.createMediaStreamSource(stream);\n const workletNode = new AudioWorkletNode(\n audioContext,\n \"audio-capture-processor\",\n );\n workletNodeRef.current = workletNode;\n\n workletNode.port.onmessage = (event) => {\n const { type, data, rms } = event.data;\n\n if (type === \"audio\") {\n chunksRef.current.push(data);\n } else if (type === \"level\") {\n // Boost audio level for better visualization\n const boostedLevel = Math.min(rms * AUDIO_LEVEL_BOOST, 1);\n $audioLevel.set(boostedLevel);\n }\n };\n\n source.connect(workletNode);\n // Don't connect to destination - we don't want to play back the mic\n\n setIsRecording(true);\n } catch (err) {\n const captureError =\n err instanceof Error ? err : new Error(\"Microphone access failed\");\n setError(captureError);\n throw captureError;\n }\n }, []);\n\n const stop = useCallback(async (): Promise<Blob> => {\n // Stop the media stream tracks\n if (streamRef.current) {\n streamRef.current.getTracks().forEach((track) => track.stop());\n streamRef.current = null;\n }\n\n // Disconnect and close audio nodes\n if (workletNodeRef.current) {\n workletNodeRef.current.disconnect();\n workletNodeRef.current = null;\n }\n\n if (audioContextRef.current) {\n await audioContextRef.current.close();\n audioContextRef.current = null;\n }\n\n // Reset audio level\n $audioLevel.set(0);\n\n // Encode captured audio as WAV\n const audioData = mergeAudioChunks(chunksRef.current);\n const wavBlob = encodeWAV(audioData, SAMPLE_RATE);\n\n chunksRef.current = [];\n setIsRecording(false);\n\n return wavBlob;\n }, []);\n\n return { start, stop, isRecording, error };\n}\n","import html2canvas from \"html2canvas-pro\";\nimport type { ScreenshotResult } from \"../../core/types\";\n\nconst MAX_WIDTH = 1280;\n\nfunction getCaptureMetrics() {\n return {\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n };\n}\n\n/**\n * Resize canvas to max width while maintaining aspect ratio\n */\nfunction resizeCanvas(\n canvas: HTMLCanvasElement,\n maxWidth: number,\n): HTMLCanvasElement {\n if (canvas.width <= maxWidth) {\n return canvas;\n }\n\n const scale = maxWidth / canvas.width;\n const resized = document.createElement(\"canvas\");\n resized.width = maxWidth;\n resized.height = Math.round(canvas.height * scale);\n\n const ctx = resized.getContext(\"2d\");\n if (ctx) {\n ctx.drawImage(canvas, 0, 0, resized.width, resized.height);\n }\n\n return resized;\n}\n\n/**\n * Create a fallback canvas when screenshot capture fails.\n * Returns a simple gray canvas with an error message.\n */\nfunction createFallbackCanvas(): HTMLCanvasElement {\n const canvas = document.createElement(\"canvas\");\n canvas.width = Math.min(window.innerWidth, MAX_WIDTH);\n canvas.height = Math.round(\n (window.innerHeight / window.innerWidth) * canvas.width,\n );\n\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n ctx.fillStyle = \"#f0f0f0\";\n ctx.fillRect(0, 0, canvas.width, canvas.height);\n ctx.fillStyle = \"#666\";\n ctx.font = \"16px sans-serif\";\n ctx.textAlign = \"center\";\n ctx.fillText(\"Screenshot unavailable\", canvas.width / 2, canvas.height / 2);\n }\n\n return canvas;\n}\n\n/**\n * Capture a screenshot of the current viewport.\n * Uses html2canvas to render the DOM to a canvas, then exports as JPEG.\n * Falls back to a placeholder if capture fails (e.g., due to unsupported CSS).\n */\nexport async function captureViewport(): Promise<ScreenshotResult> {\n const captureMetrics = getCaptureMetrics();\n let canvas: HTMLCanvasElement;\n\n try {\n canvas = await html2canvas(document.body, {\n scale: 1,\n useCORS: true,\n logging: false,\n width: captureMetrics.viewportWidth,\n height: captureMetrics.viewportHeight,\n x: window.scrollX,\n y: window.scrollY,\n });\n } catch (err) {\n canvas = createFallbackCanvas();\n }\n\n const resized = resizeCanvas(canvas, MAX_WIDTH);\n\n return {\n imageData: resized.toDataURL(\"image/jpeg\", 0.8),\n width: resized.width,\n height: resized.height,\n viewportWidth: captureMetrics.viewportWidth,\n viewportHeight: captureMetrics.viewportHeight,\n };\n}\n","import { useCallback, useState } from \"react\"\nimport { captureViewport } from \"../utils/screenshot\"\nimport type { ScreenshotResult } from \"../../core/types\"\n\nexport interface UseScreenCaptureReturn {\n /** Capture a screenshot of the current viewport */\n capture: () => Promise<ScreenshotResult>\n /** Whether a capture is in progress */\n isCapturing: boolean\n /** Last captured screenshot (null if none) */\n lastCapture: ScreenshotResult | null\n /** Last error (null if none) */\n error: Error | null\n}\n\n/**\n * Hook for capturing viewport screenshots.\n */\nexport function useScreenCapture(): UseScreenCaptureReturn {\n const [isCapturing, setIsCapturing] = useState(false)\n const [lastCapture, setLastCapture] = useState<ScreenshotResult | null>(null)\n const [error, setError] = useState<Error | null>(null)\n\n const capture = useCallback(async (): Promise<ScreenshotResult> => {\n setIsCapturing(true)\n setError(null)\n\n try {\n const result = await captureViewport()\n setLastCapture(result)\n return result\n } catch (err) {\n const captureError =\n err instanceof Error ? err : new Error(\"Screenshot capture failed\")\n setError(captureError)\n throw captureError\n } finally {\n setIsCapturing(false)\n }\n }, [])\n\n return { capture, isCapturing, lastCapture, error }\n}\n","import { useEffect } from \"react\"\nimport { $cursorPosition } from \"../../core/atoms\"\n\n/**\n * Hook that tracks mouse cursor position and updates the $cursorPosition atom.\n * Should be used once at the provider level.\n */\nexport function useCursorPosition(): void {\n useEffect(() => {\n function handleMouseMove(event: MouseEvent) {\n $cursorPosition.set({ x: event.clientX, y: event.clientY })\n }\n\n window.addEventListener(\"mousemove\", handleMouseMove)\n\n return () => {\n window.removeEventListener(\"mousemove\", handleMouseMove)\n }\n }, [])\n}\n","export default \"/**\\n * Cursor Buddy Styles\\n *\\n * Customize by overriding CSS variables in your own stylesheet:\\n *\\n * :root {\\n * --cursor-buddy-color-idle: #8b5cf6;\\n * }\\n */\\n\\n:root {\\n /* Cursor colors by state */\\n --cursor-buddy-color-idle: #3b82f6;\\n --cursor-buddy-color-listening: #ef4444;\\n --cursor-buddy-color-processing: #eab308;\\n --cursor-buddy-color-responding: #22c55e;\\n --cursor-buddy-cursor-stroke: #ffffff;\\n --cursor-buddy-cursor-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\\n\\n /* Speech bubble */\\n --cursor-buddy-bubble-bg: #ffffff;\\n --cursor-buddy-bubble-text: #1f2937;\\n --cursor-buddy-bubble-radius: 8px;\\n --cursor-buddy-bubble-padding: 8px 12px;\\n --cursor-buddy-bubble-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\\n --cursor-buddy-bubble-max-width: 200px;\\n --cursor-buddy-bubble-font-size: 14px;\\n\\n /* Waveform */\\n --cursor-buddy-waveform-color: #ef4444;\\n --cursor-buddy-waveform-bar-width: 4px;\\n --cursor-buddy-waveform-bar-radius: 2px;\\n --cursor-buddy-waveform-gap: 3px;\\n\\n /* Overlay */\\n --cursor-buddy-z-index: 2147483647;\\n\\n /* Animation durations */\\n --cursor-buddy-transition-fast: 0.1s;\\n --cursor-buddy-transition-normal: 0.2s;\\n --cursor-buddy-animation-duration: 0.3s;\\n}\\n\\n/* Overlay container */\\n.cursor-buddy-overlay {\\n position: fixed;\\n inset: 0;\\n pointer-events: none;\\n isolation: isolate;\\n z-index: var(--cursor-buddy-z-index);\\n}\\n\\n/* Buddy container (cursor + accessories) */\\n.cursor-buddy-container {\\n position: absolute;\\n transform: translate(-16px, -16px);\\n}\\n\\n/* Cursor SVG */\\n.cursor-buddy-cursor {\\n transition: transform var(--cursor-buddy-transition-fast) ease-out;\\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow));\\n}\\n\\n.cursor-buddy-cursor polygon {\\n stroke: var(--cursor-buddy-cursor-stroke);\\n stroke-width: 2;\\n transition: fill var(--cursor-buddy-transition-normal) ease-out;\\n}\\n\\n.cursor-buddy-cursor--idle polygon {\\n fill: var(--cursor-buddy-color-idle);\\n}\\n\\n.cursor-buddy-cursor--listening polygon {\\n fill: var(--cursor-buddy-color-listening);\\n}\\n\\n.cursor-buddy-cursor--processing polygon {\\n fill: var(--cursor-buddy-color-processing);\\n}\\n\\n.cursor-buddy-cursor--responding polygon {\\n fill: var(--cursor-buddy-color-responding);\\n}\\n\\n/* Cursor pulse animation during listening */\\n.cursor-buddy-cursor--listening {\\n animation: cursor-buddy-pulse 1.5s ease-in-out infinite;\\n}\\n\\n@keyframes cursor-buddy-pulse {\\n 0%,\\n 100% {\\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow));\\n }\\n 50% {\\n filter: drop-shadow(0 0 8px var(--cursor-buddy-color-listening));\\n }\\n}\\n\\n/* Processing spinner effect */\\n.cursor-buddy-cursor--processing {\\n animation: cursor-buddy-spin-subtle 2s linear infinite;\\n}\\n\\n@keyframes cursor-buddy-spin-subtle {\\n 0% {\\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow)) hue-rotate(0deg);\\n }\\n 100% {\\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow)) hue-rotate(360deg);\\n }\\n}\\n\\n/* Speech bubble */\\n.cursor-buddy-bubble {\\n position: absolute;\\n left: 40px;\\n top: -8px;\\n pointer-events: auto;\\n cursor: pointer;\\n max-width: var(--cursor-buddy-bubble-max-width);\\n padding: var(--cursor-buddy-bubble-padding);\\n background-color: var(--cursor-buddy-bubble-bg);\\n color: var(--cursor-buddy-bubble-text);\\n border-radius: var(--cursor-buddy-bubble-radius);\\n box-shadow: var(--cursor-buddy-bubble-shadow);\\n font-size: var(--cursor-buddy-bubble-font-size);\\n line-height: 1.4;\\n width: max-content;\\n overflow-wrap: break-word;\\n word-break: break-word;\\n user-select: none;\\n animation: cursor-buddy-fade-in var(--cursor-buddy-animation-duration)\\n ease-out;\\n}\\n\\n@keyframes cursor-buddy-fade-in {\\n from {\\n opacity: 0;\\n transform: translateY(-4px);\\n }\\n to {\\n opacity: 1;\\n transform: translateY(0);\\n }\\n}\\n\\n/* Waveform container */\\n.cursor-buddy-waveform {\\n position: absolute;\\n left: 40px;\\n top: 4px;\\n display: flex;\\n align-items: center;\\n gap: var(--cursor-buddy-waveform-gap);\\n height: 24px;\\n animation: cursor-buddy-fade-in var(--cursor-buddy-animation-duration)\\n ease-out;\\n}\\n\\n/* Waveform bars */\\n.cursor-buddy-waveform-bar {\\n width: var(--cursor-buddy-waveform-bar-width);\\n background-color: var(--cursor-buddy-waveform-color);\\n border-radius: var(--cursor-buddy-waveform-bar-radius);\\n transition: height 0.05s ease-out;\\n}\\n\\n/* Fade out animation (applied via JS) */\\n.cursor-buddy-fade-out {\\n animation: cursor-buddy-fade-out var(--cursor-buddy-animation-duration)\\n ease-out forwards;\\n}\\n\\n@keyframes cursor-buddy-fade-out {\\n from {\\n opacity: 1;\\n }\\n to {\\n opacity: 0;\\n }\\n}\\n\";","// 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 { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useActor } from \"@xstate/react\";\nimport { useStore } from \"@nanostores/react\";\nimport { CursorBuddyContext, type CursorBuddyContextValue } from \"./context\";\nimport { cursorBuddyMachine } from \"../core/machine\";\nimport {\n $audioLevel,\n $isEnabled,\n $isSpeaking,\n $pointingTarget,\n $conversationHistory,\n $buddyPosition,\n $buddyRotation,\n $buddyScale,\n $cursorPosition,\n} from \"../core/atoms\";\nimport { parsePointingTag, stripPointingTag } from \"../core/pointing\";\nimport { animateBezierFlight } from \"../core/bezier\";\nimport { useVoiceCapture } from \"./hooks/useVoiceCapture\";\nimport { useScreenCapture } from \"./hooks/useScreenCapture\";\nimport { useCursorPosition } from \"./hooks/useCursorPosition\";\nimport { injectStyles } from \"./utils/inject-styles\";\nimport type {\n VoiceState,\n ConversationMessage,\n PointingTarget,\n ScreenshotResult,\n} from \"../core/types\";\n\nconst POINTING_LOCK_TIMEOUT_MS = 10_000;\n\ntype PointerMode = \"follow\" | \"flying\" | \"anchored\";\n\nexport interface CursorBuddyProviderProps {\n /** API endpoint for cursor buddy server */\n endpoint: string;\n /** Whether TTS is muted */\n muted?: boolean;\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: { x: number; y: number; label: string }) => void;\n /** Callback when state changes */\n onStateChange?: (state: VoiceState) => void;\n /** Callback when error occurs */\n onError?: (error: Error) => void;\n /** Children */\n children: React.ReactNode;\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(Math.max(value, min), max);\n}\n\nfunction mapPointToViewport(\n target: PointingTarget,\n screenshot: ScreenshotResult,\n): PointingTarget {\n if (screenshot.width <= 0 || screenshot.height <= 0) {\n return target;\n }\n\n const scaleX = screenshot.viewportWidth / screenshot.width;\n const scaleY = screenshot.viewportHeight / screenshot.height;\n\n return {\n ...target,\n x: clamp(\n Math.round(target.x * scaleX),\n 0,\n Math.max(screenshot.viewportWidth - 1, 0),\n ),\n y: clamp(\n Math.round(target.y * scaleY),\n 0,\n Math.max(screenshot.viewportHeight - 1, 0),\n ),\n };\n}\n\nexport function CursorBuddyProvider({\n endpoint,\n muted = false,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n children,\n}: CursorBuddyProviderProps) {\n const [snapshot, send] = useActor(cursorBuddyMachine);\n const voiceCapture = useVoiceCapture();\n const screenCapture = useScreenCapture();\n\n // Track cursor position\n useCursorPosition();\n\n // Inject styles on mount\n useEffect(() => {\n injectStyles();\n }, []);\n\n // Subscribe to atoms\n const audioLevel = useStore($audioLevel);\n const isEnabled = useStore($isEnabled);\n const isSpeaking = useStore($isSpeaking);\n const pointingTarget = useStore($pointingTarget);\n const cursorPosition = useStore($cursorPosition);\n\n // Local state\n const [pointerMode, setPointerMode] = useState<PointerMode>(\"follow\");\n const audioRef = useRef<HTMLAudioElement | null>(null);\n const cancelPointingRef = useRef<(() => void) | null>(null);\n const dismissPointingTimeoutRef = useRef<number | null>(null);\n // Track recording state via ref to avoid stale closure issues in callbacks\n const isRecordingRef = useRef(false);\n\n // Derive state from machine\n const state = snapshot.value as VoiceState;\n const transcript = snapshot.context.transcript;\n const response = snapshot.context.response;\n const error = snapshot.context.error;\n const isPointing = pointerMode !== \"follow\";\n\n // Notify on state changes\n useEffect(() => {\n onStateChange?.(state);\n }, [state, onStateChange]);\n\n // Notify on errors\n useEffect(() => {\n if (error) {\n onError?.(error);\n }\n }, [error, onError]);\n\n const clearPointingTimeout = useCallback(() => {\n if (dismissPointingTimeoutRef.current !== null) {\n window.clearTimeout(dismissPointingTimeoutRef.current);\n dismissPointingTimeoutRef.current = null;\n }\n }, []);\n\n const cancelPointingAnimation = useCallback(() => {\n if (cancelPointingRef.current) {\n cancelPointingRef.current();\n cancelPointingRef.current = null;\n }\n }, []);\n\n const releasePointingLock = useCallback(() => {\n clearPointingTimeout();\n cancelPointingAnimation();\n setPointerMode(\"follow\");\n $pointingTarget.set(null);\n $buddyPosition.set($cursorPosition.get());\n $buddyRotation.set(0);\n $buddyScale.set(1);\n }, [cancelPointingAnimation, clearPointingTimeout]);\n\n const schedulePointingRelease = useCallback(() => {\n clearPointingTimeout();\n dismissPointingTimeoutRef.current = window.setTimeout(() => {\n dismissPointingTimeoutRef.current = null;\n releasePointingLock();\n }, POINTING_LOCK_TIMEOUT_MS);\n }, [clearPointingTimeout, releasePointingLock]);\n\n // Update buddy position to follow cursor whenever it is not locked to a point\n useEffect(() => {\n if (pointerMode === \"follow\") {\n $buddyPosition.set(cursorPosition);\n $buddyRotation.set(0);\n $buddyScale.set(1);\n }\n }, [pointerMode, cursorPosition]);\n\n useEffect(() => {\n return () => {\n clearPointingTimeout();\n cancelPointingAnimation();\n };\n }, [cancelPointingAnimation, clearPointingTimeout]);\n\n const handlePointing = useCallback(\n (target: { x: number; y: number; label: string }) => {\n clearPointingTimeout();\n cancelPointingAnimation();\n $pointingTarget.set(target);\n setPointerMode(\"flying\");\n schedulePointingRelease();\n\n const startPos = $buddyPosition.get();\n const endPos = { x: target.x, y: target.y };\n\n cancelPointingRef.current = animateBezierFlight(startPos, endPos, 800, {\n onFrame: (position, rotation, scale) => {\n $buddyPosition.set(position);\n $buddyRotation.set(rotation);\n $buddyScale.set(scale);\n },\n onComplete: () => {\n cancelPointingRef.current = null;\n setPointerMode(\"anchored\");\n $buddyPosition.set(endPos);\n $buddyRotation.set(0);\n $buddyScale.set(1);\n send({ type: \"POINTING_COMPLETE\" });\n },\n });\n },\n [cancelPointingAnimation, clearPointingTimeout, schedulePointingRelease, send],\n );\n\n const startListening = useCallback(async () => {\n if (!isEnabled || isRecordingRef.current) return;\n\n try {\n releasePointingLock();\n isRecordingRef.current = true;\n send({ type: \"HOTKEY_PRESSED\" });\n await voiceCapture.start();\n } catch (err) {\n isRecordingRef.current = false;\n const captureError =\n err instanceof Error ? err : new Error(\"Failed to start recording\");\n send({ type: \"ERROR\", error: captureError });\n }\n }, [isEnabled, releasePointingLock, send, voiceCapture]);\n\n const stopListening = useCallback(async () => {\n // Use ref instead of state to avoid stale closure issues\n if (!isRecordingRef.current) {\n return;\n }\n isRecordingRef.current = false;\n\n try {\n send({ type: \"HOTKEY_RELEASED\" });\n\n // Stop recording and get audio\n const audioBlob = await voiceCapture.stop();\n\n // Capture screenshot\n const screenshot = await screenCapture.capture();\n\n // Transcribe audio\n const formData = new FormData();\n formData.append(\"audio\", audioBlob, \"recording.wav\");\n\n const transcribeResponse = await fetch(`${endpoint}/transcribe`, {\n method: \"POST\",\n body: formData,\n });\n\n if (!transcribeResponse.ok) {\n throw new Error(\"Transcription failed\");\n }\n\n const { text: transcriptText } = await transcribeResponse.json();\n send({ type: \"TRANSCRIPTION_COMPLETE\", transcript: transcriptText });\n onTranscript?.(transcriptText);\n\n // Get conversation history\n const history = $conversationHistory.get();\n\n // Send to AI\n const chatResponse = await fetch(`${endpoint}/chat`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n screenshot: screenshot.imageData,\n capture: {\n width: screenshot.width,\n height: screenshot.height,\n },\n transcript: transcriptText,\n history,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(\"Chat request failed\");\n }\n\n // Stream the response\n const reader = chatResponse.body?.getReader();\n if (!reader) throw new Error(\"No response body\");\n\n const decoder = new TextDecoder();\n let fullResponse = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n const chunk = decoder.decode(value, { stream: true });\n fullResponse += chunk;\n send({ type: \"AI_RESPONSE_CHUNK\", text: chunk });\n }\n\n // Parse pointing tag and strip from response\n const rawPointTarget = parsePointingTag(fullResponse);\n const pointTarget = rawPointTarget\n ? mapPointToViewport(rawPointTarget, screenshot)\n : null;\n const cleanResponse = stripPointingTag(fullResponse);\n\n send({ type: \"AI_RESPONSE_COMPLETE\", response: cleanResponse });\n onResponse?.(cleanResponse);\n\n // Update conversation history\n const newHistory: ConversationMessage[] = [\n ...history,\n { role: \"user\", content: transcriptText },\n { role: \"assistant\", content: cleanResponse },\n ];\n $conversationHistory.set(newHistory);\n\n // Handle pointing if present\n if (pointTarget) {\n onPoint?.(pointTarget);\n handlePointing(pointTarget);\n }\n\n // Play TTS if not muted\n if (!muted && cleanResponse) {\n await playTTS(cleanResponse);\n }\n\n send({ type: \"TTS_COMPLETE\" });\n } catch (err) {\n const processError =\n err instanceof Error ? err : new Error(\"Processing failed\");\n send({ type: \"ERROR\", error: processError });\n }\n }, [\n send,\n voiceCapture,\n screenCapture,\n endpoint,\n muted,\n onTranscript,\n onResponse,\n onPoint,\n handlePointing,\n ]);\n\n const playTTS = useCallback(\n async (text: string) => {\n $isSpeaking.set(true);\n\n try {\n const response = await fetch(`${endpoint}/tts`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ text }),\n });\n\n if (!response.ok) {\n throw new Error(\"TTS request failed\");\n }\n\n const audioBlob = await response.blob();\n const audioUrl = URL.createObjectURL(audioBlob);\n\n const audio = new Audio(audioUrl);\n audioRef.current = audio;\n\n await new Promise<void>((resolve, reject) => {\n audio.onended = () => {\n URL.revokeObjectURL(audioUrl);\n resolve();\n };\n audio.onerror = () => {\n URL.revokeObjectURL(audioUrl);\n reject(new Error(\"Audio playback failed\"));\n };\n audio.play();\n });\n } finally {\n $isSpeaking.set(false);\n audioRef.current = null;\n }\n },\n [endpoint],\n );\n\n const speak = useCallback(\n async (text: string) => {\n if (muted) return;\n await playTTS(text);\n },\n [muted, playTTS],\n );\n\n const pointAt = useCallback(\n (x: number, y: number, label: string) => {\n handlePointing({ x, y, label });\n },\n [handlePointing],\n );\n\n const dismissPointing = useCallback(() => {\n releasePointingLock();\n }, [releasePointingLock]);\n\n const setEnabled = useCallback((enabled: boolean) => {\n $isEnabled.set(enabled);\n }, []);\n\n const reset = useCallback(() => {\n // Stop any playing audio\n if (audioRef.current) {\n audioRef.current.pause();\n audioRef.current = null;\n }\n\n // Reset recording state\n isRecordingRef.current = false;\n\n // Reset atoms\n $isSpeaking.set(false);\n releasePointingLock();\n\n // Send cancel to machine\n send({ type: \"CANCEL\" });\n }, [releasePointingLock, send]);\n\n const contextValue: CursorBuddyContextValue = useMemo(\n () => ({\n state,\n transcript,\n response,\n audioLevel,\n isEnabled,\n isSpeaking,\n isPointing,\n error,\n startListening,\n stopListening,\n setEnabled,\n speak,\n pointAt,\n dismissPointing,\n reset,\n }),\n [\n state,\n transcript,\n response,\n audioLevel,\n isEnabled,\n isSpeaking,\n isPointing,\n error,\n startListening,\n stopListening,\n setEnabled,\n speak,\n pointAt,\n dismissPointing,\n reset,\n ],\n );\n\n return (\n <CursorBuddyContext.Provider value={contextValue}>\n {children}\n </CursorBuddyContext.Provider>\n );\n}\n","import { useContext } from \"react\"\nimport { CursorBuddyContext, type CursorBuddyContextValue } from \"../context\"\n\n/**\n * Hook to access cursor buddy state and actions.\n * Must be used within a CursorBuddyProvider.\n */\nexport function useCursorBuddy(): CursorBuddyContextValue {\n const context = useContext(CursorBuddyContext)\n\n if (!context) {\n throw new Error(\"useCursorBuddy must be used within a CursorBuddyProvider\")\n }\n\n return context\n}\n","import type { CursorRenderProps } from \"../../core/types\"\n\n/**\n * Default cursor component - a colored triangle pointer.\n * Color and animations change based on voice state via CSS classes.\n */\nexport function DefaultCursor({ state, rotation, scale }: CursorRenderProps) {\n const stateClass = `cursor-buddy-cursor--${state}`\n\n return (\n <svg\n width=\"32\"\n height=\"32\"\n viewBox=\"0 0 32 32\"\n className={`cursor-buddy-cursor ${stateClass}`}\n style={{\n transform: `rotate(${rotation}rad) scale(${scale})`,\n }}\n >\n <polygon points=\"16,4 28,28 16,22 4,28\" />\n </svg>\n )\n}\n","import type { SpeechBubbleRenderProps } from \"../../core/types\"\n\n/**\n * Default speech bubble component.\n * Displays pointing label or response text next to the cursor.\n */\nexport function DefaultSpeechBubble({\n text,\n isVisible,\n onClick,\n}: SpeechBubbleRenderProps) {\n if (!isVisible || !text) return null\n\n return (\n <div\n className=\"cursor-buddy-bubble\"\n onClick={onClick}\n onKeyDown={(event) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault()\n onClick?.()\n }\n }}\n role=\"button\"\n tabIndex={0}\n >\n {text}\n </div>\n )\n}\n","import type { WaveformRenderProps } from \"../../core/types\"\n\nconst BAR_COUNT = 5\n\n/**\n * Default waveform component.\n * Shows audio level visualization during recording.\n */\nexport function DefaultWaveform({\n audioLevel,\n isListening,\n}: WaveformRenderProps) {\n if (!isListening) return null\n\n return (\n <div className=\"cursor-buddy-waveform\">\n {Array.from({ length: BAR_COUNT }).map((_, i) => {\n // Create varied heights based on audio level and bar position\n const baseHeight = 4\n const variance = Math.sin((i / BAR_COUNT) * Math.PI) * 0.5 + 0.5\n const height = baseHeight + audioLevel * 16 * 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","import { useState, useEffect } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { useStore } from \"@nanostores/react\"\nimport {\n $buddyPosition,\n $buddyRotation,\n $buddyScale,\n $audioLevel,\n $pointingTarget,\n} from \"../../core/atoms\"\nimport { useCursorBuddy } from \"../hooks/useCursorBuddy\"\nimport { DefaultCursor } from \"./Cursor\"\nimport { DefaultSpeechBubble } from \"./SpeechBubble\"\nimport { DefaultWaveform } from \"./Waveform\"\nimport type {\n CursorRenderProps,\n SpeechBubbleRenderProps,\n WaveformRenderProps,\n} from \"../../core/types\"\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","import { useEffect, useRef } from \"react\";\n\ninterface HotkeyModifiers {\n ctrl: boolean;\n alt: boolean;\n shift: boolean;\n meta: boolean;\n}\n\n/**\n * Parse a hotkey string like \"ctrl+alt\" into modifier flags\n */\nfunction parseHotkey(hotkey: string): HotkeyModifiers {\n const parts = hotkey.toLowerCase().split(\"+\");\n return {\n ctrl: parts.includes(\"ctrl\") || parts.includes(\"control\"),\n alt: parts.includes(\"alt\") || parts.includes(\"option\"),\n shift: parts.includes(\"shift\"),\n meta:\n parts.includes(\"meta\") ||\n parts.includes(\"cmd\") ||\n parts.includes(\"command\"),\n };\n}\n\n/**\n * Check if a keyboard event matches the required modifiers\n */\nfunction matchesHotkey(\n event: KeyboardEvent,\n modifiers: HotkeyModifiers,\n): boolean {\n return (\n event.ctrlKey === modifiers.ctrl &&\n event.altKey === modifiers.alt &&\n event.shiftKey === modifiers.shift &&\n event.metaKey === modifiers.meta\n );\n}\n\n/**\n * Hook for detecting push-to-talk hotkey press/release.\n *\n * @param hotkey - Hotkey string like \"ctrl+alt\" or \"ctrl+shift\"\n * @param onPress - Called when hotkey is pressed\n * @param onRelease - Called when hotkey is released\n * @param enabled - Whether the hotkey listener is active (default: true)\n */\nexport function useHotkey(\n hotkey: string,\n onPress: () => void,\n onRelease: () => void,\n enabled: boolean = true,\n): void {\n const isPressedRef = useRef(false);\n const modifiersRef = useRef<HotkeyModifiers>(parseHotkey(hotkey));\n\n // Use refs for callbacks to avoid stale closures in event handlers\n const onPressRef = useRef(onPress);\n const onReleaseRef = useRef(onRelease);\n onPressRef.current = onPress;\n onReleaseRef.current = onRelease;\n\n // Update modifiers when hotkey changes\n useEffect(() => {\n modifiersRef.current = parseHotkey(hotkey);\n }, [hotkey]);\n\n useEffect(() => {\n if (!enabled) {\n // If disabled while pressed, trigger release\n if (isPressedRef.current) {\n isPressedRef.current = false;\n onReleaseRef.current();\n }\n return;\n }\n\n function handleKeyDown(event: KeyboardEvent) {\n if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {\n isPressedRef.current = true;\n event.preventDefault();\n onPressRef.current();\n }\n }\n\n function handleKeyUp(event: KeyboardEvent) {\n // Release when any required modifier is released\n if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {\n isPressedRef.current = false;\n onReleaseRef.current();\n }\n }\n\n function handleBlur() {\n // Release if window loses focus while hotkey is pressed\n if (isPressedRef.current) {\n isPressedRef.current = false;\n onReleaseRef.current();\n }\n }\n\n window.addEventListener(\"keydown\", handleKeyDown);\n window.addEventListener(\"keyup\", handleKeyUp);\n window.addEventListener(\"blur\", handleBlur);\n\n return () => {\n window.removeEventListener(\"keydown\", handleKeyDown);\n window.removeEventListener(\"keyup\", handleKeyUp);\n window.removeEventListener(\"blur\", handleBlur);\n };\n }, [enabled]);\n}\n","\"use client\"\n\nimport { useEffect } from \"react\"\nimport {\n CursorBuddyProvider,\n type CursorBuddyProviderProps,\n} from \"./CursorBuddyProvider\"\nimport { Overlay, type OverlayProps } from \"./components/Overlay\"\nimport { useHotkey } from \"./hooks/useHotkey\"\nimport { useCursorBuddy } from \"./hooks/useCursorBuddy\"\nimport type { PointingTarget, VoiceState } from \"../core/types\"\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 /** Whether TTS is muted */\n muted?: boolean\n /** Container element for portal (defaults to document.body) */\n container?: HTMLElement | null\n /** Callback when transcript is ready */\n onTranscript?: (text: string) => void\n /** Callback when AI responds */\n onResponse?: (text: string) => void\n /** Callback when pointing at element */\n onPoint?: (target: PointingTarget) => void\n /** Callback when state changes */\n onStateChange?: (state: VoiceState) => void\n /** Callback when error occurs */\n onError?: (error: Error) => void\n}\n\n/**\n * Internal component that sets up hotkey handling\n */\nfunction CursorBuddyInner({\n hotkey = \"ctrl+alt\",\n cursor,\n speechBubble,\n waveform,\n container,\n}: Pick<\n CursorBuddyProps,\n \"hotkey\" | \"cursor\" | \"speechBubble\" | \"waveform\" | \"container\"\n>) {\n const { startListening, stopListening, isEnabled } = useCursorBuddy()\n\n // Set up hotkey\n useHotkey(hotkey, startListening, stopListening, isEnabled)\n\n return (\n <Overlay\n cursor={cursor}\n speechBubble={speechBubble}\n waveform={waveform}\n container={container}\n />\n )\n}\n\n/**\n * Drop-in cursor buddy component.\n *\n * Adds an AI-powered cursor companion to your app. Users hold the hotkey\n * (default: Ctrl+Alt) to speak. The SDK captures a screenshot, transcribes\n * the speech, sends it to the AI, speaks the response, and can point at\n * elements on screen.\n *\n * @example\n * ```tsx\n * import { CursorBuddy } from \"cursor-buddy/client\"\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 muted,\n container,\n cursor,\n speechBubble,\n waveform,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n}: CursorBuddyProps) {\n return (\n <CursorBuddyProvider\n endpoint={endpoint}\n muted={muted}\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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"}
@@ -0,0 +1,2 @@
1
+ import { a as VoiceState, n as Point, r as PointingTarget } from "./types-b2KrNyuu.mjs";
2
+ export { type Point, type PointingTarget, type VoiceState };
package/dist/index.mjs ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { t as CursorBuddyHandler } from "../../types-B2GUdTzP.mjs";
2
+
3
+ //#region src/server/adapters/next.d.ts
4
+ /**
5
+ * Convert a CursorBuddyHandler to Next.js App Router route handlers.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // app/api/cursor-buddy/[...path]/route.ts
10
+ * import { toNextJsHandler } from "cursor-buddy/server/next"
11
+ * import { cursorBuddy } from "@/lib/cursor-buddy"
12
+ *
13
+ * export const { GET, POST } = toNextJsHandler(cursorBuddy)
14
+ * ```
15
+ */
16
+ declare function toNextJsHandler(cursorBuddy: CursorBuddyHandler): {
17
+ GET: (request: Request) => Promise<Response>;
18
+ POST: (request: Request) => Promise<Response>;
19
+ };
20
+ //#endregion
21
+ export { toNextJsHandler };
22
+ //# sourceMappingURL=next.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"next.d.mts","names":[],"sources":["../../../src/server/adapters/next.ts"],"mappings":";;;;;AAcA;;;;;;;;;;iBAAgB,eAAA,CAAgB,WAAA,EAAa,kBAAA;iBACjB,OAAA,KAAO,OAAA,CAAA,QAAA;kBAAP,OAAA,KAAO,OAAA,CAAA,QAAA;AAAA"}
@@ -0,0 +1,24 @@
1
+ //#region src/server/adapters/next.ts
2
+ /**
3
+ * Convert a CursorBuddyHandler to Next.js App Router route handlers.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * // app/api/cursor-buddy/[...path]/route.ts
8
+ * import { toNextJsHandler } from "cursor-buddy/server/next"
9
+ * import { cursorBuddy } from "@/lib/cursor-buddy"
10
+ *
11
+ * export const { GET, POST } = toNextJsHandler(cursorBuddy)
12
+ * ```
13
+ */
14
+ function toNextJsHandler(cursorBuddy) {
15
+ const handler = (request) => cursorBuddy.handler(request);
16
+ return {
17
+ GET: handler,
18
+ POST: handler
19
+ };
20
+ }
21
+ //#endregion
22
+ export { toNextJsHandler };
23
+
24
+ //# sourceMappingURL=next.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"next.mjs","names":[],"sources":["../../../src/server/adapters/next.ts"],"sourcesContent":["import type { CursorBuddyHandler } from \"../types\"\n\n/**\n * Convert a CursorBuddyHandler to Next.js App Router route handlers.\n *\n * @example\n * ```ts\n * // app/api/cursor-buddy/[...path]/route.ts\n * import { toNextJsHandler } from \"cursor-buddy/server/next\"\n * import { cursorBuddy } from \"@/lib/cursor-buddy\"\n *\n * export const { GET, POST } = toNextJsHandler(cursorBuddy)\n * ```\n */\nexport function toNextJsHandler(cursorBuddy: CursorBuddyHandler) {\n const handler = (request: Request) => cursorBuddy.handler(request)\n\n return {\n GET: handler,\n POST: handler,\n }\n}\n"],"mappings":";;;;;;;;;;;;;AAcA,SAAgB,gBAAgB,aAAiC;CAC/D,MAAM,WAAW,YAAqB,YAAY,QAAQ,QAAQ;AAElE,QAAO;EACL,KAAK;EACL,MAAM;EACP"}
@@ -0,0 +1,34 @@
1
+ import { n as CursorBuddyHandlerConfig, t as CursorBuddyHandler } from "../types-B2GUdTzP.mjs";
2
+
3
+ //#region src/server/handler.d.ts
4
+ /**
5
+ * Create a cursor buddy request handler.
6
+ *
7
+ * The handler responds to three routes based on the last path segment:
8
+ * - /chat - Screenshot + transcript → AI SSE stream
9
+ * - /transcribe - Audio → text
10
+ * - /tts - Text → audio
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { createCursorBuddyHandler } from "cursor-buddy/server"
15
+ * import { openai } from "@ai-sdk/openai"
16
+ *
17
+ * const cursorBuddy = createCursorBuddyHandler({
18
+ * model: openai("gpt-4o"),
19
+ * speechModel: openai.speech("tts-1"),
20
+ * transcriptionModel: openai.transcription("whisper-1"),
21
+ * })
22
+ * ```
23
+ */
24
+ declare function createCursorBuddyHandler(config: CursorBuddyHandlerConfig): CursorBuddyHandler;
25
+ //#endregion
26
+ //#region src/server/system-prompt.d.ts
27
+ /**
28
+ * Default system prompt for the cursor buddy AI.
29
+ * Instructs the model on how to respond conversationally and use POINT tags.
30
+ */
31
+ declare const DEFAULT_SYSTEM_PROMPT = "You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally \u2014 your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response:\n\n[POINT:x,y:label]\n\nWhere:\n- x,y are coordinates in screenshot image pixels (top-left origin)\n- label is a brief description shown in a speech bubble\n\nExample: \"The submit button is right here. [POINT:450,320:Submit button]\"\n\nGuidelines:\n- Only point when it genuinely helps (showing a specific button, field, or element)\n- Use natural descriptions (\"this button\", \"over here\", \"right there\")\n- If the screenshot image size is provided in text, always point in that screenshot image pixel space.\n- Coordinates should be the CENTER of the element you're pointing at\n- Keep labels short (2-4 words)\n\n## Response Style\n\n- Be concise \u2014 aim for 1-3 sentences\n- Sound natural when spoken aloud\n- Avoid technical jargon unless the user is technical\n- If you can't see something clearly, say so\n- Never mention that you're looking at a \"screenshot\" \u2014 say \"I can see...\" or \"Looking at your screen...\"\n";
32
+ //#endregion
33
+ export { type CursorBuddyHandler, type CursorBuddyHandlerConfig, DEFAULT_SYSTEM_PROMPT, createCursorBuddyHandler };
34
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/server/handler.ts","../../src/server/system-prompt.ts"],"mappings":";;;;;AAyBA;;;;;;;;;;;;ACrBA;;;;;;iBDqBgB,wBAAA,CACd,MAAA,EAAQ,wBAAA,GACP,kBAAA;;;;;;AAFH;cCrBa,qBAAA"}
@@ -0,0 +1,163 @@
1
+ import { experimental_generateSpeech, experimental_transcribe, streamText } from "ai";
2
+ //#region src/server/system-prompt.ts
3
+ /**
4
+ * Default system prompt for the cursor buddy AI.
5
+ * Instructs the model on how to respond conversationally and use POINT tags.
6
+ */
7
+ const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant that lives inside a web page as a cursor companion.
8
+
9
+ You can see screenshots of the user's viewport and hear their voice. Respond conversationally — your responses will be spoken aloud via text-to-speech, so keep them concise and natural.
10
+
11
+ ## Pointing at Elements
12
+
13
+ When you want to direct the user's attention to something on screen, add a pointing tag at the END of your response:
14
+
15
+ [POINT:x,y:label]
16
+
17
+ Where:
18
+ - x,y are coordinates in screenshot image pixels (top-left origin)
19
+ - label is a brief description shown in a speech bubble
20
+
21
+ Example: "The submit button is right here. [POINT:450,320:Submit button]"
22
+
23
+ Guidelines:
24
+ - Only point when it genuinely helps (showing a specific button, field, or element)
25
+ - Use natural descriptions ("this button", "over here", "right there")
26
+ - If the screenshot image size is provided in text, always point in that screenshot image pixel space.
27
+ - Coordinates should be the CENTER of the element you're pointing at
28
+ - Keep labels short (2-4 words)
29
+
30
+ ## Response Style
31
+
32
+ - Be concise — aim for 1-3 sentences
33
+ - Sound natural when spoken aloud
34
+ - Avoid technical jargon unless the user is technical
35
+ - If you can't see something clearly, say so
36
+ - Never mention that you're looking at a "screenshot" — say "I can see..." or "Looking at your screen..."
37
+ `;
38
+ //#endregion
39
+ //#region src/server/routes/chat.ts
40
+ /**
41
+ * Handle chat requests: screenshot + transcript → AI SSE stream
42
+ */
43
+ async function handleChat(request, config) {
44
+ const { screenshot, transcript, history, capture } = await request.json();
45
+ const systemPrompt = typeof config.system === "function" ? config.system({ defaultPrompt: DEFAULT_SYSTEM_PROMPT }) : config.system ?? DEFAULT_SYSTEM_PROMPT;
46
+ const maxMessages = (config.maxHistory ?? 10) * 2;
47
+ const trimmedHistory = history.slice(-maxMessages);
48
+ const captureContext = capture ? `The screenshot image size is ${capture.width}x${capture.height} pixels.
49
+ If you include a [POINT:x,y:label] tag, x and y MUST use that screenshot image pixel space.` : null;
50
+ const messages = [...trimmedHistory.map((msg) => ({
51
+ role: msg.role,
52
+ content: msg.content
53
+ })), {
54
+ role: "user",
55
+ content: [
56
+ ...captureContext ? [{
57
+ type: "text",
58
+ text: captureContext
59
+ }] : [],
60
+ {
61
+ type: "image",
62
+ image: screenshot
63
+ },
64
+ {
65
+ type: "text",
66
+ text: transcript
67
+ }
68
+ ]
69
+ }];
70
+ return streamText({
71
+ model: config.model,
72
+ system: systemPrompt,
73
+ messages,
74
+ tools: config.tools
75
+ }).toTextStreamResponse();
76
+ }
77
+ //#endregion
78
+ //#region src/server/routes/transcribe.ts
79
+ /**
80
+ * Handle transcription requests: audio file → text
81
+ */
82
+ async function handleTranscribe(request, config) {
83
+ const audioFile = (await request.formData()).get("audio");
84
+ if (!audioFile || !(audioFile instanceof File)) return new Response(JSON.stringify({ error: "No audio file provided" }), {
85
+ status: 400,
86
+ headers: { "Content-Type": "application/json" }
87
+ });
88
+ const audioBuffer = await audioFile.arrayBuffer();
89
+ const response = { text: (await experimental_transcribe({
90
+ model: config.transcriptionModel,
91
+ audio: new Uint8Array(audioBuffer)
92
+ })).text };
93
+ return new Response(JSON.stringify(response), { headers: { "Content-Type": "application/json" } });
94
+ }
95
+ //#endregion
96
+ //#region src/server/routes/tts.ts
97
+ /**
98
+ * Handle TTS requests: text → audio
99
+ */
100
+ async function handleTTS(request, config) {
101
+ const { text } = await request.json();
102
+ if (!text) return new Response(JSON.stringify({ error: "No text provided" }), {
103
+ status: 400,
104
+ headers: { "Content-Type": "application/json" }
105
+ });
106
+ const result = await experimental_generateSpeech({
107
+ model: config.speechModel,
108
+ text
109
+ });
110
+ const audioData = new Uint8Array(result.audio.uint8Array);
111
+ return new Response(audioData, { headers: { "Content-Type": "audio/mpeg" } });
112
+ }
113
+ //#endregion
114
+ //#region src/server/handler.ts
115
+ /**
116
+ * Create a cursor buddy request handler.
117
+ *
118
+ * The handler responds to three routes based on the last path segment:
119
+ * - /chat - Screenshot + transcript → AI SSE stream
120
+ * - /transcribe - Audio → text
121
+ * - /tts - Text → audio
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * import { createCursorBuddyHandler } from "cursor-buddy/server"
126
+ * import { openai } from "@ai-sdk/openai"
127
+ *
128
+ * const cursorBuddy = createCursorBuddyHandler({
129
+ * model: openai("gpt-4o"),
130
+ * speechModel: openai.speech("tts-1"),
131
+ * transcriptionModel: openai.transcription("whisper-1"),
132
+ * })
133
+ * ```
134
+ */
135
+ function createCursorBuddyHandler(config) {
136
+ const handler = async (request) => {
137
+ const pathSegments = new URL(request.url).pathname.split("/").filter(Boolean);
138
+ switch (pathSegments[pathSegments.length - 1]) {
139
+ case "chat": return handleChat(request, config);
140
+ case "transcribe": return handleTranscribe(request, config);
141
+ case "tts": return handleTTS(request, config);
142
+ default: return new Response(JSON.stringify({
143
+ error: "Not found",
144
+ availableRoutes: [
145
+ "/chat",
146
+ "/transcribe",
147
+ "/tts"
148
+ ]
149
+ }), {
150
+ status: 404,
151
+ headers: { "Content-Type": "application/json" }
152
+ });
153
+ }
154
+ };
155
+ return {
156
+ handler,
157
+ config
158
+ };
159
+ }
160
+ //#endregion
161
+ export { DEFAULT_SYSTEM_PROMPT, createCursorBuddyHandler };
162
+
163
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["transcribe","generateSpeech"],"sources":["../../src/server/system-prompt.ts","../../src/server/routes/chat.ts","../../src/server/routes/transcribe.ts","../../src/server/routes/tts.ts","../../src/server/handler.ts"],"sourcesContent":["/**\n * Default system prompt for the cursor buddy AI.\n * Instructs the model on how to respond conversationally and use POINT tags.\n */\nexport const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally — your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response:\n\n[POINT:x,y:label]\n\nWhere:\n- x,y are coordinates in screenshot image pixels (top-left origin)\n- label is a brief description shown in a speech bubble\n\nExample: \"The submit button is right here. [POINT:450,320:Submit button]\"\n\nGuidelines:\n- Only point when it genuinely helps (showing a specific button, field, or element)\n- Use natural descriptions (\"this button\", \"over here\", \"right there\")\n- If the screenshot image size is provided in text, always point in that screenshot image pixel space.\n- Coordinates should be the CENTER of the element you're pointing at\n- Keep labels short (2-4 words)\n\n## Response Style\n\n- Be concise — aim for 1-3 sentences\n- Sound natural when spoken aloud\n- Avoid technical jargon unless the user is technical\n- If you can't see something clearly, say so\n- Never mention that you're looking at a \"screenshot\" — say \"I can see...\" or \"Looking at your screen...\"\n`\n","import { streamText } from \"ai\"\nimport type { CursorBuddyHandlerConfig, ChatRequestBody } from \"../types\"\nimport { DEFAULT_SYSTEM_PROMPT } from \"../system-prompt\"\n\n/**\n * Handle chat requests: screenshot + transcript → AI SSE stream\n */\nexport async function handleChat(\n request: Request,\n config: CursorBuddyHandlerConfig\n): Promise<Response> {\n const body = (await request.json()) as ChatRequestBody\n const { screenshot, transcript, history, capture } = body\n\n // Resolve system prompt (string or function)\n const systemPrompt =\n typeof config.system === \"function\"\n ? config.system({ defaultPrompt: DEFAULT_SYSTEM_PROMPT })\n : config.system ?? DEFAULT_SYSTEM_PROMPT\n\n // Trim history to maxHistory (default 10 exchanges = 20 messages)\n const maxMessages = (config.maxHistory ?? 10) * 2\n const trimmedHistory = history.slice(-maxMessages)\n\n const captureContext = capture\n ? `The screenshot image size is ${capture.width}x${capture.height} pixels.\nIf you include a [POINT:x,y:label] tag, x and y MUST use that screenshot image pixel space.`\n : null\n\n // Build messages array with vision content\n const messages = [\n ...trimmedHistory.map((msg) => ({\n role: msg.role as \"user\" | \"assistant\",\n content: msg.content,\n })),\n {\n role: \"user\" as const,\n content: [\n ...(captureContext\n ? [\n {\n type: \"text\" as const,\n text: captureContext,\n },\n ]\n : []),\n {\n type: \"image\" as const,\n image: screenshot,\n },\n {\n type: \"text\" as const,\n text: transcript,\n },\n ],\n },\n ]\n\n const result = streamText({\n model: config.model,\n system: systemPrompt,\n messages,\n tools: config.tools,\n })\n\n return result.toTextStreamResponse()\n}\n","import { experimental_transcribe as transcribe } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TranscribeResponse } from \"../types\"\n\n/**\n * Handle transcription requests: audio file → text\n */\nexport async function handleTranscribe(\n request: Request,\n config: CursorBuddyHandlerConfig\n): Promise<Response> {\n const formData = await request.formData()\n const audioFile = formData.get(\"audio\")\n\n if (!audioFile || !(audioFile instanceof File)) {\n return new Response(JSON.stringify({ error: \"No audio file provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const audioBuffer = await audioFile.arrayBuffer()\n\n const result = await transcribe({\n model: config.transcriptionModel,\n audio: new Uint8Array(audioBuffer),\n })\n\n const response: TranscribeResponse = { text: result.text }\n\n return new Response(JSON.stringify(response), {\n headers: { \"Content-Type\": \"application/json\" },\n })\n}\n","import { experimental_generateSpeech as generateSpeech } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TTSRequestBody } from \"../types\"\n\n/**\n * Handle TTS requests: text → audio\n */\nexport async function handleTTS(\n request: Request,\n config: CursorBuddyHandlerConfig\n): Promise<Response> {\n const body = (await request.json()) as TTSRequestBody\n const { text } = body\n\n if (!text) {\n return new Response(JSON.stringify({ error: \"No text provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const result = await generateSpeech({\n model: config.speechModel,\n text,\n })\n\n // Create a new ArrayBuffer copy to satisfy TypeScript's strict typing\n const audioData = new Uint8Array(result.audio.uint8Array)\n\n return new Response(audioData, {\n headers: {\n \"Content-Type\": \"audio/mpeg\",\n },\n })\n}\n","import type { CursorBuddyHandlerConfig, CursorBuddyHandler } from \"./types\"\nimport { handleChat } from \"./routes/chat\"\nimport { handleTranscribe } from \"./routes/transcribe\"\nimport { handleTTS } from \"./routes/tts\"\n\n/**\n * Create a cursor buddy request handler.\n *\n * The handler responds to three routes based on the last path segment:\n * - /chat - Screenshot + transcript → AI SSE stream\n * - /transcribe - Audio → text\n * - /tts - Text → audio\n *\n * @example\n * ```ts\n * import { createCursorBuddyHandler } from \"cursor-buddy/server\"\n * import { openai } from \"@ai-sdk/openai\"\n *\n * const cursorBuddy = createCursorBuddyHandler({\n * model: openai(\"gpt-4o\"),\n * speechModel: openai.speech(\"tts-1\"),\n * transcriptionModel: openai.transcription(\"whisper-1\"),\n * })\n * ```\n */\nexport function createCursorBuddyHandler(\n config: CursorBuddyHandlerConfig\n): CursorBuddyHandler {\n const handler = async (request: Request): Promise<Response> => {\n const url = new URL(request.url)\n const pathSegments = url.pathname.split(\"/\").filter(Boolean)\n const route = pathSegments[pathSegments.length - 1]\n\n switch (route) {\n case \"chat\":\n return handleChat(request, config)\n\n case \"transcribe\":\n return handleTranscribe(request, config)\n\n case \"tts\":\n return handleTTS(request, config)\n\n default:\n return new Response(\n JSON.stringify({\n error: \"Not found\",\n availableRoutes: [\"/chat\", \"/transcribe\", \"/tts\"],\n }),\n {\n status: 404,\n headers: { \"Content-Type\": \"application/json\" },\n }\n )\n }\n }\n\n return { handler, config }\n}\n"],"mappings":";;;;;;AAIA,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACGrC,eAAsB,WACpB,SACA,QACmB;CAEnB,MAAM,EAAE,YAAY,YAAY,SAAS,YAD3B,MAAM,QAAQ,MAAM;CAIlC,MAAM,eACJ,OAAO,OAAO,WAAW,aACrB,OAAO,OAAO,EAAE,eAAe,uBAAuB,CAAC,GACvD,OAAO,UAAU;CAGvB,MAAM,eAAe,OAAO,cAAc,MAAM;CAChD,MAAM,iBAAiB,QAAQ,MAAM,CAAC,YAAY;CAElD,MAAM,iBAAiB,UACnB,gCAAgC,QAAQ,MAAM,GAAG,QAAQ,OAAO;+FAEhE;CAGJ,MAAM,WAAW,CACf,GAAG,eAAe,KAAK,SAAS;EAC9B,MAAM,IAAI;EACV,SAAS,IAAI;EACd,EAAE,EACH;EACE,MAAM;EACN,SAAS;GACP,GAAI,iBACA,CACE;IACE,MAAM;IACN,MAAM;IACP,CACF,GACD,EAAE;GACN;IACE,MAAM;IACN,OAAO;IACR;GACD;IACE,MAAM;IACN,MAAM;IACP;GACF;EACF,CACF;AASD,QAPe,WAAW;EACxB,OAAO,OAAO;EACd,QAAQ;EACR;EACA,OAAO,OAAO;EACf,CAAC,CAEY,sBAAsB;;;;;;;AC3DtC,eAAsB,iBACpB,SACA,QACmB;CAEnB,MAAM,aADW,MAAM,QAAQ,UAAU,EACd,IAAI,QAAQ;AAEvC,KAAI,CAAC,aAAa,EAAE,qBAAqB,MACvC,QAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,EAAE;EACvE,QAAQ;EACR,SAAS,EAAE,gBAAgB,oBAAoB;EAChD,CAAC;CAGJ,MAAM,cAAc,MAAM,UAAU,aAAa;CAOjD,MAAM,WAA+B,EAAE,OALxB,MAAMA,wBAAW;EAC9B,OAAO,OAAO;EACd,OAAO,IAAI,WAAW,YAAY;EACnC,CAAC,EAEkD,MAAM;AAE1D,QAAO,IAAI,SAAS,KAAK,UAAU,SAAS,EAAE,EAC5C,SAAS,EAAE,gBAAgB,oBAAoB,EAChD,CAAC;;;;;;;ACzBJ,eAAsB,UACpB,SACA,QACmB;CAEnB,MAAM,EAAE,SADM,MAAM,QAAQ,MAAM;AAGlC,KAAI,CAAC,KACH,QAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,oBAAoB,CAAC,EAAE;EACjE,QAAQ;EACR,SAAS,EAAE,gBAAgB,oBAAoB;EAChD,CAAC;CAGJ,MAAM,SAAS,MAAMC,4BAAe;EAClC,OAAO,OAAO;EACd;EACD,CAAC;CAGF,MAAM,YAAY,IAAI,WAAW,OAAO,MAAM,WAAW;AAEzD,QAAO,IAAI,SAAS,WAAW,EAC7B,SAAS,EACP,gBAAgB,cACjB,EACF,CAAC;;;;;;;;;;;;;;;;;;;;;;;;ACPJ,SAAgB,yBACd,QACoB;CACpB,MAAM,UAAU,OAAO,YAAwC;EAE7D,MAAM,eADM,IAAI,IAAI,QAAQ,IAAI,CACP,SAAS,MAAM,IAAI,CAAC,OAAO,QAAQ;AAG5D,UAFc,aAAa,aAAa,SAAS,IAEjD;GACE,KAAK,OACH,QAAO,WAAW,SAAS,OAAO;GAEpC,KAAK,aACH,QAAO,iBAAiB,SAAS,OAAO;GAE1C,KAAK,MACH,QAAO,UAAU,SAAS,OAAO;GAEnC,QACE,QAAO,IAAI,SACT,KAAK,UAAU;IACb,OAAO;IACP,iBAAiB;KAAC;KAAS;KAAe;KAAO;IAClD,CAAC,EACF;IACE,QAAQ;IACR,SAAS,EAAE,gBAAgB,oBAAoB;IAChD,CACF;;;AAIP,QAAO;EAAE;EAAS;EAAQ"}
@@ -0,0 +1,37 @@
1
+ import { LanguageModel, SpeechModel, Tool, TranscriptionModel } from "ai";
2
+
3
+ //#region src/server/types.d.ts
4
+ /**
5
+ * Configuration for createCursorBuddyHandler
6
+ */
7
+ interface CursorBuddyHandlerConfig {
8
+ /** AI SDK language model for chat (e.g., openai("gpt-4o")) */
9
+ model: LanguageModel;
10
+ /** AI SDK speech model for TTS (e.g., openai.speech("tts-1")) */
11
+ speechModel: SpeechModel;
12
+ /** AI SDK transcription model (e.g., openai.transcription("whisper-1")) */
13
+ transcriptionModel: TranscriptionModel;
14
+ /**
15
+ * System prompt for the AI. Can be a string or a function that receives
16
+ * the default prompt and returns a modified version.
17
+ */
18
+ system?: string | ((ctx: {
19
+ defaultPrompt: string;
20
+ }) => string);
21
+ /** AI SDK tools available to the model */
22
+ tools?: Record<string, Tool>;
23
+ /** Maximum conversation history messages to include (default: 10) */
24
+ maxHistory?: number;
25
+ }
26
+ /**
27
+ * Return type of createCursorBuddyHandler
28
+ */
29
+ interface CursorBuddyHandler {
30
+ /** The main request handler */
31
+ handler: (request: Request) => Promise<Response>;
32
+ /** The resolved configuration */
33
+ config: CursorBuddyHandlerConfig;
34
+ }
35
+ //#endregion
36
+ export { CursorBuddyHandlerConfig as n, CursorBuddyHandler as t };
37
+ //# sourceMappingURL=types-B2GUdTzP.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-B2GUdTzP.d.mts","names":[],"sources":["../src/server/types.ts"],"mappings":";;;;;AAKA;UAAiB,wBAAA;;EAEf,KAAA,EAAO,aAAA;EAGM;EAAb,WAAA,EAAa,WAAA;EAYU;EATvB,kBAAA,EAAoB,kBAAA;EASN;;;;EAHd,MAAA,cAAoB,GAAA;IAAO,aAAA;EAAA;EANP;EASpB,KAAA,GAAQ,MAAA,SAAe,IAAA;EAHI;EAM3B,UAAA;AAAA;;;;UAMe,kBAAA;EANL;EAQV,OAAA,GAAU,OAAA,EAAS,OAAA,KAAY,OAAA,CAAQ,QAAA;EAFN;EAKjC,MAAA,EAAQ,wBAAA;AAAA"}
@@ -0,0 +1,59 @@
1
+ //#region src/core/types.d.ts
2
+ /**
3
+ * Voice state machine states
4
+ */
5
+ type VoiceState = "idle" | "listening" | "processing" | "responding";
6
+ /**
7
+ * Point coordinates parsed from AI response [POINT:x,y:label]
8
+ */
9
+ interface PointingTarget {
10
+ /** X coordinate in viewport pixels (top-left origin) */
11
+ x: number;
12
+ /** Y coordinate in viewport pixels (top-left origin) */
13
+ y: number;
14
+ /** Label to display in speech bubble */
15
+ label: string;
16
+ }
17
+ /**
18
+ * 2D point
19
+ */
20
+ interface Point {
21
+ x: number;
22
+ y: number;
23
+ }
24
+ /**
25
+ * Cursor render props passed to custom cursor components
26
+ */
27
+ interface CursorRenderProps {
28
+ /** Current voice state */
29
+ state: VoiceState;
30
+ /** Whether cursor is currently engaged with a pointing target */
31
+ isPointing: boolean;
32
+ /** Rotation in radians (direction of travel during pointing) */
33
+ rotation: number;
34
+ /** Scale factor (1.0 normal, up to 1.3 during flight) */
35
+ scale: number;
36
+ }
37
+ /**
38
+ * Speech bubble render props
39
+ */
40
+ interface SpeechBubbleRenderProps {
41
+ /** Text to display */
42
+ text: string;
43
+ /** Whether bubble is visible */
44
+ isVisible: boolean;
45
+ /** Called when the bubble should be dismissed */
46
+ onClick?: () => void;
47
+ }
48
+ /**
49
+ * Waveform render props
50
+ */
51
+ interface WaveformRenderProps {
52
+ /** Current audio level (0-1) */
53
+ audioLevel: number;
54
+ /** Whether currently listening */
55
+ isListening: boolean;
56
+ }
57
+ //#endregion
58
+ export { VoiceState as a, SpeechBubbleRenderProps as i, Point as n, WaveformRenderProps as o, PointingTarget as r, CursorRenderProps as t };
59
+ //# sourceMappingURL=types-b2KrNyuu.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-b2KrNyuu.d.mts","names":[],"sources":["../src/core/types.ts"],"mappings":";;AAGA;;KAAY,UAAA;;;AAKZ;UAAiB,cAAA;;EAEf,CAAA;EAAA;EAEA,CAAA;EAEA;EAAA,KAAA;AAAA;AAMF;;;AAAA,UAAiB,KAAA;EACf,CAAA;EACA,CAAA;AAAA;;;;UA8Be,iBAAA;;EAEf,KAAA,EAAO,UAAA;;EAEP,UAAA;;EAEA,QAAA;;EAEA,KAAA;AAAA;;;;UAMe,uBAAA;;EAEf,IAAA;;EAEA,SAAA;;EAEA,OAAA;AAAA;;;;UAMe,mBAAA;;EAEf,UAAA;;EAEA,WAAA;AAAA"}