Merge main
This commit is contained in:
@@ -1,6 +1,37 @@
|
||||
## [Unreleased]
|
||||
## [1.21.0] - 2026-08-26
|
||||
|
||||
- **Chat context attachments:** diff and file comments, terminal selections, and linked issues/PRs now show in the conversation as compact context cards — source header, captured content behind an expander, your comment below — instead of raw text inside the message.
|
||||
- **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message; the selection stays highlighted while you type.
|
||||
- Chat: the view no longer stays stuck on its loading screen on slow or remote connections, including code-server behind a reverse proxy (thanks to @VinciYan).
|
||||
- Composer: hovering a context chip above the input opens a stacked preview of everything attached, where comments can be edited in place or items removed before sending.
|
||||
- Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible.
|
||||
- Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name finds the files inside it.
|
||||
- Search in dropdowns: searchable pickers (agents, models, providers, branches) now put the best matches first, match multi-word queries in any order, and ignore punctuation (so "gpt4o" finds "gpt-4o").
|
||||
- Permissions: cards answer to the keyboard with Alt+Enter to allow once, Alt+Shift+Enter to allow always, and Alt+Backspace to deny; the keys are printed on the buttons.
|
||||
- Keyboard: dropdown menus and pickers answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and shortcut labels in tooltips and menus show the binding you actually have set (thanks to @ChangeHow).
|
||||
- Chat: Cmd/Ctrl+Shift+T now cycles through every thinking level offered by the selected model instead of skipping levels after reaching the end (thanks to @nimobeeren).
|
||||
- Chat: OpenCode notices now share one style.
|
||||
- Chat: the timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran).
|
||||
|
||||
## [1.20.0] - 2026-08-23
|
||||
|
||||
- **/btw side questions:** type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation. The answer streams into a panel above the composer; collapse it, keep it as a full session, or discard it without touching the chat (thanks to @jaygupta17).
|
||||
- **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search and direct links to each skill's repository.
|
||||
- Settings: the workspace selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show instead of moving the chat, session list and file tree to another workspace.
|
||||
- Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels.
|
||||
- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately.
|
||||
- Settings/Providers: the provider you select no longer jumps to a different one when the chat selection or provider data changes.
|
||||
- Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed.
|
||||
- Providers: expanded support for custom providers.
|
||||
- Sessions created outside OpenChamber now appear in the sidebar and Recent list without a page refresh (thanks to @tomzx).
|
||||
- If OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117).
|
||||
- Usage: Z.ai credit limits now appear alongside its other quota windows.
|
||||
- Chat: file paths in messages now open from the session's workspace, even if you last browsed files in another workspace (thanks to @tomzx).
|
||||
- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings.
|
||||
- While a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes.
|
||||
- Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off.
|
||||
- Chat: long user messages can be expanded even when their final layout finishes after they first appear.
|
||||
- UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer).
|
||||
|
||||
## [1.19.0] - 2026-08-19
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "openchamber",
|
||||
"displayName": "OpenChamber",
|
||||
"description": "%extension.description%",
|
||||
"version": "1.19.0",
|
||||
"version": "1.21.0",
|
||||
"publisher": "fedaykindev",
|
||||
"private": true,
|
||||
"repository": {
|
||||
@@ -245,7 +245,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@opencode-ai/sdk": "1.18.23",
|
||||
"adm-zip": "^0.6.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"react": "^19.1.1",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { scheduleCachedStateRetries } from './webviewCachedStateRetry';
|
||||
import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge';
|
||||
import { getThemeKindName } from './theme';
|
||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
@@ -23,6 +24,19 @@ export class AgentManagerPanelProvider {
|
||||
private _sseStreams = new Map<string, AbortController>();
|
||||
private readonly _webviewDevServerUrl: string | null;
|
||||
|
||||
/**
|
||||
* See webviewCachedStateRetry.ts — a single postMessage can be dropped
|
||||
* before the webview bridge is ready, leaving the loading screen stuck.
|
||||
*/
|
||||
private _scheduleCachedStateRetries(targetPanel: vscode.WebviewPanel | undefined): void {
|
||||
scheduleCachedStateRetries({
|
||||
target: targetPanel ?? this._panel,
|
||||
getCurrent: () => this._panel,
|
||||
isConnected: () => this._cachedStatus === 'connected',
|
||||
send: () => this._sendCachedState(),
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
private readonly _extensionUri: vscode.Uri,
|
||||
@@ -64,6 +78,9 @@ export class AgentManagerPanelProvider {
|
||||
|
||||
// Send cached connection status
|
||||
this._sendCachedState();
|
||||
// The webview bridge may not be ready yet; keep re-sending so a dropped
|
||||
// `connectionStatus` can never leave the webview stuck on its loading screen.
|
||||
this._scheduleCachedStateRetries(this._panel);
|
||||
|
||||
// Handle panel disposal
|
||||
this._panel.onDidDispose(() => {
|
||||
@@ -126,6 +143,13 @@ export class AgentManagerPanelProvider {
|
||||
|
||||
// Send to webview if it exists
|
||||
this._sendCachedState();
|
||||
|
||||
// When we become connected, keep re-sending at staggered delays so the
|
||||
// webview cannot miss the transition (postMessage is dropped if the
|
||||
// webview bridge is not ready yet).
|
||||
if (status === 'connected') {
|
||||
this._scheduleCachedStateRetries(this._panel);
|
||||
}
|
||||
}
|
||||
|
||||
public notifySettingsSynced(settings: unknown): void {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { scheduleCachedStateRetries } from './webviewCachedStateRetry';
|
||||
import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge';
|
||||
import { getThemeKindName } from './theme';
|
||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
@@ -58,6 +59,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
private readonly _MESSAGE_TIMEOUT = 5000; // 5 seconds
|
||||
private readonly _MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* See webviewCachedStateRetry.ts — a single postMessage can be dropped
|
||||
* before the webview bridge is ready, leaving the loading screen stuck.
|
||||
*/
|
||||
private _scheduleCachedStateRetries(targetView: vscode.WebviewView | undefined): void {
|
||||
scheduleCachedStateRetries({
|
||||
target: targetView ?? this._view,
|
||||
getCurrent: () => this._view,
|
||||
isConnected: () => this._cachedStatus === 'connected',
|
||||
send: () => this._sendCachedState(),
|
||||
});
|
||||
}
|
||||
|
||||
private _createMessageId(): string {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -102,6 +116,9 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
// Send cached connection status and API URL (may have been set before webview was resolved)
|
||||
this._sendCachedState();
|
||||
// The webview bridge may not be ready yet; keep re-sending so a dropped
|
||||
// `connectionStatus` can never leave the webview stuck on its loading screen.
|
||||
this._scheduleCachedStateRetries(webviewView);
|
||||
|
||||
// Send current active editor file state to the new webview
|
||||
this._lastActiveEditorFilePayload = null;
|
||||
@@ -185,6 +202,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
// Send to webview if it exists
|
||||
this._sendCachedState();
|
||||
|
||||
// When we become connected, keep re-sending at staggered delays so the
|
||||
// webview cannot miss the transition (postMessage is dropped if the
|
||||
// webview bridge is not ready yet).
|
||||
if (status === 'connected') {
|
||||
this._scheduleCachedStateRetries(this._view);
|
||||
}
|
||||
}
|
||||
|
||||
public addTextToInput(text: string) {
|
||||
|
||||
@@ -44,6 +44,8 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
||||
|
||||
The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`.
|
||||
|
||||
The webview build emits each worker as one self-contained file. VS Code webviews cannot load workers directly from extension resource URLs or load module imports from inside a worker. The shared Shiki client therefore fetches the built worker, starts it from a `blob:` URL, and relies on the worker CSP allowance above.
|
||||
|
||||
- `bridge-localfs-proxy-runtime.ts`
|
||||
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.
|
||||
- Workspace-contained Markdown gallery images use these local filesystem
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { scheduleCachedStateRetries } from './webviewCachedStateRetry';
|
||||
import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge';
|
||||
import { getThemeKindName } from './theme';
|
||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
@@ -49,6 +50,19 @@ export class SessionEditorPanelProvider {
|
||||
private _lastActiveEditorFilePayload: ActiveEditorFilePayload | null = null;
|
||||
private readonly _webviewDevServerUrl: string | null;
|
||||
|
||||
/**
|
||||
* See webviewCachedStateRetry.ts — a single postMessage can be dropped
|
||||
* before the webview bridge is ready, leaving the loading screen stuck.
|
||||
*/
|
||||
private _scheduleCachedStateRetries(panelId: string, entry: SessionPanelState): void {
|
||||
scheduleCachedStateRetries({
|
||||
target: entry.panel,
|
||||
getCurrent: () => this._panels.get(panelId)?.panel,
|
||||
isConnected: () => this._cachedStatus === 'connected',
|
||||
send: () => this._sendCachedStateToPanel(entry),
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
private readonly _extensionUri: vscode.Uri,
|
||||
@@ -116,6 +130,9 @@ export class SessionEditorPanelProvider {
|
||||
|
||||
void this.updateTheme(vscode.window.activeColorTheme.kind);
|
||||
this._sendCachedStateToPanel(state);
|
||||
// The webview bridge may not be ready yet; keep re-sending so a dropped
|
||||
// `connectionStatus` can never leave the webview stuck on its loading screen.
|
||||
this._scheduleCachedStateRetries(panelId, state);
|
||||
void this._broadcastActiveEditorFile();
|
||||
|
||||
panel.onDidDispose(() => {
|
||||
@@ -187,6 +204,15 @@ export class SessionEditorPanelProvider {
|
||||
for (const entry of this._panels.values()) {
|
||||
this._sendCachedStateToPanel(entry);
|
||||
}
|
||||
|
||||
// When we become connected, keep re-sending at staggered delays so the
|
||||
// webview cannot miss the transition (postMessage is dropped if the
|
||||
// webview bridge is not ready yet).
|
||||
if (status === 'connected') {
|
||||
for (const [panelId, entry] of this._panels.entries()) {
|
||||
this._scheduleCachedStateRetries(panelId, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public notifySettingsSynced(settings: unknown): void {
|
||||
|
||||
@@ -173,17 +173,20 @@ const readSharedSettingsFromDisk = (): Record<string, unknown> => {
|
||||
};
|
||||
|
||||
const writeSharedSettingsToDisk = async (changes: Record<string, unknown>): Promise<void> => {
|
||||
let tmp: string | null = null;
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true });
|
||||
const current = readSharedSettingsFromDisk();
|
||||
const next: Record<string, unknown> = { ...current, ...changes };
|
||||
// Atomic write: tmp file + rename. Readers never see a partial/truncated
|
||||
// JSON that would fail to parse and silently get coerced to {}.
|
||||
const tmp = `${OPENCHAMBER_SHARED_SETTINGS_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
tmp = `${OPENCHAMBER_SHARED_SETTINGS_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await fs.promises.writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
|
||||
await fs.promises.rename(tmp, OPENCHAMBER_SHARED_SETTINGS_PATH);
|
||||
} catch {
|
||||
// ignore
|
||||
if (tmp) {
|
||||
await fs.promises.rm(tmp, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
type CommandCodeCredits = {
|
||||
credits?: { monthlyCredits?: number; purchasedCredits?: number; freeCredits?: number };
|
||||
windowLimits?: {
|
||||
fiveHour?: { used?: number; cap?: number; resetAt?: number };
|
||||
weekly?: { used?: number; cap?: number; resetAt?: number };
|
||||
};
|
||||
};
|
||||
|
||||
type WindowData = { usedPercent: number | null; resetAt: number | null; windowSeconds: number | null; valueLabel: string };
|
||||
|
||||
const toWindow = (data: WindowData) => ({
|
||||
usedPercent: data.usedPercent,
|
||||
remainingPercent: data.usedPercent === null ? null : Math.max(0, 100 - data.usedPercent),
|
||||
windowSeconds: data.windowSeconds,
|
||||
resetAfterSeconds: data.resetAt === null ? null : Math.max(0, Math.floor((data.resetAt - Date.now()) / 1000)),
|
||||
resetAt: data.resetAt,
|
||||
resetAtFormatted: null,
|
||||
resetAfterFormatted: null,
|
||||
valueLabel: data.valueLabel,
|
||||
});
|
||||
|
||||
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
const formatCredits = (value: number): string => String(Math.round((value + Number.EPSILON) * 100) / 100);
|
||||
|
||||
const parseCredits = (value: unknown): CommandCodeCredits | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const payload = value as CommandCodeCredits;
|
||||
return payload;
|
||||
};
|
||||
|
||||
const parseOrgId = (value: unknown): string | null | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const org = (value as { org?: { id?: unknown } }).org;
|
||||
return typeof org?.id === 'string' && org.id.trim() ? org.id.trim() : null;
|
||||
};
|
||||
|
||||
const parseCommandCodeCredits = (payload: CommandCodeCredits) => {
|
||||
const windows: Record<string, ReturnType<typeof toWindow>> = {};
|
||||
for (const [label, value] of [['monthly_credits', payload.credits?.monthlyCredits], ['purchased_credits', payload.credits?.purchasedCredits], ['free_credits', payload.credits?.freeCredits]] as const) {
|
||||
if (isFiniteNumber(value)) windows[label] = toWindow({ usedPercent: null, resetAt: null, windowSeconds: null, valueLabel: formatCredits(value) });
|
||||
}
|
||||
for (const [label, limit, seconds] of [['5h', payload.windowLimits?.fiveHour, 5 * 60 * 60], ['weekly', payload.windowLimits?.weekly, 7 * 24 * 60 * 60]] as const) {
|
||||
if (!isFiniteNumber(limit?.used) || !isFiniteNumber(limit.cap) || limit.cap <= 0) continue;
|
||||
const resetAt = isFiniteNumber(limit.resetAt) ? (limit.resetAt < 1_000_000_000_000 ? limit.resetAt * 1000 : limit.resetAt) : null;
|
||||
windows[label] = toWindow({ usedPercent: Math.min(100, Math.max(0, limit.used / limit.cap * 100)), resetAt, windowSeconds: seconds, valueLabel: `${formatCredits(limit.used)} / ${formatCredits(limit.cap)}` });
|
||||
}
|
||||
return windows;
|
||||
};
|
||||
|
||||
const requestJson = async (path: string, apiKey: string): Promise<unknown> => {
|
||||
const response = await fetch(`https://api.commandcode.ai${path}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(15_000) });
|
||||
if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed');
|
||||
if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`);
|
||||
return response.json().catch(() => null);
|
||||
};
|
||||
|
||||
export const fetchCommandCodeUsage = async (apiKey: string) => {
|
||||
const orgId = parseOrgId(await requestJson('/alpha/whoami', apiKey));
|
||||
if (orgId === undefined) throw new Error('Command Code account could not be determined');
|
||||
const creditsPath = orgId ? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}` : '/alpha/billing/credits';
|
||||
const payload = parseCredits(await requestJson(creditsPath, apiKey));
|
||||
if (!payload) throw new Error('Command Code usage data could not be parsed');
|
||||
const windows = parseCommandCodeCredits(payload);
|
||||
if (!Object.keys(windows).length) throw new Error('Command Code usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
@@ -15,6 +15,17 @@ import { applyProviderEnvAliases } from './provider-env-aliases';
|
||||
const t = vscode.l10n.t;
|
||||
|
||||
const READY_CHECK_TIMEOUT_MS = 30000;
|
||||
|
||||
// Reuse a single output channel across restarts instead of creating (and
|
||||
// leaking) a new one on every waitForReady call.
|
||||
let managerOutputChannel: vscode.OutputChannel | null = null;
|
||||
|
||||
function getManagerOutputChannel(): vscode.OutputChannel {
|
||||
if (!managerOutputChannel) {
|
||||
managerOutputChannel = vscode.window.createOutputChannel('OpenChamberManager');
|
||||
}
|
||||
return managerOutputChannel;
|
||||
}
|
||||
const WINDOWS_EXECUTABLE_EXTENSIONS = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
||||
.split(';')
|
||||
.map((ext) => ext.trim().toLowerCase())
|
||||
@@ -160,6 +171,19 @@ function stripWrappingQuotes(value: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function killProcessTree(pid: number | undefined): void {
|
||||
if (!Number.isInteger(pid)) return;
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
||||
stdio: 'ignore', timeout: 5000, windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendToPath(dir: string) {
|
||||
const trimmed = (dir || '').trim();
|
||||
if (!trimmed) return;
|
||||
@@ -613,7 +637,6 @@ async function waitForReady(
|
||||
timeoutMs = 15000,
|
||||
authHeaders: Record<string, string> = {}
|
||||
): Promise<ReadyResult> {
|
||||
const outputChannel = vscode.window.createOutputChannel('OpenChamberManager');
|
||||
const start = Date.now();
|
||||
const candidates = getCandidateBaseUrls(serverUrl);
|
||||
let attempts = 0;
|
||||
@@ -641,7 +664,7 @@ async function waitForReady(
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
outputChannel?.appendLine(
|
||||
getManagerOutputChannel().appendLine(
|
||||
`Health check to ${url.toString()} returned ${res.status} with body: ${JSON.stringify(body)}`
|
||||
);
|
||||
|
||||
@@ -743,6 +766,7 @@ async function spawnManagedOpenCodeServer(
|
||||
return {
|
||||
url,
|
||||
close: () => {
|
||||
killProcessTree(child.pid);
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
|
||||
@@ -17,7 +17,6 @@ const AUTH = JSON.stringify({
|
||||
crof: { key: 'test-token' },
|
||||
neuralwatt: { key: 'test-token' },
|
||||
'opencode-go': { key: 'test-token' },
|
||||
'command-code': { type: 'oauth', access: 'test-token' },
|
||||
'zai-coding-plan': { key: 'test-token' },
|
||||
deepseek: { key: 'test-token' },
|
||||
anthropic: { access: 'test-token', refresh: 'test-refresh' },
|
||||
@@ -104,57 +103,6 @@ describe('OpenCode Go quota provider (VS Code parity)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Code quota provider (VS Code parity)', () => {
|
||||
test('uses the OAuth access token and resolves server-backed limits', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||||
requests.push({ url, init });
|
||||
return mockResponse(url.endsWith('/alpha/whoami')
|
||||
? { org: { id: 'org/a' } }
|
||||
: { credits: { monthlyCredits: 120 }, windowLimits: { fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 } } });
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('command-code');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(requests.map(({ url }) => url), [
|
||||
'https://api.commandcode.ai/alpha/whoami',
|
||||
'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa',
|
||||
]);
|
||||
assert.equal((requests[0].init?.headers as Record<string, string>).Authorization, 'Bearer test-token');
|
||||
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
|
||||
assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '120');
|
||||
});
|
||||
|
||||
test('omits orgId for personal accounts', async () => {
|
||||
const urls: string[] = [];
|
||||
globalThis.fetch = (async (url: string) => {
|
||||
urls.push(url);
|
||||
return mockResponse(url.endsWith('/alpha/whoami')
|
||||
? { user: { id: 'user-1' }, org: null }
|
||||
: { credits: { monthlyCredits: 120 } });
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('command-code');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(urls, [
|
||||
'https://api.commandcode.ai/alpha/whoami',
|
||||
'https://api.commandcode.ai/alpha/billing/credits',
|
||||
]);
|
||||
});
|
||||
|
||||
test('formats fractional credit values for display', async () => {
|
||||
globalThis.fetch = (async (url: string) => mockResponse(url.endsWith('/alpha/whoami')
|
||||
? { org: null }
|
||||
: { credits: { monthlyCredits: 69.7947070034 }, windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } } })) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('command-code');
|
||||
|
||||
assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '69.79');
|
||||
assert.equal(result.usage!.windows['5h']!.valueLabel, '0.21 / 14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Crof quota provider (VS Code parity)', () => {
|
||||
test('reports credits balance as valueLabel with null percent', async () => {
|
||||
@@ -327,6 +275,33 @@ describe('Z.ai quota provider (VS Code parity)', () => {
|
||||
assert.equal(windows['MCP Tools']!.windowSeconds, 30 * 24 * 60 * 60);
|
||||
assert.equal(windows['MCP Tools']!.resetAt, 1787128459979);
|
||||
});
|
||||
|
||||
test('maps CREDIT_LIMIT entries to windows with credit value labels and plan level', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
code: 200,
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'CREDIT_LIMIT', unit: 3, number: 5, usage: 12000, currentValue: 65, remaining: 11934, percentage: 1, nextResetTime: 1787257978907 },
|
||||
{ type: 'CREDIT_LIMIT', unit: 6, number: 1, usage: 60000, currentValue: 65, remaining: 59934, percentage: 1, nextResetTime: 1787844668997 },
|
||||
],
|
||||
level: 'pro',
|
||||
},
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('zai-coding-plan');
|
||||
const windows = result.usage!.windows;
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.planLabel, 'pro');
|
||||
assert.equal(windows['5h']!.usedPercent, 1);
|
||||
assert.equal(windows['5h']!.windowSeconds, 5 * 60 * 60);
|
||||
assert.equal(windows['5h']!.resetAt, 1787257978907);
|
||||
assert.equal(windows['5h']!.valueLabel, '65 / 12k credits');
|
||||
assert.equal(windows.weekly!.usedPercent, 1);
|
||||
assert.equal(windows.weekly!.windowSeconds, 7 * 24 * 60 * 60);
|
||||
assert.equal(windows.weekly!.resetAt, 1787844668997);
|
||||
assert.equal(windows.weekly!.valueLabel, '65 / 60k credits');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NeuralWatt quota provider (VS Code parity)', () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
import { fetchCommandCodeUsage } from './commandCodeQuota';
|
||||
import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials';
|
||||
import { getProviderAuth, updateProviderAuth } from './opencodeAuth';
|
||||
|
||||
@@ -72,13 +71,31 @@ type ZaiLimit = {
|
||||
type?: string;
|
||||
number?: number;
|
||||
unit?: number;
|
||||
usage?: number;
|
||||
currentValue?: number;
|
||||
remaining?: number;
|
||||
nextResetTime?: number;
|
||||
percentage?: number;
|
||||
};
|
||||
|
||||
// CREDIT_LIMIT entries carry `usage` (total credits) and `currentValue` (consumed);
|
||||
// TOKENS_LIMIT entries only carry a percentage.
|
||||
const formatZaiCreditAmount = (value: number): string => {
|
||||
if (value < 1000) return value.toLocaleString('en-US');
|
||||
return `${Math.round(value / 100) / 10}k`;
|
||||
};
|
||||
|
||||
const formatZaiCreditValueLabel = (limit: ZaiLimit): string | null => {
|
||||
const used = toNumber(limit.currentValue);
|
||||
const total = toNumber(limit.usage);
|
||||
if (used === null || total === null) return null;
|
||||
return `${formatZaiCreditAmount(used)} / ${formatZaiCreditAmount(total)} credits`;
|
||||
};
|
||||
|
||||
type ZaiPayload = {
|
||||
data?: {
|
||||
limits?: ZaiLimit[];
|
||||
level?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -411,15 +428,20 @@ const buildResult = (data: {
|
||||
configured: boolean;
|
||||
usage?: ProviderUsage | null;
|
||||
error?: string;
|
||||
}): ProviderResult => ({
|
||||
providerId: data.providerId,
|
||||
providerName: data.providerName,
|
||||
ok: data.ok,
|
||||
configured: data.configured,
|
||||
usage: data.usage ?? null,
|
||||
...(data.error ? { error: data.error } : {}),
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
planLabel?: string | null;
|
||||
}): ProviderResult => {
|
||||
const result: ProviderResult = {
|
||||
providerId: data.providerId,
|
||||
providerName: data.providerName,
|
||||
ok: data.ok,
|
||||
configured: data.configured,
|
||||
usage: data.usage ?? null,
|
||||
...(data.error ? { error: data.error } : {}),
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
if (data.planLabel) result.planLabel = data.planLabel;
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveXaiAuth = (): XaiAuthEntry | null => {
|
||||
const entry = getProviderAuth('xai');
|
||||
@@ -750,9 +772,6 @@ export const listConfiguredQuotaProviders = () => {
|
||||
const configured = new Set<string>();
|
||||
const openCodeGoAuth = normalizeAuthEntry(getAuthEntry(auth, ['opencode-go']));
|
||||
if (openCodeGoAuth && (typeof openCodeGoAuth.key === 'string' || typeof openCodeGoAuth.token === 'string')) configured.add('opencode-go');
|
||||
const commandCodeAuth = normalizeAuthEntry(getAuthEntry(auth, ['command-code']));
|
||||
if (commandCodeAuth && (typeof commandCodeAuth.key === 'string' || typeof commandCodeAuth.access === 'string' || typeof commandCodeAuth.token === 'string')) configured.add('command-code');
|
||||
if (process.env.COMMAND_CODE_API_KEY?.trim()) configured.add('command-code');
|
||||
if (readCredential('ollama-cloud')) configured.add('ollama-cloud');
|
||||
if (readCredential('cursor')) configured.add('cursor');
|
||||
|
||||
@@ -1291,7 +1310,7 @@ const buildClaudeRateLimitResult = (): ProviderResult => (
|
||||
providerName: 'Claude',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Rate limited by Anthropic. Retrying shortly.',
|
||||
error: 'Rate limited. Retrying soon.',
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2059,16 +2078,19 @@ const fetchZaiQuota = async (): Promise<ProviderResult> => {
|
||||
const payload = await response.json() as ZaiPayload;
|
||||
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
|
||||
const windows: Record<string, UsageWindow> = {};
|
||||
for (const tokensLimit of limits.filter((limit) => limit?.type === 'TOKENS_LIMIT')) {
|
||||
const windowSeconds = resolveWindowSeconds(tokensLimit as Record<string, unknown>);
|
||||
// The API renamed TOKENS_LIMIT to CREDIT_LIMIT; field semantics stayed the same,
|
||||
// so both limit types map to the same windows.
|
||||
for (const limit of limits.filter((entry) => entry?.type === 'TOKENS_LIMIT' || entry?.type === 'CREDIT_LIMIT')) {
|
||||
const windowSeconds = resolveWindowSeconds(limit as Record<string, unknown>);
|
||||
const windowLabel = resolveWindowLabel(windowSeconds);
|
||||
const resetAt = tokensLimit.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
|
||||
const usedPercent = typeof tokensLimit.percentage === 'number' ? tokensLimit.percentage : null;
|
||||
const resetAt = limit.nextResetTime ? normalizeTimestamp(limit.nextResetTime) : null;
|
||||
const usedPercent = typeof limit.percentage === 'number' ? limit.percentage : null;
|
||||
|
||||
windows[windowLabel] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
resetAt,
|
||||
valueLabel: formatZaiCreditValueLabel(limit),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2087,6 +2109,7 @@ const fetchZaiQuota = async (): Promise<ProviderResult> => {
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows },
|
||||
planLabel: payload?.data?.level || null,
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
@@ -2848,18 +2871,6 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise<Pro
|
||||
return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
}
|
||||
}
|
||||
case 'command-code': {
|
||||
try {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), ['command-code']));
|
||||
const stored = typeof entry?.key === 'string' ? entry.key : typeof entry?.access === 'string' ? entry.access : typeof entry?.token === 'string' ? entry.token : null;
|
||||
const environment = process.env.COMMAND_CODE_API_KEY?.trim() || null;
|
||||
const apiKey = stored?.trim() || environment;
|
||||
if (!apiKey) return buildResult({ providerId, providerName: 'Command Code', ok: false, configured: false, error: 'Not configured' });
|
||||
return buildResult({ providerId, providerName: 'Command Code', ok: true, configured: true, usage: { windows: await fetchCommandCodeUsage(apiKey) } });
|
||||
} catch (error) {
|
||||
return buildResult({ providerId, providerName: 'Command Code', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
}
|
||||
}
|
||||
case 'cursor':
|
||||
return fetchCursorQuota();
|
||||
case 'crof':
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, test } from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('../vite.config.ts', import.meta.url), 'utf8');
|
||||
|
||||
describe('VS Code webview worker build', () => {
|
||||
test('bundles worker imports into one file', () => {
|
||||
assert.match(source, /worker:\s*\{[\s\S]*?inlineDynamicImports:\s*true/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* The webview only leaves its initial loading screen once it receives a
|
||||
* `connectionStatus: connected` message. VS Code drops postMessage calls made
|
||||
* before the webview's acquireVsCodeApi bridge is ready (common in
|
||||
* code-server / slow or flaky networks), so a single send can be lost
|
||||
* forever. Re-sending the cached state at staggered delays bounds the wait
|
||||
* without needing a webview-side ack protocol; the payload is idempotent
|
||||
* (connection status + window focus), so duplicate deliveries are harmless.
|
||||
*/
|
||||
const CACHED_STATE_RETRY_DELAYS_MS = [500, 1500, 3500, 7000, 12000, 20000];
|
||||
|
||||
export function scheduleCachedStateRetries<Target>(input: {
|
||||
/** The panel/view the retries belong to. */
|
||||
target: Target | undefined;
|
||||
/** Reads the provider's CURRENT panel/view, so a replaced target stops its stale retries. */
|
||||
getCurrent: () => Target | undefined;
|
||||
/** Retries only make sense for the connected transition. */
|
||||
isConnected: () => boolean;
|
||||
/** Re-sends the provider's cached state. */
|
||||
send: () => void;
|
||||
}): void {
|
||||
if (!input.isConnected()) return;
|
||||
const target = input.target;
|
||||
if (!target) return;
|
||||
for (const delayMs of CACHED_STATE_RETRY_DELAYS_MS) {
|
||||
setTimeout(() => {
|
||||
if (input.getCurrent() !== target) return;
|
||||
input.send();
|
||||
}, delayMs);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,13 @@ export default defineConfig(({ mode }) => ({
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
// VS Code webviews cannot load module imports from inside a web worker.
|
||||
// Keep the Shiki worker self-contained instead of emitting grammar chunks.
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'),
|
||||
|
||||
Reference in New Issue
Block a user