diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml new file mode 100644 index 00000000..e52eb9de --- /dev/null +++ b/.github/workflows/vscode-extension.yml @@ -0,0 +1,61 @@ +name: Publish VS Code Extension + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build VS Code extension + run: pnpm -C packages/vscode run build + + - name: Package extension + run: pnpm -C packages/vscode exec vsce package --no-dependencies + + - name: Publish to VS Code Marketplace + if: ${{ env.VSCE_PAT != '' }} + run: pnpm -C packages/vscode exec vsce publish -p "$VSCE_PAT" --no-dependencies + + - name: Publish to Open VSX + if: ${{ env.OVSX_PAT != '' }} + run: pnpm -C packages/vscode dlx ovsx publish packages/vscode/*.vsix -p "$OVSX_PAT" + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: openchamber-vscode-vsix + path: packages/vscode/*.vsix + + - name: Attach VSIX to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: packages/vscode/*.vsix + generate_release_notes: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 4ac51bd5..0c90caa8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ dist-ssr release *.local *.tgz +*.vsix /npm /tsc /openchamber@* diff --git a/CHANGELOG.md b/CHANGELOG.md index 44451420..059df327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Added assistant answer fork flow so users can start a new session from an assistant plan/response with inherited context. +- Added OpenChamber VS Code extension with editor integration: file picker, click-to-open in tool parts +- Improved scroll performance with force flag and RAF placeholder +- Added git polling backoff optimization ## [1.0.9] - 2025-12-08 diff --git a/README.md b/README.md index 8f8cb945..eb38fa83 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The whole project was built entirely with AI coding agents under my supervision. ![Tool Output](docs/references/tool_output_example.png) ![Settings](docs/references/settings_example.png) ![Web Version](docs/references/web_version_example.png) +![VS Code Extension](docs/references/vscode_extension.png)

PWA Chat PWA Terminal @@ -45,6 +46,10 @@ The whole project was built entirely with AI coding agents under my supervision. ## Installation +### VS Code Extension + +Install from [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber) or search "OpenChamber" in Extensions. + ### CLI (Web Server) ```bash diff --git a/docs/references/vscode_extension.png b/docs/references/vscode_extension.png new file mode 100644 index 00000000..bff21f98 Binary files /dev/null and b/docs/references/vscode_extension.png differ diff --git a/docs/vscode-extension-plan.md b/docs/vscode-extension-plan.md new file mode 100644 index 00000000..0613f8b3 --- /dev/null +++ b/docs/vscode-extension-plan.md @@ -0,0 +1,165 @@ +# VS Code Extension Plan + +Chat UI sidebar panel for VS Code that connects to OpenCode backend. + +## Goals + +- Sidebar webview panel with OpenChamber chat UI +- Reuse `@openchamber/ui` components (ChatView, stores, hooks) +- Auto-detect VS Code theme (light/dark) and adapt +- Use workspace folder as OpenCode directory +- Support `@file` mentions via VS Code workspace API + +## Package Structure + +``` +packages/vscode/ +├── src/ +│ ├── extension.ts # Extension entry, registers webview +│ ├── ChatViewProvider.ts # WebviewViewProvider for sidebar +│ ├── bridge.ts # Webview <-> Extension host messaging +│ └── theme.ts # VS Code theme detection +├── webview/ +│ ├── main.tsx # Webview React entry +│ ├── App.tsx # Minimal App (chat-only, no layout) +│ └── api/ +│ ├── index.ts # createVSCodeAPIs() +│ ├── files.ts # File listing via extension host +│ └── settings.ts # Theme/config from VS Code +├── package.json # Extension manifest +├── tsconfig.json +├── tsconfig.webview.json +└── vite.config.ts # Webview bundler +``` + +## Implementation Tasks + +### Phase 1: Extension Scaffold + +1. Create `packages/vscode/` directory structure +2. Create `package.json` with extension manifest + - Sidebar view contribution + - Commands (new session) + - Configuration (API URL) +3. Create `tsconfig.json` for extension host (Node.js) +4. Create `tsconfig.webview.json` for webview (browser) +5. Create `vite.config.ts` for webview bundling +6. Add workspace reference in root `pnpm-workspace.yaml` + +### Phase 2: Extension Host + +1. `src/extension.ts` - activate/deactivate, register provider +2. `src/ChatViewProvider.ts` - WebviewViewProvider implementation + - Generate HTML with CSP + - Handle webview lifecycle + - Pass workspace folder to webview +3. `src/bridge.ts` - Message handler for webview requests + - `files:list` - list directory contents + - `files:search` - fuzzy file search + - `workspace:folder` - get workspace root +4. `src/theme.ts` - Detect VS Code color theme kind + +### Phase 3: Webview Runtime + +1. `webview/main.tsx` - React entry point +2. `webview/App.tsx` - Simplified app shell + - No MainLayout/Header/Sidebar + - Just ChatView + essential providers + - Theme sync with VS Code +3. `webview/api/index.ts` - createVSCodeAPIs() + - RuntimeAPIs implementation + - IPC bridge to extension host +4. `webview/api/files.ts` - FilesAPI via postMessage +5. `webview/api/settings.ts` - SettingsAPI (theme from VS Code) + +### Phase 4: Theme Integration + +1. Listen to `vscode.window.onDidChangeActiveColorTheme` +2. Map VS Code theme kind to OpenChamber light/dark +3. Post theme changes to webview +4. Apply theme CSS variables dynamically + +### Phase 5: File Context Support + +1. Implement `files:list` in extension host using `vscode.workspace.fs` +2. Implement `files:search` using `vscode.workspace.findFiles` +3. Wire up to ChatInput `@file` autocomplete + +### Phase 6: Build & Package + +1. Add build scripts to `packages/vscode/package.json` +2. Add root-level scripts (`vscode:dev`, `vscode:build`, `vscode:package`) +3. Configure `.vscodeignore` for clean packaging +4. Test extension in VS Code Extension Host + +## Dependencies + +### Extension Host (Node.js) +- `@types/vscode` - VS Code API types +- `esbuild` - Bundle extension.ts + +### Webview (Browser) +- `@openchamber/ui` (workspace) - Shared UI components +- `vite` + `@vitejs/plugin-react` - Bundle React app +- `@opencode-ai/sdk` - OpenCode API client + +## Extension Manifest Highlights + +```json +{ + "contributes": { + "views": { + "explorer": [{ + "type": "webview", + "id": "openchamber.chatView", + "name": "OpenChamber" + }] + }, + "commands": [{ + "command": "openchamber.newSession", + "title": "OpenChamber: New Chat Session" + }], + "configuration": { + "properties": { + "openchamber.apiUrl": { + "type": "string", + "default": "http://localhost:47339" + } + } + } + } +} +``` + +## Reused from @openchamber/ui + +| Module | Status | +|--------|--------| +| `components/chat/*` | Reuse all | +| `components/views/ChatView` | Reuse | +| `stores/useSessionStore` | Reuse | +| `stores/useConfigStore` | Reuse | +| `stores/useUIStore` | Partial (no sidebar state) | +| `hooks/useEventStream` | Reuse | +| `hooks/useMessageSync` | Reuse | +| `lib/opencode/client` | Reuse | +| `lib/theme/*` | Reuse (CSS generation) | +| `components/layout/*` | Skip | +| `components/views/GitView` | Skip | +| `components/views/DiffView` | Skip | +| `components/views/TerminalView` | Skip | + +## Not Needed (handled by VS Code) + +- Terminal API (VS Code integrated terminal) +- Git API (VS Code SCM) +- Notifications API (VS Code notifications) +- Permissions API (VS Code handles sandbox) +- Directory picker (workspace folder used) + +## Decisions + +- **View location**: Explorer sidebar +- **Sidebar icon**: Reuse from `packages/web/public/` (tab icon) +- **Marketplace icon**: Reuse from `packages/desktop/src-tauri/icons/app-icon-checkpoint.svg` +- **Publisher**: `fedaykindev` (dev.azure.com/fedaykindev, artmore@protonmail.com) diff --git a/package.json b/package.json index a0b1fea7..8ccb3d02 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,10 @@ "desktop:build": "pnpm -C packages/desktop build && pnpm -C packages/desktop tauri build", "desktop:lint": "pnpm -C packages/desktop run lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings", "desktop:type-check": "pnpm -C packages/desktop run type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings", + "vscode:dev": "pnpm -C packages/vscode run dev", + "vscode:build": "pnpm -C packages/vscode run build", + "vscode:package": "pnpm -C packages/vscode run package", + "vscode:type-check": "pnpm -C packages/vscode run type-check", "version:bump": "node scripts/bump-version.mjs", "release:prepare": "pnpm run build && pnpm run type-check && pnpm run lint" }, @@ -55,7 +59,7 @@ "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.0.133", + "@opencode-ai/sdk": "^1.0.150", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src/api/index.ts b/packages/desktop/src/api/index.ts index 949c3110..ff926b7f 100644 --- a/packages/desktop/src/api/index.ts +++ b/packages/desktop/src/api/index.ts @@ -32,7 +32,7 @@ export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => { }; return { - runtime: { platform: 'desktop', isDesktop: true, label: 'tauri-bootstrap' }, + runtime: { platform: 'desktop', isDesktop: true, isVSCode: false, label: 'tauri-bootstrap' }, terminal: wrappedTerminalAPI, git: createDesktopGitAPI(), files: createDesktopFilesAPI(), diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 54457498..25ffc9f7 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { MainLayout } from '@/components/layout/MainLayout'; +import { VSCodeLayout } from '@/components/layout/VSCodeLayout'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; @@ -34,6 +35,7 @@ function App({ apis }: AppProps) { const [showMemoryDebug, setShowMemoryDebug] = React.useState(false); const { uiFont, monoFont } = useFontPreferences(); const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => apis.runtime.isDesktop); + const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode); const [cliAvailable, setCliAvailable] = React.useState(() => { if (!apis.runtime.isDesktop) return true; return isCliAvailable(); @@ -41,7 +43,8 @@ function App({ apis }: AppProps) { React.useEffect(() => { setIsDesktopRuntime(apis.runtime.isDesktop); - }, [apis.runtime.isDesktop]); + setIsVSCodeRuntime(apis.runtime.isVSCode); + }, [apis.runtime.isDesktop, apis.runtime.isVSCode]); React.useEffect(() => { registerRuntimeAPIs(apis); @@ -165,6 +168,22 @@ function App({ apis }: AppProps) { ); } + // VS Code runtime - simplified layout without git/terminal views + if (isVSCodeRuntime) { + return ( + + + +

+ + +
+ + + + ); + } + return ( diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 15be9273..8de6c434 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { OpenCodeIcon } from '@/components/ui/OpenCodeIcon'; -import { isDesktopRuntime } from '@/lib/desktop'; +import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop'; import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence'; import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence'; @@ -89,15 +89,17 @@ type GateState = 'pending' | 'authenticated' | 'locked' | 'error'; export const SessionAuthGate: React.FC = ({ children }) => { const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []); - const [state, setState] = React.useState(() => (desktopRuntime ? 'authenticated' : 'pending')); + const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []); + const skipAuth = desktopRuntime || vscodeRuntime; + const [state, setState] = React.useState(() => (skipAuth ? 'authenticated' : 'pending')); const [password, setPassword] = React.useState(''); const [isSubmitting, setIsSubmitting] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); const passwordInputRef = React.useRef(null); - const hasResyncedRef = React.useRef(desktopRuntime); + const hasResyncedRef = React.useRef(skipAuth); const checkStatus = React.useCallback(async () => { - if (desktopRuntime) { + if (skipAuth) { setState('authenticated'); return; } @@ -119,20 +121,20 @@ export const SessionAuthGate: React.FC = ({ children }) => console.warn('Failed to check session status:', error); setState('error'); } - }, [desktopRuntime]); + }, [skipAuth]); React.useEffect(() => { - if (desktopRuntime) { + if (skipAuth) { return; } void checkStatus(); - }, [checkStatus, desktopRuntime]); + }, [checkStatus, skipAuth]); React.useEffect(() => { - if (!desktopRuntime && state === 'locked') { + if (!skipAuth && state === 'locked') { hasResyncedRef.current = false; } - }, [desktopRuntime, state]); + }, [skipAuth, state]); React.useEffect(() => { if (state === 'locked' && passwordInputRef.current) { @@ -142,7 +144,7 @@ export const SessionAuthGate: React.FC = ({ children }) => }, [state]); React.useEffect(() => { - if (desktopRuntime) { + if (skipAuth) { return; } if (state === 'authenticated' && !hasResyncedRef.current) { @@ -153,7 +155,7 @@ export const SessionAuthGate: React.FC = ({ children }) => await applyPersistedDirectoryPreferences(); })(); } - }, [desktopRuntime, state]); + }, [skipAuth, state]); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx index a241222b..90a85970 100644 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx @@ -129,7 +129,7 @@ export const AgentMentionAutocomplete = React.forwardRef {agents.length ? ( diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index f6a5964a..462370c1 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -48,9 +48,9 @@ export const ChatContainer: React.FC = () => { const { scrollRef, handleMessageContentChange, - getAnimationHandlers, - showScrollButton, - scrollToBottom, + getAnimationHandlers, + showScrollButton, + scrollToBottom, spacerHeight, pendingAnchorId, hasActiveAnchor, @@ -179,7 +179,7 @@ export const ChatContainer: React.FC = () => { if (sessionMessages.length === 0 && !streamingMessageId) { return ( -
+
@@ -239,7 +239,7 @@ export const ChatContainer: React.FC = () => { + )} +
+
+ + {filteredProviders.length === 0 && ( +
+ No providers or models match your search. +
+ )} + + {filteredProviders.map(({ provider, providerModels }) => { + if (providerModels.length === 0 && !normalizedQuery.length) { return null; } const isActiveProvider = provider.id === currentProviderId; - const isExpanded = expandedMobileProviders.has(provider.id); + const isExpanded = expandedMobileProviders.has(provider.id) || normalizedQuery.length > 0; return (
@@ -1112,7 +1165,7 @@ export const ModelControls: React.FC = ({ className }) => { )} - {isExpanded && ( + {isExpanded && providerModels.length > 0 && (
{providerModels.map((model: ProviderModel) => { const isSelected = isActiveProvider && model.id === currentModelId; @@ -1137,29 +1190,29 @@ export const ModelControls: React.FC = ({ className }) => { {getModelDisplayName(model)} -
- {capabilityIcons.map(({ key, icon: IconComponent, label }) => ( - - - - ))} - {inputIcons.map(({ key, icon: IconComponent, label }) => ( - - - - ))} -
- +
+
+ {(metadata?.limit?.context || metadata?.limit?.output) && ( +
+ {metadata?.limit?.context ? {formatTokens(metadata?.limit?.context)} ctx : null} + {metadata?.limit?.context && metadata?.limit?.output ? : null} + {metadata?.limit?.output ? {formatTokens(metadata?.limit?.output)} out : null} +
+ )} + {(capabilityIcons.length > 0 || inputIcons.length > 0) && ( +
+ {[...capabilityIcons, ...inputIcons].map(({ key, icon: IconComponent, label }) => ( + + + + ))} +
+ )}
); @@ -1175,7 +1228,7 @@ export const ModelControls: React.FC = ({ className }) => { }; const renderMobileAgentPanel = () => { - if (!isMobile) return null; + if (!isCompact) return null; const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode)); @@ -1397,13 +1450,13 @@ export const ModelControls: React.FC = ({ className }) => { const renderModelSelector = () => ( - {!isMobile ? ( + {!isCompact ? (
@@ -1420,7 +1473,11 @@ export const ModelControls: React.FC = ({ className }) => { )} {getCurrentModelDisplayName()} @@ -1535,8 +1592,8 @@ export const ModelControls: React.FC = ({ className }) => { onTouchEnd={handleLongPressEnd} onTouchCancel={handleLongPressEnd} className={cn( - 'flex items-center gap-1.5 min-w-0 focus:outline-none', - 'cursor-pointer hover:opacity-70 max-w-full justify-end', + 'model-controls__model-trigger flex items-center gap-1.5 min-w-0 focus:outline-none flex-1', + 'cursor-pointer hover:opacity-70', buttonHeight )} > @@ -1548,7 +1605,7 @@ export const ModelControls: React.FC = ({ className }) => { ) : ( )} - + {getCurrentModelDisplayName()} @@ -1697,7 +1754,7 @@ export const ModelControls: React.FC = ({ className }) => { }; const renderAgentSelector = () => { - if (!isMobile) { + if (!isCompact) { return (
@@ -1860,10 +1917,10 @@ export const ModelControls: React.FC = ({ className }) => { onTouchEnd={handleLongPressEnd} onTouchCancel={handleLongPressEnd} className={cn( - 'flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none', + 'model-controls__agent-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none', buttonHeight, 'cursor-pointer hover:opacity-70', - isMobile && 'ml-1' + isCompact && 'ml-1' )} > = ({ className }) => { style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined} /> {getAgentDisplayName()} @@ -1884,12 +1941,12 @@ export const ModelControls: React.FC = ({ className }) => { ); }; - const inlineClassName = cn('flex items-center min-w-0', inlineGapClass, className); + const inlineClassName = cn('@container/model-controls flex items-center min-w-0', inlineGapClass, className); return ( <>
-
+
{renderModelSelector()}
diff --git a/packages/ui/src/components/chat/ServerFilePicker.tsx b/packages/ui/src/components/chat/ServerFilePicker.tsx index f40a0af5..4561ad50 100644 --- a/packages/ui/src/components/chat/ServerFilePicker.tsx +++ b/packages/ui/src/components/chat/ServerFilePicker.tsx @@ -17,6 +17,7 @@ import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; interface FileInfo { name: string; @@ -39,6 +40,8 @@ export const ServerFilePicker: React.FC = ({ children }) => { const { isMobile } = useDeviceInfo(); + const isVSCodeRuntime = useIsVSCodeRuntime(); + const isCompact = isMobile || isVSCodeRuntime; const { currentDirectory } = useDirectoryStore(); const searchFiles = useFileSearchStore((state) => state.searchFiles); const [open, setOpen] = React.useState(false); @@ -331,7 +334,7 @@ export const ServerFilePicker: React.FC = ({ : file.name; const shouldCompact = isSearchActive && rawLabel.includes('/') && rawLabel.length > 45; const displayLabel = shouldCompact - ? truncatePathMiddle(rawLabel, { maxLength: isMobile ? 42 : 48 }) + ? truncatePathMiddle(rawLabel, { maxLength: isCompact ? 42 : 48 }) : rawLabel; const row = ( @@ -437,7 +440,7 @@ export const ServerFilePicker: React.FC = ({
); - const scrollAreaClass = isMobile ? 'flex-1 min-h-[240px]' : 'h-[300px]'; + const scrollAreaClass = isCompact ? 'flex-1 min-h-[240px]' : 'h-[300px]'; const pickerBody = ( <> @@ -524,7 +527,7 @@ export const ServerFilePicker: React.FC = ({ ); - if (isMobile) { + if (isCompact) { return ( <> {mobileTrigger} diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index e5c39140..0b1dbf6e 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -105,7 +105,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange
-
+
{popup.metadata?.input && typeof popup.metadata.input === 'object' && Object.keys(popup.metadata.input).length > 0 && popup.metadata?.tool !== 'todowrite' && @@ -127,45 +127,47 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange : 'Input:'}
{meta.tool === 'bash' && getInputValue('command') ? ( -
+
{getInputValue('command')!}
) : meta.tool === 'task' && getInputValue('prompt') ? ( -
                                             {getInputValue('description') ? `Task: ${getInputValue('description')}\n` : ''}
                                             {getInputValue('subagent_type') ? `Agent Type: ${getInputValue('subagent_type')}\n` : ''}
                                             {`Instructions:\n${getInputValue('prompt')}`}
-                                        
+
) : meta.tool === 'write' && getInputValue('content') ? ( -
+
{getInputValue('content')!}
) : ( -
                                             {formatInputForDisplay(input, meta.tool as string)}
-                                        
+
)}
); @@ -173,7 +175,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange {popup.isDiff ? ( diffViewMode === 'unified' ? ( -
+
{parseDiffToUnified(popup.content).map((hunk, hunkIdx) => (
= ({ popup, onOpenChange
= ({ popup, onOpenChange margin: 0, padding: 0, fontSize: 'inherit', - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', borderRadius: 0, overflow: 'visible', whiteSpace: 'pre-wrap', @@ -232,7 +235,8 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange }} codeTagProps={{ style: { - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', }, }} > @@ -248,7 +252,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange ))}
) : popup.diffHunks ? ( -
+
{popup.diffHunks.map((hunk, hunkIdx) => (
= ({ popup, onOpenChange
= ({ popup, onOpenChange margin: 0, padding: 0, fontSize: 'inherit', - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', borderRadius: 0, overflow: 'visible', whiteSpace: 'pre-wrap', @@ -301,7 +306,8 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange }} codeTagProps={{ style: { - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', }, }} > @@ -313,7 +319,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange
= ({ popup, onOpenChange margin: 0, padding: 0, fontSize: 'inherit', - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', borderRadius: 0, overflow: 'visible', whiteSpace: 'pre-wrap', @@ -353,7 +360,8 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange }} codeTagProps={{ style: { - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', }, }} > @@ -402,7 +410,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange PreTag="div" wrapLongLines customStyle={toolDisplayStyles.getPopupContainerStyles()} - codeTagProps={{ style: { background: 'transparent !important' } }} + codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }} > {popup.content} @@ -417,13 +425,13 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange {popup.content} ) - ); + ); } if (tool === 'grep') { return ( renderGrepOutput(popup.content, isMobile) || ( -
+                                            
                                                 {popup.content}
                                             
) @@ -433,7 +441,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange if (tool === 'glob') { return ( renderGlobOutput(popup.content, isMobile) || ( -
+                                            
                                                 {popup.content}
                                             
) @@ -444,7 +452,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange return (
{popup.content} @@ -462,7 +470,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange PreTag="div" wrapLongLines customStyle={toolDisplayStyles.getPopupContainerStyles()} - codeTagProps={{ style: { background: 'transparent !important' } }} + codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }} > {popup.content} @@ -491,7 +499,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit); return ( -
+
{shouldShowLineNumber ? lineNumber : ''} @@ -509,7 +517,8 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange margin: 0, padding: 0, fontSize: 'inherit', - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', borderRadius: 0, overflow: 'visible', whiteSpace: 'pre-wrap', @@ -518,7 +527,9 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange }} codeTagProps={{ style: { - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', + fontSize: 'inherit', }, }} > @@ -540,7 +551,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange PreTag="div" wrapLongLines customStyle={toolDisplayStyles.getPopupContainerStyles()} - codeTagProps={{ style: { background: 'transparent !important' } }} + codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }} > {popup.content} @@ -550,7 +561,7 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange ) : (
Command completed successfully
-
No output was produced
+
No output was produced
)}
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 50f0a45c..4159f490 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; import { Streamdown } from 'streamdown'; import { cn } from '@/lib/utils'; @@ -175,7 +176,7 @@ const ToolScrollableSection: React.FC = ({ }) => (
@@ -191,7 +192,7 @@ interface DiffPreviewProps { } const DiffPreview: React.FC = ({ diff, syntaxTheme, input }) => ( -
+
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
@@ -203,7 +204,7 @@ const DiffPreview: React.FC = ({ diff, syntaxTheme, input }) =
= ({ diff, syntaxTheme, input }) = PreTag="div" wrapLines wrapLongLines - customStyle={{ - margin: 0, - padding: 0, - fontSize: 'inherit', - background: 'transparent !important', - borderRadius: 0, - overflow: 'visible', - whiteSpace: 'pre-wrap', - wordBreak: 'break-all', - overflowWrap: 'anywhere', - }} - codeTagProps={{ - style: { background: 'transparent !important' }, - }} - > - {line.content} - + customStyle={{ + margin: 0, + padding: 0, + fontSize: 'inherit', + background: 'transparent', + backgroundColor: 'transparent', + borderRadius: 0, + overflow: 'visible', + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + overflowWrap: 'anywhere', + }} + codeTagProps={{ + style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' }, + }} + > + {line.content} +
))} @@ -273,7 +275,7 @@ const WriteInputPreview: React.FC = ({ content, syntaxTh
{lines.map((line, lineIdx) => ( -
+
{lineIdx + 1} @@ -288,7 +290,8 @@ const WriteInputPreview: React.FC = ({ content, syntaxTh margin: 0, padding: 0, fontSize: 'inherit', - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', borderRadius: 0, overflow: 'visible', whiteSpace: 'pre-wrap', @@ -296,7 +299,7 @@ const WriteInputPreview: React.FC = ({ content, syntaxTh overflowWrap: 'anywhere', }} codeTagProps={{ - style: { background: 'transparent !important' }, + style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' }, }} > {line || ' '} @@ -421,7 +424,7 @@ const ToolExpandedContent: React.FC = ({ const listOutput = renderListOutput(outputString, { unstyled: true }); return renderScrollableBlock( listOutput ?? ( -
+                    
                         {outputString}
                     
) @@ -432,7 +435,7 @@ const ToolExpandedContent: React.FC = ({ const grepOutput = renderGrepOutput(outputString, isMobile, { unstyled: true }); return renderScrollableBlock( grepOutput ?? ( -
+                    
                         {outputString}
                     
) @@ -443,7 +446,7 @@ const ToolExpandedContent: React.FC = ({ const globOutput = renderGlobOutput(outputString, isMobile, { unstyled: true }); return renderScrollableBlock( globOutput ?? ( -
+                    
                         {outputString}
                     
) @@ -464,7 +467,7 @@ const ToolExpandedContent: React.FC = ({ const webSearchContent = renderWebSearchOutput(outputString, syntaxTheme, { unstyled: true }); return renderScrollableBlock( webSearchContent ?? ( -
+                    
                         {outputString}
                     
) @@ -497,14 +500,14 @@ const ToolExpandedContent: React.FC = ({ const isInfoMessage = (line: string) => line.trim().startsWith('('); return renderScrollableBlock( -
+
{lines.map((line: string, idx: number) => { const isInfo = isInfoMessage(line); const lineNumber = offset + idx + 1; const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit); return ( -
+
{shouldShowLineNumber ? lineNumber : ''} @@ -522,7 +525,8 @@ const ToolExpandedContent: React.FC = ({ margin: 0, padding: 0, fontSize: 'inherit', - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', borderRadius: 0, overflow: 'visible', whiteSpace: 'pre-wrap', @@ -531,7 +535,8 @@ const ToolExpandedContent: React.FC = ({ }} codeTagProps={{ style: { - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', }, }} > @@ -559,7 +564,8 @@ const ToolExpandedContent: React.FC = ({ }} codeTagProps={{ style: { - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', }, }} wrapLongLines @@ -603,10 +609,10 @@ const ToolExpandedContent: React.FC = ({ ) : hasInputText ? (
{renderScrollableBlock( -
+
{inputTextContent}
, - { maxHeightClass: 'max-h-60' } + { maxHeightClass: 'max-h-60', className: 'tool-input-surface' } )}
) : null} @@ -674,10 +680,38 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; + const input = stateWithData.input; const diffStats = (part.tool === 'edit' || part.tool === 'multiedit') ? parseDiffStats(metadata) : null; const description = getToolDescription(part, state, isMobile, currentDirectory); const displayName = getToolMetadata(part.tool).displayName; + const runtime = React.useContext(RuntimeAPIContext); + + const handleMainClick = (e: React.MouseEvent) => { + if (!runtime?.editor) { + onToggle(part.id); + return; + } + + let filePath: unknown; + if (part.tool === 'edit' || part.tool === 'multiedit') { + filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; + } else if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) { + filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; + } + + if (typeof filePath === 'string') { + e.stopPropagation(); + let absolutePath = filePath; + if (!filePath.startsWith('/')) { + absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath; + } + runtime.editor.openFile(absolutePath); + } else { + onToggle(part.id); + } + }; + if (!isFinalized) { return null; } @@ -689,11 +723,11 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT className={cn( 'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer' )} - onClick={() => onToggle(part.id)} + onClick={handleMainClick} >
{} -
+
{ e.stopPropagation(); onToggle(part.id); }}> {}
| null>(null); const resultTimeoutRef = useRef | null>(null); const transitionTimeoutRef = useRef | null>(null); + const rafIdRef = useRef(null); + const lastCheckTimeRef = useRef(0); const lastActiveStatusRef = useRef(null); const hasShownActivityRef = useRef(false); const wasAbortedRef = useRef(false); @@ -207,7 +209,16 @@ export function WorkingPlaceholder({ wasAbortedRef.current = false; }; - const checkInterval = setInterval(() => { + const CHECK_THROTTLE_MS = 150; // Throttle checks to ~6-7 times per second + + const checkLoop = (timestamp: number) => { + // Throttle: skip if less than CHECK_THROTTLE_MS since last check + if (timestamp - lastCheckTimeRef.current < CHECK_THROTTLE_MS) { + rafIdRef.current = requestAnimationFrame(checkLoop); + return; + } + lastCheckTimeRef.current = timestamp; + const now = Date.now(); const elapsed = now - displayStartTimeRef.current; @@ -216,6 +227,7 @@ export function WorkingPlaceholder({ const shouldWaitForMinTime = !isDone && statusQueueRef.current.length > 0; if (shouldWaitForMinTime && elapsed < MIN_DISPLAY_TIME) { + rafIdRef.current = requestAnimationFrame(checkLoop); return; } @@ -257,14 +269,24 @@ export function WorkingPlaceholder({ lastActiveStatusRef.current = null; removalPendingRef.current = false; wasAbortedRef.current = false; + rafIdRef.current = requestAnimationFrame(checkLoop); return; } startFadeOut(result); } - }, 50); - return () => clearInterval(checkInterval); + rafIdRef.current = requestAnimationFrame(checkLoop); + }; + + rafIdRef.current = requestAnimationFrame(checkLoop); + + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + }; }, [isFadingOut]); diff --git a/packages/ui/src/components/chat/message/toolRenderers.tsx b/packages/ui/src/components/chat/message/toolRenderers.tsx index 7c2c5677..ef82c597 100644 --- a/packages/ui/src/components/chat/message/toolRenderers.tsx +++ b/packages/ui/src/components/chat/message/toolRenderers.tsx @@ -70,7 +70,7 @@ export const renderListOutput = (output: string, options?: { unstyled?: boolean 'w-full min-w-0 font-mono space-y-0.5', options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30' )} - style={typography.micro} + style={typography.tool.popup} > {items.map((item, idx) => (
@@ -115,13 +115,14 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: { 'space-y-2 w-full min-w-0', options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30' )} + style={typography.tool.popup} >
Found {lines.length} match{lines.length !== 1 ? 'es' : ''}
{Object.entries(fileGroups).map(([filepath, matches]) => (
-
+
{filepath}
@@ -130,7 +131,7 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: { return null; } return ( -
+
{match.lineNum && ( @@ -181,18 +182,19 @@ export const renderGlobOutput = (output: string, isMobile: boolean, options?: { 'space-y-2 w-full min-w-0', options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30' )} + style={typography.tool.popup} >
Found {paths.length} file{paths.length !== 1 ? 's' : ''}
{sortedDirs.map((dir) => (
-
+
{dir}/
{groups[dir].sort().map((filename) => ( -
+
{filename}
@@ -248,6 +250,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean 'space-y-3 w-full min-w-0', options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30' )} + style={typography.tool.popup} >
Total: {todos.length} @@ -275,7 +278,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean {todosByStatus.in_progress.map((todo, idx) => (
{getPriorityDot(todo.priority)} - {todo.content} + {todo.content}
))}
@@ -292,7 +295,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean {todosByStatus.pending.map((todo, idx) => (
{getPriorityDot(todo.priority)} - {todo.content} + {todo.content}
))}
@@ -309,7 +312,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean {todosByStatus.completed.map((todo, idx) => (
- {todo.content} + {todo.content}
))}
@@ -326,7 +329,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean {todosByStatus.cancelled.map((todo, idx) => (
× - {todo.content} + {todo.content}
))}
@@ -344,9 +347,10 @@ export const renderWebSearchOutput = (output: string, _syntaxTheme: { [key: stri return (
{output} diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx new file mode 100644 index 00000000..a80dddb5 --- /dev/null +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -0,0 +1,299 @@ +import React from 'react'; +import { ErrorBoundary } from '../ui/ErrorBoundary'; +import { SessionSidebar } from '@/components/session/SessionSidebar'; +import { ChatView } from '@/components/views'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; +import { RiAddLine, RiArrowLeftLine } from '@remixicon/react'; +import { RiLoader4Line } from '@remixicon/react'; + +type VSCodeView = 'sessions' | 'chat'; + +export const VSCodeLayout: React.FC = () => { + const [currentView, setCurrentView] = React.useState('sessions'); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const sessions = useSessionStore((state) => state.sessions); + const createSession = useSessionStore((state) => state.createSession); + const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>( + () => (typeof window !== 'undefined' + ? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as + 'connecting' | 'connected' | 'error' | 'disconnected' | undefined + : 'connecting') || 'connecting' + ); + const [connectionError, setConnectionError] = React.useState( + () => (typeof window !== 'undefined' + ? (window as { __OPENCHAMBER_CONNECTION__?: { error?: string } }).__OPENCHAMBER_CONNECTION__?.error + : undefined), + ); + const [hasEverConnected, setHasEverConnected] = React.useState(() => connectionStatus === 'connected'); + const [overlayVisible, setOverlayVisible] = React.useState(() => connectionStatus !== 'connected'); + const overlayTimer = React.useRef(null); + const configInitialized = useConfigStore((state) => state.isInitialized); + const initializeConfig = useConfigStore((state) => state.initializeApp); + const loadSessions = useSessionStore((state) => state.loadSessions); + const loadMessages = useSessionStore((state) => state.loadMessages); + const messages = useSessionStore((state) => state.messages); + const [hasInitializedOnce, setHasInitializedOnce] = React.useState(() => configInitialized); + const [isInitializing, setIsInitializing] = React.useState(false); + const autoSelectedRef = React.useRef(false); + const startedFreshSessionRef = React.useRef(false); + + // Navigate to chat when a session is selected + React.useEffect(() => { + if (currentSessionId) { + setCurrentView('chat'); + } + }, [currentSessionId]); + + // If the active session disappears (e.g., deleted), stay on the sessions list + React.useEffect(() => { + if (!currentSessionId && currentView === 'chat') { + setCurrentView('sessions'); + } + }, [currentSessionId, currentView]); + + const handleBackToSessions = React.useCallback(() => { + setCurrentView('sessions'); + }, []); + + const handleNewSession = React.useCallback(async () => { + const result = await createSession(); + if (result?.id) { + setCurrentView('chat'); + } + }, [createSession]); + + React.useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail; + const status = detail?.status; + if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') { + setConnectionStatus(status); + setConnectionError(detail?.error); + if (status === 'connected') { + setHasEverConnected(true); + } + } + }; + window.addEventListener('openchamber:connection-status', handler as EventListener); + return () => window.removeEventListener('openchamber:connection-status', handler as EventListener); + }, []); + + const showConnectionOverlay = React.useMemo(() => { + if (hasInitializedOnce && connectionStatus === 'connected' && !isInitializing) { + return false; + } + if (!hasInitializedOnce) { + return connectionStatus !== 'connected' || isInitializing; + } + return connectionStatus === 'error'; + }, [connectionStatus, hasInitializedOnce, isInitializing]); + + const overlayDelay = hasEverConnected ? 800 : 250; + + React.useEffect(() => { + if (overlayTimer.current) { + window.clearTimeout(overlayTimer.current); + overlayTimer.current = null; + } + + if (showConnectionOverlay) { + overlayTimer.current = window.setTimeout(() => setOverlayVisible(true), overlayDelay); + } else { + setOverlayVisible(false); + } + + return () => { + if (overlayTimer.current) { + window.clearTimeout(overlayTimer.current); + overlayTimer.current = null; + } + }; + }, [overlayDelay, showConnectionOverlay]); + + React.useEffect(() => { + const runBootstrap = async () => { + if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') { + return; + } + setIsInitializing(true); + try { + if (!configInitialized) { + await initializeConfig(); + } + await loadSessions(); + setHasInitializedOnce(true); + } catch { + // Ignore bootstrap failures; overlay will remain until next attempt + } finally { + setIsInitializing(false); + } + }; + void runBootstrap(); + }, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]); + + React.useEffect(() => { + const hydrateMessages = async () => { + if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat') { + return; + } + const targetSessionId = currentSessionId || sessions[0]?.id; + if (!targetSessionId) return; + + const hasMessages = messages.has(targetSessionId) && (messages.get(targetSessionId)?.length || 0) > 0; + if (!hasMessages) { + if (!currentSessionId) { + setCurrentSession(targetSessionId); + } + try { + await loadMessages(targetSessionId); + } catch { /* ignored */ } + } + }; + + void hydrateMessages(); + }, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, sessions, setCurrentSession]); + + React.useEffect(() => { + if (!hasInitializedOnce || autoSelectedRef.current) { + return; + } + if (!currentSessionId && sessions.length > 0) { + setCurrentSession(sessions[0].id); + autoSelectedRef.current = true; + } + }, [currentSessionId, hasInitializedOnce, sessions, setCurrentSession]); + + React.useEffect(() => { + const ensureFreshSession = async () => { + if (connectionStatus !== 'connected' || !hasInitializedOnce || startedFreshSessionRef.current) { + return; + } + + const current = sessions.find((s) => s.id === currentSessionId); + const isCurrentPlaceholder = current?.title?.toLowerCase()?.startsWith('new session'); + + if (current && isCurrentPlaceholder) { + setCurrentSession(current.id); + } else { + // Look for an existing empty session to reuse before creating a new one + const reusableSession = sessions.find((s) => s.title?.toLowerCase()?.startsWith('new session')); + + if (reusableSession) { + setCurrentSession(reusableSession.id); + } else { + const newSession = await createSession(); + if (newSession?.id) { + setCurrentSession(newSession.id); + } + } + } + startedFreshSessionRef.current = true; + }; + + void ensureFreshSession(); + }, [connectionStatus, createSession, currentSessionId, hasInitializedOnce, sessions, setCurrentSession]); + + return ( +
+ {currentView === 'sessions' ? ( +
+ +
+ setCurrentView('chat')} + hideDirectoryControls + /> +
+
+ ) : ( +
+ s.id === currentSessionId)?.title || 'Chat'} + showBack + onBack={handleBackToSessions} + showContextUsage + /> +
+ + + +
+
+ )} + {overlayVisible && ( +
+ +
+ {connectionStatus === 'connecting' + ? (hasEverConnected ? 'Reconnecting to OpenCode…' : 'Starting OpenCode API…') + : 'Lost connection to OpenCode'} +
+ {connectionError && ( +
+ {connectionError} +
+ )} +
+ )} +
+ ); +}; + +interface VSCodeHeaderProps { + title: string; + showBack?: boolean; + onBack?: () => void; + onNewSession?: () => void; + showContextUsage?: boolean; +} + +const VSCodeHeader: React.FC = ({ title, showBack, onBack, onNewSession, showContextUsage }) => { + const { getCurrentModel } = useConfigStore(); + const getContextUsage = useSessionStore((state) => state.getContextUsage); + + const currentModel = getCurrentModel(); + const limits = (currentModel?.limit && typeof currentModel.limit === 'object' + ? currentModel.limit + : null) as { context?: number; output?: number } | null; + const contextLimit = typeof limits?.context === 'number' ? limits.context : 0; + const outputLimit = typeof limits?.output === 'number' ? limits.output : 0; + const contextUsage = getContextUsage(contextLimit, outputLimit); + + return ( +
+ {showBack && onBack && ( + + )} +

{title}

+ {onNewSession && ( + + )} + {showContextUsage && contextUsage && contextUsage.totalTokens > 0 && ( + + )} +
+ ); +}; diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 3fd47f8c..ec7994d5 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -107,9 +107,17 @@ type SessionGroup = { interface SessionSidebarProps { mobileVariant?: boolean; + onSessionSelected?: (sessionId: string) => void; + allowReselect?: boolean; + hideDirectoryControls?: boolean; } -export const SessionSidebar: React.FC = ({ mobileVariant = false }) => { +export const SessionSidebar: React.FC = ({ + mobileVariant = false, + onSessionSelected, + allowReselect = false, + hideDirectoryControls = false, +}) => { const [editingId, setEditingId] = React.useState(null); const [editTitle, setEditTitle] = React.useState(''); const [copiedSessionId, setCopiedSessionId] = React.useState(null); @@ -336,9 +344,14 @@ export const SessionSidebar: React.FC = ({ mobileVariant = if (disabled) { return; } + if (!allowReselect && sessionId === currentSessionId) { + onSessionSelected?.(sessionId); + return; + } setCurrentSession(sessionId); + onSessionSelected?.(sessionId); }, - [setCurrentSession], + [allowReselect, currentSessionId, onSessionSelected, setCurrentSession], ); const handleSaveEdit = React.useCallback(async () => { @@ -922,48 +935,50 @@ export const SessionSidebar: React.FC = ({ mobileVariant = mobileVariant ? '' : isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar', )} > -
-
- - - {isGitRepo ? ( + {!hideDirectoryControls && ( +
+
- ) : null} + + {isGitRepo ? ( + + ) : null} +
-
+ )} = ({ mobileVariant = > {groupedSessions.length === 0 ? ( emptyState + ) : hideDirectoryControls && groupedSessions.length === 1 && groupedSessions[0].isMain ? ( +
+ {groupedSessions[0].sessions.length === 0 ? ( +
+ No sessions yet. +
+ ) : ( + groupedSessions[0].sessions.map((node) => renderSessionNode(node, 0, groupedSessions[0].directory)) + )} +
) : ( groupedSessions.map((group) => (
@@ -997,30 +1022,32 @@ export const SessionSidebar: React.FC = ({ mobileVariant = {group.label} - { - if (isCreatingSession) return; - e.stopPropagation(); - handleCreateSessionInGroup(group.directory); - }} - onKeyDown={(e) => { - if (isCreatingSession) return; - if (e.key === 'Enter' || e.key === ' ') { + {!hideDirectoryControls && ( + { + if (isCreatingSession) return; e.stopPropagation(); handleCreateSessionInGroup(group.directory); - } - }} - > - - + }} + onKeyDown={(e) => { + if (isCreatingSession) return; + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + handleCreateSessionInGroup(group.directory); + } + }} + > + + + )}
diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index 719f9d15..3004aa61 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -6,7 +6,7 @@ import React, { } from 'react'; import type { Theme, ThemeMode } from '@/types/theme'; import type { DesktopSettings } from '@/lib/desktop'; -import { isDesktopRuntime } from '@/lib/desktop'; +import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop'; import { CSSVariableGenerator } from '@/lib/theme/cssGenerator'; import { updateDesktopSettings } from '@/lib/persistence'; import { @@ -16,6 +16,8 @@ import { flexokiDarkTheme, } from '@/lib/theme/themes'; import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context'; +import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter'; +import { useUIStore } from '@/stores/useUIStore'; type ThemePreferences = { themeMode: ThemeMode; @@ -50,6 +52,8 @@ const ensureThemeById = (themeId: string, variant: 'light' | 'dark'): Theme => { return fallback ?? fallbackThemeForVariant(variant); }; +const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; + const validateThemeId = (themeId: string | null, variant: 'light' | 'dark'): string => { if (!themeId) { return variant === 'light' ? DEFAULT_LIGHT_ID : DEFAULT_DARK_ID; @@ -126,8 +130,19 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro const cssGenerator = useMemo(() => new CSSVariableGenerator(), []); const [preferences, setPreferences] = useState(() => buildInitialPreferences(defaultThemeId)); const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPreference()); + const [vscodeTheme, setVSCodeTheme] = useState(() => { + if (typeof window === 'undefined' || !isVSCodeRuntime()) { + return null; + } + const existing = (window as unknown as { __OPENCHAMBER_VSCODE_THEME__?: Theme }).__OPENCHAMBER_VSCODE_THEME__; + return existing || null; + }); + const isVSCode = useMemo(() => isVSCodeRuntime(), []); const currentTheme = useMemo(() => { + if (isVSCode && vscodeTheme) { + return vscodeTheme; + } if (preferences.themeMode === 'light') { return ensureThemeById(preferences.lightThemeId, 'light'); } @@ -137,9 +152,42 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro return systemPrefersDark ? ensureThemeById(preferences.darkThemeId, 'dark') : ensureThemeById(preferences.lightThemeId, 'light'); - }, [preferences, systemPrefersDark]); + }, [isVSCode, preferences, systemPrefersDark, vscodeTheme]); - const availableThemes = themes; + const availableThemes = useMemo( + () => (isVSCode && vscodeTheme ? [vscodeTheme, ...themes] : themes), + [isVSCode, vscodeTheme], + ); + + useEffect(() => { + if (!isVSCode) { + return; + } + + const applyVSCodeTheme = (theme: Theme) => { + setVSCodeTheme(theme); + const variant: ThemeMode = theme.metadata.variant === 'dark' ? 'dark' : 'light'; + const uiStore = useUIStore.getState(); + if (uiStore.theme !== variant) { + uiStore.setTheme(variant); + } + }; + + const handleThemeEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.theme) { + applyVSCodeTheme(detail.theme); + } + }; + + const existing = (window as unknown as { __OPENCHAMBER_VSCODE_THEME__?: Theme }).__OPENCHAMBER_VSCODE_THEME__; + if (existing) { + applyVSCodeTheme(existing); + } + + window.addEventListener('openchamber:vscode-theme', handleThemeEvent as EventListener); + return () => window.removeEventListener('openchamber:vscode-theme', handleThemeEvent as EventListener); + }, [isVSCode]); const updateBrowserChrome = useCallback((theme: Theme) => { if (typeof document === 'undefined') { @@ -177,17 +225,25 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro metaThemeColorMedia.setAttribute('content', chromeColor); }, []); - useEffect(() => { + const applyVSCodeRuntimeClass = useCallback((enabled: boolean) => { + if (typeof document === 'undefined') { + return; + } + document.documentElement.classList.toggle('vscode-runtime', enabled); + }, []); + + useIsomorphicLayoutEffect(() => { if (typeof window === 'undefined') { return; } cssGenerator.apply(currentTheme); + applyVSCodeRuntimeClass(isVSCode); updateBrowserChrome(currentTheme); const root = document.documentElement; root.classList.remove('light', 'dark'); root.classList.add(currentTheme.metadata.variant); - }, [cssGenerator, currentTheme, updateBrowserChrome]); + }, [applyVSCodeRuntimeClass, cssGenerator, currentTheme, isVSCode, updateBrowserChrome]); useEffect(() => { if (preferences.themeMode !== 'system' || typeof window === 'undefined') { diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index a404c688..cbabcdfd 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -56,7 +56,7 @@ interface UseChatScrollManagerResult { handleMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; showScrollButton: boolean; - scrollToBottom: (options?: { instant?: boolean }) => void; + scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void; spacerHeight: number; pendingAnchorId: string | null; hasActiveAnchor: boolean; @@ -116,6 +116,7 @@ export const useChatScrollManager = ({ const anchorIdRef = React.useRef(null); const hasAnchoredOnceRef = React.useRef(false); + const userScrollOverrideRef = React.useRef(false); const currentPhase = currentSessionId ? sessionActivityPhase?.get(currentSessionId) ?? 'idle' @@ -238,13 +239,30 @@ export const useChatScrollManager = ({ } }, [pendingAnchorId]); - const scrollToBottom = React.useCallback((options?: { instant?: boolean }) => { + const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean }) => { const container = scrollRef.current; if (!container) return; + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; + + const shouldRespectUserScroll = + userScrollOverrideRef.current && + currentPhase === 'idle' && + !isSyncing && + !options?.force && + distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD; + + if (shouldRespectUserScroll) { + return; + } + + if (options?.force) { + userScrollOverrideRef.current = false; + } + const bottom = container.scrollHeight - container.clientHeight; scrollEngine.scrollToPosition(Math.max(0, bottom), options); - }, [scrollEngine]); + }, [currentPhase, isSyncing, scrollEngine]); const scrollToNewAnchor = React.useCallback((messageId: string) => { if (lastScrolledAnchorIdRef.current === messageId) { @@ -294,12 +312,16 @@ export const useChatScrollManager = ({ }); }, [scrollEngine, updateSpacerHeight]); - const handleScrollEvent = React.useCallback(() => { + const handleScrollEvent = React.useCallback((event?: Event) => { const container = scrollRef.current; if (!container || !currentSessionId) { return; } + if (event?.isTrusted) { + userScrollOverrideRef.current = true; + } + scrollEngine.handleScroll(); updateScrollButtonVisibility(); @@ -328,10 +350,10 @@ export const useChatScrollManager = ({ const container = scrollRef.current; if (!container) return; - container.addEventListener('scroll', handleScrollEvent, { passive: true }); + container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true }); return () => { - container.removeEventListener('scroll', handleScrollEvent); + container.removeEventListener('scroll', handleScrollEvent as EventListener); }; }, [handleScrollEvent]); @@ -376,6 +398,7 @@ export const useChatScrollManager = ({ spacerHeightRef.current = 0; setSpacerHeight(0); + userScrollOverrideRef.current = false; } // eslint-disable-next-line react-hooks/exhaustive-deps -- only run on session change, not message changes }, [currentSessionId, sessionMessages.length]); diff --git a/packages/ui/src/hooks/useRuntimeAPIs.ts b/packages/ui/src/hooks/useRuntimeAPIs.ts index c7cb0bb8..128d8131 100644 --- a/packages/ui/src/hooks/useRuntimeAPIs.ts +++ b/packages/ui/src/hooks/useRuntimeAPIs.ts @@ -16,3 +16,5 @@ export const useRuntimeAPI = (selector: RuntimeAPISelector): TV }; export const useIsDesktopRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isDesktop); + +export const useIsVSCodeRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isVSCode); diff --git a/packages/ui/src/hooks/useUpdateCheck.ts b/packages/ui/src/hooks/useUpdateCheck.ts index 00adbd1e..7d6613f6 100644 --- a/packages/ui/src/hooks/useUpdateCheck.ts +++ b/packages/ui/src/hooks/useUpdateCheck.ts @@ -75,25 +75,15 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => { error: null, }); - const [mockMode, setMockMode] = useState(shouldMockUpdate); - const [mockState, setMockState] = useState(null); - - // Check for mock mode changes (for console toggling) - useEffect(() => { - const interval = setInterval(() => { - const shouldMock = shouldMockUpdate(); - if (shouldMock !== mockMode) { - setMockMode(shouldMock); - if (shouldMock) { - const config = getMockConfig(); - if (config) { - setMockState(createMockUpdate(config)); - } - } - } - }, 500); - return () => clearInterval(interval); - }, [mockMode]); + // Only check mock mode once at startup - no polling + const [mockMode] = useState(shouldMockUpdate); + const [mockState, setMockState] = useState(() => { + if (shouldMockUpdate()) { + const config = getMockConfig(); + return config ? createMockUpdate(config) : null; + } + return null; + }); const checkForUpdates = useCallback(async () => { if (mockMode) { diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index c63e63f8..d88439fa 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1259,9 +1259,55 @@ html:not(.dark) .chat-scroll { Minimal overrides - fonts only, using Streamdown defaults ============================================ */ +/* VS Code webviews can inject default pre/code/blockquote backgrounds; tool input/output should inherit the card surface. */ +.tool-input-surface pre, +.tool-input-surface code, +.tool-input-surface blockquote, +.tool-input-text { + background: transparent !important; + background-color: transparent !important; +} + +/* VSCode: tool cards should honor semantic code sizing, even when syntax themes inject sizes. */ +:root.vscode-runtime .tool-input-surface, +:root.vscode-runtime .tool-output-surface { + font-size: var(--text-code) !important; +} + +:root.vscode-runtime .tool-input-surface code, +:root.vscode-runtime .tool-input-surface pre, +:root.vscode-runtime .tool-output-surface code, +:root.vscode-runtime .tool-output-surface pre { + font-size: inherit !important; +} + +/* Model/agent controls: collapse labels in narrow containers (mobile + VSCode side panel). */ +@container model-controls (max-width: 15rem) { + .model-controls__agent-label { + display: none; + } +} + +@container model-controls (max-width: 12rem) { + .model-controls__model-label { + display: none; + } +} + /* Text font: IBM Plex Sans */ .streamdown-content { font-family: var(--font-sans); + font-size: var(--text-markdown); +} + +/* Tool markdown should render at code size to match tool card typography. */ +.streamdown-content.streamdown-tool { + font-size: var(--text-code) !important; +} + +.streamdown-content.streamdown-tool code, +.streamdown-content.streamdown-tool pre { + font-size: inherit !important; } /* Code font: IBM Plex Mono */ diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index e383d193..ca938c97 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1,12 +1,14 @@ import type { WorktreeMetadata } from '@/types/worktree'; -export type RuntimePlatform = 'web' | 'desktop'; +export type RuntimePlatform = 'web' | 'desktop' | 'vscode'; export interface RuntimeDescriptor { platform: RuntimePlatform; isDesktop: boolean; + isVSCode: boolean; + label?: string; } @@ -386,6 +388,11 @@ export interface ToolsAPI { getAvailableTools(): Promise; } +export interface EditorAPI { + openFile(path: string, line?: number, column?: number): Promise; + openDiff(original: string, modified: string, label?: string): Promise; +} + export interface RuntimeAPIs { runtime: RuntimeDescriptor; terminal: TerminalAPI; @@ -396,6 +403,7 @@ export interface RuntimeAPIs { notifications: NotificationsAPI; diagnostics?: DiagnosticsAPI; tools: ToolsAPI; + editor?: EditorAPI; worktrees?: WorktreeMetadata[]; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 883c41a9..7ee766de 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -65,6 +65,12 @@ export type DesktopApi = { export const isDesktopRuntime = (): boolean => typeof window !== "undefined" && typeof window.opencodeDesktop !== "undefined"; +export const isVSCodeRuntime = (): boolean => { + if (typeof window === "undefined") return false; + const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__; + return apis?.runtime?.isVSCode === true; +}; + export const getDesktopApi = (): DesktopApi | null => { if (!isDesktopRuntime()) { return null; diff --git a/packages/ui/src/lib/theme/cssGenerator.ts b/packages/ui/src/lib/theme/cssGenerator.ts index 0207e1b9..a2701ae7 100644 --- a/packages/ui/src/lib/theme/cssGenerator.ts +++ b/packages/ui/src/lib/theme/cssGenerator.ts @@ -1,5 +1,6 @@ import type { Theme } from '@/types/theme'; -import { SEMANTIC_TYPOGRAPHY } from '@/lib/typography'; +import { SEMANTIC_TYPOGRAPHY, VSCODE_TYPOGRAPHY } from '@/lib/typography'; +import { isVSCodeRuntime } from '@/lib/desktop'; const hexToRgb = (value: string | undefined | null): string | null => { if (!value || typeof value !== 'string') { @@ -525,22 +526,23 @@ export class CSSVariableGenerator { private generateTypographyVariables(): string[] { const vars: string[] = []; + const typography = isVSCodeRuntime() ? VSCODE_TYPOGRAPHY : SEMANTIC_TYPOGRAPHY; vars.push(' /* Semantic Typography Variables */'); vars.push(' --ui-regular-font-weight: 400;'); vars.push(' /* Markdown content - all markdown elements use same size */'); - vars.push(` --text-markdown: ${SEMANTIC_TYPOGRAPHY.markdown};`); + vars.push(` --text-markdown: ${typography.markdown};`); vars.push(' /* Code content - all code elements use same size */'); - vars.push(` --text-code: ${SEMANTIC_TYPOGRAPHY.code};`); + vars.push(` --text-code: ${typography.code};`); vars.push(' /* UI headers - dialog titles, panel headers */'); - vars.push(` --text-ui-header: ${SEMANTIC_TYPOGRAPHY.uiHeader};`); + vars.push(` --text-ui-header: ${typography.uiHeader};`); vars.push(' /* UI labels - buttons, menus, navigation */'); - vars.push(` --text-ui-label: ${SEMANTIC_TYPOGRAPHY.uiLabel};`); + vars.push(` --text-ui-label: ${typography.uiLabel};`); vars.push(' /* Metadata - timestamps, status, helper text */'); - vars.push(` --text-meta: ${SEMANTIC_TYPOGRAPHY.meta};`); + vars.push(` --text-meta: ${typography.meta};`); vars.push(' /* Micro text - badges, shortcuts, indicators */'); - vars.push(` --text-micro: ${SEMANTIC_TYPOGRAPHY.micro};`); + vars.push(` --text-micro: ${typography.micro};`); vars.push(' /* Heading line height and letter spacing */'); vars.push(' --h1-line-height: 1.25rem;'); diff --git a/packages/ui/src/lib/theme/vscode/adapter.ts b/packages/ui/src/lib/theme/vscode/adapter.ts new file mode 100644 index 00000000..4cf14e2a --- /dev/null +++ b/packages/ui/src/lib/theme/vscode/adapter.ts @@ -0,0 +1,268 @@ +import type { Theme } from '@/types/theme'; +import type { ThemeMode } from '@/types/theme'; +import { flexokiDarkTheme, flexokiLightTheme } from '@/lib/theme/themes'; + +export type VSCodeThemeKind = 'light' | 'dark' | 'high-contrast'; + +export type VSCodeThemeColorToken = + | 'editor.background' + | 'editor.foreground' + | 'editor.selectionBackground' + | 'editor.selectionForeground' + | 'editor.lineHighlightBackground' + | 'editorCursor.foreground' + | 'focusBorder' + | 'sideBar.background' + | 'sideBar.foreground' + | 'panel.background' + | 'panel.foreground' + | 'panel.border' + | 'input.background' + | 'input.foreground' + | 'input.border' + | 'button.background' + | 'button.foreground' + | 'button.hoverBackground' + | 'textLink.foreground' + | 'descriptionForeground' + | 'terminal.ansiRed' + | 'terminal.ansiGreen' + | 'terminal.ansiBlue' + | 'terminal.ansiYellow' + | 'terminal.ansiCyan' + | 'editorError.foreground' + | 'editorError.background' + | 'editorWarning.foreground' + | 'editorWarning.background' + | 'editorInfo.foreground' + | 'editorInfo.background' + | 'testing.iconPassed' + | 'badge.background' + | 'badge.foreground' + | 'statusBar.background' + | 'statusBar.foreground' + | 'list.hoverBackground' + | 'list.activeSelectionBackground' + | 'textPreformat.foreground' + | 'textPreformat.background'; + +export type VSCodeThemePalette = { + kind: VSCodeThemeKind; + colors: Partial>; + mode?: ThemeMode; +}; + +export type VSCodeThemePayload = { + theme: Theme; + palette: VSCodeThemePalette; +}; + +const VARIABLE_MAP: Record = { + 'editor.background': '--vscode-editor-background', + 'editor.foreground': '--vscode-editor-foreground', + 'editor.selectionBackground': '--vscode-editor-selectionBackground', + 'editor.selectionForeground': '--vscode-editor-selectionForeground', + 'editor.lineHighlightBackground': '--vscode-editor-lineHighlightBackground', + 'editorCursor.foreground': '--vscode-editorCursor-foreground', + focusBorder: '--vscode-focusBorder', + 'sideBar.background': '--vscode-sideBar-background', + 'sideBar.foreground': '--vscode-sideBar-foreground', + 'panel.background': '--vscode-panel-background', + 'panel.foreground': '--vscode-panel-foreground', + 'panel.border': '--vscode-panel-border', + 'input.background': '--vscode-input-background', + 'input.foreground': '--vscode-input-foreground', + 'input.border': '--vscode-input-border', + 'button.background': '--vscode-button-background', + 'button.foreground': '--vscode-button-foreground', + 'button.hoverBackground': '--vscode-button-hoverBackground', + 'textLink.foreground': '--vscode-textLink-foreground', + descriptionForeground: '--vscode-descriptionForeground', + 'terminal.ansiRed': '--vscode-terminal-ansiRed', + 'terminal.ansiGreen': '--vscode-terminal-ansiGreen', + 'terminal.ansiBlue': '--vscode-terminal-ansiBlue', + 'terminal.ansiYellow': '--vscode-terminal-ansiYellow', + 'terminal.ansiCyan': '--vscode-terminal-ansiCyan', + 'editorError.foreground': '--vscode-editorError-foreground', + 'editorError.background': '--vscode-editorError-background', + 'editorWarning.foreground': '--vscode-editorWarning-foreground', + 'editorWarning.background': '--vscode-editorWarning-background', + 'editorInfo.foreground': '--vscode-editorInfo-foreground', + 'editorInfo.background': '--vscode-editorInfo-background', + 'testing.iconPassed': '--vscode-testing-iconPassed', + 'badge.background': '--vscode-badge-background', + 'badge.foreground': '--vscode-badge-foreground', + 'statusBar.background': '--vscode-statusBar-background', + 'statusBar.foreground': '--vscode-statusBar-foreground', + 'list.hoverBackground': '--vscode-list-hoverBackground', + 'list.activeSelectionBackground': '--vscode-list-activeSelectionBackground', + 'textPreformat.foreground': '--vscode-textPreformat-foreground', + 'textPreformat.background': '--vscode-textPreformat-background', +}; + +const normalizeColor = (value?: string | null): string | undefined => { + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed; +}; + +const readKind = (preferred?: VSCodeThemeKind): VSCodeThemeKind => { + if (preferred === 'light' || preferred === 'dark' || preferred === 'high-contrast') { + return preferred; + } + + if (typeof window !== 'undefined') { + const prefersLight = typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-color-scheme: light)').matches; + return prefersLight ? 'light' : 'dark'; + } + + return 'dark'; +}; + +export const readVSCodeThemePalette = ( + preferredKind?: VSCodeThemeKind, + preferredMode?: ThemeMode, +): VSCodeThemePalette | null => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return null; + } + + const styles = getComputedStyle(document.documentElement); + const colors: Partial> = {}; + + (Object.keys(VARIABLE_MAP) as VSCodeThemeColorToken[]).forEach((token) => { + const cssVar = VARIABLE_MAP[token]; + const value = normalizeColor(styles.getPropertyValue(cssVar)); + if (value) { + colors[token] = value; + } + }); + + return { + kind: readKind(preferredKind), + colors, + mode: preferredMode, + }; +}; + +export const buildVSCodeThemeFromPalette = (palette: VSCodeThemePalette): Theme => { + const base = palette.kind === 'light' ? flexokiLightTheme : flexokiDarkTheme; + const read = (token: VSCodeThemeColorToken, fallback: string): string => + palette.colors[token] ?? fallback; + + const sidebarBg = read('sideBar.background', base.colors.surface.background); + const sidebarFg = read('sideBar.foreground', read('descriptionForeground', base.colors.surface.mutedForeground)); + const panelBg = read('panel.background', read('editor.background', base.colors.surface.elevated)); + const panelFg = read('panel.foreground', read('editor.foreground', base.colors.surface.foreground)); + const background = sidebarBg; + const foreground = read('editor.foreground', base.colors.surface.foreground); + const accent = read('textLink.foreground', read('button.background', base.colors.primary.base)); + const accentFg = read('button.foreground', base.colors.primary.foreground || base.colors.surface.background); + const hoverBg = read('list.hoverBackground', read('editor.selectionBackground', base.colors.interactive.hover)); + const activeBg = read('list.activeSelectionBackground', hoverBg); + const selection = read('editor.selectionBackground', activeBg); + const selectionFg = read('editor.selectionForeground', foreground); + const focus = read('focusBorder', selection); + const border = read('input.border', read('panel.border', base.colors.interactive.border)); + const cursor = read('editorCursor.foreground', base.colors.interactive.cursor); + const badgeBg = read('badge.background', accent); + const badgeFg = read('badge.foreground', foreground); + + const inlineCode = read('textPreformat.foreground', read('terminal.ansiGreen', base.colors.syntax.base.string)); + // Tailwind's `--accent` drives hovered/selected menu items in Radix/shadcn; prefer VS Code list hover/selection. + const subtle = hoverBg; + + return { + ...base, + metadata: { + ...base.metadata, + id: 'vscode-auto', + name: 'VS Code Theme', + description: 'Mirrors your current VS Code color theme', + author: 'VS Code', + version: '1.0.0', + variant: palette.kind === 'light' ? 'light' : 'dark', + tags: ['vscode', 'auto'], + }, + colors: { + ...base.colors, + primary: { + base: accent, + hover: read('button.hoverBackground', accent), + active: read('button.hoverBackground', accent), + foreground: accentFg, + muted: read('textLink.foreground', accent), + emphasis: accent, + }, + surface: { + ...base.colors.surface, + background, + foreground, + muted: panelBg, + mutedForeground: sidebarFg, + elevated: panelBg, + elevatedForeground: panelFg, + overlay: read('statusBar.background', base.colors.surface.overlay), + subtle, + }, + interactive: { + ...base.colors.interactive, + border, + borderHover: border, + borderFocus: focus, + selection, + selectionForeground: selectionFg, + focus, + focusRing: focus, + cursor, + hover: hoverBg, + active: activeBg, + }, + status: { + ...base.colors.status, + error: read('editorError.foreground', base.colors.status.error), + errorForeground: read('editorError.foreground', base.colors.status.errorForeground), + errorBackground: read('editorError.background', base.colors.status.errorBackground), + errorBorder: read('editorError.foreground', base.colors.status.errorBorder), + warning: read('editorWarning.foreground', base.colors.status.warning), + warningForeground: read('editorWarning.foreground', base.colors.status.warningForeground), + warningBackground: read('editorWarning.background', base.colors.status.warningBackground), + warningBorder: read('editorWarning.foreground', base.colors.status.warningBorder), + success: read('testing.iconPassed', base.colors.status.success), + successForeground: read('testing.iconPassed', base.colors.status.successForeground), + successBackground: read('testing.iconPassed', base.colors.status.successBackground), + successBorder: read('testing.iconPassed', base.colors.status.successBorder), + info: read('editorInfo.foreground', base.colors.status.info), + infoForeground: read('editorInfo.foreground', base.colors.status.infoForeground), + infoBackground: read('editorInfo.background', base.colors.status.infoBackground), + infoBorder: read('editorInfo.foreground', base.colors.status.infoBorder), + }, + syntax: { + ...base.colors.syntax, + base: { + ...base.colors.syntax.base, + background, + foreground, + comment: read('editor.lineHighlightBackground', base.colors.syntax.base.comment), + keyword: accent, + string: inlineCode, + number: read('terminal.ansiYellow', base.colors.syntax.base.number), + function: read('terminal.ansiBlue', base.colors.syntax.base.function), + variable: read('terminal.ansiCyan', base.colors.syntax.base.variable), + type: read('terminal.ansiCyan', base.colors.syntax.base.type), + operator: accent, + }, + }, + badges: { + ...(base.colors.badges || {}), + default: { + bg: badgeBg, + fg: badgeFg, + border: border, + }, + }, + }, + }; +}; diff --git a/packages/ui/src/lib/typography.ts b/packages/ui/src/lib/typography.ts index 53211587..1e34f7ea 100644 --- a/packages/ui/src/lib/typography.ts +++ b/packages/ui/src/lib/typography.ts @@ -7,6 +7,15 @@ export const SEMANTIC_TYPOGRAPHY = { micro: '0.875rem', } as const; +export const VSCODE_TYPOGRAPHY = { + markdown: '0.9375rem', + code: '0.9375rem', + uiHeader: '1rem', + uiLabel: '0.9375rem', + meta: '0.9375rem', + micro: '0.875rem', +} as const; + export const SEMANTIC_TYPOGRAPHY_CSS = { '--text-markdown': SEMANTIC_TYPOGRAPHY.markdown, '--text-code': SEMANTIC_TYPOGRAPHY.code, @@ -247,7 +256,8 @@ export const toolDisplayStyles = { getCollapsedStyles: () => ({ ...typography.tool.collapsed, - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', margin: 0, padding: toolDisplayStyles.padding.collapsed, borderRadius: 0, @@ -255,7 +265,8 @@ export const toolDisplayStyles = { getPopupStyles: () => ({ ...typography.tool.popup, - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', margin: 0, padding: toolDisplayStyles.padding.popup, borderRadius: '0.75rem', @@ -263,7 +274,8 @@ export const toolDisplayStyles = { getPopupContainerStyles: () => ({ ...typography.tool.popup, - background: 'transparent !important', + background: 'transparent', + backgroundColor: 'transparent', margin: 0, padding: toolDisplayStyles.padding.popupContainer, borderRadius: '0.5rem', diff --git a/packages/ui/src/lib/typographyWatcher.ts b/packages/ui/src/lib/typographyWatcher.ts index c0523faa..fa1539c0 100644 --- a/packages/ui/src/lib/typographyWatcher.ts +++ b/packages/ui/src/lib/typographyWatcher.ts @@ -2,15 +2,29 @@ import { SEMANTIC_TYPOGRAPHY } from '@/lib/typography'; let started = false; +const TYPOGRAPHY_STYLE_ID = 'openchamber-typography-base'; + const applySemanticTypography = (): void => { if (typeof document === 'undefined') { return; } - const root = document.documentElement; - Object.entries(SEMANTIC_TYPOGRAPHY).forEach(([key, value]) => { - const cssVarName = `--text-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`; - root.style.setProperty(cssVarName, value); - }); + + const cssVars = Object.entries(SEMANTIC_TYPOGRAPHY) + .map(([key, value]) => ` --text-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value};`) + .join('\n'); + + const styleContent = `:root {\n${cssVars}\n}\n`; + + const existing = document.getElementById(TYPOGRAPHY_STYLE_ID); + if (existing) { + existing.textContent = styleContent; + return; + } + + const style = document.createElement('style'); + style.id = TYPOGRAPHY_STYLE_ID; + style.textContent = styleContent; + document.head.appendChild(style); }; export const startTypographyWatcher = (): void => { diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index a9ddd7b4..7bd65224 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -7,7 +7,9 @@ import type { GitIdentitySummary, } from '@/lib/api/types'; -const GIT_POLL_INTERVAL = 3000; +const GIT_POLL_BASE_INTERVAL = 10000; +const GIT_POLL_MAX_INTERVAL = 20000; +const GIT_POLL_BACKOFF_STEP = 5000; const LOG_STALE_THRESHOLD = 30000; interface DirectoryGitState { @@ -34,7 +36,8 @@ interface GitStore { isLoadingBranches: boolean; isLoadingIdentity: boolean; - pollIntervalId: ReturnType | null; + pollIntervalId: ReturnType | null; + currentPollInterval: number; setActiveDirectory: (directory: string | null) => void; getDirectoryState: (directory: string) => DirectoryGitState | null; @@ -139,6 +142,7 @@ export const useGitStore = create()( isLoadingBranches: false, isLoadingIdentity: false, pollIntervalId: null, + currentPollInterval: GIT_POLL_BASE_INTERVAL, setActiveDirectory: (directory) => { const { activeDirectory, directories } = get(); @@ -346,24 +350,53 @@ export const useGitStore = create()( const { pollIntervalId } = get(); if (pollIntervalId) return; - const intervalId = setInterval(async () => { - const { activeDirectory } = get(); - if (!activeDirectory) return; + const schedulePoll = () => { + const { currentPollInterval } = get(); + const timeoutId = setTimeout(async () => { + // Skip if tab not visible + if (typeof document !== 'undefined' && document.hidden) { + set({ pollIntervalId: schedulePoll() }); + return; + } - const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true }); - if (statusChanged) { - await get().fetchLog(activeDirectory, git); - } - }, GIT_POLL_INTERVAL); + const { activeDirectory } = get(); + if (!activeDirectory) { + set({ pollIntervalId: schedulePoll() }); + return; + } - set({ pollIntervalId: intervalId }); + const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true }); + if (statusChanged) { + await get().fetchLog(activeDirectory, git); + // Reset to base interval on changes + set({ currentPollInterval: GIT_POLL_BASE_INTERVAL }); + } else { + // Backoff when no changes + const newInterval = Math.min( + currentPollInterval + GIT_POLL_BACKOFF_STEP, + GIT_POLL_MAX_INTERVAL + ); + set({ currentPollInterval: newInterval }); + } + + // Schedule next poll + const { pollIntervalId: currentId } = get(); + if (currentId !== null) { + set({ pollIntervalId: schedulePoll() }); + } + }, currentPollInterval); + + return timeoutId; + }; + + set({ pollIntervalId: schedulePoll(), currentPollInterval: GIT_POLL_BASE_INTERVAL }); }, stopPolling: () => { const { pollIntervalId } = get(); if (pollIntervalId) { - clearInterval(pollIntervalId); - set({ pollIntervalId: null }); + clearTimeout(pollIntervalId); + set({ pollIntervalId: null, currentPollInterval: GIT_POLL_BASE_INTERVAL }); } }, diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore new file mode 100644 index 00000000..cffb4ea1 --- /dev/null +++ b/packages/vscode/.vscodeignore @@ -0,0 +1,11 @@ +.vscode/** +node_modules/** +src/** +webview/** +.gitignore +tsconfig.json +tsconfig.webview.json +vite.config.ts +*.map +**/*.ts +!dist/** diff --git a/packages/vscode/LICENSE b/packages/vscode/LICENSE new file mode 100644 index 00000000..ca4c073b --- /dev/null +++ b/packages/vscode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 OpenChamber contributors + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/packages/vscode/README.md b/packages/vscode/README.md new file mode 100644 index 00000000..55173829 --- /dev/null +++ b/packages/vscode/README.md @@ -0,0 +1,47 @@ +# OpenChamber VS Code Extension + +AI coding assistant for VS Code powered by the OpenCode API. Embeds the OpenChamber chat interface in VS Code's secondary sidebar. + +![VS Code Extension](../../docs/references/vscode_extension.png) + +## Features + +- Chat UI in secondary sidebar +- Session management with history +- File attachments via native VS Code file picker (10MB limit) +- Auto-start `opencode serve` if not running +- Workspace-isolated opencode instances (different workspaces get unique opencode instances) +- Adapts to VS Code's light/dark/high-contrast themes + +## Commands + +| Command | Description | +|---------|-------------| +| `OpenChamber: New Chat Session` | Create new chat session | +| `OpenChamber: Focus Chat` | Focus chat panel in secondary sidebar | +| `OpenChamber: Restart API Connection` | Restart OpenCode API process | +| `OpenChamber: Show in Secondary Side Bar` | Toggle chat panel visibility | + +## Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| `openchamber.apiUrl` | `http://localhost:47339` | OpenCode API server URL | + +## Requirements + +- OpenCode CLI installed and available in PATH (or set via `OPENCODE_BINARY` env var) +- VS Code 1.85.0+ + +## Development + +```bash +pnpm install +pnpm -C packages/vscode run build # build extension + webview +pnpm -C packages/vscode exec vsce package --no-dependencies +``` + +## Local Install + +- After packaging: `code --install-extension packages/vscode/openchamber-*.vsix` +- Or in VS Code: Extensions panel → "Install from VSIX…" and select the file diff --git a/packages/vscode/assets/app-icon-checkpoint.svg b/packages/vscode/assets/app-icon-checkpoint.svg new file mode 100644 index 00000000..e169a1db --- /dev/null +++ b/packages/vscode/assets/app-icon-checkpoint.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/vscode/assets/app-icon.png b/packages/vscode/assets/app-icon.png new file mode 100644 index 00000000..c084f0b8 Binary files /dev/null and b/packages/vscode/assets/app-icon.png differ diff --git a/packages/vscode/assets/icon.svg b/packages/vscode/assets/icon.svg new file mode 100644 index 00000000..9ecacebf --- /dev/null +++ b/packages/vscode/assets/icon.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/packages/vscode/package.json b/packages/vscode/package.json new file mode 100644 index 00000000..6204cc7f --- /dev/null +++ b/packages/vscode/package.json @@ -0,0 +1,121 @@ +{ + "name": "openchamber", + "displayName": "OpenChamber", + "description": "AI coding assistant powered by OpenCode", + "version": "1.0.9", + "publisher": "fedaykindev", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/btriapitsyn/openchamber.git" + }, + "license": "MIT", + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Programming Languages", + "Machine Learning", + "Other" + ], + "keywords": [ + "ai", + "claude", + "gpt", + "agent", + "coding", + "chatgpt", + "groq", + "assistant", + "opencode", + "openchamber" + ], + "icon": "assets/app-icon.png", + "main": "./dist/extension.js", + "activationEvents": [], + "contributes": { + "viewsContainers": { + "secondarySidebar": [ + { + "id": "openchamber", + "title": "OpenChamber", + "icon": "assets/icon.svg" + } + ] + }, + "views": { + "openchamber": [ + { + "type": "webview", + "id": "openchamber.chatView", + "name": "Chat" + } + ] + }, + "commands": [ + { + "command": "openchamber.newSession", + "title": "OpenChamber: New Chat Session" + }, + { + "command": "openchamber.focusChat", + "title": "OpenChamber: Focus Chat" + }, + { + "command": "openchamber.restartApi", + "title": "OpenChamber: Restart API Connection" + }, + { + "command": "openchamber.showInSecondarySidebar", + "title": "OpenChamber: Show in Secondary Side Bar", + "icon": "assets/icon.svg" + } + ], + "menus": { + "editor/title": [ + { + "command": "openchamber.showInSecondarySidebar", + "when": "editorIsOpen", + "group": "navigation@100" + } + ] + }, + "configuration": { + "title": "OpenChamber", + "properties": { + "openchamber.apiUrl": { + "type": "string", + "default": "http://localhost:47339", + "description": "URL of the OpenCode API server" + } + } + } + }, + "scripts": { + "vscode:prepublish": "pnpm run build", + "build": "pnpm run build:extension && pnpm run build:webview", + "build:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --minify", + "build:webview": "VITE_OPENCODE_URL=/api vite build", + "dev": "concurrently -n \"ext,web\" -c \"cyan,magenta\" \"pnpm run watch:extension\" \"pnpm run watch:webview\"", + "watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch --sourcemap", + "watch:webview": "vite build --watch", + "type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json", + "lint": "pnpm dlx eslint --ext .ts,.tsx src webview", + "package": "vsce package --no-dependencies" + }, + "devDependencies": { + "@types/vscode": "^1.85.0", + "@vitejs/plugin-react": "^5.0.0", + "concurrently": "^9.2.1", + "esbuild": "^0.24.2", + "typescript": "~5.8.3", + "vite": "^7.1.2", + "@vscode/vsce": "^3.2.1" + }, + "dependencies": { + "@openchamber/ui": "workspace:*", + "@opencode-ai/sdk": "^1.0.133", + "react": "^19.1.1", + "react-dom": "^19.1.1" + } +} diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts new file mode 100644 index 00000000..27521508 --- /dev/null +++ b/packages/vscode/src/ChatViewProvider.ts @@ -0,0 +1,120 @@ +import * as vscode from 'vscode'; +import { handleBridgeMessage, type BridgeRequest } from './bridge'; +import { getThemeKindName } from './theme'; +import type { OpenCodeManager, ConnectionStatus } from './opencode'; + +export class ChatViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'openchamber.chatView'; + + private _view?: vscode.WebviewView; + private _isVisible = false; + + constructor( + private readonly _context: vscode.ExtensionContext, + private readonly _extensionUri: vscode.Uri, + private readonly _openCodeManager?: OpenCodeManager + ) {} + + public resolveWebviewView( + webviewView: vscode.WebviewView + ) { + this._view = webviewView; + this._isVisible = webviewView.visible; + + webviewView.onDidChangeVisibility(() => { + this._isVisible = webviewView.visible; + }); + + const distUri = vscode.Uri.joinPath(this._extensionUri, 'dist'); + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this._extensionUri, distUri], + }; + + webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); + + webviewView.webview.onDidReceiveMessage(async (message: BridgeRequest) => { + if (message.type === 'restartApi') { + await this._openCodeManager?.restart(); + return; + } + const response = await handleBridgeMessage(message, { + manager: this._openCodeManager, + context: this._context, + }); + webviewView.webview.postMessage(response); + }); + } + + public newSession() { + if (this._view) { + this._view.webview.postMessage({ type: 'command', command: 'newSession' }); + } + } + + public updateTheme(kind: vscode.ColorThemeKind) { + if (this._view) { + const themeKind = getThemeKindName(kind); + this._view.webview.postMessage({ + type: 'themeChange', + theme: { kind: themeKind }, + }); + } + } + + public updateConnectionStatus(status: ConnectionStatus, error?: string) { + if (this._view) { + this._view.webview.postMessage({ + type: 'connectionStatus', + status, + error, + }); + } + } + + public isVisible(): boolean { + return this._isVisible; + } + + private _getHtmlForWebview(webview: vscode.Webview) { + const scriptPath = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'assets', 'index.js'); + const scriptUri = webview.asWebviewUri(scriptPath); + + const config = vscode.workspace.getConfiguration('openchamber'); + const apiUrl = this._openCodeManager?.getApiUrl() || config.get('apiUrl') || 'http://localhost:47339'; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind); + const initialStatus = this._openCodeManager?.getStatus() || 'disconnected'; + + return ` + + + + + + + OpenChamber + + +
+ + + +`; + } +} diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts new file mode 100644 index 00000000..3ec76dd7 --- /dev/null +++ b/packages/vscode/src/bridge.ts @@ -0,0 +1,338 @@ +import * as vscode from 'vscode'; +import * as os from 'os'; +import * as path from 'path'; +import type { OpenCodeManager } from './opencode'; + +export interface BridgeRequest { + id: string; + type: string; + payload?: unknown; +} + +export interface BridgeResponse { + id: string; + type: string; + success: boolean; + data?: unknown; + error?: string; +} + +interface FileEntry { + name: string; + path: string; + isDirectory: boolean; +} + +interface FileSearchResult { + path: string; + score?: number; +} + +export interface BridgeContext { + manager?: OpenCodeManager; + context?: vscode.ExtensionContext; +} + +const SETTINGS_KEY = 'openchamber.settings'; + +const readSettings = (ctx?: BridgeContext) => { + const stored = ctx?.context?.globalState.get>(SETTINGS_KEY) || {}; + const restStored = { ...stored }; + delete (restStored as Record).lastDirectory; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const themeVariant = + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light || + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight + ? 'light' + : 'dark'; + + return { + themeVariant, + lastDirectory: workspaceFolder, + ...restStored, + }; +}; + +const persistSettings = async (changes: Record, ctx?: BridgeContext) => { + const current = readSettings(ctx); + const restChanges = { ...(changes || {}) }; + delete restChanges.lastDirectory; + const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory }; + await ctx?.context?.globalState.update(SETTINGS_KEY, merged); + return merged; +}; + +const normalizeFsPath = (value: string) => value.replace(/\\/g, '/'); + +const listDirectoryEntries = async (dirPath: string) => { + const uri = vscode.Uri.file(dirPath); + const entries = await vscode.workspace.fs.readDirectory(uri); + return entries.map(([name, fileType]) => ({ + name, + path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath), + isDirectory: fileType === vscode.FileType.Directory, + })); +}; + +const searchDirectory = async (directory: string, query: string, limit = 60) => { + const rootPath = directory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + if (!rootPath) return []; + + const sanitizedQuery = query?.trim() || ''; + const pattern = sanitizedQuery ? `**/*${sanitizedQuery}*` : '**/*'; + const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; + const results = await vscode.workspace.findFiles( + new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), + exclude, + limit, + ); + + return results.map((file) => { + const absolute = normalizeFsPath(file.fsPath); + const relative = normalizeFsPath(path.relative(rootPath, absolute)); + const name = path.basename(absolute); + return { + name, + path: absolute, + relativePath: relative || name, + extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, + }; + }); +}; + +const fetchModelsMetadata = async () => { + const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; + const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined; + try { + const response = await fetch('https://models.dev/api.json', { + signal: controller?.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`models.dev responded with ${response.status}`); + } + return await response.json(); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +}; + +export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise { + const { id, type, payload } = message; + + try { + switch (type) { + case 'files:list': { + const { path: dirPath } = payload as { path: string }; + const uri = vscode.Uri.file(dirPath); + const entries = await vscode.workspace.fs.readDirectory(uri); + const result: FileEntry[] = entries.map(([name, fileType]) => ({ + name, + path: vscode.Uri.joinPath(uri, name).fsPath, + isDirectory: fileType === vscode.FileType.Directory, + })); + return { id, type, success: true, data: { directory: dirPath, entries: result } }; + } + + case 'files:search': { + const { query, maxResults = 50 } = payload as { query: string; maxResults?: number }; + const pattern = `**/*${query}*`; + const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults); + const results: FileSearchResult[] = files.map((file) => ({ + path: file.fsPath, + })); + return { id, type, success: true, data: results }; + } + + case 'workspace:folder': { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + return { id, type, success: true, data: { folder } }; + } + + case 'config:get': { + const { key } = payload as { key: string }; + const config = vscode.workspace.getConfiguration('openchamber'); + const value = config.get(key); + return { id, type, success: true, data: { value } }; + } + + case 'api:fs:list': { + const target = (payload as { path?: string })?.path || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const entries = await listDirectoryEntries(target); + return { id, type, success: true, data: { entries, directory: target } }; + } + + case 'api:fs:search': { + const { directory = '', query = '', limit } = (payload || {}) as { directory?: string; query?: string; limit?: number }; + const files = await searchDirectory(directory, query, limit); + return { id, type, success: true, data: { files } }; + } + + case 'api:fs:mkdir': { + const target = (payload as { path: string })?.path; + if (!target) { + return { id, type, success: false, error: 'Path is required' }; + } + await vscode.workspace.fs.createDirectory(vscode.Uri.file(target)); + return { id, type, success: true, data: { success: true, path: normalizeFsPath(target) } }; + } + + case 'api:fs/home': { + const workspaceHome = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const home = workspaceHome || os.homedir(); + return { id, type, success: true, data: { home: normalizeFsPath(home) } }; + } + + case 'api:files/pick': { + const MAX_SIZE = 10 * 1024 * 1024; + const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false; + const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri; + + const picks = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: allowMany, + defaultUri, + openLabel: 'Attach', + }); + + if (!picks || picks.length === 0) { + return { id, type, success: true, data: { files: [], skipped: [] } }; + } + + const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = []; + const skipped: Array<{ name: string; reason: string }> = []; + + const guessMime = (ext: string) => { + switch (ext) { + case '.png': + case '.jpg': + case '.jpeg': + case '.gif': + case '.bmp': + case '.webp': + return `image/${ext.replace('.', '')}`; + case '.pdf': + return 'application/pdf'; + case '.txt': + case '.log': + return 'text/plain'; + case '.json': + return 'application/json'; + case '.md': + case '.markdown': + return 'text/markdown'; + default: + return 'application/octet-stream'; + } + }; + + for (const uri of picks) { + try { + const stat = await vscode.workspace.fs.stat(uri); + const size = stat.size ?? 0; + const name = path.basename(uri.fsPath); + + if (size > MAX_SIZE) { + skipped.push({ name, reason: 'File exceeds 10MB limit' }); + continue; + } + + const bytes = await vscode.workspace.fs.readFile(uri); + const ext = path.extname(name).toLowerCase(); + const mimeType = guessMime(ext); + const base64 = Buffer.from(bytes).toString('base64'); + const dataUrl = `data:${mimeType};base64,${base64}`; + files.push({ name, mimeType, size, dataUrl }); + } catch (error) { + const name = path.basename(uri.fsPath); + skipped.push({ name, reason: error instanceof Error ? error.message : 'Failed to read file' }); + } + } + + return { id, type, success: true, data: { files, skipped } }; + } + + case 'api:config/settings:get': { + const settings = readSettings(ctx); + return { id, type, success: true, data: settings }; + } + + case 'api:config/settings:save': { + const changes = (payload as Record) || {}; + const updated = await persistSettings(changes, ctx); + return { id, type, success: true, data: updated }; + } + + case 'api:config/reload': { + await ctx?.manager?.restart(); + return { id, type, success: true, data: { restarted: true } }; + } + + case 'api:opencode/directory': { + const target = (payload as { path?: string })?.path; + if (!target) { + return { id, type, success: false, error: 'Path is required' }; + } + const result = await ctx?.manager?.setWorkingDirectory(target); + if (!result) { + return { id, type, success: false, error: 'OpenCode manager unavailable' }; + } + return { id, type, success: true, data: result }; + } + + case 'api:models/metadata': { + try { + const data = await fetchModelsMetadata(); + return { id, type, success: true, data }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'editor:openFile': { + const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number }; + try { + const doc = await vscode.workspace.openTextDocument(filePath); + const options: vscode.TextDocumentShowOptions = {}; + if (typeof line === 'number') { + const pos = new vscode.Position(Math.max(0, line - 1), column || 0); + options.selection = new vscode.Range(pos, pos); + } + await vscode.window.showTextDocument(doc, options); + return { id, type, success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'editor:openDiff': { + const { original, modified, label } = payload as { original: string; modified: string; label?: string }; + try { + // If the paths are just content, we need to create virtual documents or temp files. + // However, 'editor:openDiff' usually implies comparing two URIs. + // If the payload contains file paths: + const originalUri = vscode.Uri.file(original); + const modifiedUri = vscode.Uri.file(modified); + const title = label || `${path.basename(original)} ↔ ${path.basename(modified)}`; + + await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title); + return { id, type, success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + default: + return { id, type, success: false, error: `Unknown message type: ${type}` }; + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + return { id, type, success: false, error: errorMessage }; + } +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts new file mode 100644 index 00000000..0f61d510 --- /dev/null +++ b/packages/vscode/src/extension.ts @@ -0,0 +1,76 @@ +import * as vscode from 'vscode'; +import { ChatViewProvider } from './ChatViewProvider'; +import { createOpenCodeManager, type OpenCodeManager } from './opencode'; + +let chatViewProvider: ChatViewProvider | undefined; +let openCodeManager: OpenCodeManager | undefined; + +export function activate(context: vscode.ExtensionContext) { + // Create OpenCode manager first + openCodeManager = createOpenCodeManager(context); + + // Create chat view provider with manager reference + chatViewProvider = new ChatViewProvider(context, context.extensionUri, openCodeManager); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + ChatViewProvider.viewType, + chatViewProvider, + { webviewOptions: { retainContextWhenHidden: true } } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.newSession', () => { + chatViewProvider?.newSession(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.focusChat', () => { + vscode.commands.executeCommand('openchamber.chatView.focus'); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.restartApi', async () => { + await openCodeManager?.restart(); + }) + ); + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.showInSecondarySidebar', async () => { + const viewId = ChatViewProvider.viewType; + const isVisible = chatViewProvider?.isVisible() === true; + + if (isVisible) { + await vscode.commands.executeCommand('workbench.action.toggleAuxiliaryBar'); + return; + } + + await vscode.commands.executeCommand('workbench.action.focusAuxiliaryBar'); + await vscode.commands.executeCommand(`${viewId}.focus`); + }) + ); + + context.subscriptions.push( + vscode.window.onDidChangeActiveColorTheme((theme) => { + chatViewProvider?.updateTheme(theme.kind); + }) + ); + + // Subscribe to status changes + context.subscriptions.push( + openCodeManager.onStatusChange((status, error) => { + chatViewProvider?.updateConnectionStatus(status, error); + }) + ); + + // Auto-start OpenCode API + openCodeManager.start(); +} + +export function deactivate() { + openCodeManager?.stop(); + openCodeManager = undefined; + chatViewProvider = undefined; +} diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts new file mode 100644 index 00000000..cbacf582 --- /dev/null +++ b/packages/vscode/src/opencode.ts @@ -0,0 +1,365 @@ +import * as vscode from 'vscode'; +import { spawn, ChildProcess, spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as net from 'net'; + +const DEFAULT_PORT = 47339; +const HEALTH_CHECK_INTERVAL = 5000; +const STARTUP_TIMEOUT = 10000; +const SHUTDOWN_TIMEOUT = 3000; + +const BIN_CANDIDATES = [ + process.env.OPENCHAMBER_OPENCODE_PATH, + process.env.OPENCHAMBER_OPENCODE_BIN, + process.env.OPENCODE_PATH, + process.env.OPENCODE_BINARY, + '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', + '/usr/bin/opencode', + path.join(os.homedir(), '.local/bin/opencode'), +].filter(Boolean) as string[]; + +export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; + +export interface OpenCodeManager { + start(workdir?: string): Promise; + stop(): Promise; + restart(): Promise; + setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>; + getStatus(): ConnectionStatus; + getApiUrl(): string; + getWorkingDirectory(): string; + onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable; +} + +function isExecutable(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +function resolveCliPath(): string | null { + for (const candidate of BIN_CANDIDATES) { + if (candidate && isExecutable(candidate)) { + return candidate; + } + } + + const envPath = process.env.PATH || ''; + for (const segment of envPath.split(path.delimiter)) { + const candidate = path.join(segment, 'opencode'); + if (isExecutable(candidate)) { + return candidate; + } + } + + if (process.platform !== 'win32') { + const shellCandidates = [ + process.env.SHELL, + '/bin/bash', + '/bin/zsh', + '/bin/sh', + ].filter(Boolean) as string[]; + + for (const shellPath of shellCandidates) { + if (!isExecutable(shellPath)) continue; + try { + const result = spawnSync(shellPath, ['-lic', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0) { + const candidate = result.stdout.trim().split(/\s+/).pop(); + if (candidate && isExecutable(candidate)) { + return candidate; + } + } + } catch { + // continue + } + } + } + + return null; +} + +async function checkHealth(apiUrl: string): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const candidates = [`${apiUrl}/health`, `${apiUrl}/api/health`]; + + for (const target of candidates) { + try { + const response = await fetch(target, { signal: controller.signal }); + if (response.ok) { + clearTimeout(timeout); + return true; + } + } catch { + // try next candidate + } + } + clearTimeout(timeout); + } catch { + // ignore + } + return false; +} + +function hashWorkspaceIdentifier(identifier: string): number { + let hash = 0; + for (let i = 0; i < identifier.length; i++) { + hash = (hash * 31 + identifier.charCodeAt(i)) >>> 0; + } + return hash; +} + +async function findAvailablePort(startPort: number, maxAttempts = 20): Promise { + let port = startPort; + for (let i = 0; i < maxAttempts; i += 1) { + const available = await new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => { + server.close(); + resolve(false); + }); + server.listen(port, () => { + server.close(() => resolve(true)); + }); + }); + if (available) { + return port; + } + port += 1; + } + return startPort; +} + +export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager { + let childProcess: ChildProcess | null = null; + let status: ConnectionStatus = 'disconnected'; + let healthCheckInterval: NodeJS.Timeout | null = null; + let lastError: string | undefined; + const listeners = new Set<(status: ConnectionStatus, error?: string) => void>(); + let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const workspaceKey = `openchamber.api.port.${hashWorkspaceIdentifier(workspaceFolder)}`; + + const config = vscode.workspace.getConfiguration('openchamber'); + const configuredApiUrl = config.get('apiUrl') || ''; + + const storedPort = context.workspaceState.get(workspaceKey); + let apiUrl: string = storedPort && Number.isFinite(storedPort) + ? `http://localhost:${storedPort}` + : `http://localhost:${DEFAULT_PORT}`; + let desiredPort: number = storedPort && Number.isFinite(storedPort) ? storedPort : DEFAULT_PORT; + + const parseApiUrl = (candidate: string): { url: string; port: number } | null => { + try { + const parsed = new URL(candidate); + const origin = parsed.origin; + const pathname = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/+$/, '') : ''; + const normalized = `${origin}${pathname}`; + const port = parsed.port ? parseInt(parsed.port, 10) : DEFAULT_PORT; + return { + url: normalized, + port: Number.isFinite(port) && port > 0 ? port : DEFAULT_PORT, + }; + } catch { + return null; + } + }; + + const resolveApi = async () => { + // If user explicitly set a non-default URL, honor it (shared across workspaces). + const parsed = configuredApiUrl ? parseApiUrl(configuredApiUrl) : null; + const isDefault = !parsed || parsed.port === DEFAULT_PORT; + + if (!isDefault && parsed) { + return parsed; + } + + // Workspace-isolated port selection + const storedPort = context.workspaceState.get(workspaceKey); + const basePort = storedPort && Number.isFinite(storedPort) ? storedPort : DEFAULT_PORT + (hashWorkspaceIdentifier(workspaceFolder) % 1000); + const port = await findAvailablePort(basePort); + void context.workspaceState.update(workspaceKey, port); + return { url: `http://localhost:${port}`, port }; + }; + + const apiConfigPromise = resolveApi().then((result) => { + apiUrl = result.url; + desiredPort = result.port; + return result; + }).catch(() => null); + + function setStatus(newStatus: ConnectionStatus, error?: string) { + if (status !== newStatus || lastError !== error) { + status = newStatus; + lastError = error; + listeners.forEach(cb => cb(status, error)); + } + } + + async function waitForHealthy(timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await checkHealth(apiUrl)) { + return true; + } + await new Promise(r => setTimeout(r, 500)); + } + return false; + } + + function startHealthCheck() { + stopHealthCheck(); + healthCheckInterval = setInterval(async () => { + const healthy = await checkHealth(apiUrl); + if (healthy && status !== 'connected') { + setStatus('connected'); + } else if (!healthy && status === 'connected') { + setStatus('disconnected'); + } + }, HEALTH_CHECK_INTERVAL); + } + + function stopHealthCheck() { + if (healthCheckInterval) { + clearInterval(healthCheckInterval); + healthCheckInterval = null; + } + } + + async function start(workdir?: string) { + await apiConfigPromise; + + if (typeof workdir === 'string' && workdir.trim().length > 0) { + workingDirectory = workdir.trim(); + } + + // First check if API is already running + if (await checkHealth(apiUrl)) { + setStatus('connected'); + startHealthCheck(); + return; + } + + setStatus('connecting'); + + const cliPath = resolveCliPath(); + if (!cliPath) { + setStatus('error', 'OpenCode CLI not found. Install it or set OPENCODE_BINARY env var.'); + vscode.window.showErrorMessage( + 'OpenCode CLI not found. Please install it or set the OPENCODE_BINARY environment variable.', + 'More Info' + ).then(selection => { + if (selection === 'More Info') { + vscode.env.openExternal(vscode.Uri.parse('https://github.com/opencode-ai/opencode')); + } + }); + return; + } + + const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + + try { + childProcess = spawn(cliPath, ['serve', '--port', desiredPort.toString()], { + cwd: spawnCwd, + env: { + ...process.env, + OPENCODE_PORT: desiredPort.toString(), + }, + detached: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + childProcess.stdout?.on('data', (data) => { + console.log('[OpenCode]', data.toString()); + }); + + childProcess.stderr?.on('data', (data) => { + console.error('[OpenCode]', data.toString()); + }); + + childProcess.on('error', (err) => { + setStatus('error', `Failed to start OpenCode: ${err.message}`); + childProcess = null; + }); + + childProcess.on('exit', () => { + if (status !== 'disconnected') { + setStatus('disconnected'); + } + childProcess = null; + }); + + // Wait for API to become healthy + const healthy = await waitForHealthy(STARTUP_TIMEOUT); + if (healthy) { + setStatus('connected'); + startHealthCheck(); + } else { + setStatus('error', 'OpenCode API did not start in time'); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setStatus('error', `Failed to start OpenCode: ${message}`); + } + } + + async function stop() { + stopHealthCheck(); + + if (childProcess) { + try { + childProcess.kill('SIGTERM'); + // Wait a bit for graceful shutdown + await new Promise(r => setTimeout(r, SHUTDOWN_TIMEOUT)); + if (childProcess && !childProcess.killed && childProcess.exitCode === null) { + childProcess.kill('SIGKILL'); + } + } catch { + // ignore + } + childProcess = null; + } + + setStatus('disconnected'); + } + + async function restart() { + await stop(); + await start(); + } + + async function setWorkingDirectory(path: string) { + const target = typeof path === 'string' && path.trim().length > 0 ? path.trim() : workingDirectory; + workingDirectory = target; + await restart(); + return { success: true, restarted: true, path: target }; + } + + return { + start, + stop, + restart, + setWorkingDirectory, + getStatus: () => status, + getApiUrl: () => apiUrl, + getWorkingDirectory: () => workingDirectory, + onStatusChange(callback) { + listeners.add(callback); + // Immediately call with current status + callback(status, lastError); + return new vscode.Disposable(() => listeners.delete(callback)); + }, + }; +} diff --git a/packages/vscode/src/theme.ts b/packages/vscode/src/theme.ts new file mode 100644 index 00000000..ff207c8b --- /dev/null +++ b/packages/vscode/src/theme.ts @@ -0,0 +1,15 @@ +import * as vscode from 'vscode'; + +export type ThemeKindName = 'light' | 'dark'; + +export function getThemeKindName(kind: vscode.ColorThemeKind): ThemeKindName { + switch (kind) { + case vscode.ColorThemeKind.Light: + case vscode.ColorThemeKind.HighContrastLight: + return 'light'; + case vscode.ColorThemeKind.Dark: + case vscode.ColorThemeKind.HighContrast: + default: + return 'dark'; + } +} diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json new file mode 100644 index 00000000..b53f4f1b --- /dev/null +++ b/packages/vscode/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "webview"] +} diff --git a/packages/vscode/tsconfig.webview.json b/packages/vscode/tsconfig.webview.json new file mode 100644 index 00000000..53fba48a --- /dev/null +++ b/packages/vscode/tsconfig.webview.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "types": ["vite/client"], + "paths": { + "@/*": ["../ui/src/*"], + "@vscode/*": ["./webview/*"], + "@openchamber/ui/*": ["../ui/src/*"] + } + }, + "include": ["webview/**/*", "../ui/src/**/*"], + "exclude": [ + "webview/components/**/*", + "webview/stores/**/*", + "webview/hooks/**/*", + "webview/App.tsx" + ] +} diff --git a/packages/vscode/vite.config.ts b/packages/vscode/vite.config.ts new file mode 100644 index 00000000..3cc151a8 --- /dev/null +++ b/packages/vscode/vite.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root: path.resolve(__dirname, 'webview'), + base: './', // Use relative paths for VS Code webview + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, '../ui/src'), + '@vscode': path.resolve(__dirname, './webview'), + '@openchamber/ui': path.resolve(__dirname, '../ui/src'), + '@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'), + }, + }, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + 'global': 'globalThis', + }, + envPrefix: ['VITE_'], + optimizeDeps: { + include: ['@opencode-ai/sdk'], + }, + build: { + outDir: path.resolve(__dirname, 'dist/webview'), + emptyOutDir: true, + rollupOptions: { + input: path.resolve(__dirname, 'webview/index.html'), + external: ['node:child_process', 'node:fs', 'node:path', 'node:url'], + output: { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/[name].[ext]', + }, + }, + }, +}); diff --git a/packages/vscode/webview/App.tsx b/packages/vscode/webview/App.tsx new file mode 100644 index 00000000..102979a7 --- /dev/null +++ b/packages/vscode/webview/App.tsx @@ -0,0 +1,272 @@ +import React from 'react'; +import { useChatStore } from './stores/chatStore'; +import { useNavigation } from './hooks/useNavigation'; + +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; + +function ConnectionStatusBanner({ status, error, onRetry }: { + status: ConnectionStatus; + error?: string; + onRetry: () => void; +}) { + if (status === 'connected') return null; + + const messages: Record = { + disconnected: 'Not connected to OpenCode API', + connecting: 'Connecting...', + connected: '', + error: error || 'Connection error', + }; + + return ( +
+ {status === 'connecting' && ( + + )} + {messages[status]} + {(status === 'disconnected' || status === 'error') && ( + + )} +
+ ); +} + +function SessionsList() { + const { sessions, currentSessionId, selectSession, createSession, isLoadingSessions } = useChatStore(); + const { goToChat } = useNavigation(); + const [isCreating, setIsCreating] = React.useState(false); + + const handleSelectSession = async (sessionId: string) => { + await selectSession(sessionId); + goToChat(); + }; + + const handleNewSession = async () => { + setIsCreating(true); + const sessionId = await createSession(); + setIsCreating(false); + if (sessionId) { + goToChat(); + } + }; + + const formatTime = (timestamp?: number) => { + if (!timestamp) return ''; + const date = new Date(timestamp); + const now = new Date(); + const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000); + if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (diffDays === 1) return 'Yesterday'; + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + }; + + return ( +
+
+

Sessions

+ +
+ +
+ {isLoadingSessions ? ( +
Loading...
+ ) : sessions.length === 0 ? ( +
+
No sessions yet
+ +
+ ) : ( +
+ {sessions.map((session) => ( + + ))} +
+ )} +
+
+ ); +} + +function ChatPanel() { + const { currentSessionId, sessions, messages, sendMessage, abortMessage, isSending, streamingSessionId } = useChatStore(); + const { goToSessions } = useNavigation(); + const [input, setInput] = React.useState(''); + const messagesEndRef = React.useRef(null); + + const currentSession = sessions.find((s) => s.id === currentSessionId); + const sessionMessages = currentSessionId ? messages.get(currentSessionId) || [] : []; + const isStreaming = streamingSessionId === currentSessionId; + + React.useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [sessionMessages.length]); + + const handleSend = async () => { + if (!input.trim() || isSending) return; + const text = input.trim(); + setInput(''); + await sendMessage(text); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+
+ +

{currentSession?.title || 'New Chat'}

+
+ +
+ {sessionMessages.length === 0 ? ( +
+ Start a conversation +
+ ) : ( +
+ {sessionMessages.map((msg, idx) => ( + + ))} + {isStreaming && ( +
+
+ +
+
+ )} +
+
+ )} +
+ +
+
+