diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 9824981a..8ad0536a 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import type { AiFluencyExtensionApi, ExtensionPointButton } from './extensionPoints'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -239,6 +240,9 @@ class CopilotTokenTracker implements vscode.Disposable { /** * Scan a workspace folder for customization files according to `customizationPatterns.json`. */ + /** Buttons registered by companion extensions via the extension points API. */ + private readonly _extensionPointButtons = new Map void | Promise }>(); + private _disposed = false; private updateInterval: NodeJS.Timeout | undefined; private detailsPanel: vscode.WebviewPanel | undefined; @@ -432,11 +436,35 @@ class CopilotTokenTracker implements vscode.Disposable { } } + public registerExtensionPointButton(button: ExtensionPointButton, handler: () => void | Promise): { dispose(): void } { + this._extensionPointButtons.set(button.id, { config: button, handler }); + return { + dispose: () => { + this._extensionPointButtons.delete(button.id); + }, + }; + } + + private extensionPointButtonsScript(nonce: string): string { + const data = [...this._extensionPointButtons.values()].map(e => ({ id: e.config.id, label: e.config.label })); + return ``; + } + + private async handleExtensionPointAction(buttonId: string): Promise { + const entry = this._extensionPointButtons.get(buttonId); + if (!entry) { return false; } + await this.dispatch(`extensionPoint:${buttonId}`, () => entry.handler()); + return true; + } + /** * Dispatch a shared navigation command that is common across all webview panels. * Returns true if the command was recognised and dispatched, false if it is panel-specific. */ - private async dispatchSharedCommand(command: string): Promise { + private async dispatchSharedCommand(message: { command: string; [key: string]: any }): Promise { + if (message.command === 'extensionPointAction' && typeof message.buttonId === 'string') { + return this.handleExtensionPointAction(message.buttonId); + } const handlers: Record unknown> = { showDetails: () => this.showDetails(), showChart: () => this.showChart(), @@ -447,9 +475,9 @@ class CopilotTokenTracker implements vscode.Disposable { showEnvironmental: () => this.showEnvironmental(), showFluencyLevelViewer: () => this.showFluencyLevelViewer(), }; - const handler = handlers[command]; + const handler = handlers[message.command]; if (!handler) { return false; } - await this.dispatch(command, handler); + await this.dispatch(message.command, handler); return true; } @@ -4156,7 +4184,7 @@ usageAnalysis: undefined // Handle messages from the webview this.detailsPanel.webview.onDidReceiveMessage(async (message) => { if (this.handleLocalViewRegressionMessage(message)) { return; } - if (await this.dispatchSharedCommand(message.command)) { return; } + if (await this.dispatchSharedCommand(message)) { return; } switch (message.command) { case 'refresh': await this.dispatch('refresh:details', () => this.refreshDetailsPanel()); @@ -4209,7 +4237,7 @@ usageAnalysis: undefined this.environmentalPanel.webview.onDidReceiveMessage(async (message) => { if (this.handleLocalViewRegressionMessage(message)) { return; } - if (await this.dispatchSharedCommand(message.command)) { return; } + if (await this.dispatchSharedCommand(message)) { return; } if (message.command === 'refresh') { await this.dispatch('refresh:environmental', async () => { const refreshed = await this.updateTokenStats(); @@ -4260,6 +4288,7 @@ usageAnalysis: undefined
+ ${this.extensionPointButtonsScript(nonce)} ${this.getLocalViewRegressionProbeScript('environmental', nonce)} @@ -4301,7 +4330,7 @@ usageAnalysis: undefined // Handle messages from the webview this.chartPanel.webview.onDidReceiveMessage(async (message) => { if (this.handleLocalViewRegressionMessage(message)) { return; } - if (await this.dispatchSharedCommand(message.command)) { return; } + if (await this.dispatchSharedCommand(message)) { return; } if (message.command === 'refresh') { await this.dispatch('refresh:chart', () => this.refreshChartPanel()); } @@ -4364,7 +4393,7 @@ usageAnalysis: undefined // Handle messages from the webview this.analysisPanel.webview.onDidReceiveMessage(async (message) => { if (this.handleLocalViewRegressionMessage(message)) { return; } - if (await this.dispatchSharedCommand(message.command)) { return; } + if (await this.dispatchSharedCommand(message)) { return; } switch (message.command) { case 'refresh': await this.dispatch('refresh:analysis', () => this.refreshAnalysisPanel()); @@ -4762,7 +4791,7 @@ Return ONLY the JSON object, no markdown formatting, no explanations.`; // Handle messages from the webview this.logViewerPanel.webview.onDidReceiveMessage(async (message) => { - if (await this.dispatchSharedCommand(message.command)) { return; } + if (await this.dispatchSharedCommand(message)) { return; } switch (message.command) { case 'openRawFile': await this.dispatch('openRawFile:logviewer', async () => { @@ -4990,6 +5019,7 @@ Return ONLY the JSON object, no markdown formatting, no explanations.`;
+ ${this.extensionPointButtonsScript(nonce)} `; @@ -5082,7 +5112,7 @@ Return ONLY the JSON object, no markdown formatting, no explanations.`; const fluencyLevels = isDebugMode ? this.getFluencyLevelData(isDebugMode).categories : undefined; this.maturityPanel.webview.onDidReceiveMessage(async (message) => { if (this.handleLocalViewRegressionMessage(message)) { return; } - if (await this.dispatchSharedCommand(message.command)) { return; } + if (await this.dispatchSharedCommand(message)) { return; } switch (message.command) { case 'refresh': await this.dispatch('refresh:maturity', () => this.refreshMaturityPanel()); @@ -5574,7 +5604,7 @@ ${hashtag}`; this.fluencyLevelViewerPanel.webview.onDidReceiveMessage( async (message) => { if (this.handleLocalViewRegressionMessage(message)) { return; } - if (await this.dispatchSharedCommand(message.command)) { return; } + if (await this.dispatchSharedCommand(message)) { return; } if (message.command === "refresh") { await this.dispatch('refresh:fluencyLevelViewer', () => this.refreshFluencyLevelViewerPanel()); } @@ -5607,6 +5637,8 @@ ${hashtag}`; this.log("✅ Scoring Guide refreshed"); } + + private getFluencyLevelData(isDebugMode: boolean): ReturnType { return _getFluencyLevelData(isDebugMode); } @@ -5666,6 +5698,7 @@ ${hashtag}`;
+ ${this.extensionPointButtonsScript(nonce)} ${this.getLocalViewRegressionProbeScript('fluency-level-viewer', nonce)} @@ -5734,6 +5767,7 @@ ${hashtag}`;
+ ${this.extensionPointButtonsScript(nonce)} ${this.getLocalViewRegressionProbeScript('maturity', nonce)} @@ -5789,7 +5823,7 @@ ${hashtag}`; ); this.dashboardPanel.webview.onDidReceiveMessage(async (message) => { - if (await this.dispatchSharedCommand(message.command)) { return; } + if (await this.dispatchSharedCommand(message)) { return; } switch (message.command) { case "refresh": await this.dispatch('refresh:dashboard', () => this.refreshDashboardPanel()); @@ -6505,6 +6539,7 @@ ${hashtag}`;
${initialDataScript} + ${this.extensionPointButtonsScript(nonce)} `; @@ -6575,6 +6610,7 @@ ${hashtag}`;
+ ${this.extensionPointButtonsScript(nonce)} ${this.getLocalViewRegressionProbeScript('details', nonce)} @@ -6888,7 +6924,7 @@ ${hashtag}`; // Handle messages from the webview this.diagnosticsPanel.webview.onDidReceiveMessage(async (message) => { if (this.handleLocalViewRegressionMessage(message)) { return; } - if (await this.dispatchSharedCommand(message.command)) { return; } + if (await this.dispatchSharedCommand(message)) { return; } switch (message.command) { case "copyReport": await this.dispatch('copyReport:diagnostics', async () => { @@ -7790,6 +7826,7 @@ ${hashtag}`;
+ ${this.extensionPointButtonsScript(nonce)} ${this.getLocalViewRegressionProbeScript('diagnostics', nonce)} @@ -8070,6 +8107,7 @@ ${hashtag}`;
+ ${this.extensionPointButtonsScript(nonce)} ${this.getLocalViewRegressionProbeScript('chart', nonce)} @@ -8139,6 +8177,7 @@ ${hashtag}`;
+ ${this.extensionPointButtonsScript(nonce)} ${this.getLocalViewRegressionProbeScript('usage', nonce)} @@ -8280,7 +8319,7 @@ async function checkForLegacyExtensionConflict(context: vscode.ExtensionContext) } } -export async function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext): Promise { // Create the token tracker const tokenTracker = new CopilotTokenTracker(context.extensionUri, context); @@ -8520,7 +8559,10 @@ export async function activate(context: vscode.ExtensionContext) { tokenTracker.log("Extension activation complete"); - return {}; + return { + registerButton: (button: ExtensionPointButton, handler: () => void | Promise) => + tokenTracker.registerExtensionPointButton(button, handler), + }; } export function deactivate() { diff --git a/vscode-extension/src/extensionPoints.ts b/vscode-extension/src/extensionPoints.ts new file mode 100644 index 00000000..f8c0cee4 --- /dev/null +++ b/vscode-extension/src/extensionPoints.ts @@ -0,0 +1,22 @@ +/** + * A button contribution registered via the extension points API. + */ +export interface ExtensionPointButton { + /** Stable unique identifier for this button. Used to route click events. */ + readonly id: string; + /** Display label shown in the button. */ + readonly label: string; +} + +/** + * Public API exported by the AI Engineering Fluency extension. + * Companion extensions can acquire this via `vscode.extensions.getExtension(...).exports`. + */ +export interface AiFluencyExtensionApi { + /** + * Register a button to appear in the navigation toolbar of all webview panels. + * The handler is called when the user clicks the button. + * Returns a Disposable; call dispose() to remove the button. + */ + registerButton(button: ExtensionPointButton, handler: () => void | Promise): { dispose(): void }; +} diff --git a/vscode-extension/src/webview/chart/main.ts b/vscode-extension/src/webview/chart/main.ts index 9247515a..ded9ed8a 100644 --- a/vscode-extension/src/webview/chart/main.ts +++ b/vscode-extension/src/webview/chart/main.ts @@ -2,6 +2,7 @@ import { el, createButton } from '../shared/domUtils'; import { BUTTONS } from '../shared/buttonConfig'; import { formatCompact, setCompactNumbers } from '../shared/formatUtils'; +import { wireExtensionPointButtons } from '../shared/extensionPoints'; // CSS imported as text via esbuild import themeStyles from '../shared/theme.css'; import styles from './styles.css'; @@ -322,6 +323,8 @@ function wireInteractions(data: InitialChartData): void { const environmental = document.getElementById('btn-environmental'); environmental?.addEventListener('click', () => vscode.postMessage({ command: 'showEnvironmental' })); + wireExtensionPointButtons(vscode); + // Period toggle buttons const periodButtons: Array<{ id: string; period: ChartPeriod }> = [ { id: 'period-day', period: 'day' }, diff --git a/vscode-extension/src/webview/dashboard/main.ts b/vscode-extension/src/webview/dashboard/main.ts index c79eece3..14c9ca11 100644 --- a/vscode-extension/src/webview/dashboard/main.ts +++ b/vscode-extension/src/webview/dashboard/main.ts @@ -3,6 +3,7 @@ import { BUTTONS } from "../shared/buttonConfig"; import { createButton, el } from "../shared/domUtils"; import { formatCost, formatNumber, formatCompact, setCompactNumbers } from "../shared/formatUtils"; import { getModelDisplayName } from "../shared/modelUtils"; +import { wireExtensionPointButtons } from "../shared/extensionPoints"; import themeStyles from "../shared/theme.css"; import styles from "./styles.css"; @@ -612,6 +613,7 @@ function wireButtons(): void { }); // Note: No dashboard button handler - users are already on the dashboard + wireExtensionPointButtons(vscode); } // Listen for messages from the extension diff --git a/vscode-extension/src/webview/details/main.ts b/vscode-extension/src/webview/details/main.ts index f443a06e..f709fa14 100644 --- a/vscode-extension/src/webview/details/main.ts +++ b/vscode-extension/src/webview/details/main.ts @@ -3,6 +3,7 @@ import { getModelDisplayName } from '../shared/modelUtils'; import { getEditorIcon, getCharsPerToken, formatFixed, formatPercent, formatNumber, formatCost, formatCompact, setCompactNumbers } from '../shared/formatUtils'; import { el, createButton } from '../shared/domUtils'; import { BUTTONS } from '../shared/buttonConfig'; +import { wireExtensionPointButtons } from '../shared/extensionPoints'; // Token estimators loaded from JSON // @ts-ignore import tokenEstimatorsJson from '../../tokenEstimators.json'; @@ -839,6 +840,8 @@ function wireButtons(): void { const environmental = document.getElementById('btn-environmental'); environmental?.addEventListener('click', () => vscode.postMessage({ command: 'showEnvironmental' })); + + wireExtensionPointButtons(vscode); } async function bootstrap(): Promise { diff --git a/vscode-extension/src/webview/diagnostics/main.ts b/vscode-extension/src/webview/diagnostics/main.ts index 08a45371..08ebd7a9 100644 --- a/vscode-extension/src/webview/diagnostics/main.ts +++ b/vscode-extension/src/webview/diagnostics/main.ts @@ -1,5 +1,6 @@ // Diagnostics Report webview with tabbed interface import { buttonHtml } from "../shared/buttonConfig"; +import { wireExtensionPointButtons } from "../shared/extensionPoints"; // CSS imported as text via esbuild import themeStyles from "../shared/theme.css"; import styles from "./styles.css"; @@ -2331,6 +2332,7 @@ function renderLayout(data: DiagnosticsData): void { ?.addEventListener("click", () => vscode.postMessage({ command: "showEnvironmental" }), ); + wireExtensionPointButtons(vscode); setupSortHandlers(); setupEditorFilterHandlers(); diff --git a/vscode-extension/src/webview/environmental/main.ts b/vscode-extension/src/webview/environmental/main.ts index e009007f..efd50485 100644 --- a/vscode-extension/src/webview/environmental/main.ts +++ b/vscode-extension/src/webview/environmental/main.ts @@ -2,6 +2,7 @@ import { el, createButton } from '../shared/domUtils'; import { BUTTONS } from '../shared/buttonConfig'; import { formatFixed, formatNumber, formatCompact, setCompactNumbers } from '../shared/formatUtils'; +import { wireExtensionPointButtons } from '../shared/extensionPoints'; // CSS imported as text via esbuild import themeStyles from '../shared/theme.css'; import styles from './styles.css'; @@ -298,6 +299,7 @@ function wireButtons(): void { document.getElementById('btn-diagnostics')?.addEventListener('click', () => vscode.postMessage({ command: 'showDiagnostics' })); document.getElementById('btn-maturity')?.addEventListener('click', () => vscode.postMessage({ command: 'showMaturity' })); document.getElementById('btn-dashboard')?.addEventListener('click', () => vscode.postMessage({ command: 'showDashboard' })); + wireExtensionPointButtons(vscode); } window.addEventListener('message', (event: MessageEvent) => { diff --git a/vscode-extension/src/webview/fluency-level-viewer/main.ts b/vscode-extension/src/webview/fluency-level-viewer/main.ts index 3b32e49a..8d483fc9 100644 --- a/vscode-extension/src/webview/fluency-level-viewer/main.ts +++ b/vscode-extension/src/webview/fluency-level-viewer/main.ts @@ -1,5 +1,6 @@ // Fluency Level Viewer webview import { buttonHtml } from '../shared/buttonConfig'; +import { wireExtensionPointButtons } from '../shared/extensionPoints'; import styles from './styles.css'; // ── Types ────────────────────────────────────────────────────────────── @@ -203,6 +204,7 @@ function renderLayout(data: FluencyLevelData): void { document.getElementById('btn-dashboard')?.addEventListener('click', () => { vscode.postMessage({ command: 'showDashboard' }); }); + wireExtensionPointButtons(vscode); // Wire up category selection buttons document.querySelectorAll('.category-btn').forEach(btn => { diff --git a/vscode-extension/src/webview/maturity/main.ts b/vscode-extension/src/webview/maturity/main.ts index 189b1e76..1b5ac7b0 100644 --- a/vscode-extension/src/webview/maturity/main.ts +++ b/vscode-extension/src/webview/maturity/main.ts @@ -1,6 +1,7 @@ // Maturity Score webview import { buttonHtml } from '../shared/buttonConfig'; import type { ContextReferenceUsage } from '../shared/contextRefUtils'; +import { wireExtensionPointButtons } from '../shared/extensionPoints'; import themeStyles from '../shared/theme.css'; import styles from './styles.css'; @@ -566,6 +567,7 @@ function renderLayout(data: MaturityData): void { document.getElementById('btn-environmental')?.addEventListener('click', () => { vscode.postMessage({ command: 'showEnvironmental' }); }); + wireExtensionPointButtons(vscode); // Wire up share to issue button document.getElementById('btn-share-issue')?.addEventListener('click', () => { diff --git a/vscode-extension/src/webview/shared/extensionPoints.ts b/vscode-extension/src/webview/shared/extensionPoints.ts new file mode 100644 index 00000000..7e644b2e --- /dev/null +++ b/vscode-extension/src/webview/shared/extensionPoints.ts @@ -0,0 +1,36 @@ +interface ExtensionPointButtonData { + id: string; + label: string; +} + +declare global { + interface Window { + __EXTENSION_POINT_BUTTONS__?: ExtensionPointButtonData[]; + } +} + +/** + * Appends any extension-point buttons to the `.button-row` element and wires + * their click handlers to post `extensionPointAction` messages back to the host. + * + * Call this after every render that rebuilds the button row. + */ +export function wireExtensionPointButtons( + vscodeApi: { postMessage: (message: unknown) => void }, +): void { + const buttons = window.__EXTENSION_POINT_BUTTONS__ ?? []; + if (buttons.length === 0) { return; } + + const buttonRow = document.querySelector('.button-row'); + if (!buttonRow) { return; } + + for (const btn of buttons) { + const el = document.createElement('vscode-button'); + el.id = `ext-point-${btn.id}`; + el.textContent = btn.label; + el.addEventListener('click', () => { + vscodeApi.postMessage({ command: 'extensionPointAction', buttonId: btn.id }); + }); + buttonRow.append(el); + } +} diff --git a/vscode-extension/src/webview/usage/main.ts b/vscode-extension/src/webview/usage/main.ts index c423961e..6712efbb 100644 --- a/vscode-extension/src/webview/usage/main.ts +++ b/vscode-extension/src/webview/usage/main.ts @@ -3,6 +3,7 @@ import { el } from '../shared/domUtils'; import { buttonHtml } from '../shared/buttonConfig'; import { ContextReferenceUsage, getTotalContextRefs } from '../shared/contextRefUtils'; import { formatFixed, formatNumber, formatPercent, setFormatLocale } from '../shared/formatUtils'; +import { wireExtensionPointButtons } from '../shared/extensionPoints'; // CSS imported as text via esbuild import themeStyles from '../shared/theme.css'; import styles from './styles.css'; @@ -1427,6 +1428,7 @@ function renderLayout(stats: UsageAnalysisStats): void { document.getElementById('btn-environmental')?.addEventListener('click', () => { vscode.postMessage({ command: 'showEnvironmental' }); }); + wireExtensionPointButtons(vscode); // Repository analysis buttons document.getElementById('btn-analyse-repo')?.addEventListener('click', () => {