├── apps
├── web
│ ├── .npmrc
│ ├── static
│ │ └── robots.txt
│ ├── .vscode
│ │ └── settings.json
│ ├── src
│ │ ├── lib
│ │ │ ├── assets
│ │ │ │ ├── og.png
│ │ │ │ └── favicon.svg
│ │ │ ├── CopyButton.svelte
│ │ │ └── stores
│ │ │ │ ├── ShikiStore.svelte.ts
│ │ │ │ └── ThemeStore.svelte.ts
│ │ ├── app.d.ts
│ │ ├── routes
│ │ │ ├── layout.css
│ │ │ ├── og
│ │ │ │ └── +page.svelte
│ │ │ ├── models
│ │ │ │ └── +page.svelte
│ │ │ ├── commands
│ │ │ │ └── +page.svelte
│ │ │ ├── +layout.svelte
│ │ │ ├── +page.svelte
│ │ │ └── getting-started
│ │ │ │ └── +page.svelte
│ │ └── app.html
│ ├── .prettierignore
│ ├── vite.config.ts
│ ├── .gitignore
│ ├── .prettierrc
│ ├── svelte.config.js
│ ├── tsconfig.json
│ ├── README.md
│ └── package.json
└── cli
│ ├── src
│ ├── tui
│ │ ├── index.tsx
│ │ ├── types.ts
│ │ ├── theme.ts
│ │ ├── clipboard.ts
│ │ ├── commands.ts
│ │ ├── components
│ │ │ ├── RemoveRepoPrompt.tsx
│ │ │ ├── CommandPalette.tsx
│ │ │ ├── RepoSelector.tsx
│ │ │ ├── ModelConfig.tsx
│ │ │ └── AddRepoWizard.tsx
│ │ ├── services.ts
│ │ └── App.tsx
│ ├── sandbox
│ │ ├── index.tsx
│ │ ├── commands.ts
│ │ ├── AddRepoPrompt.tsx
│ │ ├── RepoSelector.tsx
│ │ ├── CommandPalette.tsx
│ │ └── App.tsx
│ ├── index.ts
│ ├── lib
│ │ ├── errors.ts
│ │ ├── utils
│ │ │ ├── git.ts
│ │ │ ├── files.ts
│ │ │ └── validation.ts
│ │ └── prompts.ts
│ └── services
│ │ ├── oc.ts
│ │ ├── config.ts
│ │ └── cli.ts
│ ├── scripts
│ ├── build.ts
│ └── build-binaries.ts
│ ├── .gitignore
│ ├── tsconfig.json
│ ├── bin.js
│ ├── package.json
│ └── README.md
├── .prettierrc
├── .prettierignore
├── packages
└── shared
│ ├── README.md
│ ├── package.json
│ ├── .gitignore
│ ├── tsconfig.json
│ └── src
│ └── index.ts
├── turbo.json
├── .vscode
└── settings.json
├── .gitignore
├── LICENSE.md
├── tsconfig.json
├── package.json
├── README.md
└── AGENTS.md
/apps/web/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/apps/web/static/robots.txt:
--------------------------------------------------------------------------------
1 | # allow crawling everything by default
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/apps/web/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.associations": {
3 | "*.css": "tailwindcss"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/apps/web/src/lib/assets/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davis7dotsh/better-context/HEAD/apps/web/src/lib/assets/og.png
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 100
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/.prettierignore:
--------------------------------------------------------------------------------
1 | # Package Managers
2 | package-lock.json
3 | pnpm-lock.yaml
4 | yarn.lock
5 | bun.lock
6 | bun.lockb
7 |
8 | # Miscellaneous
9 | /static/
10 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Package Managers
2 | package-lock.json
3 | pnpm-lock.yaml
4 | yarn.lock
5 | bun.lock
6 | bun.lockb
7 |
8 | # Miscellaneous
9 | /static/
10 |
11 | .svelte-kit
12 | .turbo
--------------------------------------------------------------------------------
/packages/shared/README.md:
--------------------------------------------------------------------------------
1 | # shared
2 |
3 | To install dependencies:
4 |
5 | ```bash
6 | bun install
7 | ```
8 |
9 | To run:
10 |
11 | ```bash
12 | bun run index.ts
13 | ```
14 |
15 | This project was created using `bun init` in bun v1.3.4. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
16 |
--------------------------------------------------------------------------------
/apps/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import devtoolsJson from 'vite-plugin-devtools-json';
2 | import tailwindcss from '@tailwindcss/vite';
3 | import { sveltekit } from '@sveltejs/kit/vite';
4 | import { defineConfig } from 'vite';
5 |
6 | export default defineConfig({
7 | plugins: [tailwindcss(), sveltekit(), devtoolsJson()]
8 | });
9 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.com/schema.json",
3 | "tasks": {
4 | "build": {
5 | "outputs": [".svelte-kit/**", "dist/**"]
6 | },
7 | "dev": {
8 | "persistent": true,
9 | "cache": false
10 | },
11 | "check": {
12 | "cache": false
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/apps/web/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | # Output
4 | .output
5 | .vercel
6 | .netlify
7 | .wrangler
8 | /.svelte-kit
9 | /build
10 |
11 | # OS
12 | .DS_Store
13 | Thumbs.db
14 |
15 | # Env
16 | .env
17 | .env.*
18 | !.env.example
19 | !.env.test
20 |
21 | # Vite
22 | vite.config.js.timestamp-*
23 | vite.config.ts.timestamp-*
24 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/index.tsx:
--------------------------------------------------------------------------------
1 | import { createCliRenderer } from '@opentui/core';
2 | import { createRoot } from '@opentui/react';
3 | import { App } from './App.tsx';
4 |
5 | export async function launchTui() {
6 | const renderer = await createCliRenderer({
7 | exitOnCtrlC: true
8 | });
9 |
10 | createRoot(renderer).render( );
11 | }
12 |
--------------------------------------------------------------------------------
/apps/web/src/app.d.ts:
--------------------------------------------------------------------------------
1 | // See https://svelte.dev/docs/kit/types#app.d.ts
2 | // for information about these interfaces
3 | declare global {
4 | namespace App {
5 | // interface Error {}
6 | // interface Locals {}
7 | // interface PageData {}
8 | // interface PageState {}
9 | // interface Platform {}
10 | }
11 | }
12 |
13 | export {};
14 |
--------------------------------------------------------------------------------
/packages/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@btca/shared",
3 | "version": "0.0.1",
4 | "module": "src/index.ts",
5 | "type": "module",
6 | "private": true,
7 | "exports": {
8 | ".": "./src/index.ts"
9 | },
10 | "devDependencies": {
11 | "@types/bun": "latest"
12 | },
13 | "peerDependencies": {
14 | "typescript": "^5"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/apps/cli/src/sandbox/index.tsx:
--------------------------------------------------------------------------------
1 | import { createCliRenderer } from '@opentui/core';
2 | import { createRoot } from '@opentui/react';
3 | import { App } from './App.tsx';
4 |
5 | async function main() {
6 | const renderer = await createCliRenderer({
7 | exitOnCtrlC: true
8 | });
9 |
10 | createRoot(renderer).render( );
11 | }
12 |
13 | main().catch(console.error);
14 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "./node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true,
4 | "workbench.colorCustomizations": {
5 | "titleBar.activeBackground": "#d35400",
6 | "titleBar.activeForeground": "#ffffff",
7 | "titleBar.inactiveBackground": "#d35400",
8 | "titleBar.inactiveForeground": "#ffffffcc"
9 | }
10 | }
--------------------------------------------------------------------------------
/apps/cli/scripts/build.ts:
--------------------------------------------------------------------------------
1 | import packageJson from "../package.json";
2 |
3 | const VERSION = packageJson.version;
4 |
5 | console.log(`Building btca v${VERSION}`);
6 |
7 | await Bun.build({
8 | entrypoints: ["src/index.ts"],
9 | outdir: "dist",
10 | target: "bun",
11 | define: {
12 | __VERSION__: JSON.stringify(VERSION),
13 | },
14 | });
15 |
16 | console.log("Done");
17 |
--------------------------------------------------------------------------------
/apps/web/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 100,
6 | "plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-svelte"],
7 | "overrides": [
8 | {
9 | "files": "*.svelte",
10 | "options": {
11 | "parser": "svelte"
12 | }
13 | }
14 | ],
15 | "tailwindStylesheet": "./src/routes/layout.css"
16 | }
17 |
--------------------------------------------------------------------------------
/apps/web/src/lib/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/apps/web/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-vercel';
2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | preprocess: vitePreprocess(),
7 |
8 | kit: {
9 | adapter: adapter(),
10 | experimental: {
11 | remoteFunctions: true
12 | }
13 | },
14 | compilerOptions: {
15 | experimental: {
16 | async: true
17 | }
18 | }
19 | };
20 |
21 | export default config;
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies (bun install)
2 | node_modules
3 |
4 | # output
5 | out
6 | dist
7 | *.tgz
8 |
9 | .turbo
10 |
11 | # code coverage
12 | coverage
13 | *.lcov
14 |
15 | # logs
16 | logs
17 | _.log
18 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
19 |
20 | # dotenv environment variable files
21 | .env
22 | .env.development.local
23 | .env.test.local
24 | .env.production.local
25 | .env.local
26 |
27 | # caches
28 | .eslintcache
29 | .cache
30 | *.tsbuildinfo
31 |
32 | # IntelliJ based IDEs
33 | .idea
34 |
35 | # Finder (MacOS) folder config
36 | .DS_Store
37 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/types.ts:
--------------------------------------------------------------------------------
1 | export interface Repo {
2 | name: string;
3 | url: string;
4 | branch: string;
5 | specialNotes?: string;
6 | }
7 |
8 | export interface Message {
9 | role: 'user' | 'assistant' | 'system';
10 | content: string;
11 | }
12 |
13 | export type Mode = 'chat' | 'add-repo' | 'remove-repo' | 'config-model' | 'loading';
14 |
15 | export type CommandMode = 'add-repo' | 'remove-repo' | 'config-model' | 'chat' | 'ask' | 'clear';
16 |
17 | export interface Command {
18 | name: string;
19 | description: string;
20 | mode: CommandMode;
21 | }
22 |
--------------------------------------------------------------------------------
/packages/shared/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies (bun install)
2 | node_modules
3 |
4 | # output
5 | out
6 | dist
7 | *.tgz
8 |
9 | # code coverage
10 | coverage
11 | *.lcov
12 |
13 | # logs
14 | logs
15 | _.log
16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17 |
18 | # dotenv environment variable files
19 | .env
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 | .env.local
24 |
25 | # caches
26 | .eslintcache
27 | .cache
28 | *.tsbuildinfo
29 |
30 | # IntelliJ based IDEs
31 | .idea
32 |
33 | # Finder (MacOS) folder config
34 | .DS_Store
35 |
--------------------------------------------------------------------------------
/apps/cli/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies (bun install)
2 | node_modules
3 |
4 | dist-check
5 |
6 | # output
7 | out
8 | dist
9 | *.tgz
10 |
11 | # code coverage
12 | coverage
13 | *.lcov
14 |
15 | # logs
16 | logs
17 | _.log
18 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
19 |
20 | # dotenv environment variable files
21 | .env
22 | .env.development.local
23 | .env.test.local
24 | .env.production.local
25 | .env.local
26 |
27 | # caches
28 | .eslintcache
29 | .cache
30 | *.tsbuildinfo
31 |
32 | # IntelliJ based IDEs
33 | .idea
34 |
35 | # Finder (MacOS) folder config
36 | .DS_Store
37 |
--------------------------------------------------------------------------------
/apps/cli/src/sandbox/commands.ts:
--------------------------------------------------------------------------------
1 | export interface Command {
2 | name: string;
3 | description: string;
4 | mode: 'add-repo' | 'select-repo';
5 | }
6 |
7 | export const COMMANDS: Command[] = [
8 | {
9 | name: 'add',
10 | description: 'Add a new repo',
11 | mode: 'add-repo'
12 | },
13 | {
14 | name: 'repo',
15 | description: 'Switch to a different repo',
16 | mode: 'select-repo'
17 | }
18 | ];
19 |
20 | export function filterCommands(query: string): Command[] {
21 | const normalizedQuery = query.toLowerCase();
22 | return COMMANDS.filter((cmd) => cmd.name.toLowerCase().startsWith(normalizedQuery));
23 | }
24 |
--------------------------------------------------------------------------------
/apps/web/src/routes/layout.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 | @plugin '@tailwindcss/typography';
3 |
4 | @custom-variant dark (&:where(.dark, .dark *));
5 |
6 | @layer base {
7 | :root {
8 | color-scheme: light;
9 | }
10 |
11 | :root.dark {
12 | color-scheme: dark;
13 | }
14 |
15 | html,
16 | body {
17 | height: 100%;
18 | }
19 |
20 | body {
21 | @apply bg-neutral-50 text-neutral-950 antialiased dark:bg-neutral-950 dark:text-neutral-50;
22 | }
23 |
24 | a {
25 | @apply underline decoration-neutral-300 underline-offset-4 hover:decoration-neutral-500 dark:decoration-neutral-700 dark:hover:decoration-neutral-500;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/theme.ts:
--------------------------------------------------------------------------------
1 | // Color palette matching apps/web (dark mode)
2 | export const colors = {
3 | // Backgrounds
4 | bg: '#0a0a0a', // neutral-950
5 | bgSubtle: '#171717', // neutral-900
6 | bgMuted: '#262626', // neutral-800
7 |
8 | // Text
9 | text: '#fafafa', // neutral-50
10 | textMuted: '#a3a3a3', // neutral-400
11 | textSubtle: '#737373', // neutral-500
12 |
13 | // Borders
14 | border: '#262626', // neutral-800
15 | borderSubtle: '#404040', // neutral-700
16 |
17 | // Accent (orange)
18 | accent: '#f97316', // orange-500
19 | accentDark: '#c2410c', // orange-700
20 |
21 | // Semantic
22 | success: '#22c55e', // green-500
23 | info: '#3b82f6', // blue-500
24 | error: '#ef4444' // red-500
25 | } as const;
26 |
27 | export type Colors = typeof colors;
28 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "rewriteRelativeImportExtensions": true,
5 | "allowJs": true,
6 | "checkJs": true,
7 | "esModuleInterop": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "resolveJsonModule": true,
10 | "skipLibCheck": true,
11 | "sourceMap": true,
12 | "strict": true,
13 | "moduleResolution": "bundler"
14 | }
15 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
16 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
17 | //
18 | // To make changes to top-level options such as include and exclude, we recommend extending
19 | // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
20 | }
21 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Environment setup & latest features
4 | "lib": ["ESNext"],
5 | "target": "ESNext",
6 | "module": "Preserve",
7 | "moduleDetection": "force",
8 | "jsx": "react-jsx",
9 | "allowJs": true,
10 |
11 | // Bundler mode
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "verbatimModuleSyntax": true,
15 | "noEmit": true,
16 |
17 | // Best practices
18 | "strict": true,
19 | "skipLibCheck": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedIndexedAccess": true,
22 | "noImplicitOverride": true,
23 |
24 | // Some stricter flags (disabled by default)
25 | "noUnusedLocals": false,
26 | "noUnusedParameters": false,
27 | "noPropertyAccessFromIndexSignature": false
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/apps/web/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
19 | %sveltekit.head%
20 |
21 |
22 | %sveltekit.body%
23 |
24 |
25 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/clipboard.ts:
--------------------------------------------------------------------------------
1 | import { spawn } from 'bun';
2 |
3 | export async function copyToClipboard(text: string): Promise {
4 | const platform = process.platform;
5 |
6 | if (platform === 'darwin') {
7 | const proc = spawn(['pbcopy'], { stdin: 'pipe' });
8 | proc.stdin.write(text);
9 | proc.stdin.end();
10 | await proc.exited;
11 | } else if (platform === 'linux') {
12 | // Try xclip first, fall back to xsel
13 | try {
14 | const proc = spawn(['xclip', '-selection', 'clipboard'], { stdin: 'pipe' });
15 | proc.stdin.write(text);
16 | proc.stdin.end();
17 | await proc.exited;
18 | } catch {
19 | const proc = spawn(['xsel', '--clipboard', '--input'], { stdin: 'pipe' });
20 | proc.stdin.write(text);
21 | proc.stdin.end();
22 | await proc.exited;
23 | }
24 | } else if (platform === 'win32') {
25 | const proc = spawn(['clip'], { stdin: 'pipe' });
26 | proc.stdin.write(text);
27 | proc.stdin.end();
28 | await proc.exited;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/commands.ts:
--------------------------------------------------------------------------------
1 | import type { Command } from './types.ts';
2 |
3 | export const COMMANDS: Command[] = [
4 | {
5 | name: 'add',
6 | description: 'Add a new repo (wizard)',
7 | mode: 'add-repo'
8 | },
9 | {
10 | name: 'remove',
11 | description: 'Remove a repo from config',
12 | mode: 'remove-repo'
13 | },
14 | {
15 | name: 'model',
16 | description: 'Configure model & provider',
17 | mode: 'config-model'
18 | },
19 | {
20 | name: 'chat',
21 | description: 'Start chat session (opens OpenCode)',
22 | mode: 'chat'
23 | },
24 | {
25 | name: 'ask',
26 | description: 'Ask question, copy answer to clipboard',
27 | mode: 'ask'
28 | },
29 | {
30 | name: 'clear',
31 | description: 'Clear chat history',
32 | mode: 'clear'
33 | }
34 | ];
35 |
36 | export function filterCommands(query: string): Command[] {
37 | const lowerQuery = query.toLowerCase();
38 | return COMMANDS.filter((cmd) => cmd.name.toLowerCase().startsWith(lowerQuery));
39 | }
40 |
--------------------------------------------------------------------------------
/apps/web/src/lib/CopyButton.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
24 | {#if copied}
25 |
26 | {:else}
27 |
28 | {/if}
29 |
30 |
--------------------------------------------------------------------------------
/apps/web/README.md:
--------------------------------------------------------------------------------
1 | # sv
2 |
3 | Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
4 |
5 | ## Creating a project
6 |
7 | If you're seeing this, you've probably already done this step. Congrats!
8 |
9 | ```sh
10 | # create a new project in the current directory
11 | npx sv create
12 |
13 | # create a new project in my-app
14 | npx sv create my-app
15 | ```
16 |
17 | ## Developing
18 |
19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
20 |
21 | ```sh
22 | npm run dev
23 |
24 | # or start the server and open the app in a new browser tab
25 | npm run dev -- --open
26 | ```
27 |
28 | ## Building
29 |
30 | To create a production version of your app:
31 |
32 | ```sh
33 | npm run build
34 | ```
35 |
36 | You can preview the production build with `npm run preview`.
37 |
38 | > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
39 |
--------------------------------------------------------------------------------
/apps/cli/src/index.ts:
--------------------------------------------------------------------------------
1 | import { BunContext, BunRuntime } from '@effect/platform-bun';
2 | import { Cause, Effect, Exit } from 'effect';
3 | import { CliService } from './services/cli.ts';
4 |
5 | // Check if no arguments provided (just "btca" or "bunx btca")
6 | const hasNoArgs = process.argv.length <= 2;
7 |
8 | if (hasNoArgs) {
9 | // Launch the TUI
10 | import('./tui/index.tsx').then(({ launchTui }) => launchTui());
11 | } else {
12 | // Run the CLI with arguments
13 | Effect.gen(function* () {
14 | const cli = yield* CliService;
15 | yield* cli.run(process.argv);
16 | }).pipe(
17 | Effect.provide(CliService.Default),
18 | Effect.provide(BunContext.layer),
19 | BunRuntime.runMain({
20 | teardown: (exit) => {
21 | // Force exit: opencode SDK's server.close() sends SIGTERM but doesn't
22 | // wait for child process termination, keeping Node's event loop alive
23 | const code = Exit.isFailure(exit) && !Cause.isInterruptedOnly(exit.cause) ? 1 : 0;
24 | process.exit(code);
25 | }
26 | })
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/src/lib/stores/ShikiStore.svelte.ts:
--------------------------------------------------------------------------------
1 | import { createContext, onDestroy, onMount } from 'svelte';
2 | import { createHighlighter, type Highlighter } from 'shiki/bundle/web';
3 |
4 | class ShikiStore {
5 | private highlighterPromise = createHighlighter({
6 | themes: ['dark-plus', "light-plus"],
7 | langs: ['bash', 'json']
8 | });
9 |
10 | highlighter = $state(null);
11 |
12 | constructor() {
13 | onMount(async () => {
14 | this.highlighter = await this.highlighterPromise;
15 | });
16 | onDestroy(() => {
17 | this.highlighter?.dispose();
18 | });
19 | }
20 | }
21 |
22 | const [internalGet, internalSet] = createContext();
23 |
24 | export const getShikiStore = () => {
25 | const store = internalGet();
26 | if (!store) {
27 | throw new Error('ShikiStore not found, did you call setShikiStore() in a parent component?');
28 | }
29 | return store;
30 | };
31 |
32 | export const setShikiStore = () => {
33 | const newStore = new ShikiStore();
34 | return internalSet(newStore);
35 | };
36 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2025 Ben Davis
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/apps/cli/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "src/**/*.ts",
4 | "scripts/**/*.ts"
5 | ],
6 | "exclude": [
7 | "node_modules",
8 | "dist",
9 | "dist-check"
10 | ],
11 | "compilerOptions": {
12 | // Environment setup & latest features
13 | "lib": [
14 | "ESNext",
15 | "DOM"
16 | ],
17 | "target": "ESNext",
18 | "module": "esnext",
19 | "moduleDetection": "force",
20 | "jsx": "react-jsx",
21 | "jsxImportSource": "@opentui/react",
22 | "allowJs": true,
23 | // Bundler mode
24 | "moduleResolution": "bundler",
25 | "allowImportingTsExtensions": true,
26 | "verbatimModuleSyntax": true,
27 | "noEmit": true,
28 | // Best practices
29 | "strict": true,
30 | "skipLibCheck": true,
31 | "noFallthroughCasesInSwitch": true,
32 | "noUncheckedIndexedAccess": true,
33 | "noImplicitOverride": true,
34 | // Some stricter flags (disabled by default)
35 | "noUnusedLocals": false,
36 | "noUnusedParameters": false,
37 | "noPropertyAccessFromIndexSignature": false
38 | }
39 | }
--------------------------------------------------------------------------------
/apps/cli/src/lib/errors.ts:
--------------------------------------------------------------------------------
1 | import { TaggedError } from "effect/Data";
2 |
3 | export class GeneralError extends TaggedError("GeneralError")<{
4 | readonly message: string;
5 | readonly cause?: unknown;
6 | }> {}
7 |
8 | export class OcError extends TaggedError("OcError")<{
9 | readonly message: string;
10 | readonly cause?: unknown;
11 | }> {}
12 |
13 | export class ConfigError extends TaggedError("ConfigError")<{
14 | readonly message: string;
15 | readonly cause?: unknown;
16 | }> {}
17 |
18 | export class InvalidProviderError extends TaggedError("InvalidProviderError")<{
19 | readonly providerId: string;
20 | readonly availableProviders: string[];
21 | }> {}
22 |
23 | export class InvalidModelError extends TaggedError("InvalidModelError")<{
24 | readonly providerId: string;
25 | readonly modelId: string;
26 | readonly availableModels: string[];
27 | }> {}
28 |
29 | export class ProviderNotConnectedError extends TaggedError(
30 | "ProviderNotConnectedError"
31 | )<{
32 | readonly providerId: string;
33 | readonly connectedProviders: string[];
34 | }> {}
35 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Environment setup & latest features
4 | "lib": [
5 | "ESNext",
6 | "DOM"
7 | ],
8 | "jsx": "react-jsx",
9 | "jsxImportSource": "@opentui/react",
10 | "target": "ESNext",
11 | "module": "Preserve",
12 | "moduleDetection": "force",
13 | "allowJs": true,
14 | // Bundler mode
15 | "moduleResolution": "bundler",
16 | "allowImportingTsExtensions": true,
17 | "verbatimModuleSyntax": true,
18 | "noEmit": true,
19 | // Best practices
20 | "strict": true,
21 | "skipLibCheck": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedIndexedAccess": true,
24 | "noImplicitOverride": true,
25 | "exactOptionalPropertyTypes": true,
26 | // Some stricter flags (disabled by default)
27 | "noUnusedLocals": false,
28 | "noUnusedParameters": false,
29 | "noPropertyAccessFromIndexSignature": false,
30 | // Effect Language Service
31 | "plugins": [
32 | {
33 | "name": "@effect/language-service"
34 | }
35 | ]
36 | }
37 | }
--------------------------------------------------------------------------------
/apps/cli/src/tui/components/RemoveRepoPrompt.tsx:
--------------------------------------------------------------------------------
1 | import type { Colors } from '../theme.ts';
2 |
3 | interface RemoveRepoPromptProps {
4 | repoName: string;
5 | colors: Colors;
6 | }
7 |
8 | export function RemoveRepoPrompt({ repoName, colors }: RemoveRepoPromptProps) {
9 | return (
10 |
24 |
25 |
26 |
27 | {`Are you sure you want to remove "`}
28 | {repoName}
29 | {`" from your configuration?`}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@btca/repo",
3 | "author": "Ben Davis",
4 | "version": "0.1.4",
5 | "description": "CLI tool for asking questions about technologies using OpenCode",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/bmdavis419/better-context"
10 | },
11 | "workspaces": [
12 | "apps/*",
13 | "packages/*"
14 | ],
15 | "keywords": [
16 | "cli",
17 | "ai",
18 | "docs"
19 | ],
20 | "engines": {
21 | "bun": ">=1.1.0"
22 | },
23 | "packageManager": "bun@1.3.3",
24 | "scripts": {
25 | "clean": "rm -rf node_modules && rm -rf apps/web/node_modules && rm -rf apps/cli/node_modules && rm -rf packages/core/node_modules",
26 | "dev": "turbo run dev",
27 | "cli": "bun run apps/cli/src/index.ts",
28 | "cli:ask": "bun run apps/cli/src/index.ts ask",
29 | "cli:serve": "bun run apps/cli/src/index.ts serve",
30 | "cli:open": "bun run apps/cli/src/index.ts open",
31 | "build": "turbo run build",
32 | "check": "turbo run check"
33 | },
34 | "devDependencies": {
35 | "@types/bun": "latest",
36 | "@typescript/native-preview": "^7.0.0-dev.20251215.1",
37 | "prettier": "^3.7.4",
38 | "turbo": "^2.6.3",
39 | "typescript": "^5.9.3"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/shared/src/index.ts:
--------------------------------------------------------------------------------
1 | type BlessedModel = {
2 | provider: string;
3 | model: string;
4 | description: string;
5 | isDefault: boolean;
6 | providerSetupUrl: string;
7 | };
8 |
9 | export const BLESSED_MODELS: BlessedModel[] = [
10 | {
11 | provider: 'opencode',
12 | model: 'btca-gemini-3-flash',
13 | description:
14 | 'Gemini 3 Flash with low reasoning (the special btca version is already configured for you in btca)',
15 | providerSetupUrl: 'https://opencode.ai/docs/providers/#opencode-zen',
16 | isDefault: false
17 | },
18 | {
19 | provider: 'anthropic',
20 | model: 'claude-haiku-4-5',
21 | description: 'Claude Haiku 4.5, no reasoning',
22 | providerSetupUrl: 'https://opencode.ai/docs/providers/#anthropic',
23 | isDefault: false
24 | },
25 | {
26 | provider: 'opencode',
27 | model: 'big-pickle',
28 | description: 'Big Pickle, surprisingly good (and free)',
29 | providerSetupUrl: 'https://opencode.ai/docs/providers/#opencode-zen',
30 | isDefault: true
31 | },
32 | {
33 | provider: 'opencode',
34 | model: 'kimi-k2',
35 | description: 'Kimi K2, no reasoning',
36 | providerSetupUrl: 'https://opencode.ai/docs/providers/#opencode-zen',
37 | isDefault: false
38 | }
39 | ];
40 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "private": true,
4 | "version": "0.0.1",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite dev",
8 | "build": "vite build",
9 | "preview": "vite preview",
10 | "prepare": "svelte-kit sync || echo ''",
11 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
13 | "format": "prettier --write .",
14 | "lint": "prettier --check ."
15 | },
16 | "devDependencies": {
17 | "@sveltejs/adapter-vercel": "^6.2.0",
18 | "@sveltejs/kit": "^2.48.5",
19 | "@sveltejs/vite-plugin-svelte": "^6.2.1",
20 | "prettier": "^3.6.2",
21 | "prettier-plugin-svelte": "^3.4.0",
22 | "prettier-plugin-tailwindcss": "^0.7.1",
23 | "@tailwindcss/typography": "^0.5.19",
24 | "@tailwindcss/vite": "^4.1.17",
25 | "shiki": "^3.19.0",
26 | "svelte": "^5.43.8",
27 | "svelte-check": "^4.3.4",
28 | "tailwindcss": "^4.1.17",
29 | "typescript": "^5.9.3",
30 | "vite": "^7.2.2",
31 | "vite-plugin-devtools-json": "^1.0.0"
32 | },
33 | "dependencies": {
34 | "@btca/shared": "workspace:*",
35 | "@lucide/svelte": "^0.560.0",
36 | "@vercel/analytics": "^1.6.1"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/apps/cli/src/sandbox/AddRepoPrompt.tsx:
--------------------------------------------------------------------------------
1 | interface Colors {
2 | bg: string;
3 | bgSubtle: string;
4 | bgMuted: string;
5 | text: string;
6 | textMuted: string;
7 | textSubtle: string;
8 | border: string;
9 | accent: string;
10 | info: string;
11 | }
12 |
13 | interface AddRepoPromptProps {
14 | value: string;
15 | onInput: (value: string) => void;
16 | onSubmit: () => void;
17 | colors: Colors;
18 | }
19 |
20 | export function AddRepoPrompt({ value, onInput, onSubmit, colors }: AddRepoPromptProps) {
21 | return (
22 |
36 |
37 |
38 |
39 |
40 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/apps/cli/scripts/build-binaries.ts:
--------------------------------------------------------------------------------
1 | import { $ } from "bun";
2 | import { FileSystem } from "@effect/platform";
3 | import { BunContext, BunRuntime } from "@effect/platform-bun";
4 | import { Effect } from "effect";
5 | import packageJson from "../package.json";
6 |
7 | const VERSION = packageJson.version;
8 |
9 | const targets = [
10 | "bun-darwin-arm64",
11 | "bun-darwin-x64",
12 | "bun-linux-x64",
13 | "bun-linux-arm64",
14 | "bun-windows-x64",
15 | ] as const;
16 |
17 | const outputNames: Record<(typeof targets)[number], string> = {
18 | "bun-darwin-arm64": "btca-darwin-arm64",
19 | "bun-darwin-x64": "btca-darwin-x64",
20 | "bun-linux-x64": "btca-linux-x64",
21 | "bun-linux-arm64": "btca-linux-arm64",
22 | "bun-windows-x64": "btca-windows-x64.exe",
23 | };
24 |
25 | const main = Effect.gen(function* () {
26 | const fs = yield* FileSystem.FileSystem;
27 |
28 | yield* fs.makeDirectory("dist", { recursive: true });
29 |
30 | for (const target of targets) {
31 | const outfile = `dist/${outputNames[target]}`;
32 | console.log(`Building ${target} -> ${outfile} (v${VERSION})`);
33 | yield* Effect.promise(
34 | () => $`bun build src/index.ts --compile --target=${target} --outfile=${outfile} --define __VERSION__='"${VERSION}"'`
35 | );
36 | }
37 |
38 | console.log("Done building all targets");
39 | });
40 |
41 | main.pipe(Effect.provide(BunContext.layer), BunRuntime.runMain);
42 |
--------------------------------------------------------------------------------
/apps/cli/bin.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { spawnSync } from "node:child_process";
4 | import fs from "node:fs";
5 | import path from "node:path";
6 | import { fileURLToPath } from "node:url";
7 |
8 | const PLATFORM_ARCH = `${process.platform}-${process.arch}`;
9 |
10 | const TARGET_MAP = {
11 | "darwin-arm64": "btca-darwin-arm64",
12 | "darwin-x64": "btca-darwin-x64",
13 | "linux-x64": "btca-linux-x64",
14 | "linux-arm64": "btca-linux-arm64",
15 | "win32-x64": "btca-windows-x64.exe",
16 | };
17 |
18 | const binaryName = TARGET_MAP[PLATFORM_ARCH];
19 |
20 | if (!binaryName) {
21 | console.error(
22 | `[btca] Unsupported platform: ${PLATFORM_ARCH}. ` +
23 | "Please open an issue with your OS/CPU details."
24 | );
25 | process.exit(1);
26 | }
27 |
28 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
29 | const binPath = path.join(__dirname, "dist", binaryName);
30 |
31 | if (!fs.existsSync(binPath)) {
32 | console.error(
33 | `[btca] Prebuilt binary not found for ${PLATFORM_ARCH}. ` +
34 | "Try reinstalling, or open an issue if the problem persists."
35 | );
36 | process.exit(1);
37 | }
38 |
39 | const result = spawnSync(binPath, process.argv.slice(2), {
40 | stdio: "inherit",
41 | });
42 |
43 | if (result.error) {
44 | console.error(`[btca] Failed to start binary: ${result.error}`);
45 | process.exit(1);
46 | }
47 |
48 | process.exit(result.status ?? 1);
49 |
--------------------------------------------------------------------------------
/apps/cli/src/lib/utils/git.ts:
--------------------------------------------------------------------------------
1 | import { Effect } from 'effect';
2 | import { ConfigError } from '../errors';
3 |
4 | export const cloneRepo = (args: {
5 | repoDir: string;
6 | url: string;
7 | branch: string;
8 | quiet?: boolean;
9 | }) =>
10 | Effect.tryPromise({
11 | try: async () => {
12 | const { repoDir, url, branch, quiet } = args;
13 | const proc = Bun.spawn(['git', 'clone', '--branch', branch, url, repoDir], {
14 | stdout: quiet ? 'ignore' : 'inherit',
15 | stderr: quiet ? 'ignore' : 'inherit'
16 | });
17 | const exitCode = await proc.exited;
18 | if (exitCode !== 0) {
19 | throw new Error(`git clone failed with exit code ${exitCode}`);
20 | }
21 | },
22 | catch: (error) => new ConfigError({ message: 'Failed to clone repo', cause: error })
23 | });
24 |
25 | export const pullRepo = (args: { repoDir: string; branch: string; quiet?: boolean }) =>
26 | Effect.tryPromise({
27 | try: async () => {
28 | const { repoDir, branch, quiet } = args;
29 | const proc = Bun.spawn(['git', 'pull', 'origin', branch], {
30 | cwd: repoDir,
31 | stdout: quiet ? 'ignore' : 'inherit',
32 | stderr: quiet ? 'ignore' : 'inherit'
33 | });
34 | const exitCode = await proc.exited;
35 | if (exitCode !== 0) {
36 | throw new Error(`git pull failed with exit code ${exitCode}`);
37 | }
38 | },
39 | catch: (error) => new ConfigError({ message: 'Failed to pull repo', cause: error })
40 | });
41 |
--------------------------------------------------------------------------------
/apps/cli/src/lib/utils/files.ts:
--------------------------------------------------------------------------------
1 | import { FileSystem, Path } from '@effect/platform';
2 | import { Effect } from 'effect';
3 | import { ConfigError } from '../errors.ts';
4 |
5 | export const expandHome = (filePath: string): Effect.Effect =>
6 | Effect.gen(function* () {
7 | const path = yield* Path.Path;
8 | if (filePath.startsWith('~/') || filePath.startsWith('~\\')) {
9 | const homeDir = Bun.env.HOME ?? Bun.env.USERPROFILE ?? '';
10 | return path.join(homeDir, filePath.slice(2));
11 | }
12 | return filePath;
13 | });
14 |
15 | export const directoryExists = (dir: string) =>
16 | Effect.gen(function* () {
17 | const fs = yield* FileSystem.FileSystem;
18 | const exists = yield* fs.exists(dir);
19 | if (!exists) return false;
20 | const stat = yield* fs.stat(dir);
21 | return stat.type === 'Directory';
22 | }).pipe(
23 | Effect.catchAll((error) =>
24 | Effect.fail(
25 | new ConfigError({
26 | message: 'Failed to check directory',
27 | cause: error
28 | })
29 | )
30 | )
31 | );
32 |
33 | export const ensureDirectory = (dir: string) =>
34 | Effect.gen(function* () {
35 | const fs = yield* FileSystem.FileSystem;
36 | const exists = yield* fs.exists(dir);
37 | if (!exists) {
38 | yield* fs.makeDirectory(dir, { recursive: true });
39 | }
40 | }).pipe(
41 | Effect.catchAll((error) =>
42 | Effect.fail(
43 | new ConfigError({
44 | message: 'Failed to create directory',
45 | cause: error
46 | })
47 | )
48 | )
49 | );
50 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Better Context (`btca`)
2 |
3 |
4 |
5 | https://btca.dev
6 |
7 | `btca` is a CLI for asking questions about libraries/frameworks by cloning their repos locally and searching the source directly.
8 |
9 | Dev docs are in the `apps/cli` directory.
10 |
11 | ## Install
12 |
13 | ```bash
14 | bun add -g btca
15 | btca --help
16 | ```
17 |
18 | ## Quick commands
19 |
20 | Ask a question:
21 |
22 | ```bash
23 | btca ask -t svelte -q "How do stores work in Svelte 5?"
24 | ```
25 |
26 | Open the TUI:
27 |
28 | ```bash
29 | btca chat -t svelte
30 | ```
31 |
32 | Run as a server:
33 |
34 | ```bash
35 | btca serve -p 8080
36 | ```
37 |
38 | Then POST `/question` with:
39 |
40 | ```json
41 | { "tech": "svelte", "question": "how does the query remote function work in sveltekit?" }
42 | ```
43 |
44 | Keep an OpenCode instance running:
45 |
46 | ```bash
47 | btca open
48 | ```
49 |
50 | ## Config
51 |
52 | On first run, `btca` creates a default config at `~/.config/btca/btca.json`. That’s where the repo list + model/provider live.
53 |
54 | ## stuff I want to add
55 |
56 | - get the git repo for a package using bun:
57 |
58 | ```bash
59 | bun pm view react repository.url
60 | ```
61 |
62 | - tui for working with btca (config, starting chat, server, etc.)
63 | - mcp server
64 | - multiple repos for a single btca instance
65 | - fetch all the branches to pick from when you add a repo in the tui
66 | - cleaner streamed output in the ask command
67 |
--------------------------------------------------------------------------------
/apps/cli/src/sandbox/RepoSelector.tsx:
--------------------------------------------------------------------------------
1 | interface Colors {
2 | bg: string;
3 | bgSubtle: string;
4 | bgMuted: string;
5 | text: string;
6 | textMuted: string;
7 | textSubtle: string;
8 | border: string;
9 | accent: string;
10 | info: string;
11 | }
12 |
13 | interface RepoSelectorProps {
14 | repos: string[];
15 | selectedIndex: number;
16 | currentRepo: number;
17 | colors: Colors;
18 | }
19 |
20 | export function RepoSelector({ repos, selectedIndex, currentRepo, colors }: RepoSelectorProps) {
21 | return (
22 |
36 |
37 |
38 |
39 | {repos.map((repo, i) => {
40 | const isSelected = i === selectedIndex;
41 | const isCurrent = i === currentRepo;
42 | return (
43 |
49 |
53 | {isCurrent && }
54 |
55 | );
56 | })}
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # AGENTS.md
2 |
3 |
4 |
5 | ## Effect Solutions Usage
6 |
7 | The Effect Solutions CLI provides curated best practices and patterns for Effect TypeScript. Before working on Effect code, check if there's a relevant topic that covers your use case.
8 |
9 | - `effect-solutions list` - List all available topics
10 | - `effect-solutions show ` - Read one or more topics
11 | - `effect-solutions search ` - Search topics by keyword
12 |
13 | **Local Effect Source:** The Effect repository is cloned to `~/.local/share/effect-solutions/effect` for reference. Use this to explore APIs, find usage examples, and understand implementation details when the documentation isn't enough.
14 |
15 |
16 |
17 | ## Code Style
18 |
19 | - **Runtime**: Bun only. No Node.js, npm, pnpm, vite, dotenv.
20 | - **TypeScript**: Strict mode enabled. ESNext target.
21 | - **Effect**: Use `Effect.gen` for async code, `BunRuntime.runMain` for entry points.
22 | - **Imports**: External packages first, then local. Use `.ts` extensions for local imports.
23 | - **Bun APIs**: Prefer `Bun.file`, `Bun.serve`, `bun:sqlite`, `Bun.$` over Node equivalents.
24 | - **Testing**: Use `bun:test` with `import { test, expect } from "bun:test"`.
25 |
26 | ## Error Handling
27 |
28 | - Use Effect's error channel for typed errors.
29 | - Use `Effect.tryPromise` for async operations, `Effect.try` for sync.
30 | - Pipe errors through Effect combinators, don't throw.
31 |
32 | ## btca
33 |
34 | Trigger: user says "use btca" (for codebase/docs questions).
35 |
36 | Run:
37 |
38 | - btca ask -t -q ""
39 |
40 | Available : svelte, tailwindcss, opentui, runed
41 |
--------------------------------------------------------------------------------
/apps/cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "btca",
3 | "author": "Ben Davis",
4 | "version": "0.5.0",
5 | "description": "CLI tool for asking questions about technologies using OpenCode",
6 | "type": "module",
7 | "license": "MIT",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/bmdavis419/better-context"
11 | },
12 | "keywords": [
13 | "cli",
14 | "ai",
15 | "docs"
16 | ],
17 | "engines": {
18 | "bun": ">=1.1.0"
19 | },
20 | "bin": {
21 | "btca": "./bin.js"
22 | },
23 | "files": [
24 | "bin.js",
25 | "dist"
26 | ],
27 | "publishConfig": {
28 | "access": "public"
29 | },
30 | "scripts": {
31 | "build": "bun run scripts/build.ts",
32 | "build:targets": "bun run scripts/build-binaries.ts",
33 | "setup:platforms": "bun install --cpu=x64 --os=darwin && bun install --cpu=x64 --os=linux && bun install --cpu=arm64 --os=linux && bun install --cpu=x64 --os=win32",
34 | "build:artifacts": "bun run setup:platforms && bun run build && bun run build:targets",
35 | "prepublishOnly": "bun run build:artifacts",
36 | "check": "tsgo --noEmit",
37 | "prepare": "effect-language-service patch"
38 | },
39 | "devDependencies": {
40 | "@btca/shared": "workspace:*",
41 | "@effect/cli": "^0.72.1",
42 | "@effect/language-service": "^0.62.1",
43 | "@effect/platform": "^0.93.5",
44 | "@effect/platform-bun": "^0.85.0",
45 | "@effect/printer": "^0.47.0",
46 | "@effect/printer-ansi": "^0.47.0",
47 | "@effect/typeclass": "^0.38.0",
48 | "@opencode-ai/sdk": "^1.0.119",
49 | "@opentui/core": "^0.1.60",
50 | "@opentui/react": "^0.1.60",
51 | "@types/bun": "latest",
52 | "@types/react": "^19.2.7",
53 | "effect": "^3.19.8",
54 | "react": "^19.2.3",
55 | "typescript": "^5.9.3"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/components/CommandPalette.tsx:
--------------------------------------------------------------------------------
1 | import type { Command } from '../types.ts';
2 | import type { Colors } from '../theme.ts';
3 |
4 | interface CommandPaletteProps {
5 | commands: Command[];
6 | selectedIndex: number;
7 | colors: Colors;
8 | }
9 |
10 | export function CommandPalette({ commands, selectedIndex, colors }: CommandPaletteProps) {
11 | if (commands.length === 0) {
12 | return (
13 |
26 |
27 |
28 | );
29 | }
30 |
31 | return (
32 |
46 |
47 |
48 | {commands.map((cmd, i) => {
49 | const isSelected = i === selectedIndex;
50 | return (
51 |
57 |
62 |
63 |
64 | );
65 | })}
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/apps/cli/src/lib/prompts.ts:
--------------------------------------------------------------------------------
1 | export const getDocsAgentPrompt = (args: { repoName: string; specialNotes?: string }) => `
2 | You are an expert internal agent who's job is to answer coding questions and provide accurate and up to date info on ${
3 | args.repoName
4 | } based on the codebase you have access to. It may be the source code, or it may be the documentation. You are running in the background, and the user cannot ask follow up questions. You must always answer the question based on the codebase you have access to. If the question is not related to the codebase you have access to, you must say so and that you are not able to answer the question.
5 |
6 | NEVER SEARCH THE WEB FOR INFORMATION. ALWAYS USE THE CODEBASE YOU HAVE ACCESS TO.
7 |
8 | You are running in the directory for: ${args.repoName}, when asked a question regarding ${args.repoName}, search the codebase to get an accurate answer.
9 |
10 |
11 | ${
12 | args.specialNotes
13 | ? `
14 | Special notes about the codebase you have access to:
15 | '${args.specialNotes}'
16 | `
17 | : ''
18 | }
19 |
20 | Always search the codebase first before using the web to try to answer the question.
21 |
22 | When you are searching the codebase, be very careful that you do not read too much at once. Only read a small amount at a time as you're searching, avoid reading dozens of files at once...
23 |
24 | When responding:
25 |
26 | - Be extremely concise. Sacrifice grammar for the sake of concision.
27 | - When outputting code snippets, include comments that explain what each piece does
28 | - Always bias towards simple practical examples over complex theoretical explanations
29 | - Give your response in markdown format, make sure to have spacing between code blocks and other content
30 | - Avoid asking follow up questions, just answer the question
31 | `;
32 |
--------------------------------------------------------------------------------
/apps/web/src/lib/stores/ThemeStore.svelte.ts:
--------------------------------------------------------------------------------
1 | import { browser } from '$app/environment';
2 | import { createContext, onMount } from 'svelte';
3 |
4 | export type Theme = 'light' | 'dark';
5 |
6 | export const readStoredTheme = (): Theme | null => {
7 | if (!browser) return null;
8 | const v = window.localStorage.getItem('theme');
9 | if (v === 'light' || v === 'dark') return v;
10 | return null;
11 | };
12 |
13 | export const readPreferredTheme = (): Theme => {
14 | if (!browser) return 'dark';
15 | const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
16 | return prefersDark ? 'dark' : 'light';
17 | };
18 |
19 | export const getInitialTheme = (): Theme => readStoredTheme() ?? readPreferredTheme();
20 |
21 | export const setTheme = (theme: Theme): void => {
22 | if (!browser) return;
23 | document.documentElement.classList.toggle('dark', theme === 'dark');
24 | if (!browser) return;
25 | window.localStorage.setItem('theme', theme);
26 | };
27 |
28 | class ThemeStore {
29 | theme = $state('dark');
30 |
31 | set = (theme: Theme) => {
32 | this.theme = theme;
33 | setTheme(theme);
34 | };
35 |
36 | init = () => {
37 | this.set(getInitialTheme());
38 | };
39 |
40 | toggle = () => {
41 | this.set(this.theme === 'dark' ? 'light' : 'dark');
42 | };
43 |
44 | constructor() {
45 | onMount(() => this.init());
46 | }
47 | }
48 |
49 | const [internalGet, internalSet] = createContext();
50 |
51 | export const getThemeStore = () => {
52 | const store = internalGet();
53 | if (!store) {
54 | throw new Error('ThemeStore not found, did you call setThemeStore() in a parent component?');
55 | }
56 | return store;
57 | };
58 |
59 | export const setThemeStore = () => {
60 | const newStore = new ThemeStore();
61 | return internalSet(newStore);
62 | };
63 |
64 |
--------------------------------------------------------------------------------
/apps/cli/src/lib/utils/validation.ts:
--------------------------------------------------------------------------------
1 | import { Effect } from "effect";
2 | import type { OpencodeClient } from "@opencode-ai/sdk";
3 | import {
4 | InvalidProviderError,
5 | InvalidModelError,
6 | ProviderNotConnectedError,
7 | } from "../errors";
8 |
9 | export const validateProviderAndModel = (
10 | client: OpencodeClient,
11 | providerId: string,
12 | modelId: string
13 | ) =>
14 | Effect.gen(function* () {
15 | const response = yield* Effect.tryPromise(() =>
16 | client.provider.list()
17 | ).pipe(
18 | Effect.option // Convert errors to None, success to Some
19 | );
20 |
21 | // If we couldn't fetch providers, skip validation (fail open)
22 | if (response._tag === "None" || !response.value.data) {
23 | return;
24 | }
25 |
26 | const { all, connected } = response.value.data;
27 |
28 | // Check if provider exists
29 | const provider = all.find((p) => p.id === providerId);
30 | if (!provider) {
31 | return yield* Effect.fail(
32 | new InvalidProviderError({
33 | providerId,
34 | availableProviders: all.map((p) => p.id),
35 | })
36 | );
37 | }
38 |
39 | // Check if provider is connected (has valid auth)
40 | if (!connected.includes(providerId)) {
41 | return yield* Effect.fail(
42 | new ProviderNotConnectedError({
43 | providerId,
44 | connectedProviders: connected,
45 | })
46 | );
47 | }
48 |
49 | // Check if model exists for this provider
50 | const modelIds = Object.keys(provider.models);
51 | if (!modelIds.includes(modelId)) {
52 | return yield* Effect.fail(
53 | new InvalidModelError({
54 | providerId,
55 | modelId,
56 | availableModels: modelIds,
57 | })
58 | );
59 | }
60 | });
61 |
--------------------------------------------------------------------------------
/apps/cli/src/sandbox/CommandPalette.tsx:
--------------------------------------------------------------------------------
1 | import type { Command } from './commands.ts';
2 |
3 | interface Colors {
4 | bg: string;
5 | bgSubtle: string;
6 | bgMuted: string;
7 | text: string;
8 | textMuted: string;
9 | textSubtle: string;
10 | border: string;
11 | accent: string;
12 | }
13 |
14 | interface CommandPaletteProps {
15 | commands: Command[];
16 | selectedIndex: number;
17 | colors: Colors;
18 | }
19 |
20 | export function CommandPalette({ commands, selectedIndex, colors }: CommandPaletteProps) {
21 | if (commands.length === 0) {
22 | return (
23 |
36 |
37 |
38 | );
39 | }
40 |
41 | return (
42 |
56 |
57 |
58 | {commands.map((cmd, i) => {
59 | const isSelected = i === selectedIndex;
60 | return (
61 |
67 |
72 |
73 |
74 | );
75 | })}
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/components/RepoSelector.tsx:
--------------------------------------------------------------------------------
1 | import type { Repo } from '../types.ts';
2 | import type { Colors } from '../theme.ts';
3 |
4 | interface RepoSelectorProps {
5 | repos: Repo[];
6 | selectedIndex: number;
7 | currentRepo: number;
8 | searchQuery: string;
9 | colors: Colors;
10 | }
11 |
12 | export function RepoSelector({
13 | repos,
14 | selectedIndex,
15 | currentRepo,
16 | searchQuery,
17 | colors
18 | }: RepoSelectorProps) {
19 | return (
20 |
34 |
35 |
36 | {searchQuery && (
37 |
38 |
39 |
40 |
41 | )}
42 |
43 | {repos.length === 0 ? (
44 |
45 | ) : (
46 | repos.map((repo, i) => {
47 | const isSelected = i === selectedIndex;
48 | // Note: currentRepo is an index into the ORIGINAL repos array, not filtered
49 | // We can't directly compare, so we skip this indicator when filtering
50 | return (
51 |
57 |
61 |
62 | );
63 | })
64 | )}
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/services.ts:
--------------------------------------------------------------------------------
1 | import { BunContext } from '@effect/platform-bun';
2 | import { Effect, Layer, ManagedRuntime, Stream } from 'effect';
3 | import { ConfigService } from '../services/config.ts';
4 | import { OcService, type OcEvent } from '../services/oc.ts';
5 | import type { Repo } from './types.ts';
6 |
7 | const ServicesLayer = Layer.mergeAll(OcService.Default, ConfigService.Default).pipe(
8 | Layer.provideMerge(BunContext.layer)
9 | );
10 |
11 | const runtime = ManagedRuntime.make(ServicesLayer);
12 |
13 | export const services = {
14 | getRepos: (): Promise =>
15 | runtime.runPromise(
16 | Effect.gen(function* () {
17 | const config = yield* ConfigService;
18 | const repos = yield* config.getRepos();
19 | // Convert readonly to mutable
20 | return repos.map((r) => ({ ...r }));
21 | })
22 | ),
23 |
24 | addRepo: (repo: Repo): Promise =>
25 | runtime.runPromise(
26 | Effect.gen(function* () {
27 | const config = yield* ConfigService;
28 | const added = yield* config.addRepo(repo);
29 | return { ...added };
30 | })
31 | ),
32 |
33 | removeRepo: (name: string): Promise =>
34 | runtime.runPromise(
35 | Effect.gen(function* () {
36 | const config = yield* ConfigService;
37 | yield* config.removeRepo(name);
38 | })
39 | ),
40 |
41 | getModel: (): Promise<{ provider: string; model: string }> =>
42 | runtime.runPromise(
43 | Effect.gen(function* () {
44 | const config = yield* ConfigService;
45 | return yield* config.getModel();
46 | })
47 | ),
48 |
49 | updateModel: (provider: string, model: string): Promise<{ provider: string; model: string }> =>
50 | runtime.runPromise(
51 | Effect.gen(function* () {
52 | const config = yield* ConfigService;
53 | return yield* config.updateModel({ provider, model });
54 | })
55 | ),
56 |
57 | // OC operations
58 | spawnTui: (tech: string): Promise =>
59 | runtime.runPromise(
60 | Effect.gen(function* () {
61 | const oc = yield* OcService;
62 | yield* oc.spawnTui({ tech });
63 | })
64 | ),
65 |
66 | askQuestion: (tech: string, question: string, onEvent: (event: OcEvent) => void): Promise =>
67 | runtime.runPromise(
68 | Effect.gen(function* () {
69 | const oc = yield* OcService;
70 | const stream = yield* oc.askQuestion({
71 | question,
72 | tech,
73 | suppressLogs: true
74 | });
75 |
76 | yield* Stream.runForEach(stream, (event) => Effect.sync(() => onEvent(event)));
77 | })
78 | )
79 | };
80 |
81 | export type Services = typeof services;
82 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/components/ModelConfig.tsx:
--------------------------------------------------------------------------------
1 | import type { Colors } from '../theme.ts';
2 |
3 | export type ModelConfigStep = 'provider' | 'model' | 'confirm';
4 |
5 | interface ModelConfigProps {
6 | step: ModelConfigStep;
7 | values: {
8 | provider: string;
9 | model: string;
10 | };
11 | currentInput: string;
12 | onInput: (value: string) => void;
13 | onSubmit: () => void;
14 | colors: Colors;
15 | }
16 |
17 | const STEP_INFO: Record = {
18 | provider: {
19 | title: 'Step 1/2: Provider',
20 | hint: 'Enter provider ID (e.g., "opencode", "anthropic", "openai")',
21 | placeholder: 'opencode'
22 | },
23 | model: {
24 | title: 'Step 2/2: Model',
25 | hint: 'Enter model ID (e.g., "big-pickle", "claude-sonnet-4-20250514")',
26 | placeholder: 'big-pickle'
27 | },
28 | confirm: {
29 | title: 'Confirm',
30 | hint: 'Press Enter to save, Esc to cancel',
31 | placeholder: ''
32 | }
33 | };
34 |
35 | export function ModelConfig({
36 | step,
37 | values,
38 | currentInput,
39 | onInput,
40 | onSubmit,
41 | colors
42 | }: ModelConfigProps) {
43 | const info = STEP_INFO[step];
44 |
45 | return (
46 |
60 |
61 |
62 |
63 |
64 | {step === 'confirm' ? (
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | ) : (
78 |
79 |
89 |
90 | )}
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/apps/cli/README.md:
--------------------------------------------------------------------------------
1 | # btca
2 |
3 | A CLI tool for asking questions about technologies using their source code repositories.
4 |
5 | ## Installation
6 |
7 | ```bash
8 | bun install
9 | ```
10 |
11 | ## Usage
12 |
13 | ```bash
14 | bun run src/index.ts
15 | ```
16 |
17 | Or after building:
18 |
19 | ```bash
20 | btca
21 | ```
22 |
23 | ## Commands
24 |
25 | ### `btca`
26 |
27 | Show version information.
28 |
29 | ### `btca ask`
30 |
31 | Ask a question about a technology.
32 |
33 | ```bash
34 | btca ask -t -q
35 | btca ask --tech svelte --question "How do I create a reactive store?"
36 | ```
37 |
38 | Options:
39 |
40 | - `-t, --tech` - The technology/repo to query
41 | - `-q, --question` - The question to ask
42 |
43 | ### `btca chat`
44 |
45 | Start an interactive TUI chat session.
46 |
47 | ```bash
48 | btca chat -t
49 | btca chat --tech nextjs
50 | ```
51 |
52 | Options:
53 |
54 | - `-t, --tech` - The technology/repo to chat about
55 |
56 | ### `btca serve`
57 |
58 | Start an HTTP server to answer questions via API.
59 |
60 | ```bash
61 | btca serve
62 | btca serve -p 3000
63 | ```
64 |
65 | Options:
66 |
67 | - `-p, --port` - Port to listen on (default: 8080)
68 |
69 | Endpoint:
70 |
71 | - `POST /question` - Send `{ "tech": "svelte", "question": "..." }` to get answers
72 |
73 | ### `btca open`
74 |
75 | Hold an OpenCode instance in the background for faster subsequent queries.
76 |
77 | ```bash
78 | btca open
79 | ```
80 |
81 | ### `btca config`
82 |
83 | Manage CLI configuration. Shows the config file path when run without subcommands.
84 |
85 | ```bash
86 | btca config
87 | ```
88 |
89 | #### `btca config model`
90 |
91 | View or set the model and provider.
92 |
93 | ```bash
94 | # View current model/provider
95 | btca config model
96 |
97 | # Set model and provider
98 | btca config model -p -m
99 | btca config model --provider anthropic --model claude-3-opus
100 | ```
101 |
102 | Options:
103 |
104 | - `-p, --provider` - The provider to use
105 | - `-m, --model` - The model to use
106 |
107 | Both options must be specified together when updating.
108 |
109 | #### `btca config repos list`
110 |
111 | List all configured repositories.
112 |
113 | ```bash
114 | btca config repos list
115 | ```
116 |
117 | #### `btca config repos add`
118 |
119 | Add a new repository to the configuration.
120 |
121 | ```bash
122 | btca config repos add -n -u [-b ] [--notes ]
123 | btca config repos add --name react --url https://github.com/facebook/react --branch main
124 | ```
125 |
126 | Options:
127 |
128 | - `-n, --name` - Unique name for the repo (required)
129 | - `-u, --url` - Git repository URL (required)
130 | - `-b, --branch` - Branch to use (default: "main")
131 | - `--notes` - Special instructions for the AI when using this repo
132 |
133 | ## Configuration
134 |
135 | Configuration is stored at `~/.config/btca/btca.json`. The config file includes:
136 |
137 | - `promptsDirectory` - Directory for system prompts
138 | - `reposDirectory` - Directory where repos are cloned
139 | - `port` - Default server port
140 | - `maxInstances` - Maximum concurrent OpenCode instances
141 | - `repos` - Array of configured repositories
142 | - `model` - AI model to use
143 | - `provider` - AI provider to use
144 |
--------------------------------------------------------------------------------
/apps/web/src/routes/og/+page.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
9 |
13 |
14 |
15 |
16 |
17 |
20 |
21 |
22 |
23 |
26 | Better Context
27 |
28 |
btca CLI
29 |
30 |
31 |
32 |
33 | github.com/bmdavis419/better-context
34 |
35 |
36 |
37 |
38 |
41 | AI Powered Docs Search
42 |
43 |
44 |
47 | Ask a question. Search the actual codebase. Get a real answer.
48 |
49 |
50 |
51 | Local-first CLI that clones repos and searches source directly.
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | Subcommands: ask · chat · serve · open
60 |
61 |
62 |
63 | Built with Bun + Effect + SvelteKit
64 |
65 |
66 |
67 |
70 |
bun add -g btca
73 | btca ask -t svelte -q "How do stores work?"
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/components/AddRepoWizard.tsx:
--------------------------------------------------------------------------------
1 | import type { Colors } from '../theme.ts';
2 | import type { Repo } from '../types.ts';
3 |
4 | export type WizardStep = 'name' | 'url' | 'branch' | 'notes' | 'confirm';
5 |
6 | interface AddRepoWizardProps {
7 | step: WizardStep;
8 | values: {
9 | name: string;
10 | url: string;
11 | branch: string;
12 | notes: string;
13 | };
14 | currentInput: string;
15 | onInput: (value: string) => void;
16 | onSubmit: () => void;
17 | colors: Colors;
18 | }
19 |
20 | const STEP_INFO: Record = {
21 | name: {
22 | title: 'Step 1/4: Repository Name',
23 | hint: 'Enter a unique name for this repo (e.g., "react", "svelte-docs")',
24 | placeholder: 'repo-name'
25 | },
26 | url: {
27 | title: 'Step 2/4: Repository URL',
28 | hint: 'Enter the GitHub repository URL',
29 | placeholder: 'https://github.com/owner/repo'
30 | },
31 | branch: {
32 | title: 'Step 3/4: Branch',
33 | hint: 'Enter the branch to clone (press Enter for "main")',
34 | placeholder: 'main'
35 | },
36 | notes: {
37 | title: 'Step 4/4: Special Notes (Optional)',
38 | hint: 'Any special notes for the AI? Press Enter to skip',
39 | placeholder: 'e.g., "This is the docs website, not the library"'
40 | },
41 | confirm: {
42 | title: 'Confirm',
43 | hint: 'Press Enter to add repo, Esc to cancel',
44 | placeholder: ''
45 | }
46 | };
47 |
48 | export function AddRepoWizard({
49 | step,
50 | values,
51 | currentInput,
52 | onInput,
53 | onSubmit,
54 | colors
55 | }: AddRepoWizardProps) {
56 | const info = STEP_INFO[step];
57 |
58 | return (
59 |
73 |
74 |
75 |
76 |
77 | {step === 'confirm' ? (
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | {values.notes && (
92 |
93 |
94 |
95 |
96 | )}
97 |
98 |
99 |
100 | ) : (
101 |
102 |
112 |
113 | )}
114 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/apps/web/src/routes/models/+page.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
18 | Configuration
22 | Set your preferred AI model.
23 |
24 |
25 |
28 | Models
29 |
30 |
31 |
34 | Any model that works with OpenCode works with btca. Under the hood btca uses the OpenCode SDK,
35 | which will read your local config.
36 |
37 |
38 |
39 |
40 | {#each BLESSED_MODELS as model}
41 |
44 |
45 |
46 | {model.model}
50 | {model.provider}
54 | {#if model.isDefault}
55 | Default
59 | {/if}
60 |
61 |
62 | {model.description}
63 |
64 |
67 |
68 |
69 | {#if shikiStore.highlighter}
70 | {@html shikiStore.highlighter.codeToHtml(
71 | getCommand(model.provider, model.model),
72 | {
73 | theme: shikiTheme,
74 | lang: 'bash',
75 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;'
76 | }
77 | )}
78 | {:else}
79 |
{getCommand(model.provider, model.model)}
83 | {/if}
84 |
85 |
89 |
90 |
91 |
97 | Provider setup instructions →
98 |
99 |
100 |
101 | {/each}
102 |
103 |
104 |
--------------------------------------------------------------------------------
/apps/web/src/routes/commands/+page.svelte:
--------------------------------------------------------------------------------
1 |
68 |
69 |
70 |
71 |
72 | Reference
76 | Complete list of all available commands.
77 |
78 |
79 |
82 | Commands
83 |
84 |
85 |
88 | All available btca commands with descriptions and examples.
91 |
92 |
93 |
94 |
95 | {#each commands as cmd}
96 |
99 |
100 |
101 | {cmd.name}
105 |
106 |
107 | {cmd.description}
108 |
109 |
112 |
113 |
114 | {#if shikiStore.highlighter}
115 | {@html shikiStore.highlighter.codeToHtml(cmd.example, {
116 | theme: shikiTheme,
117 | lang: 'bash',
118 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;'
119 | })}
120 | {:else}
121 |
{cmd.example}
125 | {/if}
126 |
127 |
128 |
129 |
130 |
131 |
132 | {/each}
133 |
134 |
135 |
--------------------------------------------------------------------------------
/apps/web/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 | Better Context
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
52 |
53 |
119 |
120 |
121 | {#if fullBleed}
122 | {@render children()}
123 | {:else}
124 |
125 | {@render children()}
126 |
127 | {/if}
128 |
129 |
130 |
143 |
144 |
--------------------------------------------------------------------------------
/apps/cli/src/services/oc.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createOpencode,
3 | OpencodeClient,
4 | type Event,
5 | type Config as OpenCodeConfig
6 | } from '@opencode-ai/sdk';
7 | import { spawn } from 'bun';
8 | import { Deferred, Duration, Effect, Stream } from 'effect';
9 | import { ConfigService } from './config.ts';
10 | import { OcError } from '../lib/errors.ts';
11 | import { validateProviderAndModel } from '../lib/utils/validation.ts';
12 |
13 | const spawnOpencodeTui = async (args: {
14 | config: OpenCodeConfig;
15 | repoDir: string;
16 | rawConfig: { provider: string; model: string };
17 | }) => {
18 | const proc = spawn(['opencode', `--model=${args.rawConfig.provider}/${args.rawConfig.model}`], {
19 | stdin: 'inherit',
20 | stdout: 'inherit',
21 | stderr: 'inherit',
22 | cwd: args.repoDir,
23 | env: {
24 | ...process.env,
25 | OPENCODE_CONFIG_CONTENT: JSON.stringify(args.config)
26 | }
27 | });
28 |
29 | await proc.exited;
30 | };
31 |
32 | export type { Event as OcEvent };
33 |
34 | const ocService = Effect.gen(function* () {
35 | const config = yield* ConfigService;
36 |
37 | const rawConfig = yield* config.rawConfig();
38 |
39 | const getOpencodeInstance = ({ tech }: { tech: string }) =>
40 | Effect.gen(function* () {
41 | let portOffset = 0;
42 | const maxInstances = 5;
43 | const { ocConfig, repoDir } = yield* config.getOpenCodeConfig({ repoName: tech });
44 |
45 | yield* Effect.sync(() => process.chdir(repoDir));
46 |
47 | while (portOffset < maxInstances) {
48 | const result = yield* Effect.tryPromise(() =>
49 | createOpencode({
50 | port: 3420 + portOffset,
51 | config: ocConfig
52 | })
53 | ).pipe(
54 | Effect.catchAll((err) => {
55 | if (err.cause instanceof Error && err.cause.stack?.includes('port')) {
56 | portOffset++;
57 | return Effect.succeed(null);
58 | }
59 | return Effect.fail(
60 | new OcError({
61 | message: 'FAILED TO CREATE OPENCODE CLIENT',
62 | cause: err
63 | })
64 | );
65 | })
66 | );
67 | if (result !== null) {
68 | return result;
69 | }
70 | }
71 | return yield* Effect.fail(
72 | new OcError({
73 | message: 'FAILED TO CREATE OPENCODE CLIENT - all ports exhausted',
74 | cause: null
75 | })
76 | );
77 | });
78 |
79 | const streamSessionEvents = (args: { sessionID: string; client: OpencodeClient }) =>
80 | Effect.gen(function* () {
81 | const { sessionID, client } = args;
82 |
83 | const events = yield* Effect.tryPromise({
84 | try: () => client.event.subscribe(),
85 | catch: (err) =>
86 | new OcError({
87 | message: 'Failed to subscribe to events',
88 | cause: err
89 | })
90 | });
91 |
92 | return Stream.fromAsyncIterable(
93 | events.stream,
94 | (e) => new OcError({ message: 'Event stream error', cause: e })
95 | ).pipe(
96 | Stream.filter((event) => {
97 | const props = event.properties;
98 | if (!('sessionID' in props)) return true;
99 | return props.sessionID === sessionID;
100 | }),
101 | Stream.takeUntil(
102 | (event) => event.type === 'session.idle' && event.properties.sessionID === sessionID
103 | )
104 | );
105 | });
106 |
107 | const firePrompt = (args: {
108 | sessionID: string;
109 | text: string;
110 | errorDeferred: Deferred.Deferred;
111 | client: OpencodeClient;
112 | }) =>
113 | Effect.promise(() =>
114 | args.client.session.prompt({
115 | path: { id: args.sessionID },
116 | body: {
117 | agent: 'docs',
118 | model: {
119 | providerID: rawConfig.provider,
120 | modelID: rawConfig.model
121 | },
122 | parts: [{ type: 'text', text: args.text }]
123 | }
124 | })
125 | ).pipe(
126 | Effect.catchAll((err) =>
127 | Deferred.fail(args.errorDeferred, new OcError({ message: String(err), cause: err }))
128 | )
129 | );
130 |
131 | const streamPrompt = (args: {
132 | sessionID: string;
133 | prompt: string;
134 | client: OpencodeClient;
135 | cleanup: () => void;
136 | }) =>
137 | Effect.gen(function* () {
138 | const { sessionID, prompt, client } = args;
139 |
140 | const eventStream = yield* streamSessionEvents({ sessionID, client });
141 |
142 | const errorDeferred = yield* Deferred.make();
143 |
144 | yield* firePrompt({
145 | sessionID,
146 | text: prompt,
147 | errorDeferred,
148 | client
149 | }).pipe(Effect.forkDaemon);
150 |
151 | // Transform stream to fail on session.error, race with prompt error
152 | return eventStream.pipe(
153 | Stream.mapEffect((event) =>
154 | Effect.gen(function* () {
155 | if (event.type === 'session.error') {
156 | const props = event.properties as { error?: { name?: string } };
157 | return yield* Effect.fail(
158 | new OcError({
159 | message: props.error?.name ?? 'Unknown session error',
160 | cause: props.error
161 | })
162 | );
163 | }
164 | return event;
165 | })
166 | ),
167 | Stream.ensuring(Effect.sync(() => args.cleanup())),
168 | Stream.interruptWhen(Deferred.await(errorDeferred))
169 | );
170 | });
171 |
172 | return {
173 | spawnTui: (args: { tech: string }) =>
174 | Effect.gen(function* () {
175 | const { tech } = args;
176 |
177 | yield* config.cloneOrUpdateOneRepoLocally(tech, { suppressLogs: false });
178 |
179 | const { ocConfig, repoDir } = yield* config.getOpenCodeConfig({
180 | repoName: tech
181 | });
182 |
183 | yield* Effect.tryPromise({
184 | try: () => spawnOpencodeTui({ config: ocConfig, repoDir, rawConfig }),
185 | catch: (err) => new OcError({ message: 'TUI exited with error', cause: err })
186 | });
187 | }),
188 | holdOpenInstanceInBg: () =>
189 | Effect.gen(function* () {
190 | const { client, server } = yield* getOpencodeInstance({
191 | tech: 'svelte'
192 | });
193 |
194 | yield* Effect.log(`OPENCODE SERVER IS UP AT ${server.url}`);
195 |
196 | yield* Effect.sleep(Duration.days(1));
197 | }),
198 | askQuestion: (args: { question: string; tech: string; suppressLogs: boolean }) =>
199 | Effect.gen(function* () {
200 | const { question, tech, suppressLogs } = args;
201 |
202 | yield* config.cloneOrUpdateOneRepoLocally(tech, { suppressLogs });
203 |
204 | const { client, server } = yield* getOpencodeInstance({ tech });
205 |
206 | yield* validateProviderAndModel(client, rawConfig.provider, rawConfig.model);
207 |
208 | const session = yield* Effect.promise(() => client.session.create());
209 |
210 | if (session.error) {
211 | return yield* Effect.fail(
212 | new OcError({
213 | message: 'FAILED TO START OPENCODE SESSION',
214 | cause: session.error
215 | })
216 | );
217 | }
218 |
219 | const sessionID = session.data.id;
220 |
221 | return yield* streamPrompt({
222 | sessionID,
223 | prompt: question,
224 | client,
225 | cleanup: () => {
226 | server.close();
227 | }
228 | });
229 | })
230 | };
231 | });
232 |
233 | export class OcService extends Effect.Service()('OcService', {
234 | effect: ocService,
235 | dependencies: [ConfigService.Default]
236 | }) {}
237 |
--------------------------------------------------------------------------------
/apps/cli/src/sandbox/App.tsx:
--------------------------------------------------------------------------------
1 | import { useKeyboard } from '@opentui/react';
2 | import { useState, useMemo } from 'react';
3 | import { filterCommands, type Command } from './commands.ts';
4 | import { CommandPalette } from './CommandPalette.tsx';
5 | import { AddRepoPrompt } from './AddRepoPrompt.tsx';
6 | import { RepoSelector } from './RepoSelector.tsx';
7 |
8 | // Color palette matching apps/web (dark mode)
9 | const colors = {
10 | // Backgrounds
11 | bg: '#0a0a0a', // neutral-950
12 | bgSubtle: '#171717', // neutral-900
13 | bgMuted: '#262626', // neutral-800
14 |
15 | // Text
16 | text: '#fafafa', // neutral-50
17 | textMuted: '#a3a3a3', // neutral-400
18 | textSubtle: '#737373', // neutral-500
19 |
20 | // Borders
21 | border: '#262626', // neutral-800
22 | borderSubtle: '#404040', // neutral-700
23 |
24 | // Accent (orange)
25 | accent: '#f97316', // orange-500
26 | accentDark: '#c2410c', // orange-700
27 |
28 | // Semantic
29 | success: '#22c55e', // green-500
30 | info: '#3b82f6' // blue-500
31 | };
32 |
33 | const MODELS = {
34 | sonnet: 'anthropic/claude-sonnet-4-20250514',
35 | opus: 'anthropic/claude-opus-4-20250514',
36 | haiku: 'anthropic/claude-haiku-3-20240307'
37 | } as const;
38 |
39 | type ModelKey = keyof typeof MODELS;
40 | type Mode = 'chat' | 'command-palette' | 'add-repo' | 'select-repo';
41 |
42 | interface Message {
43 | role: 'user' | 'assistant' | 'system';
44 | content: string;
45 | }
46 |
47 | export function App() {
48 | // Core state
49 | const [repos, setRepos] = useState(['opentui', 'effect', 'svelte', 'nextjs', 'react', 'clerk']);
50 | const [selectedRepo, setSelectedRepo] = useState(0);
51 | const [messages, setMessages] = useState([
52 | { role: 'user', content: 'How do I create a TUI with opentui?' },
53 | {
54 | role: 'assistant',
55 | content: 'Use createCliRenderer() to start, then add components to the root.'
56 | }
57 | ]);
58 |
59 | // Mode and input state
60 | const [mode, setMode] = useState('chat');
61 | const [inputValue, setInputValue] = useState('');
62 | const [commandIndex, setCommandIndex] = useState(0);
63 | const [addRepoValue, setAddRepoValue] = useState('');
64 | const [repoSelectorIndex, setRepoSelectorIndex] = useState(0);
65 |
66 | // Derived state
67 | const showCommandPalette = mode === 'chat' && inputValue.startsWith('/');
68 | const commandQuery = inputValue.slice(1); // Remove the '/'
69 | const filteredCommands = useMemo(() => filterCommands(commandQuery), [commandQuery]);
70 |
71 | // Reset command index when filtered commands change
72 | const clampedCommandIndex = Math.min(commandIndex, Math.max(0, filteredCommands.length - 1));
73 |
74 | const handleInputChange = (value: string) => {
75 | setInputValue(value);
76 | // Reset command index when input changes
77 | setCommandIndex(0);
78 | };
79 |
80 | const executeCommand = (command: Command) => {
81 | setInputValue('');
82 | setCommandIndex(0);
83 |
84 | if (command.mode === 'add-repo') {
85 | setMode('add-repo');
86 | setAddRepoValue('');
87 | } else if (command.mode === 'select-repo') {
88 | setMode('select-repo');
89 | setRepoSelectorIndex(selectedRepo);
90 | }
91 | };
92 |
93 | const handleChatSubmit = () => {
94 | const value = inputValue.trim();
95 | if (!value) return;
96 |
97 | // If showing command palette and pressing enter, execute selected command
98 | if (showCommandPalette && filteredCommands.length > 0) {
99 | const command = filteredCommands[clampedCommandIndex];
100 | if (command) {
101 | executeCommand(command);
102 | return;
103 | }
104 | }
105 |
106 | // Regular message
107 | setMessages((prev) => [...prev, { role: 'user', content: value }]);
108 | setInputValue('');
109 | };
110 |
111 | const handleAddRepo = () => {
112 | const value = addRepoValue.trim();
113 | if (!value) return;
114 |
115 | setRepos((prev) => [...prev, value]);
116 | setMessages((prev) => [...prev, { role: 'system', content: `Added repo: ${value}` }]);
117 | setAddRepoValue('');
118 | setMode('chat');
119 | };
120 |
121 | const handleSelectRepo = () => {
122 | setSelectedRepo(repoSelectorIndex);
123 | setMessages([]); // Clear chat when switching repo
124 | setMode('chat');
125 | };
126 |
127 | const cancelMode = () => {
128 | setMode('chat');
129 | setInputValue('');
130 | setAddRepoValue('');
131 | };
132 |
133 | useKeyboard((key) => {
134 | // Escape always cancels current mode
135 | if (key.name === 'escape') {
136 | key.preventDefault();
137 | if (mode !== 'chat') {
138 | cancelMode();
139 | } else if (showCommandPalette) {
140 | setInputValue('');
141 | }
142 | return;
143 | }
144 |
145 | // Mode-specific keyboard handling
146 | if (mode === 'chat' && showCommandPalette) {
147 | // Command palette navigation
148 | if (key.name === 'up') {
149 | key.preventDefault();
150 | setCommandIndex((prev) => (prev === 0 ? filteredCommands.length - 1 : prev - 1));
151 | } else if (key.name === 'down') {
152 | key.preventDefault();
153 | setCommandIndex((prev) => (prev === filteredCommands.length - 1 ? 0 : prev + 1));
154 | }
155 | } else if (mode === 'select-repo') {
156 | // Repo selector navigation
157 | if (key.name === 'up') {
158 | key.preventDefault();
159 | setRepoSelectorIndex((prev) => (prev === 0 ? repos.length - 1 : prev - 1));
160 | } else if (key.name === 'down') {
161 | key.preventDefault();
162 | setRepoSelectorIndex((prev) => (prev === repos.length - 1 ? 0 : prev + 1));
163 | }
164 | }
165 |
166 | if (key.name === 'return' && mode === 'select-repo') {
167 | key.preventDefault();
168 | handleSelectRepo();
169 | }
170 | });
171 |
172 | return (
173 |
181 | {/* Header */}
182 |
196 |
197 | {'◆'}
198 | {' btca'}
199 | {' - Better Context AI'}
200 |
201 |
202 |
203 |
204 | {/* Content area */}
205 |
212 | {/* Sidebar */}
213 |
223 |
224 |
225 | {repos.map((repo, i) => (
226 |
231 | ))}
232 |
233 |
234 | {/* Chat area */}
235 |
245 |
246 |
247 | {messages.map((msg, i) => (
248 |
249 |
258 | {msg.role === 'user' ? 'You ' : msg.role === 'system' ? 'SYS ' : 'AI '}
259 |
260 |
261 |
262 | ))}
263 |
264 |
265 |
266 | {/* Overlays - positioned relative to the input area */}
267 | {showCommandPalette && (
268 |
273 | )}
274 |
275 | {mode === 'add-repo' && (
276 |
282 | )}
283 |
284 | {mode === 'select-repo' && (
285 |
291 | )}
292 |
293 | {/* Input area */}
294 |
304 |
317 |
318 |
319 | {/* Status bar */}
320 |
331 |
343 |
344 |
345 | );
346 | }
347 |
--------------------------------------------------------------------------------
/apps/web/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 | AI Powered Docs Search
26 | Ask a Question, Search the Actual Codebase, Get a Real Answer.
29 |
30 |
31 |
34 | Up to Date Info About any Technology
35 |
36 |
37 |
40 | btca
41 | is a CLI for asking questions about libraries/frameworks by cloning their repos locally and searching
42 | the source directly
43 |
44 |
45 |
61 |
62 |
63 |
92 |
93 |
227 |
228 |
242 |
243 |
--------------------------------------------------------------------------------
/apps/cli/src/services/config.ts:
--------------------------------------------------------------------------------
1 | import type { Config as OpenCodeConfig, ProviderConfig } from '@opencode-ai/sdk';
2 | import { FileSystem, Path } from '@effect/platform';
3 | import { Effect, Schema } from 'effect';
4 | import { getDocsAgentPrompt } from '../lib/prompts.ts';
5 | import { ConfigError } from '../lib/errors.ts';
6 | import { cloneRepo, pullRepo } from '../lib/utils/git.ts';
7 | import { directoryExists, expandHome } from '../lib/utils/files.ts';
8 | import { BLESSED_MODELS } from '@btca/shared';
9 |
10 | const CONFIG_DIRECTORY = '~/.config/btca';
11 | const CONFIG_FILENAME = 'btca.json';
12 |
13 | const repoSchema = Schema.Struct({
14 | name: Schema.String,
15 | url: Schema.String,
16 | branch: Schema.String,
17 | specialNotes: Schema.String.pipe(Schema.optional)
18 | });
19 |
20 | const configSchema = Schema.Struct({
21 | reposDirectory: Schema.String,
22 | port: Schema.Number,
23 | maxInstances: Schema.Number,
24 | repos: Schema.Array(repoSchema),
25 | model: Schema.String,
26 | provider: Schema.String
27 | });
28 |
29 | type Config = typeof configSchema.Type;
30 | type Repo = typeof repoSchema.Type;
31 |
32 | const DEFAULT_CONFIG: Config = {
33 | reposDirectory: '~/.local/share/btca/repos',
34 | port: 3420,
35 | maxInstances: 5,
36 | repos: [
37 | {
38 | name: 'svelte',
39 | url: 'https://github.com/sveltejs/svelte.dev',
40 | branch: 'main',
41 | specialNotes:
42 | 'This is the svelte docs website repo, not the actual svelte repo. Use the docs to answer questions about svelte.'
43 | },
44 | {
45 | name: 'tailwindcss',
46 | url: 'https://github.com/tailwindlabs/tailwindcss.com',
47 | branch: 'main',
48 | specialNotes:
49 | 'This is the tailwindcss docs website repo, not the actual tailwindcss repo. Use the docs to answer questions about tailwindcss.'
50 | },
51 | {
52 | name: 'nextjs',
53 | url: 'https://github.com/vercel/next.js',
54 | branch: 'canary'
55 | }
56 | ],
57 | model: 'big-pickle',
58 | provider: 'opencode'
59 | };
60 |
61 | const collapseHome = (path: string): string => {
62 | const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
63 | if (home && path.startsWith(home)) {
64 | return '~' + path.slice(home.length);
65 | }
66 | return path;
67 | };
68 |
69 | const writeConfig = (config: Config) =>
70 | Effect.gen(function* () {
71 | const path = yield* Path.Path;
72 | const fs = yield* FileSystem.FileSystem;
73 |
74 | const configDir = yield* expandHome(CONFIG_DIRECTORY);
75 | const configPath = path.join(configDir, CONFIG_FILENAME);
76 |
77 | // Collapse expanded paths back to tilde for storage
78 | const configToWrite: Config = {
79 | ...config,
80 | reposDirectory: collapseHome(config.reposDirectory)
81 | };
82 |
83 | yield* fs.writeFileString(configPath, JSON.stringify(configToWrite, null, 2)).pipe(
84 | Effect.catchAll((error) =>
85 | Effect.fail(
86 | new ConfigError({
87 | message: 'Failed to write config',
88 | cause: error
89 | })
90 | )
91 | )
92 | );
93 |
94 | return configToWrite;
95 | });
96 |
97 | // models setup the way I like them, the ones I would recommend for use are:
98 | // gemini 3 flash with low reasoning, haiku 4.5 with no reasoning, big pickle (surprisingly good), and kimi K2
99 | const BTCA_PRESET_MODELS: Record = {
100 | opencode: {
101 | models: {
102 | 'btca-gemini-3-flash': {
103 | id: 'gemini-3-flash',
104 | options: {
105 | generationConfig: {
106 | thinkingConfig: {
107 | thinkingLevel: 'low'
108 | }
109 | }
110 | }
111 | }
112 | }
113 | }
114 | };
115 |
116 | const OPENCODE_CONFIG = (args: {
117 | repoName: string;
118 | specialNotes?: string;
119 | }): Effect.Effect =>
120 | Effect.gen(function* () {
121 | const path = yield* Path.Path;
122 | return {
123 | provider: BTCA_PRESET_MODELS,
124 | agent: {
125 | build: {
126 | disable: true
127 | },
128 | explore: {
129 | disable: true
130 | },
131 | general: {
132 | disable: true
133 | },
134 | plan: {
135 | disable: true
136 | },
137 | docs: {
138 | prompt: getDocsAgentPrompt({
139 | repoName: args.repoName,
140 | specialNotes: args.specialNotes
141 | }),
142 | disable: false,
143 | description: 'Get answers about libraries and frameworks by searching their source code',
144 | permission: {
145 | webfetch: 'deny',
146 | edit: 'deny',
147 | bash: 'deny',
148 | external_directory: 'deny',
149 | doom_loop: 'deny'
150 | },
151 | mode: 'primary',
152 | tools: {
153 | write: false,
154 | bash: false,
155 | delete: false,
156 | read: true,
157 | grep: true,
158 | glob: true,
159 | list: true,
160 | path: false,
161 | todowrite: false,
162 | todoread: false,
163 | websearch: false
164 | }
165 | }
166 | }
167 | };
168 | });
169 |
170 | const onStartLoadConfig = Effect.gen(function* () {
171 | const path = yield* Path.Path;
172 | const fs = yield* FileSystem.FileSystem;
173 |
174 | const configDir = yield* expandHome(CONFIG_DIRECTORY);
175 | const configPath = path.join(configDir, CONFIG_FILENAME);
176 |
177 | const exists = yield* fs.exists(configPath);
178 |
179 | if (!exists) {
180 | yield* Effect.log(`Config file not found at ${configPath}, creating default config...`);
181 | // Ensure directory exists
182 | yield* fs
183 | .makeDirectory(configDir, { recursive: true })
184 | .pipe(Effect.catchAll(() => Effect.void));
185 | yield* fs.writeFileString(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2)).pipe(
186 | Effect.catchAll((error) =>
187 | Effect.fail(
188 | new ConfigError({
189 | message: 'Failed to create default config',
190 | cause: error
191 | })
192 | )
193 | )
194 | );
195 | yield* Effect.log(`Default config created at ${configPath}`);
196 | const reposDir = yield* expandHome(DEFAULT_CONFIG.reposDirectory);
197 | const config = {
198 | ...DEFAULT_CONFIG,
199 | reposDirectory: reposDir
200 | } satisfies Config;
201 | return {
202 | config,
203 | configPath
204 | };
205 | } else {
206 | const content = yield* fs.readFileString(configPath).pipe(
207 | Effect.catchAll((error) =>
208 | Effect.fail(
209 | new ConfigError({
210 | message: 'Failed to load config',
211 | cause: error
212 | })
213 | )
214 | )
215 | );
216 | const parsed = JSON.parse(content);
217 | return yield* Effect.succeed(parsed).pipe(
218 | Effect.flatMap(Schema.decode(configSchema)),
219 | Effect.flatMap((loadedConfig) =>
220 | Effect.gen(function* () {
221 | const reposDir = yield* expandHome(loadedConfig.reposDirectory);
222 | const config = {
223 | ...loadedConfig,
224 | reposDirectory: reposDir
225 | } satisfies Config;
226 | return {
227 | config,
228 | configPath
229 | };
230 | })
231 | )
232 | );
233 | }
234 | });
235 |
236 | const configService = Effect.gen(function* () {
237 | const path = yield* Path.Path;
238 | const loadedConfig = yield* onStartLoadConfig;
239 |
240 | let { config, configPath } = loadedConfig;
241 |
242 | const getRepo = ({ repoName, config }: { repoName: string; config: Config }) =>
243 | Effect.gen(function* () {
244 | const repo = config.repos.find((repo) => repo.name === repoName);
245 | if (!repo) {
246 | return yield* Effect.fail(new ConfigError({ message: 'Repo not found' }));
247 | }
248 | return repo;
249 | });
250 |
251 | return {
252 | getConfigPath: () => Effect.succeed(configPath),
253 | cloneOrUpdateOneRepoLocally: (repoName: string, options: { suppressLogs: boolean }) =>
254 | Effect.gen(function* () {
255 | const repo = yield* getRepo({ repoName, config });
256 | const repoDir = path.join(config.reposDirectory, repo.name);
257 | const branch = repo.branch ?? 'main';
258 | const suppressLogs = options.suppressLogs;
259 |
260 | const exists = yield* directoryExists(repoDir);
261 | if (exists) {
262 | if (!suppressLogs) yield* Effect.log(`Pulling latest changes for ${repo.name}...`);
263 | yield* pullRepo({ repoDir, branch, quiet: suppressLogs });
264 | } else {
265 | if (!suppressLogs) yield* Effect.log(`Cloning ${repo.name}...`);
266 | yield* cloneRepo({ repoDir, url: repo.url, branch, quiet: suppressLogs });
267 | }
268 | if (!suppressLogs) yield* Effect.log(`Done with ${repo.name}`);
269 | return repo;
270 | }),
271 | getOpenCodeConfig: (args: { repoName: string }) =>
272 | Effect.gen(function* () {
273 | const repo = yield* getRepo({ repoName: args.repoName, config }).pipe(
274 | Effect.catchTag('ConfigError', () => Effect.succeed(undefined))
275 | );
276 | const ocConfig = yield* OPENCODE_CONFIG({
277 | repoName: args.repoName,
278 | specialNotes: repo?.specialNotes
279 | });
280 |
281 | const repoDir = path.join(config.reposDirectory, args.repoName);
282 |
283 | return {
284 | ocConfig,
285 | repoDir
286 | };
287 | }),
288 | rawConfig: () => Effect.succeed(config),
289 | getRepos: () => Effect.succeed(config.repos),
290 | getModel: () => Effect.succeed({ provider: config.provider, model: config.model }),
291 | updateModel: (args: { provider: string; model: string }) =>
292 | Effect.gen(function* () {
293 | config = { ...config, provider: args.provider, model: args.model };
294 | yield* writeConfig(config);
295 | return { provider: config.provider, model: config.model };
296 | }),
297 | addRepo: (repo: Repo) =>
298 | Effect.gen(function* () {
299 | const existing = config.repos.find((r) => r.name === repo.name);
300 | if (existing) {
301 | return yield* Effect.fail(
302 | new ConfigError({ message: `Repo "${repo.name}" already exists` })
303 | );
304 | }
305 | config = { ...config, repos: [...config.repos, repo] };
306 | yield* writeConfig(config);
307 | return repo;
308 | }),
309 | getBlessedModels: () => Effect.succeed(BLESSED_MODELS),
310 | removeRepo: (repoName: string) =>
311 | Effect.gen(function* () {
312 | const existing = config.repos.find((r) => r.name === repoName);
313 | if (!existing) {
314 | return yield* Effect.fail(new ConfigError({ message: `Repo "${repoName}" not found` }));
315 | }
316 | config = { ...config, repos: config.repos.filter((r) => r.name !== repoName) };
317 | yield* writeConfig(config);
318 | }),
319 | getReposDirectory: () => Effect.succeed(config.reposDirectory)
320 | };
321 | });
322 |
323 | export class ConfigService extends Effect.Service()('ConfigService', {
324 | effect: configService
325 | }) {}
326 |
--------------------------------------------------------------------------------
/apps/cli/src/services/cli.ts:
--------------------------------------------------------------------------------
1 | import { Command, Options } from '@effect/cli';
2 | import {
3 | FileSystem,
4 | HttpRouter,
5 | HttpServer,
6 | HttpServerRequest,
7 | HttpServerResponse
8 | } from '@effect/platform';
9 | import { BunHttpServer } from '@effect/platform-bun';
10 | import { Effect, Layer, Schema, Stream } from 'effect';
11 | import * as readline from 'readline';
12 | import { OcService, type OcEvent } from './oc.ts';
13 | import { ConfigService } from './config.ts';
14 |
15 | declare const __VERSION__: string;
16 | const VERSION: string = typeof __VERSION__ !== 'undefined' ? __VERSION__ : '0.0.0-dev';
17 |
18 | const programLayer = Layer.mergeAll(OcService.Default, ConfigService.Default);
19 |
20 | // === Ask Subcommand ===
21 | const questionOption = Options.text('question').pipe(Options.withAlias('q'));
22 | const techOption = Options.text('tech').pipe(Options.withAlias('t'));
23 |
24 | const askCommand = Command.make(
25 | 'ask',
26 | { question: questionOption, tech: techOption },
27 | ({ question, tech }) =>
28 | Effect.gen(function* () {
29 | const oc = yield* OcService;
30 | const eventStream = yield* oc.askQuestion({ tech, question, suppressLogs: false });
31 |
32 | let currentMessageId: string | null = null;
33 |
34 | yield* eventStream.pipe(
35 | Stream.runForEach((event) =>
36 | Effect.sync(() => {
37 | switch (event.type) {
38 | case 'message.part.updated':
39 | if (event.properties.part.type === 'text') {
40 | if (currentMessageId === event.properties.part.messageID) {
41 | process.stdout.write(event.properties.delta ?? '');
42 | } else {
43 | currentMessageId = event.properties.part.messageID;
44 | process.stdout.write('\n\n' + event.properties.part.text);
45 | }
46 | }
47 | break;
48 | default:
49 | break;
50 | }
51 | })
52 | )
53 | );
54 |
55 | console.log('\n');
56 | }).pipe(
57 | Effect.catchTags({
58 | InvalidProviderError: (e) =>
59 | Effect.sync(() => {
60 | console.error(`Error: Unknown provider "${e.providerId}"`);
61 | console.error(`Available providers: ${e.availableProviders.join(', ')}`);
62 | process.exit(1);
63 | }),
64 | InvalidModelError: (e) =>
65 | Effect.sync(() => {
66 | console.error(`Error: Unknown model "${e.modelId}" for provider "${e.providerId}"`);
67 | console.error(`Available models: ${e.availableModels.join(', ')}`);
68 | process.exit(1);
69 | }),
70 | ProviderNotConnectedError: (e) =>
71 | Effect.sync(() => {
72 | console.error(`Error: Provider "${e.providerId}" is not connected`);
73 | console.error(`Connected providers: ${e.connectedProviders.join(', ')}`);
74 | console.error(`Run "opencode auth" to configure provider credentials.`);
75 | process.exit(1);
76 | })
77 | }),
78 | Effect.provide(programLayer)
79 | )
80 | );
81 |
82 | // === Open Subcommand ===
83 | const openCommand = Command.make('open', {}, () =>
84 | Effect.gen(function* () {
85 | const oc = yield* OcService;
86 | yield* oc.holdOpenInstanceInBg();
87 | }).pipe(Effect.provide(programLayer))
88 | );
89 |
90 | // === Chat Subcommand ===
91 | const chatTechOption = Options.text('tech').pipe(Options.withAlias('t'));
92 |
93 | const chatCommand = Command.make('chat', { tech: chatTechOption }, ({ tech }) =>
94 | Effect.gen(function* () {
95 | const oc = yield* OcService;
96 | yield* oc.spawnTui({ tech });
97 | }).pipe(Effect.provide(programLayer))
98 | );
99 |
100 | // === Serve Subcommand ===
101 | const QuestionRequest = Schema.Struct({
102 | tech: Schema.String,
103 | question: Schema.String
104 | });
105 |
106 | const portOption = Options.integer('port').pipe(Options.withAlias('p'), Options.withDefault(8080));
107 |
108 | const serveCommand = Command.make('serve', { port: portOption }, ({ port }) =>
109 | Effect.gen(function* () {
110 | const router = HttpRouter.empty.pipe(
111 | HttpRouter.post(
112 | '/question',
113 | Effect.gen(function* () {
114 | const body = yield* HttpServerRequest.schemaBodyJson(QuestionRequest);
115 | const oc = yield* OcService;
116 |
117 | const eventStream = yield* oc.askQuestion({
118 | tech: body.tech,
119 | question: body.question,
120 | suppressLogs: false
121 | });
122 |
123 | const chunks: string[] = [];
124 | let currentMessageId: string | null = null;
125 | yield* eventStream.pipe(
126 | Stream.runForEach((event) =>
127 | Effect.sync(() => {
128 | switch (event.type) {
129 | case 'message.part.updated':
130 | if (event.properties.part.type === 'text') {
131 | if (currentMessageId === event.properties.part.messageID) {
132 | chunks[chunks.length - 1] += event.properties.delta ?? '';
133 | } else {
134 | currentMessageId = event.properties.part.messageID;
135 | chunks.push(event.properties.part.text ?? '');
136 | }
137 | }
138 | break;
139 | default:
140 | break;
141 | }
142 | })
143 | )
144 | );
145 |
146 | return yield* HttpServerResponse.json({ answer: chunks.join('') });
147 | })
148 | )
149 | );
150 |
151 | const ServerLive = BunHttpServer.layer({ port });
152 |
153 | const HttpLive = router.pipe(
154 | HttpServer.serve(),
155 | HttpServer.withLogAddress,
156 | Layer.provide(ServerLive)
157 | );
158 |
159 | return yield* Layer.launch(HttpLive);
160 | }).pipe(Effect.scoped, Effect.provide(programLayer))
161 | );
162 |
163 | // === Config Subcommands ===
164 |
165 | // config model - view or set model/provider
166 | const providerOption = Options.text('provider').pipe(Options.withAlias('p'), Options.optional);
167 | const modelOption = Options.text('model').pipe(Options.withAlias('m'), Options.optional);
168 |
169 | const configModelCommand = Command.make(
170 | 'model',
171 | { provider: providerOption, model: modelOption },
172 | ({ provider, model }) =>
173 | Effect.gen(function* () {
174 | const config = yield* ConfigService;
175 |
176 | // If both options provided, update the config
177 | if (provider._tag === 'Some' && model._tag === 'Some') {
178 | const result = yield* config.updateModel({
179 | provider: provider.value,
180 | model: model.value
181 | });
182 | console.log(`Updated model configuration:`);
183 | console.log(` Provider: ${result.provider}`);
184 | console.log(` Model: ${result.model}`);
185 | } else if (provider._tag === 'Some' || model._tag === 'Some') {
186 | // If only one is provided, show an error
187 | console.error('Error: Both --provider and --model must be specified together');
188 | process.exit(1);
189 | } else {
190 | // No options, show current values
191 | const current = yield* config.getModel();
192 | console.log(`Current model configuration:`);
193 | console.log(` Provider: ${current.provider}`);
194 | console.log(` Model: ${current.model}`);
195 | }
196 | }).pipe(Effect.provide(programLayer))
197 | );
198 |
199 | // config repos list - list all repos
200 | const configReposListCommand = Command.make('list', {}, () =>
201 | Effect.gen(function* () {
202 | const config = yield* ConfigService;
203 | const repos = yield* config.getRepos();
204 |
205 | if (repos.length === 0) {
206 | console.log('No repos configured.');
207 | return;
208 | }
209 |
210 | console.log('Configured repos:\n');
211 | for (const repo of repos) {
212 | console.log(` ${repo.name}`);
213 | console.log(` URL: ${repo.url}`);
214 | console.log(` Branch: ${repo.branch}`);
215 | if (repo.specialNotes) {
216 | console.log(` Notes: ${repo.specialNotes}`);
217 | }
218 | console.log();
219 | }
220 | }).pipe(Effect.provide(programLayer))
221 | );
222 |
223 | // config repos add - add a new repo
224 | const repoNameOption = Options.text('name').pipe(Options.withAlias('n'));
225 | const repoUrlOption = Options.text('url').pipe(Options.withAlias('u'));
226 | const repoBranchOption = Options.text('branch').pipe(
227 | Options.withAlias('b'),
228 | Options.withDefault('main')
229 | );
230 | const repoNotesOption = Options.text('notes').pipe(Options.optional);
231 |
232 | const configReposAddCommand = Command.make(
233 | 'add',
234 | {
235 | name: repoNameOption.pipe(Options.optional),
236 | url: repoUrlOption.pipe(Options.optional),
237 | branch: repoBranchOption,
238 | notes: repoNotesOption
239 | },
240 | ({ name, url, branch, notes }) =>
241 | Effect.gen(function* () {
242 | const config = yield* ConfigService;
243 |
244 | let repoName: string;
245 | if (name._tag === 'Some') {
246 | repoName = name.value;
247 | } else {
248 | repoName = yield* askText('Enter repo name: ');
249 | }
250 |
251 | if (!repoName) {
252 | console.log('No repo name provided.');
253 | return;
254 | }
255 |
256 | let repoUrl: string;
257 | if (url._tag === 'Some') {
258 | repoUrl = url.value;
259 | } else {
260 | repoUrl = yield* askText('Enter repo URL: ');
261 | }
262 |
263 | if (!repoUrl) {
264 | console.log('No repo URL provided.');
265 | return;
266 | }
267 |
268 | const repo = {
269 | name: repoName,
270 | url: repoUrl,
271 | branch,
272 | ...(notes._tag === 'Some' ? { specialNotes: notes.value } : {})
273 | };
274 |
275 | yield* config.addRepo(repo);
276 | console.log(`Added repo "${repoName}":`);
277 | console.log(` URL: ${repoUrl}`);
278 | console.log(` Branch: ${branch}`);
279 | if (notes._tag === 'Some') {
280 | console.log(` Notes: ${notes.value}`);
281 | }
282 | }).pipe(
283 | Effect.catchTag('ConfigError', (e) =>
284 | Effect.sync(() => {
285 | console.error(`Error: ${e.message}`);
286 | process.exit(1);
287 | })
288 | ),
289 | Effect.provide(programLayer)
290 | )
291 | );
292 |
293 | // config repos clear - clear all downloaded repos
294 | const askConfirmation = (question: string): Effect.Effect =>
295 | Effect.async((resume) => {
296 | const rl = readline.createInterface({
297 | input: process.stdin,
298 | output: process.stdout
299 | });
300 |
301 | rl.question(question, (answer) => {
302 | rl.close();
303 | const normalized = answer.toLowerCase().trim();
304 | resume(Effect.succeed(normalized === 'y' || normalized === 'yes'));
305 | });
306 | });
307 |
308 | const askText = (question: string): Effect.Effect =>
309 | Effect.async((resume) => {
310 | const rl = readline.createInterface({
311 | input: process.stdin,
312 | output: process.stdout
313 | });
314 |
315 | rl.question(question, (answer) => {
316 | rl.close();
317 | resume(Effect.succeed(answer.trim()));
318 | });
319 | });
320 |
321 | const configReposRemoveCommand = Command.make(
322 | 'remove',
323 | { name: repoNameOption.pipe(Options.optional) },
324 | ({ name }) =>
325 | Effect.gen(function* () {
326 | const config = yield* ConfigService;
327 |
328 | let repoName: string;
329 | if (name._tag === 'Some') {
330 | repoName = name.value;
331 | } else {
332 | repoName = yield* askText('Enter repo name to remove: ');
333 | }
334 |
335 | if (!repoName) {
336 | console.log('No repo name provided.');
337 | return;
338 | }
339 |
340 | // Check if repo exists
341 | const repos = yield* config.getRepos();
342 | const exists = repos.find((r) => r.name === repoName);
343 | if (!exists) {
344 | console.error(`Error: Repo "${repoName}" not found.`);
345 | process.exit(1);
346 | }
347 |
348 | const confirmed = yield* askConfirmation(
349 | `Are you sure you want to remove repo "${repoName}" from config? (y/N): `
350 | );
351 |
352 | if (!confirmed) {
353 | console.log('Aborted.');
354 | return;
355 | }
356 |
357 | yield* config.removeRepo(repoName);
358 | console.log(`Removed repo "${repoName}".`);
359 | }).pipe(
360 | Effect.catchTag('ConfigError', (e) =>
361 | Effect.sync(() => {
362 | console.error(`Error: ${e.message}`);
363 | process.exit(1);
364 | })
365 | ),
366 | Effect.provide(programLayer)
367 | )
368 | );
369 |
370 | const configReposClearCommand = Command.make('clear', {}, () =>
371 | Effect.gen(function* () {
372 | const config = yield* ConfigService;
373 | const fs = yield* FileSystem.FileSystem;
374 |
375 | const reposDir = yield* config.getReposDirectory();
376 |
377 | // Check if repos directory exists
378 | const exists = yield* fs.exists(reposDir);
379 | if (!exists) {
380 | console.log('Repos directory does not exist. Nothing to clear.');
381 | return;
382 | }
383 |
384 | // List all directories in the repos directory
385 | const entries = yield* fs.readDirectory(reposDir);
386 | const repoPaths: string[] = [];
387 |
388 | for (const entry of entries) {
389 | const fullPath = `${reposDir}/${entry}`;
390 | const stat = yield* fs.stat(fullPath);
391 | if (stat.type === 'Directory') {
392 | repoPaths.push(fullPath);
393 | }
394 | }
395 |
396 | if (repoPaths.length === 0) {
397 | console.log('No repos found in the repos directory. Nothing to clear.');
398 | return;
399 | }
400 |
401 | console.log('The following repos will be deleted:\n');
402 | for (const repoPath of repoPaths) {
403 | console.log(` ${repoPath}`);
404 | }
405 | console.log();
406 |
407 | const confirmed = yield* askConfirmation(
408 | 'Are you sure you want to delete these repos? (y/N): '
409 | );
410 |
411 | if (!confirmed) {
412 | console.log('Aborted.');
413 | return;
414 | }
415 |
416 | for (const repoPath of repoPaths) {
417 | yield* fs.remove(repoPath, { recursive: true });
418 | console.log(`Deleted: ${repoPath}`);
419 | }
420 |
421 | console.log('\nAll repos have been cleared.');
422 | }).pipe(Effect.provide(programLayer))
423 | );
424 |
425 | // config repos - parent command for repo subcommands
426 | const configReposCommand = Command.make('repos', {}, () =>
427 | Effect.sync(() => {
428 | console.log('Usage: btca config repos ');
429 | console.log('');
430 | console.log('Commands:');
431 | console.log(' list List all configured repos');
432 | console.log(' add Add a new repo');
433 | console.log(' remove Remove a configured repo');
434 | console.log(' clear Clear all downloaded repos');
435 | })
436 | ).pipe(
437 | Command.withSubcommands([
438 | configReposListCommand,
439 | configReposAddCommand,
440 | configReposRemoveCommand,
441 | configReposClearCommand
442 | ])
443 | );
444 |
445 | // config - parent command
446 | const configCommand = Command.make('config', {}, () =>
447 | Effect.gen(function* () {
448 | const config = yield* ConfigService;
449 | const configPath = yield* config.getConfigPath();
450 |
451 | console.log(`Config file: ${configPath}`);
452 | console.log('');
453 | console.log('Usage: btca config ');
454 | console.log('');
455 | console.log('Commands:');
456 | console.log(' model View or set the model and provider');
457 | console.log(' repos Manage configured repos');
458 | }).pipe(Effect.provide(programLayer))
459 | ).pipe(Command.withSubcommands([configModelCommand, configReposCommand]));
460 |
461 | // === Main Command ===
462 | const mainCommand = Command.make('btca', {}, () =>
463 | Effect.sync(() => {
464 | console.log(`btca v${VERSION}. run btca --help for more information.`);
465 | })
466 | ).pipe(
467 | Command.withSubcommands([askCommand, serveCommand, openCommand, chatCommand, configCommand])
468 | );
469 |
470 | const cliService = Effect.gen(function* () {
471 | return {
472 | run: (argv: string[]) =>
473 | Command.run(mainCommand, {
474 | name: 'btca',
475 | version: VERSION
476 | })(argv)
477 | };
478 | });
479 |
480 | export class CliService extends Effect.Service()('CliService', {
481 | effect: cliService
482 | }) {}
483 |
484 | export { type OcEvent };
485 |
--------------------------------------------------------------------------------
/apps/web/src/routes/getting-started/+page.svelte:
--------------------------------------------------------------------------------
1 |
120 |
121 |
122 |
123 |
124 | Getting started
128 | Install, configure, and use btca effectively.
129 |
130 |
131 |
134 | Getting started with btca
135 |
136 |
137 |
140 | Install btca, add it to your agent rules, and start asking questions
142 |
143 |
144 |
145 |
201 |
202 |
231 |
232 |
347 |
348 |
388 |
389 |
422 |
423 |
458 |
459 |
--------------------------------------------------------------------------------
/apps/cli/src/tui/App.tsx:
--------------------------------------------------------------------------------
1 | import { useKeyboard } from '@opentui/react';
2 | import { useState, useMemo, useEffect, useRef } from 'react';
3 | import type { InputRenderable } from '@opentui/core';
4 | import { colors } from './theme.ts';
5 | import { filterCommands } from './commands.ts';
6 | import { services } from './services.ts';
7 | import { copyToClipboard } from './clipboard.ts';
8 | import type { Mode, Message, Repo, Command } from './types.ts';
9 | import type { WizardStep } from './components/AddRepoWizard.tsx';
10 | import type { ModelConfigStep } from './components/ModelConfig.tsx';
11 | import { CommandPalette } from './components/CommandPalette.tsx';
12 | import { AddRepoWizard } from './components/AddRepoWizard.tsx';
13 | import { RemoveRepoPrompt } from './components/RemoveRepoPrompt.tsx';
14 | import { ModelConfig } from './components/ModelConfig.tsx';
15 |
16 | declare const __VERSION__: string;
17 | const VERSION: string = typeof __VERSION__ !== 'undefined' ? __VERSION__ : '0.0.0-dev';
18 |
19 | const parseAtMention = (
20 | input: string
21 | ): { repoQuery: string; question: string; hasSpace: boolean } | null => {
22 | if (!input.startsWith('@')) return null;
23 | const spaceIndex = input.indexOf(' ');
24 | if (spaceIndex === -1) {
25 | return { repoQuery: input.slice(1), question: '', hasSpace: false };
26 | }
27 | return {
28 | repoQuery: input.slice(1, spaceIndex),
29 | question: input.slice(spaceIndex + 1),
30 | hasSpace: true
31 | };
32 | };
33 |
34 | export function App() {
35 | const [repos, setRepos] = useState([]);
36 | const [messages, setMessages] = useState([]);
37 | const [modelConfig, setModelConfig] = useState({ provider: '', model: '' });
38 |
39 | const [mode, setMode] = useState('chat');
40 | const [inputValue, setInputValue] = useState('');
41 | const [commandIndex, setCommandIndex] = useState(0);
42 |
43 | const [repoMentionIndex, setRepoMentionIndex] = useState(0);
44 |
45 | const inputRef = useRef(null);
46 |
47 | const [wizardStep, setWizardStep] = useState('name');
48 | const [wizardValues, setWizardValues] = useState({
49 | name: '',
50 | url: '',
51 | branch: '',
52 | notes: ''
53 | });
54 | const [wizardInput, setWizardInput] = useState('');
55 |
56 | const [modelStep, setModelStep] = useState('provider');
57 | const [modelValues, setModelValues] = useState({ provider: '', model: '' });
58 | const [modelInput, setModelInput] = useState('');
59 |
60 | const [removeRepoName, setRemoveRepoName] = useState('');
61 |
62 | const [isLoading, setIsLoading] = useState(false);
63 | const [loadingText, setLoadingText] = useState('');
64 |
65 | useEffect(() => {
66 | services.getRepos().then(setRepos).catch(console.error);
67 | services.getModel().then(setModelConfig).catch(console.error);
68 | }, []);
69 |
70 | const showCommandPalette = mode === 'chat' && inputValue.startsWith('/');
71 | const commandQuery = inputValue.slice(1);
72 | const filteredCommands = useMemo(() => filterCommands(commandQuery), [commandQuery]);
73 | const clampedCommandIndex = Math.min(commandIndex, Math.max(0, filteredCommands.length - 1));
74 |
75 | const atMention = useMemo(() => parseAtMention(inputValue), [inputValue]);
76 | const showRepoMentionPalette = mode === 'chat' && atMention !== null && !atMention.hasSpace;
77 | const filteredMentionRepos = useMemo(() => {
78 | if (!atMention) return [];
79 | const query = atMention.repoQuery.toLowerCase();
80 | return repos.filter((repo) => repo.name.toLowerCase().includes(query));
81 | }, [repos, atMention]);
82 | const clampedRepoMentionIndex = Math.min(
83 | repoMentionIndex,
84 | Math.max(0, filteredMentionRepos.length - 1)
85 | );
86 |
87 | const handleInputChange = (value: string) => {
88 | setInputValue(value);
89 | setCommandIndex(0);
90 | setRepoMentionIndex(0);
91 | };
92 |
93 | const executeCommand = async (command: Command) => {
94 | setInputValue('');
95 | setCommandIndex(0);
96 |
97 | if (command.mode === 'add-repo') {
98 | setMode('add-repo');
99 | setWizardStep('name');
100 | setWizardValues({ name: '', url: '', branch: '', notes: '' });
101 | setWizardInput('');
102 | } else if (command.mode === 'clear') {
103 | setMessages([]);
104 | setMessages((prev) => [...prev, { role: 'system', content: 'Chat cleared.' }]);
105 | } else if (command.mode === 'remove-repo') {
106 | if (repos.length === 0) {
107 | setMessages((prev) => [...prev, { role: 'system', content: 'No repos to remove' }]);
108 | return;
109 | }
110 | setRemoveRepoName('');
111 | setMode('remove-repo');
112 | } else if (command.mode === 'config-model') {
113 | setMode('config-model');
114 | setModelStep('provider');
115 | setModelValues({ provider: modelConfig.provider, model: modelConfig.model });
116 | setModelInput(modelConfig.provider);
117 | } else if (command.mode === 'chat') {
118 | setMessages((prev) => [
119 | ...prev,
120 | {
121 | role: 'system',
122 | content: 'Use @reponame to start a chat. Example: @daytona How do I...?'
123 | }
124 | ]);
125 | } else if (command.mode === 'ask') {
126 | setMessages((prev) => [
127 | ...prev,
128 | {
129 | role: 'system',
130 | content: 'Use @reponame to ask a question. Example: @daytona What is...?'
131 | }
132 | ]);
133 | }
134 | };
135 |
136 | const selectRepoMention = () => {
137 | const selectedRepo = filteredMentionRepos[clampedRepoMentionIndex];
138 | if (!selectedRepo) return;
139 | const newValue = `@${selectedRepo.name} `;
140 | setInputValue(newValue);
141 | setRepoMentionIndex(0);
142 | setTimeout(() => {
143 | if (inputRef.current) {
144 | inputRef.current.cursorPosition = newValue.length;
145 | }
146 | }, 0);
147 | };
148 |
149 | const handleChatSubmit = async () => {
150 | const value = inputValue.trim();
151 | if (!value) return;
152 |
153 | if (showCommandPalette && filteredCommands.length > 0) {
154 | const command = filteredCommands[clampedCommandIndex];
155 | if (command) {
156 | executeCommand(command);
157 | return;
158 | }
159 | }
160 |
161 | if (showRepoMentionPalette && filteredMentionRepos.length > 0) {
162 | selectRepoMention();
163 | return;
164 | }
165 |
166 | if (isLoading) return;
167 |
168 | const mention = parseAtMention(value);
169 | if (!mention || !mention.question.trim()) {
170 | setMessages((prev) => [
171 | ...prev,
172 | {
173 | role: 'system',
174 | content: 'Use @reponame followed by your question. Example: @daytona How do I...?'
175 | }
176 | ]);
177 | return;
178 | }
179 |
180 | const targetRepo = repos.find((r) => r.name.toLowerCase() === mention.repoQuery.toLowerCase());
181 | if (!targetRepo) {
182 | setMessages((prev) => [
183 | ...prev,
184 | {
185 | role: 'system',
186 | content: `Repo "${mention.repoQuery}" not found. Use /add to add a repo.`
187 | }
188 | ]);
189 | return;
190 | }
191 |
192 | setMessages((prev) => [
193 | ...prev,
194 | { role: 'user', content: `@${targetRepo.name} ${mention.question}` }
195 | ]);
196 | setInputValue('');
197 | setIsLoading(true);
198 | setMode('loading');
199 | setLoadingText('');
200 |
201 | let fullResponse = '';
202 |
203 | try {
204 | await services.askQuestion(targetRepo.name, mention.question, (event) => {
205 | if (
206 | event.type === 'message.part.updated' &&
207 | 'part' in event.properties &&
208 | event.properties.part?.type === 'text'
209 | ) {
210 | const delta = (event.properties as { delta?: string }).delta ?? '';
211 | fullResponse += delta;
212 | setLoadingText(fullResponse);
213 | }
214 | });
215 |
216 | await copyToClipboard(fullResponse);
217 |
218 | setMessages((prev) => [
219 | ...prev,
220 | { role: 'assistant', content: fullResponse },
221 | { role: 'system', content: 'Answer copied to clipboard!' }
222 | ]);
223 | } catch (error) {
224 | setMessages((prev) => [...prev, { role: 'system', content: `Error: ${error}` }]);
225 | } finally {
226 | setIsLoading(false);
227 | setMode('chat');
228 | setLoadingText('');
229 | }
230 | };
231 |
232 | const handleWizardSubmit = () => {
233 | const value = wizardInput.trim();
234 |
235 | if (wizardStep === 'name') {
236 | if (!value) return;
237 | setWizardValues((prev) => ({ ...prev, name: value }));
238 | setWizardStep('url');
239 | setWizardInput('');
240 | } else if (wizardStep === 'url') {
241 | if (!value) return;
242 | setWizardValues((prev) => ({ ...prev, url: value }));
243 | setWizardStep('branch');
244 | setWizardInput('main');
245 | } else if (wizardStep === 'branch') {
246 | setWizardValues((prev) => ({ ...prev, branch: value || 'main' }));
247 | setWizardStep('notes');
248 | setWizardInput('');
249 | } else if (wizardStep === 'notes') {
250 | setWizardValues((prev) => ({ ...prev, notes: value }));
251 | setWizardStep('confirm');
252 | } else if (wizardStep === 'confirm') {
253 | const newRepo: Repo = {
254 | name: wizardValues.name,
255 | url: wizardValues.url,
256 | branch: wizardValues.branch || 'main',
257 | ...(wizardValues.notes && { specialNotes: wizardValues.notes })
258 | };
259 |
260 | services
261 | .addRepo(newRepo)
262 | .then(() => {
263 | setRepos((prev) => [...prev, newRepo]);
264 | setMessages((prev) => [
265 | ...prev,
266 | { role: 'system', content: `Added repo: ${newRepo.name}` }
267 | ]);
268 | })
269 | .catch((error) => {
270 | setMessages((prev) => [...prev, { role: 'system', content: `Error: ${error}` }]);
271 | })
272 | .finally(() => {
273 | setMode('chat');
274 | });
275 | }
276 | };
277 |
278 | const handleModelSubmit = () => {
279 | const value = modelInput.trim();
280 |
281 | if (modelStep === 'provider') {
282 | if (!value) return;
283 | setModelValues((prev) => ({ ...prev, provider: value }));
284 | setModelStep('model');
285 | setModelInput(modelConfig.model);
286 | } else if (modelStep === 'model') {
287 | if (!value) return;
288 | setModelValues((prev) => ({ ...prev, model: value }));
289 | setModelStep('confirm');
290 | } else if (modelStep === 'confirm') {
291 | services
292 | .updateModel(modelValues.provider, modelValues.model)
293 | .then((result) => {
294 | setModelConfig(result);
295 | setMessages((prev) => [
296 | ...prev,
297 | {
298 | role: 'system',
299 | content: `Model updated: ${result.provider}/${result.model}`
300 | }
301 | ]);
302 | })
303 | .catch((error) => {
304 | setMessages((prev) => [...prev, { role: 'system', content: `Error: ${error}` }]);
305 | })
306 | .finally(() => {
307 | setMode('chat');
308 | });
309 | }
310 | };
311 |
312 | const handleRemoveRepo = async (repoName: string) => {
313 | try {
314 | await services.removeRepo(repoName);
315 | setRepos((prev) => prev.filter((r) => r.name !== repoName));
316 | setMessages((prev) => [...prev, { role: 'system', content: `Removed repo: ${repoName}` }]);
317 | } catch (error) {
318 | setMessages((prev) => [...prev, { role: 'system', content: `Error: ${error}` }]);
319 | } finally {
320 | setMode('chat');
321 | setRemoveRepoName('');
322 | }
323 | };
324 |
325 | const cancelMode = () => {
326 | setMode('chat');
327 | setInputValue('');
328 | setWizardInput('');
329 | setModelInput('');
330 | setRemoveRepoName('');
331 | };
332 |
333 | useKeyboard((key) => {
334 | if (key.name === 'escape') {
335 | key.preventDefault();
336 | if (mode !== 'chat' && mode !== 'loading') {
337 | cancelMode();
338 | } else if (showCommandPalette || showRepoMentionPalette) {
339 | setInputValue('');
340 | }
341 | return;
342 | }
343 |
344 | if (mode === 'chat' && showCommandPalette) {
345 | if (key.name === 'up') {
346 | key.preventDefault();
347 | setCommandIndex((prev) => (prev === 0 ? filteredCommands.length - 1 : prev - 1));
348 | } else if (key.name === 'down') {
349 | key.preventDefault();
350 | setCommandIndex((prev) => (prev === filteredCommands.length - 1 ? 0 : prev + 1));
351 | }
352 | } else if (mode === 'chat' && showRepoMentionPalette) {
353 | if (key.name === 'up') {
354 | key.preventDefault();
355 | setRepoMentionIndex((prev) => (prev === 0 ? filteredMentionRepos.length - 1 : prev - 1));
356 | } else if (key.name === 'down') {
357 | key.preventDefault();
358 | setRepoMentionIndex((prev) => (prev === filteredMentionRepos.length - 1 ? 0 : prev + 1));
359 | } else if (key.name === 'tab') {
360 | key.preventDefault();
361 | selectRepoMention();
362 | }
363 | } else if (mode === 'remove-repo') {
364 | if (key.name === 'y' || key.name === 'Y') {
365 | key.preventDefault();
366 | if (removeRepoName) {
367 | handleRemoveRepo(removeRepoName);
368 | }
369 | } else if (key.name === 'n' || key.name === 'N') {
370 | key.preventDefault();
371 | cancelMode();
372 | }
373 | } else if (mode === 'add-repo' && wizardStep === 'confirm') {
374 | if (key.name === 'return') {
375 | key.preventDefault();
376 | handleWizardSubmit();
377 | }
378 | } else if (mode === 'config-model' && modelStep === 'confirm') {
379 | if (key.name === 'return') {
380 | key.preventDefault();
381 | handleModelSubmit();
382 | }
383 | }
384 | });
385 |
386 | return (
387 |
395 |
409 |
410 | {'◆'}
411 | {' btca'}
412 | {' - The Better Context App'}
413 |
414 |
415 |
416 |
417 |
424 |
434 |
435 |
436 | {messages.map((msg, i) => (
437 |
438 |
447 | {msg.role === 'user' ? 'You ' : msg.role === 'system' ? 'SYS ' : 'AI '}
448 |
449 |
450 |
451 | ))}
452 | {mode === 'loading' && (
453 |
454 | {'AI '}
455 |
456 |
457 | )}
458 |
459 |
460 |
461 | {showCommandPalette && (
462 |
467 | )}
468 |
469 | {showRepoMentionPalette && filteredMentionRepos.length > 0 && (
470 |
483 |
484 | {(() => {
485 | const maxVisible = 8;
486 | const start = Math.max(
487 | 0,
488 | Math.min(
489 | clampedRepoMentionIndex - Math.floor(maxVisible / 2),
490 | filteredMentionRepos.length - maxVisible
491 | )
492 | );
493 | const visibleRepos = filteredMentionRepos.slice(start, start + maxVisible);
494 | return visibleRepos.map((repo, i) => {
495 | const actualIndex = start + i;
496 | return (
497 |
504 | );
505 | });
506 | })()}
507 |
508 | )}
509 |
510 | {mode === 'add-repo' && (
511 |
519 | )}
520 |
521 | {mode === 'remove-repo' && !removeRepoName && (
522 |
535 |
536 |
537 |
538 |
539 | {repos.map((repo) => (
540 |
541 | ))}
542 |
543 | {
550 | const repo = repos.find((r) => r.name.toLowerCase() === removeRepoName.toLowerCase());
551 | if (repo) {
552 | setRemoveRepoName(repo.name);
553 | }
554 | }}
555 | focused={true}
556 | style={{ height: 1, width: '100%', marginTop: 1 }}
557 | />
558 |
559 | )}
560 |
561 | {mode === 'remove-repo' && removeRepoName && (
562 |
563 | )}
564 |
565 | {mode === 'config-model' && (
566 |
574 | )}
575 |
576 |
586 |
603 |
604 |
605 |
616 |
640 |
641 |
642 |
643 | );
644 | }
645 |
--------------------------------------------------------------------------------