* feat: add French locale runtime Add French to OpenChamber's shared i18n runtime, dictionaries, and parity tests so the existing language picker can load a complete fr locale across shared UI surfaces. * fix: localize shared UI formatting Remove remaining shared UI locale hardcodings so dates, numbers, and first-party helper copy follow the active app locale instead of leaking English on French surfaces. * feat: localize VS Code French surfaces Localize VS Code bootstrap, native runtime messages, panel titles, and manifest contribution strings so French users get consistent first-party copy across the extension experience. * fix: TASK-2026-05-30-008 correct French review findings Fix broken French relative-time and weekday strings reported on PR #1482 and restore proper import order in quota utils without broadening scope. * fix: TASK-2026-05-30-008 address final PR review comments Capture the localized More Info label once in the VS Code CLI-missing flow and replace the remaining inline French-only utility strings with dictionary-driven copy plus required locale keys. * fix: TASK-2026-05-30-008 normalize French glossary Correct glossary-level French terminology on the live PR branch, keeping canonical technical terms like PR, worktree, stash, HEAD, Mermaid, Markdown, remote, and session while replacing misleading literal translations. * fix: TASK-2026-05-30-008 refine French terminology pass Clean up remaining glossary mistakes on the French PR branch, especially around Mermaid, Markdown, PR, worktree, stash, branch, remote, and commit terminology, while keeping behavior unchanged. * fix: TASK-2026-05-30-008 clean remaining French false friends Correct the SOCKS5 mistranslation and a final small set of obvious false-friend technical nouns on the French branch without changing behavior. * fix: TASK-2026-05-30-008 correct French glossary terms Replace remaining false-friend translations in the French UI dictionaries and normalize technical labels for the French PR branch. * fix: TASK-2026-05-30-008 remove remaining French Mermaid false friend Replace the last confirmed Sirène translation with Mermaid and re-run the requested blacklist and build verification on the PR branch. * fix: TASK-2026-05-30-008 enforce French glossary policy Keep skill/PR/worktree/remote terminology developer-credible in French and remove remaining machine-translated Git and settings copy. * fix: TASK-2026-05-30-008 keep prompt terminology in French Replace remaining technical invite translations with prompt wording across scheduled tasks, multi-run, prompt templates, and Magic Prompts. * fix: TASK-2026-05-30-008 finalize French terminology cleanup Polish remaining worktree/remote wording, remove visible metadata leakage, and correct final Git and settings labels on the French PR branch. * fix: TASK-2026-05-30-008 polish final French strings Correct the last aria-like artifacts and awkward worktree/remote/GitHub URL phrasing in the French dictionaries. * fix: TASK-2026-05-30-008 normalize final French glossary framing Tighten the last worktree/remote/checkout wording and fix remaining French grammar around canonical technical terms. * fix: TASK-2026-05-30-008 align final developer glossary wording Normalize the last French framing around canonical developer terms like worktree, remote, prompt, and checkout. * fix: TASK-2026-05-30-008 harmonize final French sentence framing Replace the last raw franglais around checkout, remote, worktree, and prompt-facing labels with more natural French framing while keeping the chosen technical terms. * fix: TASK-2026-05-30-008 add compact relative date keys Replace French-specific prefix stripping in compact session date labels with dedicated i18n keys across locale dictionaries, preserving existing compact label output while making French wording robust. * docs: add French documentation * docs: mention French locale folder --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
713 lines
28 KiB
TypeScript
713 lines
28 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import { ChatViewProvider } from './ChatViewProvider';
|
|
import { AgentManagerPanelProvider } from './AgentManagerPanelProvider';
|
|
import { SessionEditorPanelProvider } from './SessionEditorPanelProvider';
|
|
import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
|
import { startGlobalEventWatcher, stopGlobalEventWatcher, setChatViewProvider } from './sessionActivityWatcher';
|
|
|
|
let chatViewProvider: ChatViewProvider | undefined;
|
|
let agentManagerProvider: AgentManagerPanelProvider | undefined;
|
|
let sessionEditorProvider: SessionEditorPanelProvider | undefined;
|
|
let openCodeManager: OpenCodeManager | undefined;
|
|
let outputChannel: vscode.OutputChannel | undefined;
|
|
|
|
let activeSessionId: string | null = null;
|
|
let activeSessionTitle: string | null = null;
|
|
|
|
const t = vscode.l10n.t;
|
|
|
|
const SETTINGS_KEY = 'openchamber.settings';
|
|
const CHAT_VIEW_BOOTSTRAP_DELAY_MS = 80;
|
|
|
|
const waitForChatViewBootstrap = () => new Promise<void>((resolve) => setTimeout(resolve, CHAT_VIEW_BOOTSTRAP_DELAY_MS));
|
|
|
|
const formatIso = (value: number | null | undefined) => {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) return '(none)';
|
|
try {
|
|
return new Date(value).toISOString();
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
};
|
|
|
|
const formatDurationMs = (value: number | null | undefined) => {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) return '(none)';
|
|
const seconds = Math.round(value / 100) / 10;
|
|
return `${seconds}s`;
|
|
};
|
|
|
|
export async function activate(context: vscode.ExtensionContext) {
|
|
outputChannel = vscode.window.createOutputChannel('OpenChamber');
|
|
|
|
let moveToRightSidebarScheduled = false;
|
|
|
|
const isCursorLikeHost = () => /\bcursor\b/i.test(vscode.env.appName);
|
|
|
|
const findMoveToRightSidebarCommandId = async (): Promise<string | null> => {
|
|
const commands = await vscode.commands.getCommands(true);
|
|
|
|
const preferred = [
|
|
// Newer VS Code naming
|
|
'workbench.action.moveViewToSecondarySideBar',
|
|
'workbench.action.moveViewToSecondarySidebar',
|
|
'workbench.action.moveFocusedViewToSecondarySideBar',
|
|
'workbench.action.moveFocusedViewToSecondarySidebar',
|
|
|
|
// Some builds use "Auxiliary Bar" naming
|
|
'workbench.action.moveViewToAuxiliaryBar',
|
|
'workbench.action.moveFocusedViewToAuxiliaryBar',
|
|
];
|
|
|
|
for (const commandId of preferred) {
|
|
if (commands.includes(commandId)) return commandId;
|
|
}
|
|
|
|
const fuzzy = commands.find((commandId) => {
|
|
const id = commandId.toLowerCase();
|
|
const looksLikeMoveView = id.includes('workbench.action') && id.includes('move') && id.includes('view');
|
|
if (!looksLikeMoveView) return false;
|
|
|
|
// Support both "secondary sidebar" and "auxiliary bar" naming.
|
|
return (id.includes('secondary') && id.includes('side') && id.includes('bar')) || (id.includes('auxiliary') && id.includes('bar'));
|
|
});
|
|
|
|
return fuzzy || null;
|
|
};
|
|
|
|
const attemptMoveChatToRightSidebar = async (): Promise<'moved' | 'unsupported' | 'failed'> => {
|
|
const moveCommandId = await findMoveToRightSidebarCommandId();
|
|
if (!moveCommandId) return 'unsupported';
|
|
|
|
try {
|
|
await vscode.commands.executeCommand('openchamber.chatView.focus');
|
|
await vscode.commands.executeCommand(moveCommandId);
|
|
return 'moved';
|
|
} catch (error) {
|
|
outputChannel?.appendLine(
|
|
`[OpenChamber] Failed moving chat view to right sidebar (command=${moveCommandId}): ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
return 'failed';
|
|
}
|
|
};
|
|
|
|
const maybeMoveChatToRightSidebarOnStartup = async () => {
|
|
if (isCursorLikeHost()) return;
|
|
|
|
const attempted = context.globalState.get<boolean>('openchamber.sidebarAutoMoveAttempted') || false;
|
|
if (attempted) return;
|
|
await context.globalState.update('openchamber.sidebarAutoMoveAttempted', true);
|
|
|
|
if (moveToRightSidebarScheduled) return;
|
|
moveToRightSidebarScheduled = true;
|
|
|
|
// Defer until after activation to avoid stealing focus during startup.
|
|
setTimeout(() => {
|
|
void (async () => {
|
|
try {
|
|
await attemptMoveChatToRightSidebar();
|
|
} finally {
|
|
moveToRightSidebarScheduled = false;
|
|
}
|
|
})();
|
|
}, 800);
|
|
};
|
|
|
|
|
|
// Migration: clear legacy auto-set API URLs (ports 47680-47689 were auto-assigned by older extension versions)
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
const legacyApiUrl = config.get<string>('apiUrl') || '';
|
|
if (/^https?:\/\/localhost:4768\d\/?$/.test(legacyApiUrl.trim())) {
|
|
await config.update('apiUrl', '', vscode.ConfigurationTarget.Global);
|
|
}
|
|
|
|
// Create OpenCode manager first
|
|
openCodeManager = createOpenCodeManager(context);
|
|
|
|
// Create chat view provider with manager reference
|
|
// The webview will show a loading state until OpenCode is ready
|
|
chatViewProvider = new ChatViewProvider(context, context.extensionUri, openCodeManager);
|
|
|
|
context.subscriptions.push(
|
|
vscode.window.registerWebviewViewProvider(
|
|
ChatViewProvider.viewType,
|
|
chatViewProvider,
|
|
{ webviewOptions: { retainContextWhenHidden: true } }
|
|
)
|
|
);
|
|
|
|
// Register sidebar/focus commands AFTER the webview view provider is registered
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.openSidebar', async () => {
|
|
// Best-effort: open the container (if available), then focus the chat view.
|
|
try {
|
|
await vscode.commands.executeCommand('workbench.view.extension.openchamber');
|
|
} catch (e) {
|
|
outputChannel?.appendLine(`[OpenChamber] workbench.view.extension.openchamber failed: ${e}`);
|
|
}
|
|
|
|
try {
|
|
await vscode.commands.executeCommand('openchamber.chatView.focus');
|
|
} catch (e) {
|
|
outputChannel?.appendLine(`[OpenChamber] openchamber.chatView.focus failed: ${e}`);
|
|
vscode.window.showErrorMessage(t('OpenChamber: Failed to open sidebar - {0}', String(e)));
|
|
return false;
|
|
}
|
|
|
|
if (!chatViewProvider?.hasResolvedView()) {
|
|
outputChannel?.appendLine('[OpenChamber] Chat sidebar focus completed before the webview was resolved');
|
|
vscode.window.showWarningMessage(t('OpenChamber: Chat sidebar is not ready'));
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
);
|
|
|
|
const revealChatViewForPayload = async () => {
|
|
const opened = await vscode.commands.executeCommand<boolean>('openchamber.openSidebar');
|
|
if (!opened) {
|
|
return false;
|
|
}
|
|
|
|
await waitForChatViewBootstrap();
|
|
if (!chatViewProvider?.hasResolvedView()) {
|
|
outputChannel?.appendLine('[OpenChamber] Chat sidebar webview was disposed before payload delivery');
|
|
vscode.window.showWarningMessage(t('OpenChamber: Chat sidebar is not ready'));
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.focusChat', async () => {
|
|
await vscode.commands.executeCommand('openchamber.chatView.focus');
|
|
})
|
|
);
|
|
|
|
void maybeMoveChatToRightSidebarOnStartup();
|
|
|
|
// Create Agent Manager panel provider
|
|
agentManagerProvider = new AgentManagerPanelProvider(context, context.extensionUri, openCodeManager);
|
|
sessionEditorProvider = new SessionEditorPanelProvider(context, context.extensionUri, openCodeManager);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.internal.settingsSynced', (settings: unknown) => {
|
|
chatViewProvider?.notifySettingsSynced(settings);
|
|
sessionEditorProvider?.notifySettingsSynced(settings);
|
|
agentManagerProvider?.notifySettingsSynced(settings);
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.window.onDidChangeWindowState((state) => {
|
|
chatViewProvider?.notifyWindowFocusChanged(state.focused);
|
|
sessionEditorProvider?.notifyWindowFocusChanged(state.focused);
|
|
agentManagerProvider?.notifyWindowFocusChanged(state.focused);
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.openAgentManager', () => {
|
|
agentManagerProvider?.createOrShow();
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.setActiveSession', (sessionId: unknown, title?: unknown) => {
|
|
if (typeof sessionId === 'string' && sessionId.trim().length > 0) {
|
|
activeSessionId = sessionId.trim();
|
|
activeSessionTitle = typeof title === 'string' && title.trim().length > 0 ? title.trim() : null;
|
|
return;
|
|
}
|
|
|
|
activeSessionId = null;
|
|
activeSessionTitle = null;
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.openActiveSessionInEditor', () => {
|
|
if (!activeSessionId) {
|
|
vscode.window.showInformationMessage(t('OpenChamber: No active session'));
|
|
return;
|
|
}
|
|
sessionEditorProvider?.createOrShow(activeSessionId, activeSessionTitle ?? undefined);
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.openSessionInEditor', (sessionId: string, title?: string) => {
|
|
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
|
|
return;
|
|
}
|
|
sessionEditorProvider?.createOrShow(sessionId.trim(), title);
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.openNewSessionInEditor', () => {
|
|
sessionEditorProvider?.createOrShowNewSession();
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.openCurrentOrNewSessionInEditor', () => {
|
|
if (activeSessionId) {
|
|
sessionEditorProvider?.createOrShow(activeSessionId, activeSessionTitle ?? undefined);
|
|
} else {
|
|
sessionEditorProvider?.createOrShowNewSession();
|
|
}
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.restartApi', async () => {
|
|
try {
|
|
// Prefer the full in-app reload flow (overlay + managed restart via the
|
|
// bridge + config/data refresh) driven by the webview — same as after an
|
|
// OpenCode update. Fall back to a bare manager restart when no webview is
|
|
// open to drive it.
|
|
if (chatViewProvider?.reloadOpenCode()) {
|
|
return;
|
|
}
|
|
await openCodeManager?.restart();
|
|
vscode.window.showInformationMessage(t('OpenChamber: API connection restarted'));
|
|
} catch (e) {
|
|
vscode.window.showErrorMessage(t('OpenChamber: Failed to restart API - {0}', String(e)));
|
|
}
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.addToContext', async () => {
|
|
const editor = vscode.window.activeTextEditor;
|
|
if (!editor) {
|
|
vscode.window.showWarningMessage(t('OpenChamber [Add to Context]: No active editor'));
|
|
return;
|
|
}
|
|
|
|
const selection = editor.selection;
|
|
const selectedText = editor.document.getText(selection);
|
|
|
|
if (!selectedText) {
|
|
vscode.window.showWarningMessage(t('OpenChamber [Add to Context]: No text selected'));
|
|
return;
|
|
}
|
|
|
|
// Get file info for context
|
|
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
|
// Get line numbers (1-based for display)
|
|
const startLine = selection.start.line + 1;
|
|
const endLine = selection.end.line + 1;
|
|
const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
|
|
|
const filename = `${editor.document.fileName.split(/[\\/]/).pop() || filePath}:${lineRange}`;
|
|
const contextSelection = {
|
|
filePath: editor.document.uri.fsPath,
|
|
filename,
|
|
text: selectedText,
|
|
};
|
|
|
|
if (!sessionEditorProvider?.addContextSelectionToActivePanel(contextSelection)) {
|
|
if (!(await revealChatViewForPayload())) {
|
|
return;
|
|
}
|
|
chatViewProvider?.addContextSelection(contextSelection);
|
|
}
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.attachExplorerToChat', async (resource?: vscode.Uri, resources?: vscode.Uri[]) => {
|
|
const uriCandidates: vscode.Uri[] = [];
|
|
if (Array.isArray(resources)) {
|
|
uriCandidates.push(...resources.filter((entry): entry is vscode.Uri => entry instanceof vscode.Uri));
|
|
}
|
|
if (resource instanceof vscode.Uri) {
|
|
uriCandidates.push(resource);
|
|
}
|
|
if (uriCandidates.length === 0) {
|
|
const activeEditorUri = vscode.window.activeTextEditor?.document.uri;
|
|
if (activeEditorUri) {
|
|
uriCandidates.push(activeEditorUri);
|
|
}
|
|
}
|
|
|
|
const uniqueUris = Array.from(new Map(uriCandidates.map((uri) => [uri.toString(), uri])).values());
|
|
const attachedFiles: Array<{ filePath: string; fileName: string; fileSize: number | null }> = [];
|
|
const skippedEntries: string[] = [];
|
|
|
|
for (const uri of uniqueUris) {
|
|
if (uri.scheme !== 'file') {
|
|
skippedEntries.push(uri.toString());
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const stat = await vscode.workspace.fs.stat(uri);
|
|
if ((stat.type & vscode.FileType.Directory) !== 0) {
|
|
skippedEntries.push(vscode.workspace.asRelativePath(uri, false));
|
|
continue;
|
|
}
|
|
} catch {
|
|
skippedEntries.push(vscode.workspace.asRelativePath(uri, false));
|
|
continue;
|
|
}
|
|
|
|
const filePath = uri.fsPath.trim();
|
|
const fileName = uri.fsPath.replace(/\\/g, '/').split('/').pop() || vscode.workspace.asRelativePath(uri, false).replace(/\\/g, '/').trim();
|
|
if (!filePath || !fileName) {
|
|
skippedEntries.push(uri.fsPath || uri.toString());
|
|
continue;
|
|
}
|
|
let fileSize: number | null = null;
|
|
try {
|
|
const stat = await vscode.workspace.fs.stat(uri);
|
|
fileSize = stat.size;
|
|
} catch {
|
|
fileSize = null;
|
|
}
|
|
attachedFiles.push({ filePath, fileName, fileSize });
|
|
}
|
|
|
|
if (attachedFiles.length === 0) {
|
|
vscode.window.showWarningMessage(t('OpenChamber: No file selected to mention'));
|
|
return;
|
|
}
|
|
|
|
if (!sessionEditorProvider?.addFileAttachmentsToActivePanel(attachedFiles)) {
|
|
if (!(await revealChatViewForPayload())) {
|
|
return;
|
|
}
|
|
chatViewProvider?.addFileAttachments(attachedFiles);
|
|
}
|
|
|
|
if (skippedEntries.length > 0) {
|
|
vscode.window.showInformationMessage(t('OpenChamber: Some selected entries were skipped (folders or unsupported resources)'));
|
|
}
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.explain', async () => {
|
|
const editor = vscode.window.activeTextEditor;
|
|
if (!editor) {
|
|
vscode.window.showWarningMessage(t('OpenChamber [Explain]: No active editor'));
|
|
return;
|
|
}
|
|
|
|
const selection = editor.selection;
|
|
const selectedText = editor.document.getText(selection);
|
|
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
|
const languageId = editor.document.languageId;
|
|
|
|
let prompt: string;
|
|
|
|
if (selectedText) {
|
|
// Selection exists - explain the selected code
|
|
const startLine = selection.start.line + 1;
|
|
const endLine = selection.end.line + 1;
|
|
const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
|
prompt = `${t('Explain the following Code / Text:')}\n\n${filePath}:${lineRange}\n\`\`\`${languageId}\n${selectedText}\n\`\`\``;
|
|
} else {
|
|
// No selection - explain the entire file
|
|
prompt = `${t('Explain the following Code / Text:')}\n\n${filePath}`;
|
|
}
|
|
|
|
if (!sessionEditorProvider?.createSessionWithPromptInActivePanel(prompt)) {
|
|
if (!(await revealChatViewForPayload())) {
|
|
return;
|
|
}
|
|
chatViewProvider?.createNewSessionWithPrompt(prompt);
|
|
}
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.improveCode', async () => {
|
|
const editor = vscode.window.activeTextEditor;
|
|
if (!editor) {
|
|
vscode.window.showWarningMessage(t('OpenChamber [Improve Code]: No active editor'));
|
|
return;
|
|
}
|
|
|
|
const selection = editor.selection;
|
|
const selectedText = editor.document.getText(selection);
|
|
|
|
if (!selectedText) {
|
|
vscode.window.showWarningMessage(t('OpenChamber [Improve Code]: No text selected'));
|
|
return;
|
|
}
|
|
|
|
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
|
const languageId = editor.document.languageId;
|
|
const startLine = selection.start.line + 1;
|
|
const endLine = selection.end.line + 1;
|
|
const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
|
|
|
const prompt = `${t('Improve the following Code:')}\n\n${filePath}:${lineRange}\n\`\`\`${languageId}\n${selectedText}\n\`\`\``;
|
|
|
|
if (!sessionEditorProvider?.createSessionWithPromptInActivePanel(prompt)) {
|
|
if (!(await revealChatViewForPayload())) {
|
|
return;
|
|
}
|
|
chatViewProvider?.createNewSessionWithPrompt(prompt);
|
|
}
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.newSession', () => {
|
|
chatViewProvider?.createNewSession();
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.showSettings', () => {
|
|
chatViewProvider?.showSettings();
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand('openchamber.showOpenCodeStatus', async () => {
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
const configuredApiUrl = (config.get<string>('apiUrl') || '').trim();
|
|
|
|
const extensionVersion = String(context.extension?.packageJSON?.version || '');
|
|
const workspaceFolders = (vscode.workspace.workspaceFolders || []).map((folder) => folder.uri.fsPath);
|
|
const primaryWorkspace = workspaceFolders[0] || '';
|
|
|
|
const debug = openCodeManager?.getDebugInfo();
|
|
const resolvedApiUrl = openCodeManager?.getApiUrl();
|
|
const workingDirectory = openCodeManager?.getWorkingDirectory() ?? '';
|
|
const workingDirectoryMatchesWorkspace = Boolean(primaryWorkspace && workingDirectory === primaryWorkspace);
|
|
let resolvedApiPath = '';
|
|
if (resolvedApiUrl) {
|
|
try {
|
|
resolvedApiPath = new URL(resolvedApiUrl).pathname || '/';
|
|
} catch {
|
|
resolvedApiPath = '(invalid url)';
|
|
}
|
|
}
|
|
|
|
const safeFetch = async (input: string, timeoutMs = 6000) => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
const startedAt = Date.now();
|
|
const openCodeAuthHeaders = openCodeManager?.getOpenCodeAuthHeaders() || {};
|
|
try {
|
|
const resp = await fetch(input, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json', ...openCodeAuthHeaders },
|
|
signal: controller.signal,
|
|
});
|
|
const elapsedMs = Date.now() - startedAt;
|
|
const contentType = resp.headers.get('content-type') || '';
|
|
const isJson = contentType.toLowerCase().includes('json') && !contentType.toLowerCase().includes('text/html');
|
|
|
|
let summary = '';
|
|
if (isJson) {
|
|
const json = await resp.json().catch(() => null);
|
|
if (Array.isArray(json)) {
|
|
summary = `json[array] len=${json.length}`;
|
|
} else if (json && typeof json === 'object') {
|
|
const keys = Object.keys(json).slice(0, 8);
|
|
summary = `json[object] keys=${keys.join(',')}${Object.keys(json).length > keys.length ? ',…' : ''}`;
|
|
} else {
|
|
summary = `json[${typeof json}]`;
|
|
}
|
|
} else {
|
|
summary = contentType ? `content-type=${contentType}` : 'no content-type';
|
|
}
|
|
|
|
return { ok: resp.ok && isJson, status: resp.status, elapsedMs, summary };
|
|
} catch (error) {
|
|
const elapsedMs = Date.now() - startedAt;
|
|
const isAbort =
|
|
controller.signal.aborted ||
|
|
(error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted')));
|
|
const message = isAbort
|
|
? `timeout after ${timeoutMs}ms`
|
|
: error instanceof Error
|
|
? error.message
|
|
: String(error);
|
|
return { ok: false, status: 0, elapsedMs, summary: `error=${message}` };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
};
|
|
|
|
const buildProbeUrl = (pathname: string, includeDirectory = true) => {
|
|
if (!resolvedApiUrl) return null;
|
|
const base = `${resolvedApiUrl.replace(/\/+$/, '')}/`;
|
|
const url = new URL(pathname.replace(/^\/+/, ''), base);
|
|
if (includeDirectory && workingDirectory) {
|
|
url.searchParams.set('directory', workingDirectory);
|
|
}
|
|
return url.toString();
|
|
};
|
|
|
|
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
|
|
{ label: 'health', path: '/global/health', includeDirectory: false },
|
|
{ label: 'config', path: '/config', includeDirectory: true },
|
|
{ label: 'providers', path: '/config/providers', includeDirectory: true },
|
|
// Can be slower on large configs; keep the probe from producing false negatives.
|
|
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 },
|
|
{ label: 'commands', path: '/command', includeDirectory: true, timeoutMs: 10000 },
|
|
{ label: 'project', path: '/project/current', includeDirectory: true },
|
|
{ label: 'path', path: '/path', includeDirectory: true },
|
|
// Session listing is what powers the sidebar. This helps diagnose "no sessions shown" bugs.
|
|
{ label: 'sessions', path: '/session', includeDirectory: true, timeoutMs: 12000 },
|
|
{ label: 'sessionStatus', path: '/session/status', includeDirectory: true },
|
|
];
|
|
|
|
const probes = resolvedApiUrl
|
|
? await Promise.all(
|
|
probeTargets.map(async (entry) => {
|
|
const url = buildProbeUrl(entry.path, entry.includeDirectory !== false);
|
|
if (!url) {
|
|
return { label: entry.label, url: '(none)', result: null as null };
|
|
}
|
|
const result = await safeFetch(url, typeof entry.timeoutMs === 'number' ? entry.timeoutMs : undefined);
|
|
return { label: entry.label, url, result };
|
|
})
|
|
)
|
|
: [];
|
|
|
|
const storedSettings = context.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
|
|
const settingsKeys = Object.keys(storedSettings).filter((key) => key !== 'lastDirectory');
|
|
|
|
const lines = [
|
|
`Time: ${new Date().toISOString()}`,
|
|
`OpenChamber version: ${extensionVersion || '(unknown)'}`,
|
|
`OpenCode Version: ${debug?.version ?? '(unknown)'}`,
|
|
`VS Code version: ${vscode.version}`,
|
|
`Platform: ${process.platform} ${process.arch}`,
|
|
`Workspace folders: ${workspaceFolders.length}${workspaceFolders.length ? ` (${workspaceFolders.join(', ')})` : ''}`,
|
|
`Status: ${openCodeManager?.getStatus() ?? 'unknown'}`,
|
|
`Working directory: ${workingDirectory}`,
|
|
`Working dir matches workspace: ${workingDirectoryMatchesWorkspace ? 'yes' : 'no'}`,
|
|
`API URL (configured): ${configuredApiUrl || '(none)'}`,
|
|
`OpenCode binary (configured): ${(vscode.workspace.getConfiguration('openchamber').get<string>('opencodeBinary') || '').trim() || '(none)'}`,
|
|
`API URL (resolved): ${openCodeManager?.getApiUrl() ?? '(none)'}`,
|
|
`API URL path: ${resolvedApiPath || '(none)'}`,
|
|
debug
|
|
? `OpenCode server URL: ${debug.serverUrl ?? '(none)'}`
|
|
: `OpenCode server URL: (unknown)`,
|
|
debug
|
|
? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})`
|
|
: `OpenCode mode: (unknown)`,
|
|
debug
|
|
? `Secure OpenCode connection: ${debug.secureConnection ? 'true' : 'false'}`
|
|
: `Secure OpenCode connection: (unknown)`,
|
|
debug
|
|
? `OpenCode auth source: ${debug.authSource ?? '(none)'}`
|
|
: `OpenCode auth source: (unknown)`,
|
|
debug
|
|
? `OpenCode CLI path: ${debug.cliPath || '(not found)'}`
|
|
: `OpenCode CLI path: (unknown)`,
|
|
debug
|
|
? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}`
|
|
: `OpenCode detected port: (unknown)`,
|
|
debug
|
|
? `OpenCode API prefix: ${debug.apiPrefixDetected ? (debug.apiPrefix || '(root)') : '(unknown)'}`
|
|
: `OpenCode API prefix: (unknown)`,
|
|
debug
|
|
? `Last start: ${formatIso(debug.lastStartAt)}`
|
|
: `Last start: (unknown)`,
|
|
debug
|
|
? `Last ready: ${debug.lastReadyElapsedMs !== null ? `${debug.lastReadyElapsedMs}ms` : '(unknown)'}`
|
|
: `Last ready: (unknown)`,
|
|
debug
|
|
? `Ready attempts: ${debug.lastReadyAttempts ?? '(unknown)'}`
|
|
: `Ready attempts: (unknown)`,
|
|
debug
|
|
? `Start attempts: ${debug.lastStartAttempts ?? '(unknown)'}`
|
|
: `Start attempts: (unknown)`,
|
|
debug
|
|
? `Last connected: ${formatIso(debug.lastConnectedAt)}`
|
|
: `Last connected: (unknown)`,
|
|
debug && debug.lastConnectedAt ? `Connected for: ${formatDurationMs(Date.now() - debug.lastConnectedAt)}` : `Connected for: (n/a)`,
|
|
debug && debug.lastExitCode !== null ? `Last exit code: ${debug.lastExitCode}` : `Last exit code: (none)`,
|
|
debug?.lastError ? `Last error: ${debug.lastError}` : `Last error: (none)`,
|
|
`Settings keys (stored): ${settingsKeys.length ? settingsKeys.join(', ') : '(none)'}`,
|
|
probes.length ? '' : '',
|
|
...(probes.length
|
|
? [
|
|
'OpenCode API probes:',
|
|
...probes.map((probe) => {
|
|
if (!probe.result) return `- ${probe.label}: (no url)`;
|
|
const { ok, status, elapsedMs, summary } = probe.result;
|
|
const suffix = ok ? '' : ` url=${probe.url}`;
|
|
return `- ${probe.label}: ${ok ? 'ok' : 'fail'} status=${status} time=${elapsedMs}ms ${summary}${suffix}`;
|
|
}),
|
|
]
|
|
: []),
|
|
'',
|
|
];
|
|
|
|
outputChannel?.appendLine(lines.join('\n'));
|
|
outputChannel?.show(true);
|
|
})
|
|
);
|
|
|
|
context.subscriptions.push(
|
|
vscode.window.onDidChangeActiveColorTheme((theme) => {
|
|
chatViewProvider?.updateTheme(theme.kind);
|
|
agentManagerProvider?.updateTheme(theme.kind);
|
|
sessionEditorProvider?.updateTheme(theme.kind);
|
|
})
|
|
);
|
|
|
|
// Theme changes can update the `workbench.colorTheme` setting slightly after the
|
|
// `activeColorTheme` event. Listen for config changes too so we can re-resolve
|
|
// the contributed theme JSON and update Shiki themes in the webview.
|
|
context.subscriptions.push(
|
|
vscode.workspace.onDidChangeConfiguration((event) => {
|
|
if (
|
|
event.affectsConfiguration('workbench.colorTheme') ||
|
|
event.affectsConfiguration('workbench.preferredLightColorTheme') ||
|
|
event.affectsConfiguration('workbench.preferredDarkColorTheme')
|
|
) {
|
|
chatViewProvider?.updateTheme(vscode.window.activeColorTheme.kind);
|
|
agentManagerProvider?.updateTheme(vscode.window.activeColorTheme.kind);
|
|
sessionEditorProvider?.updateTheme(vscode.window.activeColorTheme.kind);
|
|
}
|
|
})
|
|
);
|
|
|
|
// Subscribe to status changes - this broadcasts to webview
|
|
context.subscriptions.push(
|
|
openCodeManager.onStatusChange((status, error) => {
|
|
chatViewProvider?.updateConnectionStatus(status, error);
|
|
agentManagerProvider?.updateConnectionStatus(status, error);
|
|
sessionEditorProvider?.updateConnectionStatus(status, error);
|
|
|
|
// Start/stop global event watcher based on connection status
|
|
// Mirrors web server and desktop behavior
|
|
if (status === 'connected' && chatViewProvider && openCodeManager) {
|
|
setChatViewProvider(chatViewProvider);
|
|
void startGlobalEventWatcher(openCodeManager, chatViewProvider);
|
|
} else if (status === 'disconnected' || status === 'error') {
|
|
stopGlobalEventWatcher();
|
|
}
|
|
})
|
|
);
|
|
|
|
// Start OpenCode API without blocking activation.
|
|
// Blocking here delays webview resolution and causes a blank panel until startup completes.
|
|
void openCodeManager.start();
|
|
}
|
|
|
|
export async function deactivate() {
|
|
stopGlobalEventWatcher();
|
|
await openCodeManager?.stop();
|
|
openCodeManager = undefined;
|
|
chatViewProvider = undefined;
|
|
agentManagerProvider = undefined;
|
|
sessionEditorProvider = undefined;
|
|
outputChannel?.dispose();
|
|
outputChannel = undefined;
|
|
}
|