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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions 2.0-final-launch-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 2.0 Final Launch Commands

## CLI commands

- `btca` — Launches the TUI by default; use `--no-tui` for REPL mode.
- `btca add [url-or-path]` — Add a resource from a Git repo or local directory.
- `btca remove [name]` — Remove a configured resource.
- `btca resources` — List all configured resources.
- `btca ask` — Ask a one-shot question about one or more resources.
- `btca connect` — Configure AI provider and model.
- `btca disconnect` — Remove saved provider credentials.
- `btca init` — Initialize btca configuration for a project.
- `btca clear` — Clear all locally cloned resources.
- `btca serve` — Start the local btca server.
- `btca skill` — Install the btca CLI skill via skills.sh.
- `btca mcp` — Run local MCP server or configure MCP for editors.
- `btca mcp local` — Generate local MCP config for your editor.
- `btca telemetry` — Manage anonymous CLI telemetry state.
- `btca telemetry on` — Enable anonymous telemetry.
- `btca telemetry off` — Disable anonymous telemetry.
- `btca telemetry status` — Show telemetry status.

## CLI commands to remove

- `btca remote` — Manage btca cloud service workflows.
- `btca remote link` — Authenticate and save a cloud API key.
- `btca remote unlink` — Remove stored cloud authentication.
- `btca remote status` — Show cloud sandbox/project status.
- `btca remote wake` — Pre-warm the cloud sandbox.
- `btca remote add [url]` — Add a git resource to local remote config and sync.
- `btca remote sync` — Sync local remote config with cloud.
- `btca remote ask` — Ask a question via cloud sandbox.
- `btca remote grab <threadId>` — Fetch a full thread transcript.
- `btca remote init` — Create a local remote config file.
- `btca remote mcp [agent]` — Output MCP setup for `opencode` or `claude`.
- `btca mcp remote` — Generate remote MCP config for your editor.

## Local server endpoints

- `GET /` — Health check endpoint.
- `GET /config` — Get current server config summary.
- `GET /resources` — List configured resources.
- `GET /providers` — List all/connected providers.
- `POST /reload-config` — Reload config from disk.
- `POST /question` — Ask a non-streaming question.
- `POST /question/stream` — Ask a question with SSE streaming.
- `PUT /config/model` — Update provider and model configuration.
- `POST /config/resources` — Add a new resource.
- `DELETE /config/resources` — Remove a resource by name.
- `POST /clear` — Clear cached resource clones.
11 changes: 4 additions & 7 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,21 @@
},
"devDependencies": {
"@btca/shared": "workspace:*",
"btca-server": "workspace:*",
"@inquirer/select": "^5.0.4",
"@opentui/core": "0.1.65",
"@opentui/solid": "0.1.65",
"@shikijs/langs": "^3.20.0",
"@shikijs/themes": "^3.20.0",
"@opentui/core": "0.1.77",
"@opentui/solid": "0.1.77",
"@tmcp/adapter-zod": "^0.1.7",
"@tmcp/transport-stdio": "^0.4.1",
"@types/bun": "latest",
"@typescript/native-preview": "^7.0.0-dev.20260109.1",
"better-result": "^2.6.0",
"btca-server": "workspace:*",
"commander": "^12.1.0",
"hono": "^4.7.11",
"marked": "^17.0.1",
"prettier": "^3.7.4",
"shiki": "^3.20.0",
"solid-js": "^1.9.10",
"tmcp": "^1.19.2",
"web-tree-sitter": "0.25.10",
"zod": "^4.3.6"
}
}
58 changes: 34 additions & 24 deletions apps/cli/src/client/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,46 @@ export async function* parseSSEStream(response: Response): AsyncGenerator<BtcaSt
const decoder = new TextDecoder();
let buffer = '';

for await (const value of response.body) {
buffer += decoder.decode(value, { stream: true });
const reader = (
response.body as unknown as { getReader: () => ReadableStreamDefaultReader<Uint8Array> }
).getReader();

// Process complete events from buffer
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // Keep incomplete line in buffer
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value ?? new Uint8Array(), { stream: true });

let eventType = '';
let eventData = '';
// Process complete events from buffer
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // Keep incomplete line in buffer

for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7);
} else if (line.startsWith('data: ')) {
eventData = line.slice(6);
} else if (line === '' && eventData) {
// Empty line = end of event
const parsed = Result.try(() => JSON.parse(eventData));
const validated = parsed.andThen((value) =>
Result.try(() => BtcaStreamEventSchema.parse(value))
);
if (Result.isOk(validated)) {
yield validated.value;
} else {
console.error('Failed to parse SSE event:', validated.error);
let eventType = '';
let eventData = '';

for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7);
} else if (line.startsWith('data: ')) {
eventData = line.slice(6);
} else if (line === '' && eventData) {
// Empty line = end of event
const parsed = Result.try(() => JSON.parse(eventData));
const validated = parsed.andThen((value) =>
Result.try(() => BtcaStreamEventSchema.parse(value))
);
if (Result.isOk(validated)) {
yield validated.value;
} else {
console.error('Failed to parse SSE event:', validated.error);
}
eventType = '';
eventData = '';
}
eventType = '';
eventData = '';
}
}
} finally {
reader.releaseLock();
}

