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
|
# 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
|
## 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
|
- Launch OpenClaude
|
||||||
- Open repository/docs
|
- Launch in Workspace Root
|
||||||
- Open VS Code theme picker
|
- Open Workspace Profile
|
||||||
- **Terminal launch command**: `OpenClaude: Launch in Terminal`
|
- Open Repository
|
||||||
- **Built-in dark theme**: `OpenClaude Terminal Black` (terminal-inspired, low-glare, neon accents)
|
- Open Setup Guide
|
||||||
|
- Open Command Palette
|
||||||
|
- **Built-in dark theme**: `OpenClaude Terminal Black`
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -20,19 +34,31 @@ A sleek VS Code companion for OpenClaude with a visual **Control Center** plus t
|
|||||||
|
|
||||||
- `OpenClaude: Open Control Center`
|
- `OpenClaude: Open Control Center`
|
||||||
- `OpenClaude: Launch in Terminal`
|
- `OpenClaude: Launch in Terminal`
|
||||||
|
- `OpenClaude: Launch in Workspace Root`
|
||||||
- `OpenClaude: Open Repository`
|
- `OpenClaude: Open Repository`
|
||||||
|
- `OpenClaude: Open Setup Guide`
|
||||||
|
- `OpenClaude: Open Workspace Profile`
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
- `openclaude.launchCommand` (default: `openclaude`)
|
- `openclaude.launchCommand` (default: `openclaude`)
|
||||||
- `openclaude.terminalName` (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
|
## Development
|
||||||
|
|
||||||
From this folder:
|
From this folder:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run test
|
||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaude-vscode",
|
"name": "openclaude-vscode",
|
||||||
"displayName": "OpenClaude",
|
"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",
|
"version": "0.1.1",
|
||||||
"publisher": "devnull-bootloader",
|
"publisher": "devnull-bootloader",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -14,11 +14,22 @@
|
|||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
"onStartupFinished",
|
"onStartupFinished",
|
||||||
"onCommand:openclaude.start",
|
"onCommand:openclaude.start",
|
||||||
|
"onCommand:openclaude.startInWorkspaceRoot",
|
||||||
"onCommand:openclaude.openDocs",
|
"onCommand:openclaude.openDocs",
|
||||||
|
"onCommand:openclaude.openSetupDocs",
|
||||||
|
"onCommand:openclaude.openWorkspaceProfile",
|
||||||
"onCommand:openclaude.openControlCenter",
|
"onCommand:openclaude.openControlCenter",
|
||||||
"onView:openclaude.controlCenter"
|
"onView:openclaude.controlCenter"
|
||||||
],
|
],
|
||||||
"main": "./src/extension.js",
|
"main": "./src/extension.js",
|
||||||
|
"files": [
|
||||||
|
"README.md",
|
||||||
|
"media/**",
|
||||||
|
"src/extension.js",
|
||||||
|
"src/presentation.js",
|
||||||
|
"src/state.js",
|
||||||
|
"themes/**"
|
||||||
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
@@ -26,11 +37,26 @@
|
|||||||
"title": "OpenClaude: Launch in Terminal",
|
"title": "OpenClaude: Launch in Terminal",
|
||||||
"category": "OpenClaude"
|
"category": "OpenClaude"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "openclaude.startInWorkspaceRoot",
|
||||||
|
"title": "OpenClaude: Launch in Workspace Root",
|
||||||
|
"category": "OpenClaude"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "openclaude.openDocs",
|
"command": "openclaude.openDocs",
|
||||||
"title": "OpenClaude: Open Repository",
|
"title": "OpenClaude: Open Repository",
|
||||||
"category": "OpenClaude"
|
"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",
|
"command": "openclaude.openControlCenter",
|
||||||
"title": "OpenClaude: Open Control Center",
|
"title": "OpenClaude: Open Control Center",
|
||||||
@@ -84,7 +110,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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"
|
"package": "npx @vscode/vsce package --no-dependencies"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"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