Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,060 changes: 1,060 additions & 0 deletions RAYCAST-EXTENSION.md

Large diffs are not rendered by default.

Binary file added apps/raycast/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions apps/raycast/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const { defineConfig } = require('eslint/config');
const raycastConfig = require('@raycast/eslint-config');

module.exports = defineConfig([...raycastConfig]);
48 changes: 48 additions & 0 deletions apps/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "better-context",
"title": "Better Context",
"description": "Ask questions about documentation with AI-powered context",
"icon": "icon.png",
"author": "anomalyco",
"license": "MIT",
"commands": [
{
"name": "ask",
"title": "Ask Question",
"description": "Ask a question with @resource tagging",
"mode": "view"
}
],
"preferences": [
{
"name": "apiKey",
"type": "password",
"required": true,
"title": "API Key",
"description": "Your Better Context API key. Get one at btca.dev/app/settings",
"placeholder": "btca_..."
}
],
"dependencies": {
"@raycast/api": "^1.94.0",
"@raycast/utils": "^1.19.1",
"zod": "^3.24.0"
},
"devDependencies": {
"@raycast/eslint-config": "^2.0.4",
"@types/node": "22.13.10",
"@types/react": "19.0.12",
"eslint": "^9.22.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
},
"scripts": {
"build": "ray build",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"prepublishOnly": "echo \"\\n\\nIt seems like you are trying to publish the Raycast extension to npm.\\n\\nIf you did intend to publish it to npm, remove the \\`prepublishOnly\\` script and rerun \\`npm publish\\` again.\\nIf you wanted to publish it to the Raycast Store instead, use \\`npm run publish\\` instead.\\n\\n\" && exit 1",
"publish": "npx @raycast/api@latest publish"
}
}
27 changes: 27 additions & 0 deletions apps/raycast/raycast-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/// <reference types="@raycast/api">

/* 🚧 🚧 🚧
* This file is auto-generated from the extension's manifest.
* Do not modify manually. Instead, update the `package.json` file.
* 🚧 🚧 🚧 */

/* eslint-disable @typescript-eslint/ban-types */

type ExtensionPreferences = {
/** API Key - Your Better Context API key. Get one at btca.dev/app/settings */
"apiKey": string
}

/** Preferences accessible in all the extension's commands */
declare type Preferences = ExtensionPreferences

declare namespace Preferences {
/** Preferences accessible in the `ask` command */
export type Ask = ExtensionPreferences & {}
}

declare namespace Arguments {
/** Arguments passed to the `ask` command */
export type Ask = {}
}

71 changes: 71 additions & 0 deletions apps/raycast/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { getPreferenceValues } from '@raycast/api';
import { ResourcesResponseSchema, type ResourcesResponse } from './types';

const CONVEX_URL = 'https://greedy-partridge-784.convex.site';

interface Preferences {
apiKey: string;
}

function getApiKey(): string {
const preferences = getPreferenceValues<Preferences>();
return preferences.apiKey;
}

function getHeaders(): HeadersInit {
return {
Authorization: `Bearer ${getApiKey()}`,
'Content-Type': 'application/json'
};
}

export async function fetchResources(): Promise<ResourcesResponse> {
const response = await fetch(`${CONVEX_URL}/raycast/resources`, {
method: 'GET',
headers: getHeaders()
});

if (!response.ok) {
const error = (await response.json().catch(() => ({ error: 'Unknown error' }))) as {
error?: string;
upgradeUrl?: string;
};
throw new ApiError(
response.status,
error.error || 'Failed to fetch resources',
error.upgradeUrl
);
}

const data = await response.json();
return ResourcesResponseSchema.parse(data);
}

export async function askQuestion(question: string): Promise<Response> {
const response = await fetch(`${CONVEX_URL}/raycast/ask`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ question })
});

if (!response.ok) {
const error = (await response.json().catch(() => ({ error: 'Unknown error' }))) as {
error?: string;
upgradeUrl?: string;
};
throw new ApiError(response.status, error.error || 'Request failed', error.upgradeUrl);
}

return response;
}

export class ApiError extends Error {
constructor(
public status: number,
message: string,
public upgradeUrl?: string
) {
super(message);
this.name = 'ApiError';
}
}
181 changes: 181 additions & 0 deletions apps/raycast/src/ask.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
Action,
ActionPanel,
Detail,
Form,
open,
openExtensionPreferences,
showToast,
Toast,
useNavigation
} from '@raycast/api';
import { usePromise } from '@raycast/utils';
import { useState } from 'react';
import { askQuestion, fetchResources, ApiError } from './api';
import { parseSSEStream } from './stream';

