* feat(vscode): redesign control center * fix(vscode): keep launch target messaging honest
1120 lines
34 KiB
JavaScript
1120 lines
34 KiB
JavaScript
const vscode = require('vscode');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const {
|
|
chooseLaunchWorkspace,
|
|
describeProviderState,
|
|
findCommandPath,
|
|
isPathInsideWorkspace,
|
|
parseProfileFile,
|
|
resolveCommandCheckPath,
|
|
} = require('./state');
|
|
const { buildControlCenterViewModel } = require('./presentation');
|
|
|
|
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';
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
async function isCommandAvailable(command, launchCwd) {
|
|
return Boolean(findCommandPath(command, { cwd: launchCwd }));
|
|
}
|
|
|
|
function getExecutableFromCommand(command) {
|
|
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];
|
|
}
|
|
|
|
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 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 Setup Guide',
|
|
'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));
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const env = {};
|
|
if (shimEnabled) {
|
|
env.CLAUDE_CODE_USE_OPENAI = '1';
|
|
}
|
|
|
|
const terminalOptions = {
|
|
name: terminalName,
|
|
env,
|
|
};
|
|
|
|
if (targetCwd) {
|
|
terminalOptions.cwd = targetCwd;
|
|
}
|
|
|
|
const terminal = vscode.window.createTerminal(terminalOptions);
|
|
terminal.show(true);
|
|
terminal.sendText(launchCommand, true);
|
|
}
|
|
|
|
async function openWorkspaceProfile() {
|
|
const state = await collectControlCenterState();
|
|
|
|
if (!state.workspaceProfilePath) {
|
|
await vscode.window.showInformationMessage(
|
|
`No ${PROFILE_FILE_NAME} file was found for the current workspace.`,
|
|
);
|
|
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 `<div class="rail-pill ${getToneClass(badge.tone)}" title="${escapeHtml(badge.label)}: ${escapeHtml(badge.value)}">
|
|
<span class="rail-label">${escapeHtml(badge.label)}</span>
|
|
<span class="rail-value">${escapeHtml(badge.value)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
function renderSummaryCard(card) {
|
|
const detail = card.detail || '';
|
|
return `<section class="summary-card" aria-label="${escapeHtml(card.label)}">
|
|
<div class="summary-label">${escapeHtml(card.label)}</div>
|
|
<div class="summary-value" title="${escapeHtml(card.value)}">${escapeHtml(card.value)}</div>
|
|
${detail ? `<div class="summary-detail" title="${escapeHtml(detail)}">${escapeHtml(detail)}</div>` : ''}
|
|
</section>`;
|
|
}
|
|
|
|
function renderDetailRow(row) {
|
|
return `<div class="detail-row ${getToneClass(row.tone)}">
|
|
<div class="detail-label">${escapeHtml(row.label)}</div>
|
|
<div class="detail-summary" title="${escapeHtml(row.summary)}">${escapeHtml(row.summary)}</div>
|
|
${row.detail ? `<div class="detail-meta" title="${escapeHtml(row.detail)}">${escapeHtml(row.detail)}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
function renderDetailSection(section) {
|
|
const sectionId = `section-${String(section.title || 'section').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
|
return `<section class="detail-module" aria-labelledby="${escapeHtml(sectionId)}">
|
|
<h2 class="module-title" id="${escapeHtml(sectionId)}">${escapeHtml(section.title)}</h2>
|
|
<div class="detail-list">${section.rows.map(renderDetailRow).join('')}</div>
|
|
</section>`;
|
|
}
|
|
|
|
function renderActionButton(action, variant = 'secondary') {
|
|
return `<button class="action-button ${variant}" id="${escapeHtml(action.id)}" type="button" ${action.disabled ? 'disabled aria-disabled="true"' : ''}>
|
|
<span class="action-label">${escapeHtml(action.label)}</span>
|
|
<span class="action-detail">${escapeHtml(action.detail)}</span>
|
|
</button>`;
|
|
}
|
|
|
|
function renderProfileEmptyState(detail) {
|
|
return `<div class="action-empty" role="status" aria-live="polite">
|
|
<div class="action-empty-title">No workspace profile yet</div>
|
|
<div class="action-empty-detail">${escapeHtml(detail)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<style>
|
|
:root {
|
|
--oc-bg: #050505;
|
|
--oc-panel: #110d0c;
|
|
--oc-panel-strong: #17110f;
|
|
--oc-panel-soft: #1d1512;
|
|
--oc-border: #645041;
|
|
--oc-border-soft: rgba(220, 195, 170, 0.14);
|
|
--oc-text: #f7efe5;
|
|
--oc-text-dim: #dcc3aa;
|
|
--oc-text-soft: #aa9078;
|
|
--oc-accent: #d77757;
|
|
--oc-accent-bright: #f09464;
|
|
--oc-accent-soft: rgba(240, 148, 100, 0.18);
|
|
--oc-positive: #e8b86b;
|
|
--oc-warning: #f3c969;
|
|
--oc-critical: #ff8a6c;
|
|
--oc-focus: #ffd3a1;
|
|
}
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
h1, h2, p {
|
|
margin: 0;
|
|
}
|
|
html, body {
|
|
margin: 0;
|
|
min-height: 100%;
|
|
}
|
|
body {
|
|
padding: 16px;
|
|
font-family: var(--vscode-font-family, "Segoe UI", sans-serif);
|
|
color: var(--oc-text);
|
|
background:
|
|
radial-gradient(circle at top right, rgba(240, 148, 100, 0.16), transparent 34%),
|
|
radial-gradient(circle at 20% 0%, rgba(215, 119, 87, 0.14), transparent 28%),
|
|
linear-gradient(180deg, #090706, #050505 58%, #090706);
|
|
line-height: 1.45;
|
|
}
|
|
button {
|
|
font: inherit;
|
|
}
|
|
.shell {
|
|
position: relative;
|
|
overflow: hidden;
|
|
border: 1px solid var(--oc-border-soft);
|
|
border-radius: 20px;
|
|
background:
|
|
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 16%),
|
|
linear-gradient(180deg, rgba(17, 13, 12, 0.98), rgba(9, 7, 6, 0.98));
|
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
}
|
|
.shell::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0 0 auto;
|
|
height: 2px;
|
|
background: linear-gradient(90deg, #ffb464, #f09464, #d77757, #814334);
|
|
opacity: 0.95;
|
|
}
|
|
.sunset-gradient {
|
|
background: linear-gradient(90deg, #ffb464, #f09464, #d77757, #814334);
|
|
}
|
|
.frame {
|
|
display: grid;
|
|
gap: 18px;
|
|
padding: 18px;
|
|
}
|
|
.hero {
|
|
display: grid;
|
|
gap: 14px;
|
|
padding: 18px;
|
|
border-radius: 16px;
|
|
background:
|
|
linear-gradient(135deg, rgba(240, 148, 100, 0.06), rgba(215, 119, 87, 0.02) 55%, transparent),
|
|
var(--oc-panel);
|
|
border: 1px solid var(--oc-border-soft);
|
|
}
|
|
.hero-top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.brand {
|
|
display: grid;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
.eyebrow {
|
|
font-size: 11px;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: var(--oc-text-soft);
|
|
}
|
|
.wordmark {
|
|
font-size: 24px;
|
|
line-height: 1;
|
|
font-weight: 700;
|
|
letter-spacing: -0.03em;
|
|
color: var(--oc-text);
|
|
}
|
|
.wordmark-accent {
|
|
color: var(--oc-accent-bright);
|
|
}
|
|
.headline {
|
|
display: grid;
|
|
gap: 4px;
|
|
max-width: 44ch;
|
|
}
|
|
.headline-title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: var(--oc-text);
|
|
}
|
|
.headline-subtitle {
|
|
font-size: 12px;
|
|
color: var(--oc-text-dim);
|
|
}
|
|
.status-rail {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
flex: 1 1 250px;
|
|
}
|
|
.rail-pill {
|
|
display: grid;
|
|
gap: 2px;
|
|
min-width: 94px;
|
|
padding: 8px 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--oc-border-soft);
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
.rail-label {
|
|
font-size: 10px;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--oc-text-soft);
|
|
}
|
|
.rail-value {
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: var(--oc-text);
|
|
}
|
|
.refresh-button {
|
|
border: 1px solid rgba(240, 148, 100, 0.28);
|
|
border-radius: 999px;
|
|
padding: 8px 12px;
|
|
background: rgba(240, 148, 100, 0.08);
|
|
color: var(--oc-text-dim);
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
.summary-grid {
|
|
display: grid;
|
|
gap: 12px;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
}
|
|
.summary-card {
|
|
display: grid;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
padding: 14px;
|
|
border-radius: 14px;
|
|
background: var(--oc-panel-strong);
|
|
border: 1px solid var(--oc-border-soft);
|
|
}
|
|
.summary-label,
|
|
.detail-label,
|
|
.module-title,
|
|
.action-section-title,
|
|
.support-title {
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--oc-text-soft);
|
|
}
|
|
.summary-value,
|
|
.detail-summary {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--oc-text);
|
|
}
|
|
.summary-detail,
|
|
.detail-meta,
|
|
.action-detail,
|
|
.action-empty-detail,
|
|
.support-copy,
|
|
.footer-note {
|
|
font-size: 12px;
|
|
color: var(--oc-text-dim);
|
|
}
|
|
.modules {
|
|
display: grid;
|
|
gap: 14px;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
}
|
|
.detail-module,
|
|
.support-card {
|
|
display: grid;
|
|
gap: 12px;
|
|
padding: 16px;
|
|
border-radius: 16px;
|
|
background: var(--oc-panel);
|
|
border: 1px solid var(--oc-border-soft);
|
|
}
|
|
.detail-list,
|
|
.action-stack,
|
|
.support-stack {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
.detail-row {
|
|
display: grid;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
padding: 12px;
|
|
border-radius: 12px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid rgba(220, 195, 170, 0.08);
|
|
}
|
|
.actions-layout {
|
|
display: grid;
|
|
gap: 14px;
|
|
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
|
align-items: start;
|
|
}
|
|
.action-panel {
|
|
display: grid;
|
|
gap: 12px;
|
|
padding: 16px;
|
|
border-radius: 16px;
|
|
background: var(--oc-panel);
|
|
border: 1px solid var(--oc-border-soft);
|
|
}
|
|
.action-button {
|
|
width: 100%;
|
|
display: grid;
|
|
gap: 4px;
|
|
padding: 14px;
|
|
text-align: left;
|
|
border-radius: 14px;
|
|
border: 1px solid rgba(220, 195, 170, 0.14);
|
|
background: rgba(255, 255, 255, 0.02);
|
|
color: var(--oc-text);
|
|
cursor: pointer;
|
|
transition: border-color 140ms ease, transform 140ms ease, background 140ms ease, box-shadow 140ms ease;
|
|
}
|
|
.action-button.primary {
|
|
border-color: rgba(240, 148, 100, 0.44);
|
|
background:
|
|
linear-gradient(135deg, rgba(255, 180, 100, 0.22), rgba(215, 119, 87, 0.12) 58%, rgba(129, 67, 52, 0.12)),
|
|
#241713;
|
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
|
}
|
|
.action-button.secondary:hover:enabled,
|
|
.action-button.primary:hover:enabled,
|
|
.refresh-button:hover {
|
|
border-color: rgba(240, 148, 100, 0.48);
|
|
transform: translateY(-1px);
|
|
background-color: rgba(240, 148, 100, 0.1);
|
|
}
|
|
.action-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.58;
|
|
transform: none;
|
|
}
|
|
.action-label,
|
|
.action-empty-title,
|
|
.support-link-label {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: var(--oc-text);
|
|
}
|
|
.action-empty {
|
|
display: grid;
|
|
gap: 4px;
|
|
padding: 14px;
|
|
border-radius: 14px;
|
|
border: 1px dashed rgba(220, 195, 170, 0.16);
|
|
background: rgba(255, 255, 255, 0.015);
|
|
}
|
|
.support-link {
|
|
width: 100%;
|
|
display: grid;
|
|
gap: 4px;
|
|
padding: 12px 0;
|
|
border: 0;
|
|
border-top: 1px solid rgba(220, 195, 170, 0.08);
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.support-link:first-of-type {
|
|
border-top: 0;
|
|
padding-top: 0;
|
|
}
|
|
.tone-positive .rail-value,
|
|
.tone-positive .detail-summary {
|
|
color: var(--oc-positive);
|
|
}
|
|
.tone-warning .rail-value,
|
|
.tone-warning .detail-summary {
|
|
color: var(--oc-warning);
|
|
}
|
|
.tone-critical .rail-value,
|
|
.tone-critical .detail-summary {
|
|
color: var(--oc-critical);
|
|
}
|
|
.tone-accent .rail-value,
|
|
.tone-accent .detail-summary {
|
|
color: var(--oc-accent-bright);
|
|
}
|
|
.action-button:focus-visible,
|
|
.support-link:focus-visible,
|
|
.refresh-button:focus-visible {
|
|
outline: 2px solid var(--oc-focus);
|
|
outline-offset: 2px;
|
|
box-shadow: 0 0 0 4px rgba(255, 211, 161, 0.16);
|
|
}
|
|
code {
|
|
padding: 1px 6px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(240, 148, 100, 0.18);
|
|
background: rgba(240, 148, 100, 0.08);
|
|
color: var(--oc-accent-bright);
|
|
font-family: var(--vscode-editor-font-family, Consolas, monospace);
|
|
font-size: 11px;
|
|
}
|
|
.footer-note {
|
|
padding-top: 2px;
|
|
}
|
|
@media (max-width: 720px) {
|
|
body {
|
|
padding: 12px;
|
|
}
|
|
.frame,
|
|
.hero {
|
|
padding: 14px;
|
|
}
|
|
.actions-layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.status-rail {
|
|
justify-content: flex-start;
|
|
}
|
|
.rail-pill {
|
|
min-width: 0;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="shell" aria-labelledby="control-center-title">
|
|
<div class="frame">
|
|
<header class="hero">
|
|
<div class="hero-top">
|
|
<div class="brand">
|
|
<div class="eyebrow">${escapeHtml(viewModel.header.eyebrow)}</div>
|
|
<div class="wordmark" aria-label="OpenClaude wordmark">Open<span class="wordmark-accent">Claude</span></div>
|
|
<div class="headline">
|
|
<h1 class="headline-title" id="control-center-title">${escapeHtml(viewModel.header.title)}</h1>
|
|
<p class="headline-subtitle">${escapeHtml(viewModel.header.subtitle)}</p>
|
|
</div>
|
|
</div>
|
|
<div class="status-rail" role="group" aria-label="Runtime, provider, and profile status">
|
|
${viewModel.headerBadges.map(renderHeaderBadge).join('')}
|
|
<button class="refresh-button" id="refresh" type="button">Refresh</button>
|
|
</div>
|
|
</div>
|
|
<section class="summary-grid" aria-label="Current launch summary">
|
|
${viewModel.summaryCards.map(renderSummaryCard).join('')}
|
|
</section>
|
|
</header>
|
|
|
|
<section class="modules" aria-label="Control center details">
|
|
${viewModel.detailSections.map(renderDetailSection).join('')}
|
|
</section>
|
|
|
|
<section class="actions-layout" aria-label="Control center actions">
|
|
<section class="action-panel" aria-labelledby="actions-title">
|
|
<h2 class="action-section-title" id="actions-title">Launch & Project</h2>
|
|
${renderActionButton(viewModel.actions.primary, 'primary')}
|
|
<div class="action-stack">
|
|
${renderActionButton(viewModel.actions.launchRoot)}
|
|
${profileActionOrEmpty}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="support-card" aria-labelledby="quick-links-title">
|
|
<h2 class="support-title" id="quick-links-title">Quick Links</h2>
|
|
<div class="support-copy">Settings and workspace status stay in view here. Reference links stay secondary.</div>
|
|
<div class="support-stack">
|
|
<button class="support-link" id="setup" type="button">
|
|
<span class="support-link-label">Open Setup Guide</span>
|
|
<span class="summary-detail">Jump to install and provider setup docs.</span>
|
|
</button>
|
|
<button class="support-link" id="repo" type="button">
|
|
<span class="support-link-label">Open Repository</span>
|
|
<span class="summary-detail">Browse the upstream OpenClaude project.</span>
|
|
</button>
|
|
<button class="support-link" id="commands" type="button">
|
|
<span class="support-link-label">Open Command Palette</span>
|
|
<span class="summary-detail">Access VS Code and OpenClaude commands quickly.</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<p class="footer-note">
|
|
Quick trigger: use <code>${escapeHtml(platform === 'darwin' ? 'Cmd+Shift+P' : 'Ctrl+Shift+P')}</code> for the command palette, then refresh this panel after workspace or profile changes.
|
|
</p>
|
|
</div>
|
|
</main>
|
|
|
|
<script nonce="${nonce}">
|
|
const vscode = acquireVsCodeApi();
|
|
document.getElementById('launch').addEventListener('click', () => vscode.postMessage({ type: 'launch' }));
|
|
document.getElementById('launchRoot').addEventListener('click', () => vscode.postMessage({ type: 'launchRoot' }));
|
|
document.getElementById('repo').addEventListener('click', () => vscode.postMessage({ type: 'repo' }));
|
|
document.getElementById('setup').addEventListener('click', () => vscode.postMessage({ type: 'setup' }));
|
|
document.getElementById('commands').addEventListener('click', () => vscode.postMessage({ type: 'commands' }));
|
|
document.getElementById('refresh').addEventListener('click', () => vscode.postMessage({ type: 'refresh' }));
|
|
|
|
const profileButton = document.getElementById('openProfile');
|
|
if (profileButton) {
|
|
profileButton.addEventListener('click', () => vscode.postMessage({ type: 'openProfile' }));
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
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 `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<style>
|
|
body {
|
|
font-family: var(--vscode-font-family);
|
|
padding: 16px;
|
|
color: var(--vscode-foreground);
|
|
background: var(--vscode-sideBar-background);
|
|
}
|
|
.panel {
|
|
border: 1px solid var(--vscode-errorForeground);
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
background: color-mix(in srgb, var(--vscode-sideBar-background) 88%, black);
|
|
}
|
|
.title {
|
|
color: var(--vscode-errorForeground);
|
|
font-weight: 700;
|
|
margin-bottom: 8px;
|
|
}
|
|
.message {
|
|
color: var(--vscode-descriptionForeground);
|
|
margin-bottom: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
button {
|
|
border: 1px solid var(--vscode-button-border, transparent);
|
|
background: var(--vscode-button-background);
|
|
color: var(--vscode-button-foreground);
|
|
border-radius: 6px;
|
|
padding: 8px 10px;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="panel">
|
|
<div class="title">Control Center Error</div>
|
|
<div class="message">${escapeHtml(message)}</div>
|
|
<button id="refresh">Refresh</button>
|
|
</div>
|
|
<script nonce="${nonce}">
|
|
const vscode = acquireVsCodeApi();
|
|
document.getElementById('refresh').addEventListener('click', () => {
|
|
vscode.postMessage({ type: 'refresh' });
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
getHtml(status) {
|
|
const nonce = crypto.randomBytes(16).toString('base64');
|
|
return renderControlCenterHtml(status, { nonce, platform: process.platform });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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 providerDisposable = vscode.window.registerWebviewViewProvider(
|
|
'openclaude.controlCenter',
|
|
provider,
|
|
);
|
|
|
|
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() {}
|
|
|
|
module.exports = {
|
|
activate,
|
|
deactivate,
|
|
OpenClaudeControlCenterProvider,
|
|
renderControlCenterHtml,
|
|
resolveLaunchTargets,
|
|
};
|