diff --git a/2.0-final-launch-commands.md b/2.0-final-launch-commands.md new file mode 100644 index 00000000..afff95de --- /dev/null +++ b/2.0-final-launch-commands.md @@ -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 ` — 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. diff --git a/apps/cli/package.json b/apps/cli/package.json index e50be446..3eb6b941 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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" } } diff --git a/apps/cli/src/client/stream.ts b/apps/cli/src/client/stream.ts index 671078f1..f23560b2 100644 --- a/apps/cli/src/client/stream.ts +++ b/apps/cli/src/client/stream.ts @@ -12,36 +12,46 @@ export async function* parseSSEStream(response: Response): AsyncGenerator ReadableStreamDefaultReader } + ).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 diff --git a/apps/cli/src/commands/repl.ts b/apps/cli/src/commands/repl.ts index 92f14c51..a7d9ac7d 100644 --- a/apps/cli/src/commands/repl.ts +++ b/apps/cli/src/commands/repl.ts @@ -109,7 +109,9 @@ function handleStreamEvent(event: BtcaStreamEvent, handlers: StreamHandlers): vo */ async function prompt(message: string): Promise { process.stdout.write(message); - const reader = Bun.stdin.stream().getReader(); + const reader = ( + Bun.stdin.stream() as unknown as { getReader: () => ReadableStreamDefaultReader } + ).getReader(); const decoder = new TextDecoder(); let input = ''; diff --git a/apps/cli/src/tui/App.tsx b/apps/cli/src/tui/App.tsx index c6a8a2ba..40e5cc3d 100644 --- a/apps/cli/src/tui/App.tsx +++ b/apps/cli/src/tui/App.tsx @@ -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(); @@ -55,6 +56,9 @@ const App: Component = () => { return ; }; +// Ensure tree-sitter parsers are registered before any markdown/code blocks render. +addDefaultParsers(parsers); + render( () => ( diff --git a/apps/cli/src/tui/components/markdown-text.tsx b/apps/cli/src/tui/components/markdown-text.tsx index 5df047cc..3c5e26e9 100644 --- a/apps/cli/src/tui/components/markdown-text.tsx +++ b/apps/cli/src/tui/components/markdown-text.tsx @@ -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 = (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 {props.content}; return ( - {props.content}}> - {(styledChunks: () => StyledChunk[]) => ( - - - {(chunk) => { - const attrs = getTextAttributes(chunk); - return ( - 0 ? attrs : undefined - }} - > - {chunk.text} - - ); - }} - - - )} - + { + 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; + }} + /> ); }; diff --git a/apps/cli/src/tui/components/messages.tsx b/apps/cli/src/tui/components/messages.tsx index 09d94570..8e447274 100644 --- a/apps/cli/src/tui/components/messages.tsx +++ b/apps/cli/src/tui/components/messages.tsx @@ -1,9 +1,19 @@ -import { For, Show, Switch, Match, createSignal, onCleanup, type Component } from 'solid-js'; +import { + For, + Index, + Show, + Switch, + Match, + createMemo, + createSignal, + onCleanup, + type Accessor, + type Component +} from 'solid-js'; import { useMessagesContext } from '../context/messages-context.tsx'; import { colors, getColor } from '../theme.ts'; import { MarkdownText } from './markdown-text.tsx'; -import type { BtcaChunk } from '../types.ts'; -import type { AssistantContent } from '../types.ts'; +import type { BtcaChunk, AssistantContent, Message } from '../types.ts'; const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -109,11 +119,7 @@ const TextChunk: Component<{ }> = (props) => { const displayText = () => stripHistoryTags(props.chunk.text); - return ( - {displayText()}}> - - - ); + return ; }; const ChunkRenderer: Component<{ chunk: BtcaChunk; isStreaming: boolean }> = (props) => { @@ -141,10 +147,6 @@ const ChunkRenderer: Component<{ chunk: BtcaChunk; isStreaming: boolean }> = (pr ); }; -type RenderItem = - | { kind: 'chunk'; chunk: BtcaChunk } - | { kind: 'tool-summary'; chunks: Extract[] }; - /** * Renders chunks in display order: reasoning, tools, text * This ensures consistent UX regardless of stream arrival order @@ -155,7 +157,7 @@ const ChunksRenderer: Component<{ isCanceled?: boolean; textColor?: string; }> = (props) => { - const renderItems = () => { + const groups = createMemo(() => { const reasoning: BtcaChunk[] = []; const tools: Extract[] = []; const text: BtcaChunk[] = []; @@ -177,62 +179,38 @@ const ChunksRenderer: Component<{ } } - const items: RenderItem[] = []; - for (const chunk of reasoning) { - items.push({ kind: 'chunk', chunk }); - } - if (tools.length > 0) { - items.push({ kind: 'tool-summary', chunks: tools }); - } - for (const chunk of text) { - items.push({ kind: 'chunk', chunk }); - } - for (const chunk of other) { - items.push({ kind: 'chunk', chunk }); - } - - return items; - }; - - const lastChunkIndex = () => { - const items = renderItems(); - for (let i = items.length - 1; i >= 0; i -= 1) { - if (items[i]?.kind === 'chunk') { - return i; - } - } - return -1; - }; - - const isLastChunk = (idx: number) => idx === lastChunkIndex(); + return { reasoning, tools, text, other }; + }); + + const lastChunkId = createMemo(() => { + const g = groups(); + const last = g.other.at(-1) ?? g.text.at(-1) ?? g.reasoning.at(-1); + return last?.id ?? null; + }); + + const isStreamingChunk = (chunk: BtcaChunk) => props.isStreaming && chunk.id === lastChunkId(); + + const renderChunk = (chunk: Accessor) => ( + } + > + + {stripHistoryTags( + chunk().type === 'text' ? (chunk() as Extract).text : '' + )} + + + ); return ( - - {(item, idx) => { - if (item.kind === 'tool-summary') { - return ; - } - - const chunk = item.chunk; - - return ( - - } - > - - {stripHistoryTags(chunk.type === 'text' ? chunk.text : '')} - - - ); - }} - + {(chunk) => renderChunk(chunk)} + 0}> + + + {(chunk) => renderChunk(chunk)} + {(chunk) => renderChunk(chunk)} ); }; @@ -256,22 +234,20 @@ const AssistantMessage: Component<{ {props.content as string}} + when={props.isCanceled} + fallback={ + + } > - } - > - {props.content as string} - + {props.content as string} - {getTextContent()}}> - }> - {getTextContent()} - + } + > + {getTextContent()} @@ -307,40 +283,45 @@ export const Messages: Component = () => { stickyStart: 'bottom' }} > - + {(m, index) => { - if (m.role === 'user') { + const role = m().role; + + if (role === 'user') { + const user = () => m() as Extract; return ( You - + {(part) => {part.content}} ); } - if (m.role === 'system') { + if (role === 'system') { + const sys = () => m() as Extract; return ( SYS - + ); } - if (m.role === 'assistant') { + if (role === 'assistant') { + const assistant = () => m() as Extract; const isLastAssistant = () => { const history = messagesState.messages(); for (let i = history.length - 1; i >= 0; i--) { if (history[i]?.role === 'assistant') { - return i === index(); + return i === index; } } return false; }; const isStreaming = () => messagesState.isStreaming() && isLastAssistant(); - const isCanceled = () => m.canceled === true; + const isCanceled = () => assistant().canceled === true; return ( @@ -353,7 +334,7 @@ export const Messages: Component = () => { @@ -361,7 +342,7 @@ export const Messages: Component = () => { ); } }} - + ); diff --git a/apps/cli/src/tui/lib/markdown-fence-lang.ts b/apps/cli/src/tui/lib/markdown-fence-lang.ts new file mode 100644 index 00000000..542212a8 --- /dev/null +++ b/apps/cli/src/tui/lib/markdown-fence-lang.ts @@ -0,0 +1,35 @@ +const langAliases: Record = { + js: 'javascript', + jsx: 'javascript', + javascriptreact: 'javascript', + ts: 'typescript', + tsx: 'typescript', + typescriptreact: 'typescript', + sh: 'bash', + shell: 'bash', + zsh: 'bash', + py: 'python', + rs: 'rust', + yml: 'yaml', + md: 'markdown' +}; + +const normalizeLang = (lang: string) => langAliases[lang.toLowerCase()] ?? lang.toLowerCase(); + +// Only normalizes the language token on fenced code block openers: +// ```ts -> ```typescript, etc. +export const normalizeFenceLang = (markdown: string) => + markdown + .split('\n') + .map((line) => { + const m = /^(\s*)(```+|~~~+)(\s*)([^\s]+)(.*)$/.exec(line); + if (!m) return line; + const indent = m[1] ?? ''; + const fence = m[2] ?? ''; + const ws = m[3] ?? ''; + const rawLang = m[4]; + const rest = m[5] ?? ''; + if (!rawLang) return line; + return `${indent}${fence}${ws}${normalizeLang(rawLang)}${rest}`; + }) + .join('\n'); diff --git a/apps/cli/src/tui/lib/markdown-renderer.ts b/apps/cli/src/tui/lib/markdown-renderer.ts deleted file mode 100644 index 8026b1c1..00000000 --- a/apps/cli/src/tui/lib/markdown-renderer.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { createHighlighterCore, type HighlighterCore } from 'shiki/core'; -import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; -import { Marked, type Token, type Tokens } from 'marked'; -import { Result } from 'better-result'; - -// Import languages -import typescript from '@shikijs/langs/typescript'; -import javascript from '@shikijs/langs/javascript'; -import json from '@shikijs/langs/json'; -import bash from '@shikijs/langs/bash'; -import python from '@shikijs/langs/python'; -import rust from '@shikijs/langs/rust'; -import go from '@shikijs/langs/go'; -import css from '@shikijs/langs/css'; -import html from '@shikijs/langs/html'; -import yaml from '@shikijs/langs/yaml'; -import markdown from '@shikijs/langs/markdown'; -import sql from '@shikijs/langs/sql'; -import diff from '@shikijs/langs/diff'; - -// Import theme (dark-plus matches the web app) -import darkPlus from '@shikijs/themes/dark-plus'; - -// Color options type used throughout the renderer -export interface ColorOptions { - accent: string; - text: string; - textMuted: string; - textSubtle: string; - success: string; - info: string; - error: string; -} - -// Styled chunk for rendering - simpler than full HAST -export interface StyledChunk { - text: string; - fg?: string; - bg?: string; - bold?: boolean; - italic?: boolean; - underline?: boolean; - isCodeBlock?: boolean; // Flag to indicate this is part of a code block -} - -const SUPPORTED_LANGS = [ - 'typescript', - 'ts', - 'javascript', - 'js', - 'json', - 'bash', - 'sh', - 'shell', - 'python', - 'py', - 'rust', - 'rs', - 'go', - 'css', - 'html', - 'yaml', - 'yml', - 'markdown', - 'md', - 'sql', - 'diff' -]; - -// Singleton highlighter instance -let highlighterPromise: Promise | null = null; - -async function getHighlighter(): Promise { - if (!highlighterPromise) { - highlighterPromise = createHighlighterCore({ - langs: [ - typescript, - javascript, - json, - bash, - python, - rust, - go, - css, - html, - yaml, - markdown, - sql, - diff - ], - themes: [darkPlus], - engine: createJavaScriptRegexEngine() - }); - } - return highlighterPromise; -} - -// Map language aliases to canonical names -function normalizeLanguage(lang: string): string { - const aliases: Record = { - ts: 'typescript', - js: 'javascript', - sh: 'bash', - shell: 'bash', - py: 'python', - rs: 'rust', - yml: 'yaml', - md: 'markdown' - }; - return aliases[lang.toLowerCase()] || lang.toLowerCase(); -} - -// Style definitions for markdown elements -function getMarkdownStyles(colors: ColorOptions): Record> { - return { - heading: { fg: colors.accent, bold: true }, - bold: { bold: true }, - italic: { italic: true }, - link: { fg: colors.info, underline: true }, - code: { fg: colors.success }, - blockquote: { fg: colors.textMuted, italic: true }, - list: { fg: colors.text }, - default: { fg: colors.text } - }; -} - -// Extract color from shiki inline style -function extractColorFromStyle(style: string): string | undefined { - const match = style.match(/color:\s*(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3})/); - return match?.[1]; -} - -// Convert shiki HAST to styled chunks -function shikiHastToChunks(node: unknown): StyledChunk[] { - if (!node || typeof node !== 'object') return []; - - const n = node as Record; - - if (n.type === 'text' && typeof n.value === 'string') { - return [{ text: n.value }]; - } - - if (n.type === 'element' || n.type === 'root') { - const props = n.properties as Record | undefined; - const style = props?.style as string | undefined; - const fg = style ? extractColorFromStyle(style) : undefined; - - const children = Array.isArray(n.children) ? n.children : []; - const childChunks = children.flatMap((child) => shikiHastToChunks(child)); - - // Apply color to all child chunks if we have one - if (fg) { - return childChunks.map((chunk) => ({ ...chunk, fg: chunk.fg || fg })); - } - - return childChunks; - } - - return []; -} - -// Parse markdown and convert to styled chunks -async function markdownToChunks(content: string, colors: ColorOptions): Promise { - const highlighter = await getHighlighter(); - const marked = new Marked(); - const tokens = marked.lexer(content); - const styles = getMarkdownStyles(colors); - - const chunks: StyledChunk[] = []; - - for (const token of tokens) { - const tokenChunks = await tokenToChunks(token, highlighter, colors, styles); - chunks.push(...tokenChunks); - } - - return chunks; -} - -async function tokenToChunks( - token: Token, - highlighter: HighlighterCore, - colors: ColorOptions, - styles: Record> -): Promise { - switch (token.type) { - case 'heading': { - const t = token as Tokens.Heading; - const prefix = '#'.repeat(t.depth) + ' '; - const childChunks = await inlineTokensToChunks(t.tokens || [], highlighter, colors, styles); - const headingStyle = styles.heading || {}; - return [ - { text: prefix, ...headingStyle }, - ...childChunks.map((c) => ({ ...c, ...headingStyle })), - { text: '\n' } - ]; - } - - case 'paragraph': { - const t = token as Tokens.Paragraph; - const childChunks = await inlineTokensToChunks(t.tokens || [], highlighter, colors, styles); - return [...childChunks, { text: '\n' }]; - } - - case 'text': { - const t = token as Tokens.Text; - if ('tokens' in t && t.tokens) { - return inlineTokensToChunks(t.tokens, highlighter, colors, styles); - } - return [{ text: t.text, fg: colors.text }]; - } - - case 'code': { - const t = token as Tokens.Code; - const lang = t.lang ? normalizeLanguage(t.lang) : 'text'; - const codeBg = '#1e1e1e'; // VS Code dark background - - if (SUPPORTED_LANGS.includes(lang) || SUPPORTED_LANGS.includes(t.lang || '')) { - const hastResult = Result.try(() => - highlighter.codeToHast(t.text, { - lang: lang, - theme: 'dark-plus' - }) - ); - if (hastResult.isOk()) { - const codeChunks = shikiHastToChunks(hastResult.value); - // Mark all chunks as code block and add background - const styledCodeChunks = codeChunks.map((c) => ({ - ...c, - bg: codeBg, - isCodeBlock: true - })); - return [{ text: '\n' }, ...styledCodeChunks, { text: '\n' }]; - } - } - - // Fallback: plain code block - return [ - { text: '\n' }, - { text: t.text, fg: colors.success, bg: codeBg, isCodeBlock: true }, - { text: '\n' } - ]; - } - - case 'codespan': { - const t = token as Tokens.Codespan; - return [{ text: t.text, ...styles.code }]; - } - - case 'strong': { - const t = token as Tokens.Strong; - const childChunks = await inlineTokensToChunks(t.tokens || [], highlighter, colors, styles); - return childChunks.map((c) => ({ ...c, bold: true })); - } - - case 'em': { - const t = token as Tokens.Em; - const childChunks = await inlineTokensToChunks(t.tokens || [], highlighter, colors, styles); - return childChunks.map((c) => ({ ...c, italic: true })); - } - - case 'link': { - const t = token as Tokens.Link; - const childChunks = await inlineTokensToChunks(t.tokens || [], highlighter, colors, styles); - return childChunks.map((c) => ({ ...c, ...styles.link })); - } - - case 'list': { - const t = token as Tokens.List; - const chunks: StyledChunk[] = []; - - for (let i = 0; i < t.items.length; i++) { - const item = t.items[i]; - const bullet = t.ordered ? `${(t.start || 1) + i}. ` : '• '; - chunks.push({ text: bullet, fg: colors.accent }); - - for (const subToken of item?.tokens || []) { - const subChunks = await tokenToChunks(subToken, highlighter, colors, styles); - // Filter out trailing newlines from sub-tokens since we add our own - const filtered = subChunks.filter( - (c, idx) => !(idx === subChunks.length - 1 && c.text === '\n') - ); - chunks.push(...filtered); - } - // Add newline after each list item - chunks.push({ text: '\n' }); - } - - return chunks; - } - - case 'blockquote': { - const t = token as Tokens.Blockquote; - const chunks: StyledChunk[] = []; - - for (const subToken of t.tokens || []) { - const subChunks = await tokenToChunks(subToken, highlighter, colors, styles); - // Prefix each line with > - for (const chunk of subChunks) { - if (chunk.text.includes('\n')) { - const lines = chunk.text.split('\n'); - for (let i = 0; i < lines.length; i++) { - if (i > 0) chunks.push({ text: '\n' }); - if (lines[i]) { - chunks.push({ text: '> ', fg: colors.textMuted }); - chunks.push({ ...chunk, text: lines[i] as string, ...styles.blockquote }); - } - } - } else { - chunks.push({ text: '> ', fg: colors.textMuted }); - chunks.push({ ...chunk, ...styles.blockquote }); - } - } - } - - return chunks; - } - - case 'hr': - return [{ text: '───────────────────────────────────────\n', fg: colors.textMuted }]; - - case 'br': - return [{ text: '\n' }]; - - case 'space': - return [{ text: '\n' }]; - - default: - // For unknown tokens, try to extract raw text - if ('raw' in token && typeof token.raw === 'string') { - return [{ text: token.raw, fg: colors.text }]; - } - return []; - } -} - -async function inlineTokensToChunks( - tokens: Token[], - highlighter: HighlighterCore, - colors: ColorOptions, - styles: Record> -): Promise { - const result: StyledChunk[] = []; - for (const token of tokens) { - const chunks = await tokenToChunks(token, highlighter, colors, styles); - result.push(...chunks); - } - return result; -} - -export interface MarkdownRenderOptions { - colors: ColorOptions; -} - -export async function renderMarkdownToChunks( - content: string, - options: MarkdownRenderOptions -): Promise { - return markdownToChunks(content, options.colors); -} diff --git a/apps/cli/src/tui/parsers-config.test.ts b/apps/cli/src/tui/parsers-config.test.ts new file mode 100644 index 00000000..6a0b69f0 --- /dev/null +++ b/apps/cli/src/tui/parsers-config.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'bun:test'; + +import { parsers } from './parsers-config.ts'; + +test('parsers-config urls are https with highlight queries', () => { + for (const p of parsers) { + expect(p.wasm.startsWith('https://')).toBeTrue(); + expect(p.queries.highlights.length).toBeGreaterThan(0); + expect(p.queries.highlights.every((u) => u.startsWith('https://'))).toBeTrue(); + if (p.queries.injections) { + expect(p.queries.injections.every((u) => u.startsWith('https://'))).toBeTrue(); + } + } +}); diff --git a/apps/cli/src/tui/parsers-config.ts b/apps/cli/src/tui/parsers-config.ts new file mode 100644 index 00000000..2e227226 --- /dev/null +++ b/apps/cli/src/tui/parsers-config.ts @@ -0,0 +1,65 @@ +import type { FiletypeParserOptions } from '@opentui/core'; + +// These parsers are loaded on-demand by OpenTUI's TreeSitterClient. +// The wasm/query assets are remote URLs, so first-use requires network access. +export const parsers = [ + { + filetype: 'json', + wasm: 'https://cdn.jsdelivr.net/npm/tree-sitter-json@0.24.8/tree-sitter-json.wasm', + queries: { + highlights: ['https://cdn.jsdelivr.net/npm/tree-sitter-json@0.24.8/queries/highlights.scm'] + } + }, + { + filetype: 'html', + wasm: 'https://cdn.jsdelivr.net/npm/tree-sitter-html@0.23.2/tree-sitter-html.wasm', + queries: { + highlights: ['https://cdn.jsdelivr.net/npm/tree-sitter-html@0.23.2/queries/highlights.scm'], + injections: ['https://cdn.jsdelivr.net/npm/tree-sitter-html@0.23.2/queries/injections.scm'] + } + }, + { + filetype: 'css', + wasm: 'https://cdn.jsdelivr.net/npm/tree-sitter-css@0.23.2/tree-sitter-css.wasm', + queries: { + highlights: ['https://cdn.jsdelivr.net/npm/tree-sitter-css@0.23.2/queries/highlights.scm'] + } + }, + { + filetype: 'python', + wasm: 'https://cdn.jsdelivr.net/npm/tree-sitter-python@0.23.6/tree-sitter-python.wasm', + queries: { + highlights: ['https://cdn.jsdelivr.net/npm/tree-sitter-python@0.23.6/queries/highlights.scm'] + } + }, + { + filetype: 'rust', + wasm: 'https://cdn.jsdelivr.net/npm/tree-sitter-rust@0.23.2/tree-sitter-rust.wasm', + queries: { + highlights: ['https://cdn.jsdelivr.net/npm/tree-sitter-rust@0.23.2/queries/highlights.scm'] + } + }, + { + filetype: 'go', + wasm: 'https://cdn.jsdelivr.net/npm/tree-sitter-go@0.23.4/tree-sitter-go.wasm', + queries: { + highlights: ['https://cdn.jsdelivr.net/npm/tree-sitter-go@0.23.4/queries/highlights.scm'] + } + }, + { + filetype: 'yaml', + wasm: 'https://cdn.jsdelivr.net/npm/@tree-sitter-grammars/tree-sitter-yaml@0.7.1/tree-sitter-yaml.wasm', + queries: { + highlights: [ + 'https://cdn.jsdelivr.net/npm/@tree-sitter-grammars/tree-sitter-yaml@0.7.1/queries/highlights.scm' + ] + } + }, + { + filetype: 'bash', + wasm: 'https://cdn.jsdelivr.net/npm/tree-sitter-bash@0.23.3/tree-sitter-bash.wasm', + queries: { + highlights: ['https://cdn.jsdelivr.net/npm/tree-sitter-bash@0.23.3/queries/highlights.scm'] + } + } +] satisfies FiletypeParserOptions[]; diff --git a/apps/cli/src/tui/syntax-theme.ts b/apps/cli/src/tui/syntax-theme.ts new file mode 100644 index 00000000..22512e9b --- /dev/null +++ b/apps/cli/src/tui/syntax-theme.ts @@ -0,0 +1,39 @@ +import { RGBA, SyntaxStyle } from '@opentui/core'; + +import { colors } from './theme.ts'; + +export const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromHex(colors.text), bg: RGBA.fromHex(colors.bg) }, + conceal: { fg: RGBA.fromHex(colors.textSubtle), bg: RGBA.fromHex(colors.bg) }, + + keyword: { fg: RGBA.fromHex('#569cd6'), bold: true }, + 'keyword.operator': { fg: RGBA.fromHex('#569cd6') }, + string: { fg: RGBA.fromHex('#ce9178') }, + comment: { fg: RGBA.fromHex('#6a9955'), italic: true }, + number: { fg: RGBA.fromHex('#b5cea8') }, + function: { fg: RGBA.fromHex('#dcdcaa') }, + type: { fg: RGBA.fromHex('#4ec9b0') }, + constant: { fg: RGBA.fromHex('#4fc1ff') }, + property: { fg: RGBA.fromHex('#9cdcfe') }, + variable: { fg: RGBA.fromHex('#9cdcfe') }, + operator: { fg: RGBA.fromHex('#d4d4d4') }, + 'punctuation.delimiter': { fg: RGBA.fromHex('#d4d4d4') }, + 'punctuation.special': { fg: RGBA.fromHex(colors.textMuted) }, + + // Markdown groups used by OpenTUI's MarkdownRenderable (note: base fallback is only the first segment) + 'markup.heading.1': { fg: RGBA.fromHex(colors.accent), bold: true }, + 'markup.heading.2': { fg: RGBA.fromHex(colors.accent), bold: true }, + 'markup.heading.3': { fg: RGBA.fromHex(colors.accent), bold: true }, + 'markup.heading.4': { fg: RGBA.fromHex(colors.accent), bold: true }, + 'markup.heading.5': { fg: RGBA.fromHex(colors.accent), bold: true }, + 'markup.heading.6': { fg: RGBA.fromHex(colors.accent), bold: true }, + 'markup.list': { fg: RGBA.fromHex(colors.accent) }, + 'markup.link': { fg: RGBA.fromHex(colors.info), underline: true }, + 'markup.link.label': { fg: RGBA.fromHex(colors.info), underline: true }, + 'markup.link.url': { fg: RGBA.fromHex(colors.info), underline: true }, + 'markup.strong': { bold: true }, + 'markup.italic': { italic: true }, + 'markup.strikethrough': { fg: RGBA.fromHex(colors.textMuted), dim: true }, + 'markup.raw': { fg: RGBA.fromHex(colors.success) }, + 'markup.raw.block': { fg: RGBA.fromHex(colors.success) } +}); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 0095f149..4ac0c877 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -2,7 +2,7 @@ "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules", "dist"], "compilerOptions": { - "lib": ["ESNext"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "target": "ESNext", "module": "esnext", "moduleDetection": "force", diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 08995c31..ea4315a0 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -18,12 +18,14 @@ X } from '@lucide/svelte'; import { goto } from '$app/navigation'; - import { createEventDispatcher } from 'svelte'; + import { createEventDispatcher, onDestroy } from 'svelte'; import { useConvexClient } from 'convex-svelte'; import { api } from '../../convex/_generated/api'; + import type { Id } from '../../convex/_generated/dataModel'; import { getAuthState, openSignIn, signOut } from '$lib/stores/auth.svelte'; import { getThemeStore } from '$lib/stores/theme.svelte'; import { getProjectStore } from '$lib/stores/project.svelte'; + import { threadPreloadStore } from '$lib/stores/threadPreload.svelte'; import InstanceStatus from '$lib/components/InstanceStatus.svelte'; import { trackEvent, ClientAnalyticsEvents } from '$lib/stores/analytics.svelte'; @@ -52,6 +54,9 @@ let searchValue = $state(''); let showUserMenu = $state(false); let showProjectsSection = $state(false); + const preloadDelayMs = 120; + const preloadTimers = new Map>(); + const preloadInFlight = new Set(); const filteredThreads = $derived.by(() => { const query = searchValue.trim().toLowerCase(); @@ -111,6 +116,64 @@ function openCreateProjectModal() { projectStore.showCreateModal = true; } + + function scheduleThreadPreload(threadId: string) { + if (threadPreloadStore.has(threadId)) { + console.debug('[threadPreload] cache hit, skipping preload', threadId); + return; + } + if (preloadInFlight.has(threadId)) { + console.debug('[threadPreload] already in-flight, skipping duplicate', threadId); + return; + } + if (preloadTimers.has(threadId)) { + console.debug('[threadPreload] timer already scheduled, skipping', threadId); + return; + } + + console.debug('[threadPreload] scheduled', threadId); + + preloadTimers.set( + threadId, + setTimeout(() => { + preloadTimers.delete(threadId); + void preloadThread(threadId); + }, preloadDelayMs) + ); + } + + async function preloadThread(threadId: string) { + if (threadPreloadStore.has(threadId) || preloadInFlight.has(threadId)) { + return; + } + preloadInFlight.add(threadId); + console.debug('[threadPreload] starting', threadId, Date.now()); + + try { + const data = await client.query(api.threads.getWithMessages, { + threadId: threadId as Id<'threads'> + }); + console.debug( + '[threadPreload] loaded', + threadId, + Array.isArray(data?.messages) ? data.messages.length : 0 + ); + threadPreloadStore.set(threadId, data); + } catch (error) { + console.debug('Thread prefetch failed', error); + } finally { + preloadInFlight.delete(threadId); + console.debug('[threadPreload] finished', threadId); + } + } + + onDestroy(() => { + for (const timer of preloadTimers.values()) { + clearTimeout(timer); + } + preloadTimers.clear(); + preloadInFlight.clear(); + }); @@ -250,6 +313,8 @@ ? 'bc-thread-item bc-thread-item-active' : 'bc-thread-item'} onclick={handleNavigate} + onmouseenter={() => scheduleThreadPreload(thread._id)} + onfocus={() => scheduleThreadPreload(thread._id)} >
diff --git a/apps/web/src/lib/stores/threadPreload.svelte.ts b/apps/web/src/lib/stores/threadPreload.svelte.ts new file mode 100644 index 00000000..c0dbb59e --- /dev/null +++ b/apps/web/src/lib/stores/threadPreload.svelte.ts @@ -0,0 +1,54 @@ +import type { Doc, Id } from '../../convex/_generated/dataModel'; + +export type PreloadedThreadWithMessages = + | ((Doc<'threads'> & { + messages: Array>; + resources: string[]; + threadResources: string[]; + activeStream: { + sessionId: string; + messageId: Id<'messages'>; + startedAt: number; + } | null; + }) & { projectId?: Id<'projects'> | undefined }) + | null; + +type CacheEntry = { + data: PreloadedThreadWithMessages; + loadedAt: number; +}; + +const CACHE_TTL_MS = 2 * 60 * 1000; +const preloadCache = new Map(); + +const normalizeThreadId = (threadId: string) => threadId; + +const isFresh = (entry: CacheEntry) => Date.now() - entry.loadedAt < CACHE_TTL_MS; +const getEntry = (threadId: string) => preloadCache.get(normalizeThreadId(threadId)); +const getFreshData = (threadId: string) => { + const entry = getEntry(threadId); + if (!entry) return null; + if (!isFresh(entry)) { + preloadCache.delete(normalizeThreadId(threadId)); + return null; + } + return entry.data; +}; + +export const threadPreloadStore = { + get(threadId: string) { + return getFreshData(threadId); + }, + set(threadId: string, data: PreloadedThreadWithMessages) { + preloadCache.set(normalizeThreadId(threadId), { + data, + loadedAt: Date.now() + }); + }, + has(threadId: string) { + return getFreshData(threadId) !== null; + }, + markConsumed(threadId: string) { + preloadCache.delete(normalizeThreadId(threadId)); + } +}; diff --git a/apps/web/src/routes/app/chat/[id]/+page.svelte b/apps/web/src/routes/app/chat/[id]/+page.svelte index d214add4..24ea845e 100644 --- a/apps/web/src/routes/app/chat/[id]/+page.svelte +++ b/apps/web/src/routes/app/chat/[id]/+page.svelte @@ -2,6 +2,7 @@ import { MessageSquare, Loader2, Send, BookOpen } from '@lucide/svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; + import { onMount } from 'svelte'; import { env } from '$env/dynamic/public'; import { useQuery, useConvexClient } from 'convex-svelte'; import ChatMessages from '$lib/components/ChatMessages.svelte'; @@ -9,6 +10,7 @@ import { getBillingStore } from '$lib/stores/billing.svelte'; import { getInstanceStore } from '$lib/stores/instance.svelte'; import { getProjectStore } from '$lib/stores/project.svelte'; + import { threadPreloadStore } from '$lib/stores/threadPreload.svelte'; import { trackEvent, ClientAnalyticsEvents } from '$lib/stores/analytics.svelte'; import { SUPPORT_URL } from '$lib/billing/plans'; import type { BtcaChunk, CancelState } from '$lib/types'; @@ -31,7 +33,12 @@ // Convex queries - only query if we have a real thread ID const threadQuery = $derived.by(() => { if (!threadId) return null; - return useQuery(api.threads.getWithMessages, { threadId }); + const initialThreadData = threadPreloadStore.get(threadId); + return useQuery( + api.threads.getWithMessages, + { threadId }, + initialThreadData ? { initialData: initialThreadData } : undefined + ); }); const selectedProjectId = $derived(projectStore.selectedProject?._id); @@ -84,11 +91,65 @@ // Get active stream from Convex (for background streams) const activeStream = $derived(thread?.activeStream ?? null); + let showLoadingSpinner = $state(false); + const loadingSpinnerDelayMs = 200; + const loadingSpinnerTimers = new Map>(); // Show "in progress" indicator when there's a background stream // (stream running but we're not connected to it) const hasBackgroundStream = $derived(activeStream !== null && !isStreamingThisThread); + $effect(() => { + routeId; + const currentThread = threadId; + const currentThreadKey = currentThread ?? ''; + + for (const key of loadingSpinnerTimers.keys()) { + if (key !== currentThreadKey) { + const oldTimer = loadingSpinnerTimers.get(key); + if (oldTimer) clearTimeout(oldTimer); + loadingSpinnerTimers.delete(key); + } + } + + showLoadingSpinner = false; + if (currentThread && threadQuery) { + const timerId = setTimeout(() => { + if (threadQuery?.isLoading && !threadQuery?.data) { + showLoadingSpinner = true; + } + loadingSpinnerTimers.delete(currentThreadKey); + }, loadingSpinnerDelayMs); + loadingSpinnerTimers.set(currentThreadKey, timerId); + } + + return () => { + const timerId = loadingSpinnerTimers.get(currentThreadKey); + if (timerId) { + clearTimeout(timerId); + loadingSpinnerTimers.delete(currentThreadKey); + } + }; + }); + + $effect(() => { + if (!threadQuery?.isLoading || threadQuery?.data) { + showLoadingSpinner = false; + const timerId = loadingSpinnerTimers.get(threadId ?? ''); + if (timerId) { + clearTimeout(timerId); + loadingSpinnerTimers.delete(threadId ?? ''); + } + } + }); + + onMount(() => { + return () => { + for (const timer of loadingSpinnerTimers.values()) clearTimeout(timer); + loadingSpinnerTimers.clear(); + }; + }); + $effect(() => { if (!auth.isSignedIn && auth.isLoaded) { goto('/app'); @@ -645,7 +706,7 @@
- {#if !isNewThread && threadQuery?.isLoading} + {#if !isNewThread && showLoadingSpinner}
diff --git a/bun.lock b/bun.lock index cacd1ff1..4a5ed8a8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@btca/repo", "devDependencies": { - "@opentui/solid": "0.1.65", + "@opentui/solid": "0.1.77", "@types/bun": "^1.3.8", "@typescript/native-preview": "^7.0.0-dev.20260204.1", "prettier": "^3.8.1", @@ -28,10 +28,8 @@ "devDependencies": { "@btca/shared": "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", @@ -40,11 +38,10 @@ "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", }, }, @@ -649,21 +646,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.39.0", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="], - "@opentui/core": ["@opentui/core@0.1.65", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.65", "@opentui/core-darwin-x64": "0.1.65", "@opentui/core-linux-arm64": "0.1.65", "@opentui/core-linux-x64": "0.1.65", "@opentui/core-win32-arm64": "0.1.65", "@opentui/core-win32-x64": "0.1.65", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-yf+4AaFMz2/AzayZ7GP2O2LK0NoO0AFS94qoGczQ9Hqq91McaN2lnpHe5HKBd9fqahdt2CwnxYdVqULFTrT1mQ=="], + "@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.65", "", { "os": "darwin", "cpu": "arm64" }, "sha512-40l6sCx7sS0SW874SutgFDM0ioJ8AcV0aqN5Wttx2YygWiO3fZe4OHBJQ98R8rz/QwmiNSJRaqpKJyvHLEVX0w=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.65", "", { "os": "darwin", "cpu": "x64" }, "sha512-loH/wUp6VUSW1V93KP3zU9jrf6QfK3jLHkRoZAR0an0dvwMvQMDRURSSp7j+ZGyG+qEvDqntZjgIKFfqCjxx1g=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.65", "", { "os": "linux", "cpu": "arm64" }, "sha512-C5sYo/fdQnV82s1MeXFIcZfY5Yu+B7fGz9uWT8B2idtSKNpyNWhfrdXKfd2ACDpMqlutvUrnXAs3jKgWU0IrkA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.65", "", { "os": "linux", "cpu": "x64" }, "sha512-JFkiEV+Gr2oNPYAS+AF3bB0KV0iMPncZ/2FChDc1QBw8kFxEtkX2mL+UA97bsYDw3iwQUqzqkawl/LGFlxtMRQ=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.65", "", { "os": "win32", "cpu": "arm64" }, "sha512-ubZBLwLoEuv57jZGWsNqi7D3xn4pKnttFvRw9B0GwiYzwF0bg+c8PFA3yg01rlTiYMlu0vzgvlOtDhPoCOb6Fw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.65", "", { "os": "win32", "cpu": "x64" }, "sha512-9iiq5ovUfaDNi3dP7B8onz1BNzvZZuxXsRl9USuI0yVIjj8RPU2cNaCWZdK1u34ZtCe0uu2CpTEB5L5KfAWJIA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="], - "@opentui/solid": ["@opentui/solid@0.1.65", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.65", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-UXFkjiY4PmbEsXkNRPtfs10RMhXVW/D4nxSBy4Exg+0rUCm06LuqJ3xQ/JF6siOUxhRnSTiM5Kz3ytWAtZAzmw=="], + "@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], diff --git a/package.json b/package.json index c06a01bb..1e4a79ba 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "clean": "find . -type d \\( -name node_modules -o -name .svelte-kit -o -name .turbo -o -name .vercel \\) -prune -exec rm -rf {} +" }, "devDependencies": { - "@opentui/solid": "0.1.65", + "@opentui/solid": "0.1.77", "@types/bun": "^1.3.8", "@typescript/native-preview": "^7.0.0-dev.20260204.1", "prettier": "^3.8.1", diff --git a/tsconfig.json b/tsconfig.json index a4cabe49..935fbd94 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ // Environment setup & latest features "lib": [ "ESNext", + "DOM", + "DOM.Iterable", ], "jsx": "preserve", "jsxImportSource": "@opentui/solid", @@ -33,4 +35,4 @@ } ] } -} \ No newline at end of file +}