From 6987a54a7147859975957f3d1d431674366ea23e Mon Sep 17 00:00:00 2001 From: Vasanth T <148849890+Vasanthdev2004@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:28:25 +0530 Subject: [PATCH] feat(vscode): redesign control center (#236) * feat(vscode): redesign control center * fix(vscode): keep launch target messaging honest --- vscode-extension/openclaude-vscode/README.md | 40 +- .../openclaude-vscode/package.json | 31 +- .../openclaude-vscode/src/extension.js | 1230 ++++++++++++++--- .../openclaude-vscode/src/extension.test.js | 249 ++++ .../openclaude-vscode/src/presentation.js | 202 +++ .../src/presentation.test.js | 291 ++++ .../openclaude-vscode/src/state.js | 389 ++++++ .../openclaude-vscode/src/state.test.js | 208 +++ 8 files changed, 2408 insertions(+), 232 deletions(-) create mode 100644 vscode-extension/openclaude-vscode/src/extension.test.js create mode 100644 vscode-extension/openclaude-vscode/src/presentation.js create mode 100644 vscode-extension/openclaude-vscode/src/presentation.test.js create mode 100644 vscode-extension/openclaude-vscode/src/state.js create mode 100644 vscode-extension/openclaude-vscode/src/state.test.js 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 `
+ ${escapeHtml(badge.label)} + ${escapeHtml(badge.value)} +
`; +} + +function renderSummaryCard(card) { + const detail = card.detail || ''; + return `
+
${escapeHtml(card.label)}
+
${escapeHtml(card.value)}
+ ${detail ? `
${escapeHtml(detail)}
` : ''} +
`; +} + +function renderDetailRow(row) { + return `
+
${escapeHtml(row.label)}
+
${escapeHtml(row.summary)}
+ ${row.detail ? `
${escapeHtml(row.detail)}
` : ''} +
`; +} + +function renderDetailSection(section) { + const sectionId = `section-${String(section.title || 'section').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + return `
+

${escapeHtml(section.title)}

+
${section.rows.map(renderDetailRow).join('')}
+
`; +} + +function renderActionButton(action, variant = 'secondary') { + return ``; +} + +function renderProfileEmptyState(detail) { + return `
+
No workspace profile yet
+
${escapeHtml(detail)}
+
`; +} + +function getPrimaryLaunchActionDetail(status) { + if (status.launchActionsShareTargetReason === 'relative-launch-command' && status.launchCwd) { + return `Project-aware launch is anchored to the workspace root by the relative command · ${status.launchCwdLabel}`; + } + + if (status.launchCwd && status.launchCwdSourceLabel === 'active file directory') { + return `Starts beside the active file · ${status.launchCwdLabel}`; + } + + if (status.launchCwd) { + return `Project-aware launch. Currently resolves to ${status.launchCwdSourceLabel} · ${status.launchCwdLabel}`; + } + + return 'Project-aware launch. Uses the VS Code default terminal cwd'; +} + +function getWorkspaceRootActionDetail(status, fallbackDetail) { + if (!status.canLaunchInWorkspaceRoot) { + return fallbackDetail; + } + + if (status.launchActionsShareTargetReason === 'relative-launch-command') { + return `Same workspace-root target as Launch OpenClaude because the relative command resolves from the workspace root · ${status.workspaceRootCwdLabel}`; + } + + return `Always starts at the workspace root · ${status.workspaceRootCwdLabel}`; +} + +function getRenderableViewModel(status) { + const viewModel = buildControlCenterViewModel(status); + const summaryCards = viewModel.summaryCards.map(card => { + if (card.key !== 'launchCwd' || card.detail) { + return card; + } + + return { + ...card, + detail: status.launchCwdSourceLabel || '', + }; + }); + + return { + ...viewModel, + summaryCards, + actions: { + ...viewModel.actions, + primary: { + ...viewModel.actions.primary, + detail: getPrimaryLaunchActionDetail(status), + }, + launchRoot: { + ...viewModel.actions.launchRoot, + detail: getWorkspaceRootActionDetail(status, viewModel.actions.launchRoot.detail), + }, + }, + }; +} + +function renderControlCenterHtml(status, options = {}) { + const nonce = options.nonce || crypto.randomBytes(16).toString('base64'); + const platform = options.platform || process.platform; + const viewModel = getRenderableViewModel(status); + const profileActionOrEmpty = viewModel.actions.openProfile + ? renderActionButton(viewModel.actions.openProfile) + : renderProfileEmptyState(status.profileStatusHint || 'Open a workspace folder to detect a saved profile'); + + return ` @@ -109,199 +472,568 @@ class OpenClaudeControlCenterProvider { -
-
- openclaude control center - online +
+
+
+
+
+
${escapeHtml(viewModel.header.eyebrow)}
+
OpenClaude
+
+

${escapeHtml(viewModel.header.title)}

+

${escapeHtml(viewModel.header.subtitle)}

+
+
+
+ ${viewModel.headerBadges.map(renderHeaderBadge).join('')} + +
+
+
+ ${viewModel.summaryCards.map(renderSummaryCard).join('')} +
+
+ +
+ ${viewModel.detailSections.map(renderDetailSection).join('')} +
+ +
+
+

Launch & Project

+ ${renderActionButton(viewModel.actions.primary, 'primary')} +
+ ${renderActionButton(viewModel.actions.launchRoot)} + ${profileActionOrEmpty} +
+
+ +
+ +
Settings and workspace status stay in view here. Reference links stay secondary.
+
+ + + +
+
+
+ +
-
-
-
READY FOR INPUT
-
Terminal-oriented workflow with direct command access.
-
- -
-
$ openclaude --status
-
runtime: ${runtimeLabel}
-
shim: ${shimLabel}
-
command: ${status.executable}
-
$ awaiting command
-
- -
- - - -
- -
- Quick trigger: use ${status.shortcut} and run OpenClaude commands from anywhere. -
-
-
+ `; +} + +class OpenClaudeControlCenterProvider { + constructor() { + this.webviewView = null; + } + + async resolveWebviewView(webviewView) { + this.webviewView = webviewView; + webviewView.webview.options = { enableScripts: true }; + + webviewView.onDidDispose(() => { + if (this.webviewView === webviewView) { + this.webviewView = null; + } + }); + + webviewView.webview.onDidReceiveMessage(async message => { + switch (message?.type) { + case 'launch': + await launchOpenClaude(); + break; + case 'launchRoot': + await launchOpenClaude({ requireWorkspace: true }); + break; + case 'openProfile': + await openWorkspaceProfile(); + break; + case 'repo': + await vscode.env.openExternal(vscode.Uri.parse(OPENCLAUDE_REPO_URL)); + break; + case 'setup': + await vscode.env.openExternal(vscode.Uri.parse(OPENCLAUDE_SETUP_URL)); + break; + case 'commands': + await vscode.commands.executeCommand('workbench.action.showCommands'); + break; + case 'refresh': + default: + break; + } + + await this.refresh(); + }); + + await this.refresh(); + } + + async refresh() { + if (!this.webviewView) { + return; + } + + try { + const status = await collectControlCenterState(); + this.webviewView.webview.html = this.getHtml(status); + } catch (error) { + this.webviewView.webview.html = this.getErrorHtml(error); + } + } + + getErrorHtml(error) { + const nonce = crypto.randomBytes(16).toString('base64'); + const message = + error instanceof Error ? error.message : 'Unknown Control Center error'; + + return ` + + + + + + + + +
+
Control Center Error
+
${escapeHtml(message)}
+ +
+ + +`; + } + + getHtml(status) { + const nonce = crypto.randomBytes(16).toString('base64'); + return renderControlCenterHtml(status, { nonce, platform: process.platform }); } } @@ -309,22 +1041,71 @@ class OpenClaudeControlCenterProvider { * @param {vscode.ExtensionContext} context */ function activate(context) { + const provider = new OpenClaudeControlCenterProvider(); + const refreshProvider = () => { + void provider.refresh(); + }; + const startCommand = vscode.commands.registerCommand('openclaude.start', async () => { await launchOpenClaude(); }); + const startInWorkspaceRootCommand = vscode.commands.registerCommand( + 'openclaude.startInWorkspaceRoot', + async () => { + await launchOpenClaude({ requireWorkspace: true }); + }, + ); + const openDocsCommand = vscode.commands.registerCommand('openclaude.openDocs', async () => { await vscode.env.openExternal(vscode.Uri.parse(OPENCLAUDE_REPO_URL)); }); + const openSetupDocsCommand = vscode.commands.registerCommand( + 'openclaude.openSetupDocs', + async () => { + await vscode.env.openExternal(vscode.Uri.parse(OPENCLAUDE_SETUP_URL)); + }, + ); + + const openWorkspaceProfileCommand = vscode.commands.registerCommand( + 'openclaude.openWorkspaceProfile', + async () => { + await openWorkspaceProfile(); + }, + ); + const openUiCommand = vscode.commands.registerCommand('openclaude.openControlCenter', async () => { await vscode.commands.executeCommand('workbench.view.extension.openclaude'); }); - const provider = new OpenClaudeControlCenterProvider(); - const providerDisposable = vscode.window.registerWebviewViewProvider('openclaude.controlCenter', provider); + const providerDisposable = vscode.window.registerWebviewViewProvider( + 'openclaude.controlCenter', + provider, + ); - context.subscriptions.push(startCommand, openDocsCommand, openUiCommand, providerDisposable); + const profileWatcher = vscode.workspace.createFileSystemWatcher(`**/${PROFILE_FILE_NAME}`); + + context.subscriptions.push( + startCommand, + startInWorkspaceRootCommand, + openDocsCommand, + openSetupDocsCommand, + openWorkspaceProfileCommand, + openUiCommand, + providerDisposable, + profileWatcher, + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('openclaude')) { + refreshProvider(); + } + }), + vscode.workspace.onDidChangeWorkspaceFolders(refreshProvider), + vscode.window.onDidChangeActiveTextEditor(refreshProvider), + profileWatcher.onDidCreate(refreshProvider), + profileWatcher.onDidChange(refreshProvider), + profileWatcher.onDidDelete(refreshProvider), + ); } function deactivate() {} @@ -332,4 +1113,7 @@ function deactivate() {} module.exports = { activate, deactivate, + OpenClaudeControlCenterProvider, + renderControlCenterHtml, + resolveLaunchTargets, }; diff --git a/vscode-extension/openclaude-vscode/src/extension.test.js b/vscode-extension/openclaude-vscode/src/extension.test.js new file mode 100644 index 00000000..474ed715 --- /dev/null +++ b/vscode-extension/openclaude-vscode/src/extension.test.js @@ -0,0 +1,249 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Module = require('node:module'); + +function createStatus(overrides = {}) { + return { + installed: true, + executable: 'openclaude', + launchCommand: 'openclaude --project-aware', + terminalName: 'OpenClaude', + shimEnabled: false, + workspaceFolder: '/workspace/openclaude/very/long/path/example-project', + workspaceSourceLabel: 'active editor workspace', + launchCwd: '/workspace/openclaude/very/long/path/example-project', + launchCwdLabel: '/workspace/openclaude/very/long/path/example-project', + canLaunchInWorkspaceRoot: true, + profileStatusLabel: 'Found', + profileStatusHint: '/workspace/openclaude/very/long/path/example-project/.openclaude-profile.json', + workspaceProfilePath: '/workspace/openclaude/very/long/path/example-project/.openclaude-profile.json', + providerState: { + label: 'Codex', + detail: 'gpt-5.4', + source: 'profile', + }, + providerSourceLabel: 'saved profile', + ...overrides, + }; +} + +function loadExtension() { + const extensionPath = require.resolve('./extension'); + delete require.cache[extensionPath]; + + const originalLoad = Module._load; + Module._load = function patchedLoad(request, parent, isMain) { + if (request === 'vscode') { + return { + workspace: {}, + window: {}, + env: {}, + commands: {}, + Uri: { parse: value => value, file: value => value }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + try { + return require('./extension'); + } finally { + Module._load = originalLoad; + } +} + +test('renderControlCenterHtml uses the OpenClaude wordmark, status rail, and warm action hierarchy', () => { + const { renderControlCenterHtml } = loadExtension(); + const html = renderControlCenterHtml(createStatus(), { nonce: 'test-nonce', platform: 'win32' }); + + assert.match(html, /OpenClaude<\/span>/); + assert.match(html, /class="status-rail"/); + assert.match(html, /\.sunset-gradient\s*\{/); + assert.match(html, /class="action-button primary" id="launch"/); + assert.match(html, /class="action-button secondary" id="launchRoot"/); + assert.match( + html, + /title="\/workspace\/openclaude\/very\/long\/path\/example-project"[^>]*>\/workspace\/openclaude\/very\/long\/path\/example-project<\//, + ); +}); + +test('renderControlCenterHtml shows explicit disabled and empty states when workspace data is missing', () => { + const { renderControlCenterHtml } = loadExtension(); + const html = renderControlCenterHtml( + createStatus({ + workspaceFolder: null, + workspaceSourceLabel: 'no workspace open', + launchCwd: null, + launchCwdLabel: 'VS Code default terminal cwd', + canLaunchInWorkspaceRoot: false, + profileStatusLabel: 'No workspace', + profileStatusHint: 'Open a workspace folder to detect a saved profile', + workspaceProfilePath: null, + }), + { nonce: 'test-nonce', platform: 'linux' }, + ); + + assert.match( + html, + /class="action-button secondary" id="launchRoot"[^>]*disabled[^>]*>[\s\S]*Open a workspace folder to enable workspace-root launch/, + ); + assert.match(html, /No workspace profile yet/); + assert.match(html, /Open a workspace folder to detect a saved profile/); + assert.doesNotMatch(html, /id="openProfile"/); +}); + +test('OpenClaudeControlCenterProvider.getHtml supplies a nonce to the redesigned renderer', () => { + const { OpenClaudeControlCenterProvider } = loadExtension(); + const provider = new OpenClaudeControlCenterProvider(); + + assert.doesNotThrow(() => provider.getHtml(createStatus())); + + const html = provider.getHtml(createStatus()); + assert.match(html, /script-src 'nonce-[^']+'/); + assert.match(html, /', + workspaceSourceLabel: 'active workspace', + launchCwdLabel: '">', + profileStatusHint: '', + workspaceProfilePath: '"/>', + providerState: { + label: 'Provider ">', + detail: '', + source: 'profile', + }, + }), + { nonce: 'test-nonce', platform: 'linux' }, + ); + + assert.match(html, /<img src=x onerror="boom\(\)">/); + assert.match(html, /"\/><script>workspace\(\)<\/script>/); + assert.match(html, /active <b>workspace<\/b>/); + assert.match(html, /<svg onload="profile\(\)">/); + assert.match(html, /Provider "><img src=x onerror="label\(\)">/); + assert.match(html, /<script>provider-detail\(\)<\/script> · saved profile/); + assert.doesNotMatch(html, /