export default function AskCommand() {
const [question, setQuestion] = useState('');
const { push } = useNavigation();

// Fetch resources on mount (for future autocomplete hints)
const { data: resourcesData, isLoading: isLoadingResources } = usePromise(async () => {
try {
return await fetchResources();
} catch (error) {
if (error instanceof ApiError) {
handleApiError(error);
}
return null;
}
}, []);

const handleSubmit = async (values: { question: string }) => {
if (!values.question.trim()) {
showToast({
style: Toast.Style.Failure,
title: 'Question required',
message: 'Please enter a question'
});
return;
}

push(<ResponseView question={values.question} />);
};

const resourceNames = resourcesData?.resources.map((r) => r.name) ?? [];
const resourceHint =
resourceNames.length > 0
? `Available: ${resourceNames
.slice(0, 5)
.map((n) => `@${n}`)
.join(', ')}${resourceNames.length > 5 ? '...' : ''}`
: '';

return (
<Form
isLoading={isLoadingResources}
actions={
<ActionPanel>
<Action.SubmitForm title="Ask Question" onSubmit={handleSubmit} />
<Action
title="Open Extension Preferences"
onAction={openExtensionPreferences}
shortcut={{ modifiers: ['cmd'], key: ',' }}
/>
</ActionPanel>
}
>
<Form.TextArea
id="question"
title="Question"
placeholder="How do I implement streaming in @svelte?"
info={`Use @resource to include context. ${resourceHint}`}
value={question}
onChange={setQuestion}
enableMarkdown={false}
/>
<Form.Description
title="Tip"
text="Tag resources with @ syntax: @svelte, @svelteKit, @tailwind, etc."
/>
</Form>
);
}

function ResponseView({ question }: { question: string }) {
const [markdown, setMarkdown] = useState('');
const [isComplete, setIsComplete] = useState(false);

const { isLoading } = usePromise(async () => {
try {
const response = await askQuestion(question);

for await (const event of parseSSEStream(response)) {
if (event.type === 'text') {
setMarkdown((prev) => prev + event.delta);
} else if (event.type === 'done') {
setMarkdown(event.text);
setIsComplete(true);
} else if (event.type === 'error') {
throw new Error(event.message);
}
}
} catch (error) {
if (error instanceof ApiError) {
handleApiError(error);
}
throw error;
}
}, []);

const displayMarkdown = markdown || (isLoading ? '*Thinking...*' : '');

return (
<Detail
isLoading={isLoading && !markdown}
markdown={displayMarkdown}
metadata={
isComplete ? (
<Detail.Metadata>
<Detail.Metadata.Label title="Status" text="Complete" />
</Detail.Metadata>
) : undefined
}
actions={
<ActionPanel>
<Action.CopyToClipboard
title="Copy Response"
content={markdown}
shortcut={{ modifiers: ['cmd'], key: 'c' }}
/>
<Action.Paste
title="Paste Response"
content={markdown}
shortcut={{ modifiers: ['cmd', 'shift'], key: 'v' }}
/>
</ActionPanel>
}
/>
);
}

function handleApiError(error: ApiError) {
if (error.status === 401) {
showToast({
style: Toast.Style.Failure,
title: 'Invalid API Key',
message: 'Check your API key in extension preferences',
primaryAction: {
title: 'Open Preferences',
onAction: () => openExtensionPreferences()
}
});
} else if (error.status === 402) {
showToast({
style: Toast.Style.Failure,
title: 'Subscription Required',
message: error.message,
primaryAction: error.upgradeUrl
? {
title: 'Upgrade',
onAction: () => {
open(error.upgradeUrl!);
}
}
: undefined
});
} else if (error.status === 503) {
showToast({
style: Toast.Style.Failure,
title: 'Service Unavailable',
message: error.message
});
} else {
showToast({
style: Toast.Style.Failure,
title: 'Error',
message: error.message
});
}
}
63 changes: 63 additions & 0 deletions apps/raycast/src/stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { StreamEventSchema, type StreamEvent } from './types';

export async function* parseSSEStream(response: Response): AsyncGenerator<StreamEvent> {
if (!response.body) {
throw new Error('Response body is null');
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';

let eventData = '';

for (const line of lines) {
if (line.startsWith('data: ')) {
eventData = line.slice(6);
} else if (line === '' && eventData) {
try {
const parsed = JSON.parse(eventData);
const validated = StreamEventSchema.parse(parsed);
yield validated;
} catch (error) {
console.error('Failed to parse SSE event:', error);
}
eventData = '';
}
}
}

// Process remaining buffer
if (buffer.trim()) {
const lines = buffer.split('\n');
let eventData = '';

for (const line of lines) {
if (line.startsWith('data: ')) {
eventData = line.slice(6);
}
}

if (eventData) {
try {
const parsed = JSON.parse(eventData);
const validated = StreamEventSchema.parse(parsed);
yield validated;
} catch {
// Ignore incomplete final event
}
}
}
} finally {
reader.releaseLock();
}
}
Loading