${status.shortcut} and run OpenClaude commands from anywhere.
- ${escapeHtml(viewModel.header.title)}
+${escapeHtml(viewModel.header.subtitle)}
+diff --git a/vscode-extension/openclaude-vscode/README.md b/vscode-extension/openclaude-vscode/README.md index 9d67cad1..0b7ab21a 100644 --- a/vscode-extension/openclaude-vscode/README.md +++ b/vscode-extension/openclaude-vscode/README.md @@ -1,15 +1,29 @@ # OpenClaude VS Code Extension -A sleek VS Code companion for OpenClaude with a visual **Control Center** plus terminal-first workflows. +A practical VS Code companion for OpenClaude with a project-aware **Control Center**, predictable terminal launch behavior, and quick access to useful OpenClaude workflows. ## Features -- **Control Center sidebar UI** in the Activity Bar: +- **Real Control Center status** in the Activity Bar: + - whether the configured `openclaude` command is installed + - the launch command being used + - whether the launch shim injects `CLAUDE_CODE_USE_OPENAI=1` + - the current workspace folder + - the launch cwd that will be used for terminal sessions + - whether `.openclaude-profile.json` exists in the current workspace root + - a conservative provider summary derived from the workspace profile or known environment flags +- **Project-aware launch behavior**: + - `Launch OpenClaude` launches from the active editor's workspace when possible + - falls back to the first workspace folder when needed + - avoids launching from an arbitrary default cwd when a project is open +- **Practical sidebar actions**: - Launch OpenClaude - - Open repository/docs - - Open VS Code theme picker -- **Terminal launch command**: `OpenClaude: Launch in Terminal` -- **Built-in dark theme**: `OpenClaude Terminal Black` (terminal-inspired, low-glare, neon accents) + - Launch in Workspace Root + - Open Workspace Profile + - Open Repository + - Open Setup Guide + - Open Command Palette +- **Built-in dark theme**: `OpenClaude Terminal Black` ## Requirements @@ -20,19 +34,31 @@ A sleek VS Code companion for OpenClaude with a visual **Control Center** plus t - `OpenClaude: Open Control Center` - `OpenClaude: Launch in Terminal` +- `OpenClaude: Launch in Workspace Root` - `OpenClaude: Open Repository` +- `OpenClaude: Open Setup Guide` +- `OpenClaude: Open Workspace Profile` ## Settings - `openclaude.launchCommand` (default: `openclaude`) - `openclaude.terminalName` (default: `OpenClaude`) -- `openclaude.useOpenAIShim` (default: `true`) +- `openclaude.useOpenAIShim` (default: `false`) + +`openclaude.useOpenAIShim` only injects `CLAUDE_CODE_USE_OPENAI=1` into terminals launched by the extension. It does not guess or configure a provider by itself. + +## Notes on Status Detection + +- Provider status prefers the real workspace `.openclaude-profile.json` file when present. +- If no saved profile exists, the extension falls back to known environment flags available to the VS Code extension host. +- If the source of truth is unclear, the extension shows `unknown` instead of guessing. ## Development From this folder: ```bash +npm run test npm run lint ``` diff --git a/vscode-extension/openclaude-vscode/package.json b/vscode-extension/openclaude-vscode/package.json index 441e28de..e66a82c4 100644 --- a/vscode-extension/openclaude-vscode/package.json +++ b/vscode-extension/openclaude-vscode/package.json @@ -1,7 +1,7 @@ { "name": "openclaude-vscode", "displayName": "OpenClaude", - "description": "Sleek VS Code extension for OpenClaude with a visual Control Center and terminal-aligned theme.", + "description": "Practical VS Code companion for OpenClaude with project-aware launch behavior and a real Control Center.", "version": "0.1.1", "publisher": "devnull-bootloader", "engines": { @@ -14,11 +14,22 @@ "activationEvents": [ "onStartupFinished", "onCommand:openclaude.start", + "onCommand:openclaude.startInWorkspaceRoot", "onCommand:openclaude.openDocs", + "onCommand:openclaude.openSetupDocs", + "onCommand:openclaude.openWorkspaceProfile", "onCommand:openclaude.openControlCenter", "onView:openclaude.controlCenter" ], "main": "./src/extension.js", + "files": [ + "README.md", + "media/**", + "src/extension.js", + "src/presentation.js", + "src/state.js", + "themes/**" + ], "contributes": { "commands": [ { @@ -26,11 +37,26 @@ "title": "OpenClaude: Launch in Terminal", "category": "OpenClaude" }, + { + "command": "openclaude.startInWorkspaceRoot", + "title": "OpenClaude: Launch in Workspace Root", + "category": "OpenClaude" + }, { "command": "openclaude.openDocs", "title": "OpenClaude: Open Repository", "category": "OpenClaude" }, + { + "command": "openclaude.openSetupDocs", + "title": "OpenClaude: Open Setup Guide", + "category": "OpenClaude" + }, + { + "command": "openclaude.openWorkspaceProfile", + "title": "OpenClaude: Open Workspace Profile", + "category": "OpenClaude" + }, { "command": "openclaude.openControlCenter", "title": "OpenClaude: Open Control Center", @@ -84,7 +110,8 @@ ] }, "scripts": { - "lint": "node --check ./src/extension.js", + "test": "node --test ./src/*.test.js", + "lint": "node -e \"for (const file of require('node:fs').readdirSync('./src')) { if (file.endsWith('.js')) { require('node:child_process').execFileSync(process.execPath, ['--check', require('node:path').join('src', file)], { stdio: 'inherit' }); } }\"", "package": "npx @vscode/vsce package --no-dependencies" }, "keywords": [ diff --git a/vscode-extension/openclaude-vscode/src/extension.js b/vscode-extension/openclaude-vscode/src/extension.js index d04fd662..b24873d4 100644 --- a/vscode-extension/openclaude-vscode/src/extension.js +++ b/vscode-extension/openclaude-vscode/src/extension.js @@ -1,48 +1,301 @@ const vscode = require('vscode'); const crypto = require('crypto'); -const { exec } = require('child_process'); -const { promisify } = require('util'); +const fs = require('fs'); +const path = require('path'); + +const { + chooseLaunchWorkspace, + describeProviderState, + findCommandPath, + isPathInsideWorkspace, + parseProfileFile, + resolveCommandCheckPath, +} = require('./state'); +const { buildControlCenterViewModel } = require('./presentation'); -const execAsync = promisify(exec); const OPENCLAUDE_REPO_URL = 'https://github.com/Gitlawb/openclaude'; +const OPENCLAUDE_SETUP_URL = 'https://github.com/Gitlawb/openclaude/blob/main/README.md#quick-start'; +const PROFILE_FILE_NAME = '.openclaude-profile.json'; -async function isCommandAvailable(command) { - try { - if (!command) { - return false; - } +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} - if (process.platform === 'win32') { - await execAsync(`where ${command}`); - } else { - await execAsync(`command -v ${command}`); - } - - return true; - } catch { - return false; - } +async function isCommandAvailable(command, launchCwd) { + return Boolean(findCommandPath(command, { cwd: launchCwd })); } function getExecutableFromCommand(command) { - return command.trim().split(/\s+/)[0]; + const normalized = String(command || '').trim(); + if (!normalized) { + return ''; + } + + const doubleQuotedMatch = normalized.match(/^"([^"]+)"/); + if (doubleQuotedMatch) { + return doubleQuotedMatch[1]; + } + + const singleQuotedMatch = normalized.match(/^'([^']+)'/); + if (singleQuotedMatch) { + return singleQuotedMatch[1]; + } + + return normalized.split(/\s+/)[0]; } -async function launchOpenClaude() { +function getWorkspacePaths() { + return (vscode.workspace.workspaceFolders || []).map(folder => folder.uri.fsPath); +} + +function getActiveWorkspacePath() { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.uri.scheme !== 'file') { + return null; + } + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(editor.document.uri); + return workspaceFolder ? workspaceFolder.uri.fsPath : null; +} + +function getActiveFilePath() { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.uri.scheme !== 'file') { + return null; + } + + return editor.document.uri.fsPath || null; +} + +function resolveLaunchTargets({ activeFilePath, workspacePath, workspaceSourceLabel, executable } = {}) { + const activeFileDirectory = isPathInsideWorkspace(activeFilePath, workspacePath) + ? path.dirname(activeFilePath) + : null; + const normalizedExecutable = String(executable || '').trim(); + const commandPath = normalizedExecutable + ? resolveCommandCheckPath(normalizedExecutable, workspacePath) + : null; + const relativeCommandRequiresWorkspaceRoot = Boolean( + workspacePath && commandPath && !path.isAbsolute(normalizedExecutable), + ); + + if (relativeCommandRequiresWorkspaceRoot) { + return { + projectAwareCwd: workspacePath, + projectAwareCwdLabel: workspacePath, + projectAwareSourceLabel: 'workspace root (required by relative launch command)', + workspaceRootCwd: workspacePath, + workspaceRootCwdLabel: workspacePath, + launchActionsShareTarget: true, + launchActionsShareTargetReason: 'relative-launch-command', + }; + } + + if (activeFileDirectory) { + return { + projectAwareCwd: activeFileDirectory, + projectAwareCwdLabel: activeFileDirectory, + projectAwareSourceLabel: 'active file directory', + workspaceRootCwd: workspacePath || null, + workspaceRootCwdLabel: workspacePath || 'No workspace open', + launchActionsShareTarget: false, + launchActionsShareTargetReason: null, + }; + } + + if (workspacePath) { + return { + projectAwareCwd: workspacePath, + projectAwareCwdLabel: workspacePath, + projectAwareSourceLabel: workspaceSourceLabel || 'workspace root', + workspaceRootCwd: workspacePath, + workspaceRootCwdLabel: workspacePath, + launchActionsShareTarget: true, + launchActionsShareTargetReason: null, + }; + } + + return { + projectAwareCwd: null, + projectAwareCwdLabel: 'VS Code default terminal cwd', + projectAwareSourceLabel: 'VS Code default terminal cwd', + workspaceRootCwd: null, + workspaceRootCwdLabel: 'No workspace open', + launchActionsShareTarget: false, + launchActionsShareTargetReason: null, + }; +} + +function resolveLaunchWorkspace() { + return chooseLaunchWorkspace({ + activeWorkspacePath: getActiveWorkspacePath(), + workspacePaths: getWorkspacePaths(), + }); +} + +function getWorkspaceSourceLabel(source) { + switch (source) { + case 'active-workspace': + return 'active editor workspace'; + case 'first-workspace': + return 'first workspace folder'; + default: + return 'no workspace open'; + } +} + +function getProviderSourceLabel(source) { + switch (source) { + case 'profile': + return 'saved profile'; + case 'env': + return 'environment'; + case 'shim': + return 'launch setting'; + default: + return 'unknown'; + } +} + +function readWorkspaceProfile(profilePath) { + if (!profilePath || !fs.existsSync(profilePath)) { + return { + profile: null, + statusLabel: 'Missing', + statusHint: `${PROFILE_FILE_NAME} not found in the workspace root`, + filePath: null, + }; + } + + try { + const raw = fs.readFileSync(profilePath, 'utf8'); + const profile = parseProfileFile(raw); + if (!profile) { + return { + profile: null, + statusLabel: 'Invalid', + statusHint: `${profilePath} has invalid JSON or an unsupported profile`, + filePath: profilePath, + }; + } + + return { + profile, + statusLabel: 'Found', + statusHint: profilePath, + filePath: profilePath, + }; + } catch (error) { + return { + profile: null, + statusLabel: 'Unreadable', + statusHint: `${profilePath} (${error instanceof Error ? error.message : 'read failed'})`, + filePath: profilePath, + }; + } +} + +async function collectControlCenterState() { const configured = vscode.workspace.getConfiguration('openclaude'); const launchCommand = configured.get('launchCommand', 'openclaude'); const terminalName = configured.get('terminalName', 'OpenClaude'); const shimEnabled = configured.get('useOpenAIShim', false); const executable = getExecutableFromCommand(launchCommand); - const installed = await isCommandAvailable(executable); + const launchWorkspace = resolveLaunchWorkspace(); + const workspaceFolder = launchWorkspace.workspacePath; + const workspaceSourceLabel = getWorkspaceSourceLabel(launchWorkspace.source); + const launchTargets = resolveLaunchTargets({ + activeFilePath: getActiveFilePath(), + workspacePath: workspaceFolder, + workspaceSourceLabel, + executable, + }); + const installed = await isCommandAvailable(executable, launchTargets.projectAwareCwd); + const profilePath = workspaceFolder + ? path.join(workspaceFolder, PROFILE_FILE_NAME) + : null; + + const profileState = workspaceFolder + ? readWorkspaceProfile(profilePath) + : { + profile: null, + statusLabel: 'No workspace', + statusHint: 'Open a workspace folder to detect a saved profile', + filePath: null, + }; + + const providerState = describeProviderState({ + shimEnabled, + env: process.env, + profile: profileState.profile, + }); + + return { + installed, + executable, + launchCommand, + terminalName, + shimEnabled, + workspaceFolder, + workspaceSourceLabel, + launchCwd: launchTargets.projectAwareCwd, + launchCwdLabel: launchTargets.projectAwareCwdLabel, + launchCwdSourceLabel: launchTargets.projectAwareSourceLabel, + workspaceRootCwd: launchTargets.workspaceRootCwd, + workspaceRootCwdLabel: launchTargets.workspaceRootCwdLabel, + launchActionsShareTarget: launchTargets.launchActionsShareTarget, + launchActionsShareTargetReason: launchTargets.launchActionsShareTargetReason, + canLaunchInWorkspaceRoot: Boolean(workspaceFolder), + profileStatusLabel: profileState.statusLabel, + profileStatusHint: profileState.statusHint, + workspaceProfilePath: profileState.filePath, + providerState, + providerSourceLabel: getProviderSourceLabel(providerState.source), + }; +} + +async function launchOpenClaude(options = {}) { + const { requireWorkspace = false } = options; + const configured = vscode.workspace.getConfiguration('openclaude'); + const launchCommand = configured.get('launchCommand', 'openclaude'); + const terminalName = configured.get('terminalName', 'OpenClaude'); + const shimEnabled = configured.get('useOpenAIShim', false); + const executable = getExecutableFromCommand(launchCommand); + const launchWorkspace = resolveLaunchWorkspace(); + + if (requireWorkspace && !launchWorkspace.workspacePath) { + await vscode.window.showWarningMessage( + 'Open a workspace folder before using Launch in Workspace Root.', + ); + return; + } + + const launchTargets = resolveLaunchTargets({ + activeFilePath: getActiveFilePath(), + workspacePath: launchWorkspace.workspacePath, + workspaceSourceLabel: getWorkspaceSourceLabel(launchWorkspace.source), + executable, + }); + const targetCwd = requireWorkspace + ? launchTargets.workspaceRootCwd + : launchTargets.projectAwareCwd; + const installed = await isCommandAvailable(executable, targetCwd); if (!installed) { const action = await vscode.window.showErrorMessage( `OpenClaude command not found: ${executable}. Install it with: npm install -g @gitlawb/openclaude`, - 'Open Repository' + 'Open Setup Guide', + 'Open Repository', ); - if (action === 'Open Repository') { + if (action === 'Open Setup Guide') { + await vscode.env.openExternal(vscode.Uri.parse(OPENCLAUDE_SETUP_URL)); + } else if (action === 'Open Repository') { await vscode.env.openExternal(vscode.Uri.parse(OPENCLAUDE_REPO_URL)); } @@ -54,54 +307,164 @@ async function launchOpenClaude() { env.CLAUDE_CODE_USE_OPENAI = '1'; } - const terminal = vscode.window.createTerminal({ + const terminalOptions = { name: terminalName, env, - }); + }; + if (targetCwd) { + terminalOptions.cwd = targetCwd; + } + + const terminal = vscode.window.createTerminal(terminalOptions); terminal.show(true); terminal.sendText(launchCommand, true); } -class OpenClaudeControlCenterProvider { - async resolveWebviewView(webviewView) { - webviewView.webview.options = { enableScripts: true }; - const configured = vscode.workspace.getConfiguration('openclaude'); - const launchCommand = configured.get('launchCommand', 'openclaude'); - const executable = getExecutableFromCommand(launchCommand); - const installed = await isCommandAvailable(executable); - const shimEnabled = configured.get('useOpenAIShim', false); - const shortcut = process.platform === 'darwin' ? 'Cmd+Shift+P' : 'Ctrl+Shift+P'; +async function openWorkspaceProfile() { + const state = await collectControlCenterState(); - webviewView.webview.html = this.getHtml(webviewView.webview, { - installed, - shimEnabled, - shortcut, - executable, - }); - - webviewView.webview.onDidReceiveMessage(async (message) => { - if (message?.type === 'launch') { - await launchOpenClaude(); - return; - } - - if (message?.type === 'docs') { - await vscode.env.openExternal(vscode.Uri.parse(OPENCLAUDE_REPO_URL)); - return; - } - - if (message?.type === 'commands') { - await vscode.commands.executeCommand('workbench.action.showCommands'); - } - }); + if (!state.workspaceProfilePath) { + await vscode.window.showInformationMessage( + `No ${PROFILE_FILE_NAME} file was found for the current workspace.`, + ); + return; } - getHtml(webview, status) { - const nonce = crypto.randomBytes(16).toString('base64'); - const runtimeLabel = status.installed ? 'available' : 'missing'; - const shimLabel = status.shimEnabled ? 'enabled (CLAUDE_CODE_USE_OPENAI=1)' : 'disabled'; - return ` + const document = await vscode.workspace.openTextDocument( + vscode.Uri.file(state.workspaceProfilePath), + ); + await vscode.window.showTextDocument(document, { preview: false }); +} + +function getToneClass(tone) { + switch (tone) { + case 'accent': + return 'tone-accent'; + case 'positive': + return 'tone-positive'; + case 'warning': + return 'tone-warning'; + case 'critical': + return 'tone-critical'; + default: + return 'tone-neutral'; + } +} + +function renderHeaderBadge(badge) { + return `