feat(vscode): redesign control center (#236)
* feat(vscode): redesign control center * fix(vscode): keep launch target messaging honest
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
249
vscode-extension/openclaude-vscode/src/extension.test.js
Normal file
249
vscode-extension/openclaude-vscode/src/extension.test.js
Normal file
@@ -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, /Open<span class="wordmark-accent">Claude<\/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, /<script nonce="[^"]+">/);
|
||||
assert.doesNotMatch(html, /nonce-undefined/);
|
||||
assert.doesNotMatch(html, /<script nonce="undefined">/);
|
||||
});
|
||||
|
||||
test('resolveLaunchTargets distinguishes project-aware launch from workspace-root launch', () => {
|
||||
const { resolveLaunchTargets } = loadExtension();
|
||||
|
||||
assert.deepEqual(
|
||||
resolveLaunchTargets({
|
||||
activeFilePath: '/workspace/openclaude/src/panels/control-center.js',
|
||||
workspacePath: '/workspace/openclaude',
|
||||
workspaceSourceLabel: 'active editor workspace',
|
||||
}),
|
||||
{
|
||||
projectAwareCwd: '/workspace/openclaude/src/panels',
|
||||
projectAwareCwdLabel: '/workspace/openclaude/src/panels',
|
||||
projectAwareSourceLabel: 'active file directory',
|
||||
workspaceRootCwd: '/workspace/openclaude',
|
||||
workspaceRootCwdLabel: '/workspace/openclaude',
|
||||
launchActionsShareTarget: false,
|
||||
launchActionsShareTargetReason: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLaunchTargets anchors relative launch commands to the workspace root', () => {
|
||||
const { resolveLaunchTargets } = loadExtension();
|
||||
|
||||
assert.deepEqual(
|
||||
resolveLaunchTargets({
|
||||
executable: './node_modules/.bin/openclaude',
|
||||
activeFilePath: '/workspace/openclaude/src/panels/control-center.js',
|
||||
workspacePath: '/workspace/openclaude',
|
||||
workspaceSourceLabel: 'active editor workspace',
|
||||
}),
|
||||
{
|
||||
projectAwareCwd: '/workspace/openclaude',
|
||||
projectAwareCwdLabel: '/workspace/openclaude',
|
||||
projectAwareSourceLabel: 'workspace root (required by relative launch command)',
|
||||
workspaceRootCwd: '/workspace/openclaude',
|
||||
workspaceRootCwdLabel: '/workspace/openclaude',
|
||||
launchActionsShareTarget: true,
|
||||
launchActionsShareTargetReason: 'relative-launch-command',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLaunchTargets ignores active files outside the selected workspace', () => {
|
||||
const { resolveLaunchTargets } = loadExtension();
|
||||
|
||||
assert.deepEqual(
|
||||
resolveLaunchTargets({
|
||||
executable: 'openclaude',
|
||||
activeFilePath: '/tmp/notes/scratch.js',
|
||||
workspacePath: '/workspace/openclaude',
|
||||
workspaceSourceLabel: 'first workspace folder',
|
||||
}),
|
||||
{
|
||||
projectAwareCwd: '/workspace/openclaude',
|
||||
projectAwareCwdLabel: '/workspace/openclaude',
|
||||
projectAwareSourceLabel: 'first workspace folder',
|
||||
workspaceRootCwd: '/workspace/openclaude',
|
||||
workspaceRootCwdLabel: '/workspace/openclaude',
|
||||
launchActionsShareTarget: true,
|
||||
launchActionsShareTargetReason: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('renderControlCenterHtml restores landmark and heading semantics', () => {
|
||||
const { renderControlCenterHtml } = loadExtension();
|
||||
const html = renderControlCenterHtml(createStatus(), { nonce: 'test-nonce', platform: 'win32' });
|
||||
|
||||
assert.match(html, /<main class="shell" aria-labelledby="control-center-title">/);
|
||||
assert.match(html, /<header class="hero">/);
|
||||
assert.match(html, /<h1 class="headline-title" id="control-center-title">/);
|
||||
assert.match(html, /<section class="modules" aria-label="Control center details">/);
|
||||
assert.match(html, /<h2 class="module-title" id="section-project">Project<\/h2>/);
|
||||
assert.match(html, /<section class="actions-layout" aria-label="Control center actions">/);
|
||||
});
|
||||
|
||||
test('renderControlCenterHtml explains distinct launch targets when an active file directory is available', () => {
|
||||
const { renderControlCenterHtml } = loadExtension();
|
||||
const html = renderControlCenterHtml(
|
||||
createStatus({
|
||||
launchCwd: '/workspace/openclaude/src/panels',
|
||||
launchCwdLabel: '/workspace/openclaude/src/panels',
|
||||
launchCwdSourceLabel: 'active file directory',
|
||||
workspaceRootCwd: '/workspace/openclaude',
|
||||
workspaceRootCwdLabel: '/workspace/openclaude',
|
||||
}),
|
||||
{ nonce: 'test-nonce', platform: 'linux' },
|
||||
);
|
||||
|
||||
assert.match(html, /Starts beside the active file · \/workspace\/openclaude\/src\/panels/);
|
||||
assert.match(html, /Always starts at the workspace root · \/workspace\/openclaude/);
|
||||
});
|
||||
|
||||
test('renderControlCenterHtml makes shared workspace-root launches explicit for relative commands', () => {
|
||||
const { renderControlCenterHtml } = loadExtension();
|
||||
const html = renderControlCenterHtml(
|
||||
createStatus({
|
||||
launchCwd: '/workspace/openclaude',
|
||||
launchCwdLabel: '/workspace/openclaude',
|
||||
launchCwdSourceLabel: 'workspace root (required by relative launch command)',
|
||||
workspaceRootCwd: '/workspace/openclaude',
|
||||
workspaceRootCwdLabel: '/workspace/openclaude',
|
||||
launchActionsShareTarget: true,
|
||||
launchActionsShareTargetReason: 'relative-launch-command',
|
||||
}),
|
||||
{ nonce: 'test-nonce', platform: 'linux' },
|
||||
);
|
||||
|
||||
assert.match(html, /Project-aware launch is anchored to the workspace root by the relative command · \/workspace\/openclaude/);
|
||||
assert.match(html, /Same workspace-root target as Launch OpenClaude because the relative command resolves from the workspace root · \/workspace\/openclaude/);
|
||||
});
|
||||
|
||||
test('renderControlCenterHtml escapes hostile text and title values', () => {
|
||||
const { renderControlCenterHtml } = loadExtension();
|
||||
const html = renderControlCenterHtml(
|
||||
createStatus({
|
||||
launchCommand: '<img src=x onerror="boom()">',
|
||||
workspaceFolder: '"/><script>workspace()</script>',
|
||||
workspaceSourceLabel: 'active <b>workspace</b>',
|
||||
launchCwdLabel: '"><script>cwd()</script>',
|
||||
profileStatusHint: '<svg onload="profile()">',
|
||||
workspaceProfilePath: '"/><script>profile-path()</script>',
|
||||
providerState: {
|
||||
label: 'Provider "><img src=x onerror="label()">',
|
||||
detail: '<script>provider-detail()</script>',
|
||||
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, /<script>workspace\(\)<\/script>/);
|
||||
assert.doesNotMatch(html, /<img src=x onerror="boom\(\)">/);
|
||||
});
|
||||
202
vscode-extension/openclaude-vscode/src/presentation.js
Normal file
202
vscode-extension/openclaude-vscode/src/presentation.js
Normal file
@@ -0,0 +1,202 @@
|
||||
function truncateMiddle(value, maxLength) {
|
||||
const text = String(value || '');
|
||||
if (!text || text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const basename = text.split(/[\\/]/).filter(Boolean).pop() || '';
|
||||
if (basename && basename.length + 4 <= maxLength) {
|
||||
const separator = text.includes('\\') ? '\\' : '/';
|
||||
return `...${separator}${basename}`;
|
||||
}
|
||||
|
||||
if (maxLength <= 3) {
|
||||
return '.'.repeat(Math.max(maxLength, 0));
|
||||
}
|
||||
|
||||
const available = maxLength - 3;
|
||||
const startLength = Math.ceil(available / 2);
|
||||
const endLength = Math.floor(available / 2);
|
||||
return `${text.slice(0, startLength)}...${text.slice(text.length - endLength)}`;
|
||||
}
|
||||
|
||||
function getPathTail(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text.split(/[\\/]/).filter(Boolean).pop() || text;
|
||||
}
|
||||
|
||||
function buildActionModel({ canLaunchInWorkspaceRoot, workspaceProfilePath } = {}) {
|
||||
return {
|
||||
primary: {
|
||||
id: 'launch',
|
||||
label: 'Launch OpenClaude',
|
||||
detail: 'Use the resolved project-aware launch directory',
|
||||
tone: 'accent',
|
||||
disabled: false,
|
||||
},
|
||||
launchRoot: {
|
||||
id: 'launchRoot',
|
||||
label: 'Launch in Workspace Root',
|
||||
detail: canLaunchInWorkspaceRoot
|
||||
? 'Launch directly from the resolved workspace root'
|
||||
: 'Open a workspace folder to enable workspace-root launch',
|
||||
tone: 'neutral',
|
||||
disabled: !canLaunchInWorkspaceRoot,
|
||||
},
|
||||
openProfile: workspaceProfilePath
|
||||
? {
|
||||
id: 'openProfile',
|
||||
label: 'Open Workspace Profile',
|
||||
detail: `Inspect ${truncateMiddle(workspaceProfilePath, 40)}`,
|
||||
tone: 'neutral',
|
||||
disabled: false,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getRuntimeTone(installed) {
|
||||
return installed ? 'positive' : 'critical';
|
||||
}
|
||||
|
||||
function getProfileTone(profileStatusLabel) {
|
||||
return profileStatusLabel === 'Invalid' || profileStatusLabel === 'Unreadable'
|
||||
? 'warning'
|
||||
: 'neutral';
|
||||
}
|
||||
|
||||
function getProviderTone(providerState) {
|
||||
return providerState?.source === 'shim' || providerState?.source === 'unknown'
|
||||
? 'warning'
|
||||
: 'neutral';
|
||||
}
|
||||
|
||||
function getProviderDetail(providerState, providerSourceLabel) {
|
||||
const detail = providerState?.detail || '';
|
||||
if (!detail) {
|
||||
return providerSourceLabel || '';
|
||||
}
|
||||
|
||||
switch (providerState?.source) {
|
||||
case 'profile':
|
||||
return [detail, providerSourceLabel].filter(Boolean).join(' · ');
|
||||
case 'env':
|
||||
return /^from environment$/i.test(detail)
|
||||
? detail
|
||||
: [detail, providerSourceLabel].filter(Boolean).join(' · ');
|
||||
case 'shim':
|
||||
case 'unknown':
|
||||
return detail;
|
||||
default:
|
||||
return [detail, providerSourceLabel].filter(Boolean).join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
function buildControlCenterViewModel(status = {}) {
|
||||
const runtimeSummary = status.installed ? 'Installed' : 'Missing';
|
||||
const runtimeDetail = status.executable || 'Unknown command';
|
||||
const providerDetail = getProviderDetail(status.providerState, status.providerSourceLabel);
|
||||
const providerTone = getProviderTone(status.providerState);
|
||||
const workspaceSummary = status.workspaceFolder ? getPathTail(status.workspaceFolder) : 'No workspace open';
|
||||
const workspaceDetail = [status.workspaceFolder, status.workspaceSourceLabel]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || 'no workspace open';
|
||||
|
||||
return {
|
||||
header: {
|
||||
eyebrow: 'OpenClaude Control Center',
|
||||
title: 'Project-aware OpenClaude companion',
|
||||
subtitle:
|
||||
'Useful local status, predictable launch behavior, and quick access to the workflows you actually use.',
|
||||
},
|
||||
headerBadges: [
|
||||
{
|
||||
key: 'runtime',
|
||||
label: 'Runtime',
|
||||
value: runtimeSummary,
|
||||
tone: getRuntimeTone(status.installed),
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
label: 'Provider',
|
||||
value: status.providerState?.label || 'Unknown',
|
||||
tone: providerTone,
|
||||
},
|
||||
{
|
||||
key: 'profileStatus',
|
||||
label: 'Profile',
|
||||
value: status.profileStatusLabel || 'Unknown',
|
||||
tone: getProfileTone(status.profileStatusLabel),
|
||||
},
|
||||
],
|
||||
summaryCards: [
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
value: status.workspaceFolder || 'No workspace open',
|
||||
detail: status.workspaceSourceLabel || 'no workspace open',
|
||||
},
|
||||
{
|
||||
key: 'launchCwd',
|
||||
label: 'Launch cwd',
|
||||
value: status.launchCwdLabel || 'VS Code default terminal cwd',
|
||||
},
|
||||
{
|
||||
key: 'launchCommand',
|
||||
label: 'Launch command',
|
||||
value: status.launchCommand || '',
|
||||
detail: status.terminalName ? `Integrated terminal: ${status.terminalName}` : '',
|
||||
},
|
||||
],
|
||||
detailSections: [
|
||||
{
|
||||
title: 'Project',
|
||||
rows: [
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace folder',
|
||||
summary: workspaceSummary,
|
||||
detail: workspaceDetail,
|
||||
},
|
||||
{
|
||||
key: 'profileStatus',
|
||||
label: 'Workspace profile',
|
||||
summary: status.profileStatusLabel || 'Unknown',
|
||||
detail: status.profileStatusHint || '',
|
||||
tone: getProfileTone(status.profileStatusLabel),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Runtime',
|
||||
rows: [
|
||||
{
|
||||
key: 'runtime',
|
||||
label: 'OpenClaude executable',
|
||||
summary: runtimeSummary,
|
||||
detail: runtimeDetail,
|
||||
tone: getRuntimeTone(status.installed),
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
label: 'Detected provider',
|
||||
summary: status.providerState?.label || 'Unknown',
|
||||
detail: providerDetail,
|
||||
tone: providerTone,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: buildActionModel(status),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncateMiddle,
|
||||
buildActionModel,
|
||||
buildControlCenterViewModel,
|
||||
};
|
||||
291
vscode-extension/openclaude-vscode/src/presentation.test.js
Normal file
291
vscode-extension/openclaude-vscode/src/presentation.test.js
Normal file
@@ -0,0 +1,291 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
function loadPresentation() {
|
||||
return require('./presentation');
|
||||
}
|
||||
|
||||
test('truncateMiddle keeps the profile filename visible', () => {
|
||||
const { truncateMiddle } = loadPresentation();
|
||||
|
||||
assert.equal(
|
||||
truncateMiddle('/Users/example/projects/openclaude/workspace/.openclaude-profile.json', 30),
|
||||
'.../.openclaude-profile.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('truncateMiddle keeps the filename visible for Windows-style paths', () => {
|
||||
const { truncateMiddle } = loadPresentation();
|
||||
|
||||
assert.equal(
|
||||
truncateMiddle('C:\\Users\\example\\openclaude\\workspace\\.openclaude-profile.json', 30),
|
||||
'...\\.openclaude-profile.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildActionModel disables workspace-root launch without a workspace', () => {
|
||||
const { buildActionModel } = loadPresentation();
|
||||
|
||||
const model = buildActionModel({
|
||||
canLaunchInWorkspaceRoot: false,
|
||||
workspaceProfilePath: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(model.launchRoot, {
|
||||
id: 'launchRoot',
|
||||
label: 'Launch in Workspace Root',
|
||||
detail: 'Open a workspace folder to enable workspace-root launch',
|
||||
tone: 'neutral',
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('buildActionModel hides workspace-profile action when no profile exists', () => {
|
||||
const { buildActionModel } = loadPresentation();
|
||||
|
||||
const model = buildActionModel({
|
||||
canLaunchInWorkspaceRoot: true,
|
||||
workspaceProfilePath: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(model.primary, {
|
||||
id: 'launch',
|
||||
label: 'Launch OpenClaude',
|
||||
detail: 'Use the resolved project-aware launch directory',
|
||||
tone: 'accent',
|
||||
disabled: false,
|
||||
});
|
||||
assert.equal(model.openProfile, null);
|
||||
});
|
||||
|
||||
test('buildActionModel includes workspace-profile action when a profile exists', () => {
|
||||
const { buildActionModel } = loadPresentation();
|
||||
|
||||
const model = buildActionModel({
|
||||
canLaunchInWorkspaceRoot: true,
|
||||
workspaceProfilePath: 'C:\\Users\\example\\openclaude\\workspace\\.openclaude-profile.json',
|
||||
});
|
||||
|
||||
assert.deepEqual(model.openProfile, {
|
||||
id: 'openProfile',
|
||||
label: 'Open Workspace Profile',
|
||||
detail: 'Inspect ...\\.openclaude-profile.json',
|
||||
tone: 'neutral',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
function createStatus(overrides = {}) {
|
||||
return {
|
||||
installed: true,
|
||||
executable: 'openclaude',
|
||||
launchCommand: 'openclaude --project-aware',
|
||||
terminalName: 'OpenClaude',
|
||||
shimEnabled: false,
|
||||
workspaceFolder: '/workspace/openclaude',
|
||||
workspaceSourceLabel: 'active editor workspace',
|
||||
launchCwd: '/workspace/openclaude',
|
||||
launchCwdLabel: '/workspace/openclaude',
|
||||
canLaunchInWorkspaceRoot: true,
|
||||
profileStatusLabel: 'Found',
|
||||
profileStatusHint: '/workspace/openclaude/.openclaude-profile.json',
|
||||
workspaceProfilePath: '/workspace/openclaude/.openclaude-profile.json',
|
||||
providerState: {
|
||||
label: 'Codex',
|
||||
detail: 'gpt-5.4',
|
||||
source: 'profile',
|
||||
},
|
||||
providerSourceLabel: 'saved profile',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildControlCenterViewModel keeps header badges and summary cards non-redundant', () => {
|
||||
const { buildControlCenterViewModel } = loadPresentation();
|
||||
|
||||
const viewModel = buildControlCenterViewModel(createStatus());
|
||||
const headerKeys = new Set(viewModel.headerBadges.map(badge => badge.key));
|
||||
const summaryKeys = new Set(viewModel.summaryCards.map(card => card.key));
|
||||
|
||||
assert.deepEqual([...headerKeys].sort(), ['profileStatus', 'provider', 'runtime']);
|
||||
assert.deepEqual([...summaryKeys].sort(), ['launchCommand', 'launchCwd', 'workspace']);
|
||||
|
||||
for (const key of headerKeys) {
|
||||
assert.equal(summaryKeys.has(key), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildControlCenterViewModel uses stable semantic tones for badges and actions', () => {
|
||||
const { buildControlCenterViewModel } = loadPresentation();
|
||||
|
||||
const viewModel = buildControlCenterViewModel(createStatus({
|
||||
installed: false,
|
||||
profileStatusLabel: 'Invalid',
|
||||
providerState: {
|
||||
label: 'OpenAI-compatible (provider unknown)',
|
||||
detail: 'launch shim enabled',
|
||||
source: 'shim',
|
||||
},
|
||||
providerSourceLabel: 'launch setting',
|
||||
}));
|
||||
|
||||
assert.deepEqual(viewModel.headerBadges, [
|
||||
{
|
||||
key: 'runtime',
|
||||
label: 'Runtime',
|
||||
value: 'Missing',
|
||||
tone: 'critical',
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
label: 'Provider',
|
||||
value: 'OpenAI-compatible (provider unknown)',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
key: 'profileStatus',
|
||||
label: 'Profile',
|
||||
value: 'Invalid',
|
||||
tone: 'warning',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(viewModel.actions.primary.tone, 'accent');
|
||||
assert.equal(viewModel.actions.launchRoot.tone, 'neutral');
|
||||
});
|
||||
|
||||
test('buildControlCenterViewModel uses a concise project summary before full path detail', () => {
|
||||
const { buildControlCenterViewModel } = loadPresentation();
|
||||
|
||||
const viewModel = buildControlCenterViewModel(createStatus());
|
||||
|
||||
assert.deepEqual(viewModel.detailSections, [
|
||||
{
|
||||
title: 'Project',
|
||||
rows: [
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace folder',
|
||||
summary: 'openclaude',
|
||||
detail: '/workspace/openclaude · active editor workspace',
|
||||
},
|
||||
{
|
||||
key: 'profileStatus',
|
||||
label: 'Workspace profile',
|
||||
summary: 'Found',
|
||||
detail: '/workspace/openclaude/.openclaude-profile.json',
|
||||
tone: 'neutral',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Runtime',
|
||||
rows: [
|
||||
{
|
||||
key: 'runtime',
|
||||
label: 'OpenClaude executable',
|
||||
summary: 'Installed',
|
||||
detail: 'openclaude',
|
||||
tone: 'positive',
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
label: 'Detected provider',
|
||||
summary: 'Codex',
|
||||
detail: 'gpt-5.4 · saved profile',
|
||||
tone: 'neutral',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildControlCenterViewModel keeps launch command only in summary cards', () => {
|
||||
const { buildControlCenterViewModel } = loadPresentation();
|
||||
|
||||
const viewModel = buildControlCenterViewModel(createStatus());
|
||||
|
||||
assert.deepEqual(viewModel.summaryCards.find(card => card.key === 'launchCommand'), {
|
||||
key: 'launchCommand',
|
||||
label: 'Launch command',
|
||||
value: 'openclaude --project-aware',
|
||||
detail: 'Integrated terminal: OpenClaude',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
viewModel.detailSections.some(section => section.rows.some(row => row.key === 'launchCommand')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildControlCenterViewModel keeps env-backed provider detail non-redundant', () => {
|
||||
const { buildControlCenterViewModel } = loadPresentation();
|
||||
|
||||
const viewModel = buildControlCenterViewModel(createStatus({
|
||||
providerState: {
|
||||
label: 'Gemini',
|
||||
detail: 'from environment',
|
||||
source: 'env',
|
||||
},
|
||||
providerSourceLabel: 'environment',
|
||||
}));
|
||||
|
||||
assert.deepEqual(viewModel.detailSections[1].rows.find(row => row.key === 'provider'), {
|
||||
key: 'provider',
|
||||
label: 'Detected provider',
|
||||
summary: 'Gemini',
|
||||
detail: 'from environment',
|
||||
tone: 'neutral',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildControlCenterViewModel keeps shim-backed provider detail honest', () => {
|
||||
const { buildControlCenterViewModel } = loadPresentation();
|
||||
|
||||
const viewModel = buildControlCenterViewModel(createStatus({
|
||||
providerState: {
|
||||
label: 'OpenAI-compatible (provider unknown)',
|
||||
detail: 'launch shim enabled',
|
||||
source: 'shim',
|
||||
},
|
||||
providerSourceLabel: 'launch setting',
|
||||
}));
|
||||
|
||||
assert.deepEqual(viewModel.detailSections[1].rows.find(row => row.key === 'provider'), {
|
||||
key: 'provider',
|
||||
label: 'Detected provider',
|
||||
summary: 'OpenAI-compatible (provider unknown)',
|
||||
detail: 'launch shim enabled',
|
||||
tone: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildControlCenterViewModel keeps unknown provider detail honest', () => {
|
||||
const { buildControlCenterViewModel } = loadPresentation();
|
||||
|
||||
const viewModel = buildControlCenterViewModel(createStatus({
|
||||
providerState: {
|
||||
label: 'Unknown',
|
||||
detail: 'no saved profile or provider env detected',
|
||||
source: 'unknown',
|
||||
},
|
||||
providerSourceLabel: 'unknown',
|
||||
}));
|
||||
|
||||
assert.deepEqual(viewModel.detailSections[1].rows.find(row => row.key === 'provider'), {
|
||||
key: 'provider',
|
||||
label: 'Detected provider',
|
||||
summary: 'Unknown',
|
||||
detail: 'no saved profile or provider env detected',
|
||||
tone: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildControlCenterViewModel carries forward the existing action model', () => {
|
||||
const { buildControlCenterViewModel, buildActionModel } = loadPresentation();
|
||||
|
||||
const status = createStatus();
|
||||
const viewModel = buildControlCenterViewModel(status);
|
||||
|
||||
assert.deepEqual(viewModel.actions, buildActionModel(status));
|
||||
});
|
||||
389
vscode-extension/openclaude-vscode/src/state.js
Normal file
389
vscode-extension/openclaude-vscode/src/state.js
Normal file
@@ -0,0 +1,389 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SAVED_PROFILES = new Set([
|
||||
'openai',
|
||||
'ollama',
|
||||
'codex',
|
||||
'gemini',
|
||||
'atomic-chat',
|
||||
]);
|
||||
|
||||
const CODEX_ALIAS_MODELS = new Set([
|
||||
'codexplan',
|
||||
'codexspark',
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.3-codex-spark',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.1-codex-max',
|
||||
'gpt-5.1-codex-mini',
|
||||
]);
|
||||
|
||||
function asNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isEnvTruthy(value) {
|
||||
const normalized = asNonEmptyString(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowered = normalized.toLowerCase();
|
||||
return lowered !== '0' && lowered !== 'false' && lowered !== 'no';
|
||||
}
|
||||
|
||||
function chooseLaunchWorkspace({ activeWorkspacePath, workspacePaths }) {
|
||||
const activePath = asNonEmptyString(activeWorkspacePath);
|
||||
if (activePath) {
|
||||
return { workspacePath: activePath, source: 'active-workspace' };
|
||||
}
|
||||
|
||||
const firstWorkspacePath = Array.isArray(workspacePaths)
|
||||
? asNonEmptyString(workspacePaths[0])
|
||||
: null;
|
||||
|
||||
if (firstWorkspacePath) {
|
||||
return { workspacePath: firstWorkspacePath, source: 'first-workspace' };
|
||||
}
|
||||
|
||||
return { workspacePath: null, source: 'none' };
|
||||
}
|
||||
|
||||
function sanitizeProfileEnv(env) {
|
||||
if (!env || typeof env !== 'object' || Array.isArray(env)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(env).filter(([, value]) => typeof value === 'string' && value.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
function parseProfileFile(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = asNonEmptyString(parsed.profile);
|
||||
if (!profile || !SAVED_PROFILES.has(profile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed.env || typeof parsed.env !== 'object' || Array.isArray(parsed.env)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
profile,
|
||||
env: sanitizeProfileEnv(parsed.env),
|
||||
createdAt: asNonEmptyString(parsed.createdAt),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalBaseUrl(baseUrl) {
|
||||
const normalized = asNonEmptyString(baseUrl);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = new URL(normalized).hostname.toLowerCase();
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname === '::1' ||
|
||||
hostname.endsWith('.local')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCommandCheckPath(command, workspacePath) {
|
||||
const normalized = asNonEmptyString(command);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!normalized.includes(path.sep) && !normalized.includes('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return workspacePath
|
||||
? path.resolve(workspacePath, normalized)
|
||||
: path.resolve(normalized);
|
||||
}
|
||||
|
||||
function getEnvValue(env, key) {
|
||||
if (!env || typeof env !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const matchedKey = Object.keys(env).find(candidate => candidate.toUpperCase() === key);
|
||||
return matchedKey ? env[matchedKey] : '';
|
||||
}
|
||||
|
||||
function canAccessExecutable(filePath, platform) {
|
||||
try {
|
||||
fs.accessSync(filePath, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findCommandPath(command, options = {}) {
|
||||
const normalized = asNonEmptyString(command);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cwd = asNonEmptyString(options.cwd);
|
||||
const env = options.env || process.env;
|
||||
const platform = options.platform || process.platform;
|
||||
const hasPathSeparators = normalized.includes(path.sep) || normalized.includes('/');
|
||||
|
||||
if (hasPathSeparators) {
|
||||
if (!path.isAbsolute(normalized) && !cwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directPath = resolveCommandCheckPath(normalized, cwd);
|
||||
return directPath && canAccessExecutable(directPath, platform) ? directPath : null;
|
||||
}
|
||||
|
||||
const pathValue = getEnvValue(env, 'PATH');
|
||||
if (!pathValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathExtValue = getEnvValue(env, 'PATHEXT');
|
||||
const hasExplicitExtension = Boolean(path.extname(normalized));
|
||||
const extensions = platform === 'win32'
|
||||
? (hasExplicitExtension
|
||||
? ['']
|
||||
: (pathExtValue || '.COM;.EXE;.BAT;.CMD')
|
||||
.split(';')
|
||||
.map(extension => extension.trim())
|
||||
.filter(Boolean))
|
||||
: [''];
|
||||
|
||||
for (const directory of pathValue.split(path.delimiter)) {
|
||||
const baseDirectory = asNonEmptyString(directory);
|
||||
if (!baseDirectory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const extension of extensions) {
|
||||
const candidatePath = path.join(baseDirectory, `${normalized}${extension}`);
|
||||
if (canAccessExecutable(candidatePath, platform)) {
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideWorkspace(filePath, workspacePath) {
|
||||
const normalizedFilePath = asNonEmptyString(filePath);
|
||||
const normalizedWorkspacePath = asNonEmptyString(workspacePath);
|
||||
if (!normalizedFilePath || !normalizedWorkspacePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedFilePath = path.resolve(normalizedFilePath);
|
||||
const resolvedWorkspacePath = path.resolve(normalizedWorkspacePath);
|
||||
const comparableFilePath = process.platform === 'win32'
|
||||
? resolvedFilePath.toLowerCase()
|
||||
: resolvedFilePath;
|
||||
const comparableWorkspacePath = process.platform === 'win32'
|
||||
? resolvedWorkspacePath.toLowerCase()
|
||||
: resolvedWorkspacePath;
|
||||
const relativePath = path.relative(comparableWorkspacePath, comparableFilePath);
|
||||
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function hasCodexBaseUrl(baseUrl) {
|
||||
const normalized = asNonEmptyString(baseUrl);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /chatgpt\.com\/backend-api\/codex/i.test(normalized);
|
||||
}
|
||||
|
||||
function hasCodexAlias(model) {
|
||||
const normalized = asNonEmptyString(model);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseModel = normalized.toLowerCase().split('?', 1)[0] || normalized.toLowerCase();
|
||||
return CODEX_ALIAS_MODELS.has(baseModel);
|
||||
}
|
||||
|
||||
function getOpenAICompatibleLabel(baseUrl, model) {
|
||||
const normalizedBaseUrl = (asNonEmptyString(baseUrl) || '').toLowerCase();
|
||||
const normalizedModel = (asNonEmptyString(model) || '').toLowerCase();
|
||||
|
||||
if (hasCodexBaseUrl(baseUrl) || (!baseUrl && hasCodexAlias(model))) {
|
||||
return 'Codex';
|
||||
}
|
||||
|
||||
if (/localhost:11434|127\.0\.0\.1:11434|0\.0\.0\.0:11434/i.test(normalizedBaseUrl)) {
|
||||
return 'Ollama';
|
||||
}
|
||||
|
||||
if (/localhost:1234|127\.0\.0\.1:1234|0\.0\.0\.0:1234/i.test(normalizedBaseUrl)) {
|
||||
return 'LM Studio';
|
||||
}
|
||||
|
||||
if (normalizedBaseUrl.includes('deepseek') || normalizedModel.includes('deepseek')) {
|
||||
return 'DeepSeek';
|
||||
}
|
||||
|
||||
if (normalizedBaseUrl.includes('openrouter')) {
|
||||
return 'OpenRouter';
|
||||
}
|
||||
|
||||
if (normalizedBaseUrl.includes('together')) {
|
||||
return 'Together AI';
|
||||
}
|
||||
|
||||
if (normalizedBaseUrl.includes('groq')) {
|
||||
return 'Groq';
|
||||
}
|
||||
|
||||
if (normalizedBaseUrl.includes('mistral') || normalizedModel.includes('mistral')) {
|
||||
return 'Mistral';
|
||||
}
|
||||
|
||||
if (normalizedBaseUrl.includes('azure')) {
|
||||
return 'Azure OpenAI';
|
||||
}
|
||||
|
||||
if (normalizedBaseUrl.includes('api.openai.com') || !normalizedBaseUrl) {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
if (isLocalBaseUrl(normalizedBaseUrl)) {
|
||||
return 'Local OpenAI-compatible';
|
||||
}
|
||||
|
||||
return 'OpenAI-compatible';
|
||||
}
|
||||
|
||||
function buildProviderState(label, detail, source) {
|
||||
return {
|
||||
label,
|
||||
detail,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function getDetail(env, fallback) {
|
||||
return (
|
||||
asNonEmptyString(env.OPENAI_MODEL) ||
|
||||
asNonEmptyString(env.GEMINI_MODEL) ||
|
||||
asNonEmptyString(env.OPENAI_BASE_URL) ||
|
||||
asNonEmptyString(env.GEMINI_BASE_URL) ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
function describeOpenAICompatible(env, source) {
|
||||
const baseUrl = asNonEmptyString(env.OPENAI_BASE_URL) || asNonEmptyString(env.OPENAI_API_BASE);
|
||||
const model = asNonEmptyString(env.OPENAI_MODEL);
|
||||
const label = getOpenAICompatibleLabel(baseUrl, model);
|
||||
|
||||
if (label === 'Codex') {
|
||||
return buildProviderState('Codex', model || 'ChatGPT Codex', source);
|
||||
}
|
||||
|
||||
return buildProviderState(label, model || baseUrl || 'OpenAI-compatible runtime', source);
|
||||
}
|
||||
|
||||
function describeSavedProfile(profile) {
|
||||
switch (profile.profile) {
|
||||
case 'ollama':
|
||||
return buildProviderState('Ollama', getDetail(profile.env, 'saved profile'), 'profile');
|
||||
case 'gemini':
|
||||
return buildProviderState('Gemini', getDetail(profile.env, 'saved profile'), 'profile');
|
||||
case 'codex':
|
||||
return buildProviderState('Codex', getDetail(profile.env, 'saved profile'), 'profile');
|
||||
case 'atomic-chat':
|
||||
return buildProviderState('Atomic Chat', getDetail(profile.env, 'saved profile'), 'profile');
|
||||
case 'openai':
|
||||
default:
|
||||
return describeOpenAICompatible(profile.env, 'profile');
|
||||
}
|
||||
}
|
||||
|
||||
function describeProviderState({ shimEnabled, env, profile }) {
|
||||
if (profile) {
|
||||
return describeSavedProfile(profile);
|
||||
}
|
||||
|
||||
if (isEnvTruthy(env.CLAUDE_CODE_USE_GEMINI)) {
|
||||
return buildProviderState('Gemini', getDetail(env, 'from environment'), 'env');
|
||||
}
|
||||
|
||||
if (isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)) {
|
||||
return buildProviderState('GitHub Models', getDetail(env, 'from environment'), 'env');
|
||||
}
|
||||
|
||||
if (isEnvTruthy(env.CLAUDE_CODE_USE_BEDROCK)) {
|
||||
return buildProviderState('Bedrock', 'from environment', 'env');
|
||||
}
|
||||
|
||||
if (isEnvTruthy(env.CLAUDE_CODE_USE_VERTEX)) {
|
||||
return buildProviderState('Vertex AI', 'from environment', 'env');
|
||||
}
|
||||
|
||||
if (isEnvTruthy(env.CLAUDE_CODE_USE_FOUNDRY)) {
|
||||
return buildProviderState('Foundry', 'from environment', 'env');
|
||||
}
|
||||
|
||||
if (isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)) {
|
||||
return describeOpenAICompatible(env, 'env');
|
||||
}
|
||||
|
||||
if (shimEnabled) {
|
||||
return buildProviderState(
|
||||
'OpenAI-compatible (provider unknown)',
|
||||
'launch shim enabled',
|
||||
'shim',
|
||||
);
|
||||
}
|
||||
|
||||
return buildProviderState(
|
||||
'Unknown',
|
||||
'no saved profile or provider env detected',
|
||||
'unknown',
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
chooseLaunchWorkspace,
|
||||
describeProviderState,
|
||||
findCommandPath,
|
||||
isPathInsideWorkspace,
|
||||
parseProfileFile,
|
||||
resolveCommandCheckPath,
|
||||
};
|
||||
208
vscode-extension/openclaude-vscode/src/state.test.js
Normal file
208
vscode-extension/openclaude-vscode/src/state.test.js
Normal file
@@ -0,0 +1,208 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
chooseLaunchWorkspace,
|
||||
describeProviderState,
|
||||
findCommandPath,
|
||||
parseProfileFile,
|
||||
resolveCommandCheckPath,
|
||||
} = require('./state');
|
||||
|
||||
test('chooseLaunchWorkspace prefers the active workspace folder', () => {
|
||||
assert.deepEqual(
|
||||
chooseLaunchWorkspace({
|
||||
activeWorkspacePath: '/repo-b',
|
||||
workspacePaths: ['/repo-a', '/repo-b'],
|
||||
}),
|
||||
{ workspacePath: '/repo-b', source: 'active-workspace' },
|
||||
);
|
||||
});
|
||||
|
||||
test('chooseLaunchWorkspace falls back to the first workspace folder', () => {
|
||||
assert.deepEqual(
|
||||
chooseLaunchWorkspace({
|
||||
activeWorkspacePath: null,
|
||||
workspacePaths: ['/repo-a', '/repo-b'],
|
||||
}),
|
||||
{ workspacePath: '/repo-a', source: 'first-workspace' },
|
||||
);
|
||||
});
|
||||
|
||||
test('parseProfileFile returns null for invalid JSON', () => {
|
||||
assert.equal(parseProfileFile('{bad json}'), null);
|
||||
});
|
||||
|
||||
test('parseProfileFile returns null for unsupported profiles', () => {
|
||||
assert.equal(
|
||||
parseProfileFile(
|
||||
JSON.stringify({
|
||||
profile: 'lmstudio',
|
||||
env: {},
|
||||
createdAt: '2026-04-03T00:00:00.000Z',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseProfileFile returns null when env is missing', () => {
|
||||
assert.equal(
|
||||
parseProfileFile(
|
||||
JSON.stringify({
|
||||
profile: 'openai',
|
||||
createdAt: '2026-04-03T00:00:00.000Z',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseProfileFile returns null when env is not an object', () => {
|
||||
assert.equal(
|
||||
parseProfileFile(
|
||||
JSON.stringify({
|
||||
profile: 'openai',
|
||||
env: ['OPENAI_MODEL=gpt-4o'],
|
||||
createdAt: '2026-04-03T00:00:00.000Z',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCommandCheckPath resolves workspace-relative executables', () => {
|
||||
assert.equal(
|
||||
resolveCommandCheckPath('./node_modules/.bin/openclaude', '/repo'),
|
||||
require('node:path').resolve('/repo', './node_modules/.bin/openclaude'),
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCommandCheckPath leaves bare commands alone', () => {
|
||||
assert.equal(resolveCommandCheckPath('openclaude', '/repo'), null);
|
||||
});
|
||||
|
||||
test('findCommandPath treats shell-like input as a literal executable name', t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaude-command-'));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const commandName = process.platform === 'win32'
|
||||
? 'openclaude & whoami'
|
||||
: 'openclaude && whoami';
|
||||
const executableName = process.platform === 'win32'
|
||||
? `${commandName}.cmd`
|
||||
: commandName;
|
||||
const executablePath = path.join(tempDir, executableName);
|
||||
|
||||
fs.writeFileSync(executablePath, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n');
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(executablePath, 0o755);
|
||||
}
|
||||
|
||||
const resolvedPath = findCommandPath(commandName, {
|
||||
cwd: null,
|
||||
env: {
|
||||
PATH: tempDir,
|
||||
PATHEXT: '.CMD;.EXE',
|
||||
},
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
assert.ok(resolvedPath);
|
||||
assert.equal(resolvedPath.toLowerCase(), executablePath.toLowerCase());
|
||||
});
|
||||
|
||||
test('describeProviderState uses saved profile when present', () => {
|
||||
assert.deepEqual(
|
||||
describeProviderState({
|
||||
shimEnabled: false,
|
||||
env: {},
|
||||
profile: {
|
||||
profile: 'ollama',
|
||||
env: { OPENAI_MODEL: 'llama3.2' },
|
||||
createdAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
label: 'Ollama',
|
||||
detail: 'llama3.2',
|
||||
source: 'profile',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('describeProviderState reports LM Studio from openai profile base url', () => {
|
||||
assert.deepEqual(
|
||||
describeProviderState({
|
||||
shimEnabled: false,
|
||||
env: {},
|
||||
profile: {
|
||||
profile: 'openai',
|
||||
env: {
|
||||
OPENAI_BASE_URL: 'http://localhost:1234/v1',
|
||||
OPENAI_MODEL: 'qwen2.5-coder',
|
||||
},
|
||||
createdAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
label: 'LM Studio',
|
||||
detail: 'qwen2.5-coder',
|
||||
source: 'profile',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('describeProviderState reports environment-backed provider details', () => {
|
||||
assert.deepEqual(
|
||||
describeProviderState({
|
||||
shimEnabled: false,
|
||||
env: {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_BASE_URL: 'http://localhost:11434/v1',
|
||||
OPENAI_MODEL: 'llama3.2:3b',
|
||||
},
|
||||
profile: null,
|
||||
}),
|
||||
{
|
||||
label: 'Ollama',
|
||||
detail: 'llama3.2:3b',
|
||||
source: 'env',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('describeProviderState reports unknown when only the shim is enabled', () => {
|
||||
assert.deepEqual(
|
||||
describeProviderState({
|
||||
shimEnabled: true,
|
||||
env: {},
|
||||
profile: null,
|
||||
}),
|
||||
{
|
||||
label: 'OpenAI-compatible (provider unknown)',
|
||||
detail: 'launch shim enabled',
|
||||
source: 'shim',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('describeProviderState stays honest when nothing is configured', () => {
|
||||
assert.deepEqual(
|
||||
describeProviderState({
|
||||
shimEnabled: false,
|
||||
env: {},
|
||||
profile: null,
|
||||
}),
|
||||
{
|
||||
label: 'Unknown',
|
||||
detail: 'no saved profile or provider env detected',
|
||||
source: 'unknown',
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user