// Process any remaining data in buffer
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/commands/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ function handleStreamEvent(event: BtcaStreamEvent, handlers: StreamHandlers): vo
*/
async function prompt(message: string): Promise<string | null> {
process.stdout.write(message);
const reader = Bun.stdin.stream().getReader();
const reader = (
Bun.stdin.stream() as unknown as { getReader: () => ReadableStreamDefaultReader<Uint8Array> }
).getReader();
const decoder = new TextDecoder();
let input = '';

Expand Down
6 changes: 5 additions & 1 deletion apps/cli/src/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { MessagesProvider } from './context/messages-context.tsx';
import { ToastProvider, useToast } from './context/toast-context.tsx';
import { render, useKeyboard, useRenderer, useSelectionHandler } from '@opentui/solid';
import { MainUi } from './index.tsx';
import { ConsolePosition } from '@opentui/core';
import { addDefaultParsers, ConsolePosition } from '@opentui/core';
import { copyToClipboard } from './clipboard.ts';
import { parsers } from './parsers-config.ts';

const App: Component = () => {
const renderer = useRenderer();
Expand Down Expand Up @@ -55,6 +56,9 @@ const App: Component = () => {
return <MainUi heightPercent={heightPercent} />;
};

// Ensure tree-sitter parsers are registered before any markdown/code blocks render.
addDefaultParsers(parsers);

render(
() => (
<ConfigProvider>
Expand Down
103 changes: 50 additions & 53 deletions apps/cli/src/tui/components/markdown-text.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,64 @@
import { createResource, For, Show, type Component } from 'solid-js';
import { TextAttributes } from '@opentui/core';
import { Result } from 'better-result';
import { createMemo, type Component } from 'solid-js';
import { CodeRenderable, getTreeSitterClient } from '@opentui/core';

import { renderMarkdownToChunks, type StyledChunk } from '../lib/markdown-renderer.ts';
import { normalizeFenceLang } from '../lib/markdown-fence-lang.ts';
import { syntaxStyle } from '../syntax-theme.ts';
import { colors } from '../theme.ts';

export interface MarkdownTextProps {
content: string;
}

// Convert our style flags to TextAttributes
function getTextAttributes(chunk: StyledChunk): number {
let attrs = 0;
if (chunk.bold) attrs |= TextAttributes.BOLD;
if (chunk.italic) attrs |= TextAttributes.ITALIC;
if (chunk.underline) attrs |= TextAttributes.UNDERLINE;
return attrs;
streaming?: boolean;
}

export const MarkdownText: Component<MarkdownTextProps> = (props) => {
const [chunks] = createResource(
() => props.content,
async (content) => {
const result = await Result.tryPromise(() =>
renderMarkdownToChunks(content, {
colors: {
accent: colors.accent,
text: colors.text,
textMuted: colors.textMuted,
textSubtle: colors.textSubtle,
success: colors.success,
info: colors.info,
error: colors.error
}
})
);
if (result.isOk()) return result.value;
// Fallback to plain text on error
const treeSitterClient = createMemo(() => {
try {
return getTreeSitterClient();
} catch {
return null;
}
);
});

const content = createMemo(() => normalizeFenceLang(props.content));

const client = () => treeSitterClient();
if (!client()) return <text fg={colors.text}>{props.content}</text>;

return (
<Show when={chunks()} fallback={<text fg={colors.text}>{props.content}</text>}>
{(styledChunks: () => StyledChunk[]) => (
<text>
<For each={styledChunks()}>
{(chunk) => {
const attrs = getTextAttributes(chunk);
return (
<span
style={{
fg: chunk.fg || colors.text,
attributes: attrs > 0 ? attrs : undefined
}}
>
{chunk.text}
</span>
);
}}
</For>
</text>
)}
</Show>
<markdown
content={content()}
syntaxStyle={syntaxStyle}
treeSitterClient={client() ?? undefined}
conceal
streaming={Boolean(props.streaming)}
renderNode={(token, context) => {
if (token.type !== 'code') return null;

const r = context.defaultRender();
if (!r) return r;

if (r instanceof CodeRenderable) {
const isStreaming = Boolean(props.streaming);
r.bg = colors.bg;
r.paddingLeft = 1;
r.paddingRight = 1;
r.wrapMode = 'none';
r.truncate = false;
r.streaming = isStreaming;

// Prevent "unstyled -> styled" flashing on every streaming update.
// We allow unstyled text for the initial highlight so content is visible immediately,
// then disable it after the first highlight pass so updates are atomic.
if (isStreaming) {
r.onHighlight = (highlights) => {
if (r.drawUnstyledText) r.drawUnstyledText = false;
return highlights;
};
}
}

return r;
}}
/>
);
};
Loading
Loading