fix(ui): preserve VS Code themes during settings broadcasts
This commit is contained in:
@@ -72,6 +72,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews
|
||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
||||
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
|
||||
- Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider.
|
||||
|
||||
- `opencode-upgrade-runtime.ts`
|
||||
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
|
||||
|
||||
@@ -83,6 +83,16 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:git/branch-push-status': {
|
||||
const { directory, branches } = (payload || {}) as { directory?: string; branches?: string[] };
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) {
|
||||
return { id, type, success: false, error: 'branches must be an array of branch names' };
|
||||
}
|
||||
return { id, type, success: true, data: await gitService.getGitUnpushedBranchCounts(directory!, branches) };
|
||||
}
|
||||
|
||||
case 'api:git/remote-branches': {
|
||||
const { directory, branch, remote } = (payload || {}) as {
|
||||
directory?: string;
|
||||
|
||||
@@ -555,7 +555,7 @@ export async function handleSystemBridgeMessage(
|
||||
case 'api:quota:credentials': {
|
||||
const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown };
|
||||
try {
|
||||
if (!providerId || !['ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
|
||||
if (!providerId || !['exe-dev', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
|
||||
if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) };
|
||||
if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; }
|
||||
if (method === 'IMPORT') {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fetchExeDevUsage, parseExeDevUsage } from './exeDevQuota';
|
||||
|
||||
const payload = {
|
||||
monthly_allowance_usd: 20,
|
||||
period_end: '2026-10-01T00:00:00Z',
|
||||
total_cost_usd: 0.11,
|
||||
};
|
||||
|
||||
describe('exe.dev quota', () => {
|
||||
it('parses monthly credit usage', () => {
|
||||
const windows = parseExeDevUsage(payload);
|
||||
assert.ok(windows);
|
||||
assert.ok(Math.abs((windows.monthly.usedPercent ?? 0) - 0.55) < 0.0001);
|
||||
assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00');
|
||||
});
|
||||
|
||||
it('executes only the billing usage command', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const windows = await fetchExeDevUsage('test-token', async (url, init) => {
|
||||
requests.push({ url: String(url), init });
|
||||
return Response.json(payload);
|
||||
});
|
||||
assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00');
|
||||
assert.equal(requests[0]?.url, 'https://exe.dev/exec');
|
||||
assert.equal(requests[0]?.init?.body, 'billing credits usage --group=day --json');
|
||||
assert.equal(new Headers(requests[0]?.init?.headers).get('Authorization'), 'Bearer test-token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
type ExeDevUsageWindow = {
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
windowSeconds: null;
|
||||
resetAfterSeconds: number | null;
|
||||
resetAt: number;
|
||||
resetAtFormatted: string;
|
||||
resetAfterFormatted: string | null;
|
||||
valueLabel: string;
|
||||
};
|
||||
|
||||
type ExeDevUsagePayload = {
|
||||
total_cost_usd?: number | null;
|
||||
monthly_allowance_usd?: number | null;
|
||||
period_end?: string | null;
|
||||
};
|
||||
|
||||
const EXEC_URL = 'https://exe.dev/exec';
|
||||
const USAGE_COMMAND = 'billing credits usage --group=day --json';
|
||||
|
||||
const numberValue = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) return null;
|
||||
return value;
|
||||
};
|
||||
|
||||
export const parseExeDevUsage = (payload: ExeDevUsagePayload | null): Record<string, ExeDevUsageWindow> | null => {
|
||||
if (!payload) return null;
|
||||
const totalCost = numberValue(payload.total_cost_usd);
|
||||
const monthlyAllowance = numberValue(payload.monthly_allowance_usd);
|
||||
const resetAt = payload.period_end ? Date.parse(payload.period_end) : Number.NaN;
|
||||
if (totalCost === null || monthlyAllowance === null || monthlyAllowance < 0 || !Number.isFinite(resetAt)) return null;
|
||||
const usedPercent = monthlyAllowance > 0 ? Math.min(100, Math.max(0, (totalCost / monthlyAllowance) * 100)) : null;
|
||||
const remainingPercent = usedPercent === null ? null : Math.max(0, 100 - usedPercent);
|
||||
const resetAfterSeconds = Math.max(0, Math.floor((resetAt - Date.now()) / 1000));
|
||||
return {
|
||||
monthly: {
|
||||
usedPercent,
|
||||
remainingPercent,
|
||||
windowSeconds: null,
|
||||
resetAfterSeconds,
|
||||
resetAt,
|
||||
resetAtFormatted: new Date(resetAt).toLocaleString(),
|
||||
resetAfterFormatted: null,
|
||||
valueLabel: `$${totalCost.toFixed(2)} / $${monthlyAllowance.toFixed(2)}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchExeDevUsage = async (usageToken: string, fetchImpl: typeof fetch = fetch) => {
|
||||
const response = await fetchImpl(EXEC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${usageToken}`,
|
||||
'Content-Type': 'text/plain',
|
||||
'User-Agent': 'OpenChamber quota provider',
|
||||
},
|
||||
body: USAGE_COMMAND,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) throw new Error('exe.dev authentication failed');
|
||||
if (!response.ok) throw new Error(`exe.dev usage API returned HTTP ${response.status}`);
|
||||
const payload: ExeDevUsagePayload | null = await response.text().then((text) => JSON.parse(text)).catch(() => null);
|
||||
const windows = parseExeDevUsage(payload);
|
||||
if (!windows) throw new Error('exe.dev usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
@@ -671,6 +671,23 @@ export interface GitBranchResult {
|
||||
branches: Record<string, GitBranchDetails>;
|
||||
}
|
||||
|
||||
export async function getGitUnpushedBranchCounts(directory: string, requestedBranches: string[]): Promise<{ counts: Record<string, number> }> {
|
||||
const requested = [...new Set(requestedBranches)].filter(Boolean).slice(0, 5);
|
||||
if (requested.length === 0) return { counts: {} };
|
||||
const local = new Set((await getGitBranchesRaw(directory)).all.filter((branch) => !branch.startsWith('remotes/')));
|
||||
const counts: Record<string, number> = {};
|
||||
await Promise.all(requested.map(async (branch) => {
|
||||
if (!local.has(branch)) return;
|
||||
const upstreamResult = await execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`], directory);
|
||||
const upstream = upstreamResult.exitCode === 0 ? upstreamResult.stdout.trim() : '';
|
||||
if (!upstream) return;
|
||||
const countResult = await execGit(['rev-list', '--count', `${upstream}..${branch}`], directory);
|
||||
const count = countResult.exitCode === 0 ? Number.parseInt(countResult.stdout.trim(), 10) : 0;
|
||||
if (Number.isFinite(count) && count > 0) counts[branch] = count;
|
||||
}));
|
||||
return { counts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all branches for a directory
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ const toWindow = (usedPercent: number, resetAt: string) => ({
|
||||
});
|
||||
|
||||
export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => {
|
||||
const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}` }, signal: AbortSignal.timeout(15_000) });
|
||||
const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}`, 'x-opencode-session': 'openchamber-usage' }, signal: AbortSignal.timeout(15_000) });
|
||||
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed');
|
||||
if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`);
|
||||
const payload = await response.json().catch(() => null) as { usage?: Record<string, { percent?: unknown; resetsAt?: unknown }> } | null;
|
||||
|
||||
@@ -2,10 +2,11 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fetchExeDevUsage } from './exeDevQuota';
|
||||
|
||||
export type ManagedProvider = 'ollama-cloud' | 'cursor';
|
||||
export type ManagedProvider = 'exe-dev' | 'ollama-cloud' | 'cursor';
|
||||
export type ManagedCredential = Record<string, string>;
|
||||
const providers = new Set<ManagedProvider>(['ollama-cloud', 'cursor']);
|
||||
const providers = new Set<ManagedProvider>(['exe-dev', 'ollama-cloud', 'cursor']);
|
||||
const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota');
|
||||
const target = (provider: ManagedProvider) => {
|
||||
if (!providers.has(provider)) throw new Error('Unsupported credential provider');
|
||||
@@ -15,6 +16,7 @@ const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(va
|
||||
|
||||
export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => {
|
||||
const data = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
if (provider === 'exe-dev') return clean(data.usageToken) ? { usageToken: clean(data.usageToken) } : null;
|
||||
if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null;
|
||||
const accessToken = clean(data.accessToken);
|
||||
const refreshToken = clean(data.refreshToken);
|
||||
@@ -52,6 +54,7 @@ export const importCursorCredential = () => {
|
||||
};
|
||||
|
||||
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => {
|
||||
if (provider === 'exe-dev') await fetchExeDevUsage(credential.usageToken);
|
||||
if (provider === 'ollama-cloud') {
|
||||
const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
|
||||
if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed');
|
||||
|
||||
@@ -99,6 +99,7 @@ describe('OpenCode Go quota provider (VS Code parity)', () => {
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal((request?.headers as Record<string, string>).Authorization, 'Bearer test-token');
|
||||
assert.equal((request?.headers as Record<string, string>)['x-opencode-session'], 'openchamber-usage');
|
||||
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
|
||||
assert.throws(() => fs.statSync(legacyPath));
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials';
|
||||
import { getProviderAuth, updateProviderAuth } from './opencodeAuth';
|
||||
import { fetchExeDevUsage } from './exeDevQuota';
|
||||
|
||||
type AuthEntry = Record<string, unknown> | string;
|
||||
type AuthFile = Record<string, AuthEntry>;
|
||||
@@ -774,6 +775,7 @@ export const listConfiguredQuotaProviders = () => {
|
||||
if (openCodeGoAuth && (typeof openCodeGoAuth.key === 'string' || typeof openCodeGoAuth.token === 'string')) configured.add('opencode-go');
|
||||
if (readCredential('ollama-cloud')) configured.add('ollama-cloud');
|
||||
if (readCredential('cursor')) configured.add('cursor');
|
||||
if (readCredential('exe-dev')) configured.add('exe-dev');
|
||||
|
||||
const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude']));
|
||||
if (anthropicAuth && ((anthropicAuth as Record<string, unknown>).access || (anthropicAuth as Record<string, unknown>).token)) {
|
||||
@@ -1946,6 +1948,16 @@ const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchExeDevQuota = async (): Promise<ProviderResult> => {
|
||||
const usageToken = readCredential('exe-dev')?.usageToken;
|
||||
if (!usageToken) return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: false, configured: false, error: 'Not configured' });
|
||||
try {
|
||||
return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: true, configured: true, usage: { windows: await fetchExeDevUsage(usageToken) } });
|
||||
} catch (error) {
|
||||
return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCursorQuota = async (): Promise<ProviderResult> => {
|
||||
const accessToken = readCredential('cursor')?.accessToken;
|
||||
if (!accessToken) return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: false, error: 'Not configured' });
|
||||
@@ -2868,6 +2880,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise<Pro
|
||||
return fetchMiniMaxCnCodingPlanQuota();
|
||||
case 'ollama-cloud':
|
||||
return fetchOllamaCloudQuota();
|
||||
case 'exe-dev':
|
||||
return fetchExeDevQuota();
|
||||
case 'openrouter':
|
||||
return fetchOpenRouterQuota();
|
||||
case 'zai-coding-plan':
|
||||
|
||||
@@ -130,6 +130,10 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
||||
return sendBridgeMessage<GitBranch>('api:git/branches', { directory, method: 'GET' });
|
||||
},
|
||||
|
||||
getGitUnpushedBranchCounts: async (directory: string, branches: string[]) => {
|
||||
return sendBridgeMessage('api:git/branch-push-status', { directory, branches });
|
||||
},
|
||||
|
||||
deleteGitBranch: async (directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> => {
|
||||
return sendBridgeMessage<{ success: boolean }>('api:git/branches', {
|
||||
directory,
|
||||
|
||||
@@ -1859,7 +1859,7 @@ window.addEventListener('openchamber:vscode-notification-event', (event) => {
|
||||
// Listen for settings sync command from extension (broadcast to all VS Code webviews)
|
||||
onCommand('settingsSynced', () => {
|
||||
import('@openchamber/ui/lib/persistence').then(({ syncDesktopSettings }) => {
|
||||
void syncDesktopSettings();
|
||||
void syncDesktopSettings({ adoptTheme: false });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user