From 389f9aea86f4d02cc7c3f85135b7895f5da35039 Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Wed, 3 Jun 2026 13:10:10 +0200 Subject: [PATCH 1/2] feat(marketplace): extract marketplace into standalone webview panel --- packages/kilo-vscode/esbuild.js | 6 + packages/kilo-vscode/knip.json | 1 + packages/kilo-vscode/src/KiloProvider.ts | 223 ++------------ .../src/MarketplacePanelProvider.ts | 290 ++++++++++++++++++ .../kilo-vscode/src/SettingsEditorProvider.ts | 9 +- packages/kilo-vscode/src/extension.ts | 23 +- .../src/kilo-provider/remove-config-item.ts | 39 +++ .../src/services/marketplace/actions.ts | 163 ++++++++++ .../src/services/marketplace/installer.ts | 16 +- .../src/services/marketplace/types.ts | 1 + .../tests/unit/font-size-arch.test.ts | 2 + .../tests/unit/marketplace-actions.test.ts | 110 +++++++ .../tests/unit/marketplace-panel-arch.test.ts | 36 +++ .../tests/unit/project-directory.test.ts | 22 ++ .../tests/unit/remove-config-item.test.ts | 55 ++++ .../agent-manager/AgentManagerApp.tsx | 3 +- .../webview-ui/marketplace/MarketplaceApp.tsx | 29 ++ .../webview-ui/marketplace/index.tsx | 7 + packages/kilo-vscode/webview-ui/src/App.tsx | 26 +- .../components/marketplace/InstallModal.tsx | 4 +- .../src/context/language-bridge.tsx | 12 + .../src/context/marketplace-session.tsx | 41 +++ packages/kilo-vscode/webview-ui/tsconfig.json | 9 +- 23 files changed, 882 insertions(+), 245 deletions(-) create mode 100644 packages/kilo-vscode/src/MarketplacePanelProvider.ts create mode 100644 packages/kilo-vscode/src/kilo-provider/remove-config-item.ts create mode 100644 packages/kilo-vscode/src/services/marketplace/actions.ts create mode 100644 packages/kilo-vscode/tests/unit/marketplace-actions.test.ts create mode 100644 packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts create mode 100644 packages/kilo-vscode/tests/unit/project-directory.test.ts create mode 100644 packages/kilo-vscode/tests/unit/remove-config-item.test.ts create mode 100644 packages/kilo-vscode/webview-ui/marketplace/MarketplaceApp.tsx create mode 100644 packages/kilo-vscode/webview-ui/marketplace/index.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/context/language-bridge.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/context/marketplace-session.tsx diff --git a/packages/kilo-vscode/esbuild.js b/packages/kilo-vscode/esbuild.js index a82df0e7d23..0badbfebc49 100644 --- a/packages/kilo-vscode/esbuild.js +++ b/packages/kilo-vscode/esbuild.js @@ -209,6 +209,9 @@ async function main() { // Build KiloClaw webview (SolidJS, standalone chat panel) const kiloClawCtx = await createBrowserWebviewContext("webview-ui/kiloclaw/index.tsx", "dist/kiloclaw.js") + // Build Marketplace webview (SolidJS, standalone catalog panel) + const marketplaceCtx = await createBrowserWebviewContext("webview-ui/marketplace/index.tsx", "dist/marketplace.js") + // Build Diff Viewer webview (SolidJS, reuses Agent Manager diff components) const diffViewerCtx = await createBrowserWebviewContext("webview-ui/diff-viewer/index.tsx", "dist/diff-viewer.js") @@ -229,6 +232,7 @@ async function main() { diffViewerCtx.watch(), diffVirtualCtx.watch(), kiloClawCtx.watch(), + marketplaceCtx.watch(), shikiWorkerCtx.watch(), ]) } else { @@ -237,6 +241,7 @@ async function main() { webviewCtx.rebuild(), agentManagerCtx.rebuild(), kiloClawCtx.rebuild(), + marketplaceCtx.rebuild(), diffViewerCtx.rebuild(), diffVirtualCtx.rebuild(), shikiWorkerCtx.rebuild(), @@ -248,6 +253,7 @@ async function main() { diffViewerCtx.dispose(), diffVirtualCtx.dispose(), kiloClawCtx.dispose(), + marketplaceCtx.dispose(), shikiWorkerCtx.dispose(), ]) } diff --git a/packages/kilo-vscode/knip.json b/packages/kilo-vscode/knip.json index 98c83d5a1f0..78b5a311058 100644 --- a/packages/kilo-vscode/knip.json +++ b/packages/kilo-vscode/knip.json @@ -6,6 +6,7 @@ "webview-ui/diff-viewer/index.tsx", "webview-ui/diff-virtual/index.tsx", "webview-ui/kiloclaw/index.tsx", + "webview-ui/marketplace/index.tsx", "webview-ui/pierre-worker.ts", "webview-ui/src/index.tsx", "src/**/__tests__/**/*.{ts,spec.ts}", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index b75fb35412f..58b3d11b3ee 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -47,12 +47,7 @@ import { GitOps } from "./agent-manager/GitOps" import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller" import { diffSummary as localDiffSummary } from "./agent-manager/local-diff" import { getWorkspaceRoot } from "./review-utils" -import { - MarketplaceService, - type MarketplaceItem, - type AgentMarketplaceItem, - type RemoveResult, -} from "./services/marketplace" +import { createMarketplaceRemover, removeAgent, removeMcp } from "./kilo-provider/remove-config-item" import type { RemoteStatusService } from "./services/RemoteStatusService" import { resolveProjectDirectory } from "./project-directory" import { getBusySessionCount, seedSessionStatuses } from "./session-status" @@ -236,10 +231,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null private autoApproveBridge: ReturnType | null = null + private readonly marketplaceRemove = createMarketplaceRemover() private ignoreController: FileIgnoreController | null = null private ignoreControllerDir: string | null = null - private marketplace: MarketplaceService | null = null private chatAutocomplete: ChatTextAreaAutocomplete | null = null private projectDirectory: string | null | undefined private slimEditMetadata = true @@ -384,6 +379,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private get removeConfigItemCtx() { + return { + connection: this.connectionService, + project: () => this.getProjectDirectory(this.currentSession?.id), + directory: () => this.getWorkspaceDirectory(), + remove: this.marketplaceRemove, + refresh: async () => { + this.cachedAgentsMessage = null + this.cachedConfigMessage = null + await Promise.all([this.fetchAndSendAgents(), this.fetchAndSendConfig()]) + }, + storage: this.extensionContext?.globalStorageUri, + } + } + private async syncWebviewState(reason: string): Promise { const serverInfo = this.connectionService.getServerInfo() console.log("[Kilo New] KiloProvider: 🔄 syncWebviewState()", { @@ -1105,44 +1115,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) break } - case "fetchMarketplaceData": { - await this.handleFetchMarketplaceData() - break - } - case "filterMarketplaceItems": { - // Client-side filtering — no server action needed - break - } - case "installMarketplaceItem": { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const scope = message.mpInstallOptions?.target ?? "project" - const result = await this.getMarketplace().install(message.mpItem, message.mpInstallOptions, workspace) - if (result.success) { - await this.invalidateAfterMarketplaceChange(scope) - } - this.postMessage({ - type: "marketplaceInstallResult", - success: result.success, - slug: result.slug, - error: result.error, - }) - break - } - case "removeInstalledMarketplaceItem": { - const scope = message.mpInstallOptions?.target ?? "project" - const result = await this.removeMarketplaceItem(message.mpItem, scope) - this.postMessage({ - type: "marketplaceRemoveResult", - success: result.success, - slug: result.slug, - error: result.error, - }) - break - } - case "dismissAgentMigrationBanner": { - await this.extensionContext?.globalState.update("kilo.agentMigrationBannerDismissed", true) - break - } } }) this.webviewMessageDisposable = watchFontSizeConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable) @@ -1156,15 +1128,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } - private async handleFetchMarketplaceData(): Promise { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const mp = this.getMarketplace() - const skills = await this.fetchCliSkills() - const data = await mp.fetchData(workspace, skills) - const dismissed = this.extensionContext?.globalState.get("kilo.agentMigrationBannerDismissed") ?? false - this.postMessage({ type: "marketplaceData", ...data, showAgentMigrationBanner: !dismissed }) - } - /** * Initialize connection to the CLI backend server. * Subscribes to the shared KiloConnectionService. @@ -1914,18 +1877,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - private async fetchCliSkills(): Promise | undefined> { - if (!this.client) return undefined - try { - const dir = this.getWorkspaceDirectory() - const { data } = await retry(() => this.client!.app.skills({ directory: dir }, { throwOnError: true })) - return data - } catch (error) { - console.error("[Kilo New] KiloProvider: Failed to fetch CLI skills for marketplace:", error) - return undefined - } - } - /** * Remove a skill via the CLI backend (deletes from disk + clears cache), then refresh. * Returns true on success, false on failure. @@ -1969,77 +1920,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } catch { // fall through to kilo.json removal } - const stub: AgentMarketplaceItem = { - id: name, - type: "agent", - name, - description: "", - content: { mode: "primary", description: "", prompt: "" }, - } - if (!(await this.removeMarketplaceItemFromAllScopes(stub))) { + if (!(await removeAgent(this.removeConfigItemCtx, name))) { console.error("[Kilo New] KiloProvider: Failed to remove agent:", name) } } private async handleRemoveMcp(name: string): Promise { - // Remove from legacy files first so that the subsequent invalidation - // causes the CLI to re-read config without the legacy entry. - await this.removeLegacyMcp(name) - - const stub = { id: name, type: "mcp" as const, name, description: "", url: "", content: "" } - const removed = await this.removeMarketplaceItemFromAllScopes(stub) + const removed = await removeMcp(this.removeConfigItemCtx, name) if (!removed) { console.error("[Kilo New] KiloProvider: Failed to remove MCP server:", name) } } - /** - * Remove an MCP server from legacy config files (.kilo/mcp.json, .kilocode/mcp.json, - * and the VS Code global storage mcp_settings.json). These files are read by the - * CLI-side McpMigrator and merged into config at the lowest precedence level. - * Returns true if the entry was found and removed from at least one file. - */ - private async removeLegacyMcp(name: string): Promise { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const files: vscode.Uri[] = [] - - // Project-level legacy files - if (workspace) { - files.push(vscode.Uri.file(path.join(workspace, ".kilo", "mcp.json"))) - files.push(vscode.Uri.file(path.join(workspace, ".kilocode", "mcp.json"))) - } - - // Global legacy file (VS Code extension global storage) - const storage = this.extensionContext?.globalStorageUri - if (storage) { - files.push(vscode.Uri.joinPath(storage, "settings", "mcp_settings.json")) - } - - let removed = false - for (const uri of files) { - const bytes = await vscode.workspace.fs.readFile(uri).then( - (b) => b, - () => null, - ) - if (!bytes) continue - - try { - const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as Record - const servers = parsed.mcpServers as Record | undefined - if (!servers?.[name]) continue - - delete servers[name] - const content = Buffer.from(JSON.stringify(parsed, null, 2), "utf8") - await vscode.workspace.fs.writeFile(uri, content) - removed = true - } catch (err) { - console.warn("[Kilo New] KiloProvider: Failed to remove legacy MCP from", uri.fsPath, err) - } - } - - return removed - } - private async fetchAndSendMcpStatus(): Promise { if (!this.client) { if (this.cachedMcpStatusMessage) { @@ -2061,75 +1953,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - /** - * Remove a marketplace item from a single scope and invalidate CLI caches. - */ - private async removeMarketplaceItem(item: MarketplaceItem, scope: "project" | "global"): Promise { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const result = await this.getMarketplace().remove(item, scope, workspace) - if (result.success) { - await this.invalidateAfterMarketplaceChange(scope) - } - return result - } - - /** - * Remove a marketplace item from both project and global scopes. - * mp.remove returns success even when the entry doesn't exist (no-op), - * so we must attempt both scopes to cover dual-scope installations. - * Returns true if at least one scope removal succeeded. - */ - private async removeMarketplaceItemFromAllScopes(item: MarketplaceItem): Promise { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const mp = this.getMarketplace() - const project = await mp.remove(item, "project", workspace) - const global = await mp.remove(item, "global", workspace) - - if (project.success || global.success) { - const scope = global.success ? "global" : "project" - await this.invalidateAfterMarketplaceChange(scope) - return true - } - return false - } - - /** - * Invalidate CLI caches and refresh the webview after a marketplace install/remove. - * - * For global scope: uses global.config.update with the freshly-written config file - * contents rather than global.dispose. This goes through Config.updateGlobal() which - * calls Config.global.reset() to invalidate the lazy-cached global config, ensuring - * the newly installed/removed MCP entry is visible on the next config.get call. - * (global.dispose alone is not sufficient on older CLI versions that lack the - * Config.global.reset() call in the dispose handler.) - * - * For project scope: instance.dispose is sufficient because the per-instance - * Config.state is cleared and re-reads all files (including global) on next access. - */ - private async invalidateAfterMarketplaceChange(scope: "project" | "global"): Promise { - if (!this.client) return - if (scope === "global") { - // Use global.config.update with an empty config to trigger Config.updateGlobal() - // which calls Config.global.reset(). This invalidates the lazy-cached global - // config in the CLI process so it re-reads kilo.json from disk. - // An empty object merge is a no-op for the file content but resets the cache. - // (global.dispose alone is insufficient on older CLI versions that lack - // the Config.global.reset() call in the dispose handler.) - await this.client.global.config.update({ config: {} }).catch((e: unknown) => { - console.warn("[Kilo New] global.config.update after marketplace change failed:", e) - }) - } - // Always dispose the per-project instance so it rebuilds state from - // the (possibly updated) global + project config on the next request. - const dir = this.getWorkspaceDirectory() - await this.client.instance.dispose({ directory: dir }).catch((e: unknown) => { - console.warn("[Kilo New] instance.dispose() after marketplace change failed:", e) - }) - this.cachedAgentsMessage = null - this.cachedConfigMessage = null - await Promise.all([this.fetchAndSendAgents(), this.fetchAndSendConfig()]) - } - /** * Fetch backend config and send to webview. */ @@ -3432,12 +3255,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // legacy-migration end --------------------------------------------------------- - private getMarketplace(): MarketplaceService { - if (this.marketplace) return this.marketplace - this.marketplace = new MarketplaceService() - return this.marketplace - } - // ── Worktree stats polling (sidebar diff badge) ────────────────── private startStatsPolling(): void { this.statsPoller?.stop() @@ -3503,6 +3320,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.sessionStatusMap.clear() this.ignoreController?.dispose() this.chatAutocomplete?.dispose() - ;(this.marketplace?.dispose(), disposeGitChangesTarget()) + disposeGitChangesTarget() } } diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts new file mode 100644 index 00000000000..b0d17807270 --- /dev/null +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -0,0 +1,290 @@ +import * as os from "os" +import * as vscode from "vscode" +import type { Event, SessionStatus } from "@kilocode/sdk/v2/client" +import { buildWebviewHtml, getWebviewFontSize } from "./utils" +import { watchFontSizeConfig } from "./kilo-provider/font-size" +import { mapSSEEventToWebviewMessage } from "./kilo-provider-utils" +import { resolvePanelProjectDirectory } from "./project-directory" +import { seedSessionStatuses } from "./session-status" +import type { KiloConnectionService } from "./services/cli-backend" +import { MarketplaceService } from "./services/marketplace" +import { + fetchMarketplaceData, + installMarketplaceItem, + removeMarketplaceItem, + type MarketplaceActionContext, +} from "./services/marketplace/actions" +import type { InstallMarketplaceItemOptions, MarketplaceItem } from "./services/marketplace/types" +import { TelemetryProxy } from "./services/telemetry" +import { TelemetryEventName } from "./services/telemetry/types" + +interface MarketplaceMessage { + type?: string + mpItem?: MarketplaceItem + mpInstallOptions?: InstallMarketplaceItemOptions + url?: unknown + event?: string + properties?: Record +} + +export class MarketplacePanelProvider implements vscode.Disposable { + public static readonly viewType = "kilo-code.new.marketplacePanel" + + private panel: vscode.WebviewPanel | undefined + private project: string | null = null + private ready = false + private statuses = new Map() + private disposables: vscode.Disposable[] = [] + private subscriptions: Array<() => void> = [] + private readonly marketplace = new MarketplaceService() + private readonly extensionVersion = + vscode.extensions.getExtension("kilocode.kilo-code")?.packageJSON?.version ?? "unknown" + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly connection: KiloConnectionService, + private readonly context: vscode.ExtensionContext, + ) {} + + private get marketplaceCtx(): MarketplaceActionContext { + return { connection: this.connection, marketplace: this.marketplace, storage: this.context.globalStorageUri } + } + + /** + * `undefined` infers the project from the active editor or workspace, + * while `null` intentionally disables project-scoped operations when no directory can be + * selected safely, such as in an ambiguous multi-root workspace. + */ + openPanel(directory?: string | null): void { + const project = directory === undefined ? this.resolveProject() : directory + if (this.panel) { + this.setProjectDirectory(project) + this.panel.reveal(vscode.ViewColumn.One) + return + } + + const panel = vscode.window.createWebviewPanel( + MarketplacePanelProvider.viewType, + "Kilo Marketplace", + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }, + ) + this.attach(panel, project) + } + + deserializePanel(panel: vscode.WebviewPanel): void { + this.attach(panel, this.resolveProject()) + } + + dispose(): void { + this.panel?.dispose() + this.cleanup() + this.marketplace.dispose() + } + + private attach(panel: vscode.WebviewPanel, project: string | null): void { + this.cleanup() + this.panel = panel + this.project = project + this.ready = false + panel.iconPath = { + light: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-light.svg"), + dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"), + } + panel.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + } + panel.webview.html = this.getHtml(panel.webview) + + this.disposables.push( + panel.webview.onDidReceiveMessage((msg) => void this.handle(msg as MarketplaceMessage)), + panel.onDidDispose(() => this.cleanup()), + watchFontSizeConfig((msg) => this.post(msg)), + ) + this.subscriptions.push( + this.connection.onStateChange((state, err) => { + this.post({ type: "connectionState", state, ...(err ? { error: err.message } : {}) }) + if (state === "connected") void this.sync(false) + }), + this.connection.onLanguageChanged((locale) => this.post({ type: "languageChanged", locale })), + this.connection.onEventFiltered( + (event) => event.type === "session.status", + (event) => this.handleStatus(event), + ), + ) + void this.connect() + } + + private cleanup(): void { + for (const disposable of this.disposables) disposable.dispose() + for (const unsubscribe of this.subscriptions) unsubscribe() + this.disposables = [] + this.subscriptions = [] + this.panel = undefined + this.ready = false + this.statuses.clear() + } + + private async connect(): Promise { + try { + await this.connection.connect(this.directory()) + await this.sync(this.statuses.size === 0) + } catch (err) { + this.post({ type: "connectionState", state: "error", error: err instanceof Error ? err.message : String(err) }) + } + } + + private async sync(reconcile: boolean): Promise { + if (!this.ready) return + const info = this.connection.getServerInfo() + if (info) { + const cfg = vscode.workspace.getConfiguration("kilo-code.new") + this.post({ + type: "ready", + serverInfo: info, + extensionVersion: this.extensionVersion, + vscodeLanguage: vscode.env.language, + languageOverride: cfg.get("language"), + fontSize: getWebviewFontSize(), + workspaceDirectory: this.project ?? "", + }) + } + this.post({ type: "connectionState", state: this.connection.getConnectionState() }) + + try { + const client = this.connection.getClient() + await seedSessionStatuses(client, this.directory(), this.statuses, (msg) => this.post(msg), reconcile) + } catch { + // Connection state above is sufficient while the shared client reconnects. + } + } + + private async handle(msg: MarketplaceMessage): Promise { + switch (msg.type) { + case "webviewReady": + this.ready = true + if (this.connection.getConnectionState() === "connected") await this.sync(true) + else await this.connect() + await this.fetchData() + return + case "retryConnection": + await this.connect() + return + case "fetchMarketplaceData": + await this.fetchData() + return + case "installMarketplaceItem": + if (msg.mpItem && msg.mpInstallOptions) await this.install(msg.mpItem, msg.mpInstallOptions) + return + case "removeInstalledMarketplaceItem": + if (msg.mpItem) await this.remove(msg.mpItem, msg.mpInstallOptions?.target ?? "project") + return + case "dismissAgentMigrationBanner": + await this.context.globalState.update("kilo.agentMigrationBannerDismissed", true) + return + case "openExternal": + this.openExternal(msg.url) + return + case "telemetry": + if (msg.event) TelemetryProxy.capture(msg.event as TelemetryEventName, msg.properties) + return + } + } + + private async fetchData(): Promise { + try { + const project = this.project ?? undefined + const data = await fetchMarketplaceData(this.marketplaceCtx, project, project) + const dismissed = this.context.globalState.get("kilo.agentMigrationBannerDismissed") ?? false + this.post({ type: "marketplaceData", ...data, showAgentMigrationBanner: !dismissed }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + console.warn("[Kilo New] Marketplace data fetch failed:", err) + this.post({ + type: "marketplaceData", + marketplaceItems: [], + marketplaceInstalledMetadata: { project: {}, global: {} }, + errors: [error], + }) + } + } + + private async install(item: MarketplaceItem, opts: InstallMarketplaceItemOptions): Promise { + const result = await installMarketplaceItem( + this.marketplaceCtx, + item, + opts, + this.project ?? undefined, + this.directory(), + ) + this.post({ type: "marketplaceInstallResult", ...result }) + } + + private async remove(item: MarketplaceItem, scope: "project" | "global"): Promise { + const result = await removeMarketplaceItem( + this.marketplaceCtx, + item, + scope, + this.project ?? undefined, + this.directory(), + ) + this.post({ type: "marketplaceRemoveResult", ...result }) + } + + private handleStatus(event: Event): void { + if (event.type !== "session.status") return + const sid = event.properties.sessionID + this.statuses.set(sid, event.properties.status.type) + const msg = mapSSEEventToWebviewMessage(event, sid) + if (msg) this.post(msg) + } + + private setProjectDirectory(project: string | null): void { + if (this.project === project) return + this.project = project + this.post({ type: "workspaceDirectoryChanged", directory: project ?? "" }) + } + + private resolveProject(): string | null { + const editor = vscode.window.activeTextEditor + const active = + editor?.document.uri.scheme === "file" + ? vscode.workspace.getWorkspaceFolder(editor.document.uri)?.uri.fsPath + : undefined + return resolvePanelProjectDirectory(active, vscode.workspace.workspaceFolders) + } + + private directory(): string { + return this.project ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir() + } + + private openExternal(raw: unknown): void { + if (typeof raw !== "string") return + const uri = vscode.Uri.parse(raw) + if (uri.scheme !== "http" && uri.scheme !== "https") return + void vscode.env.openExternal(uri) + } + + private post(msg: unknown): void { + if (!this.panel || !this.ready) return + void this.panel.webview.postMessage(msg).then(undefined, (err) => { + console.warn("[Kilo New] Marketplace panel postMessage failed:", err) + }) + } + + private getHtml(webview: vscode.Webview): string { + return buildWebviewHtml(webview, { + scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "marketplace.js")), + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "marketplace.css")), + iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), + workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")), + title: "Kilo Marketplace", + port: this.connection.getServerInfo()?.port, + }) + } +} diff --git a/packages/kilo-vscode/src/SettingsEditorProvider.ts b/packages/kilo-vscode/src/SettingsEditorProvider.ts index a03637d0110..94d2536dfd2 100644 --- a/packages/kilo-vscode/src/SettingsEditorProvider.ts +++ b/packages/kilo-vscode/src/SettingsEditorProvider.ts @@ -4,17 +4,16 @@ import { resolvePanelProjectDirectory } from "./project-directory" import type { KiloConnectionService } from "./services/cli-backend" import type { RemoteStatusService } from "./services/RemoteStatusService" -type PanelView = "settings" | "profile" | "marketplace" | "indexing" +type PanelView = "settings" | "profile" | "indexing" const PANEL_TITLES: Record = { settings: "Kilo Settings", profile: "Kilo Profile", - marketplace: "Kilo Marketplace", indexing: "Codebase Indexing", } /** - * Opens Settings, Profile, or Marketplace as an editor-area WebviewPanel, + * Opens Settings or Profile as an editor-area WebviewPanel, * keeping the sidebar chat undisturbed. * * Each view type is a singleton panel — calling openPanel() again @@ -54,10 +53,10 @@ export class SettingsEditorProvider implements vscode.Disposable { return view } - openPanel(view: PanelView, tab?: string, directory?: string | null): void { + openPanel(view: PanelView, tab?: string): void { if (tab) this.tabs.set(view, tab) - const projectDirectory = directory ?? this.getProjectDirectory() + const projectDirectory = this.getProjectDirectory() const existing = this.panels.get(view) if (existing) { this.providers.get(view)?.setProjectDirectory(projectDirectory) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index e45d49bbe33..63e75d9e3d0 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -7,6 +7,7 @@ import { DiffViewerProvider } from "./diff/DiffViewerProvider" import { DiffSourceCatalog } from "./diff/sources/catalog" import { DiffVirtualProvider } from "./DiffVirtualProvider" import { SettingsEditorProvider } from "./SettingsEditorProvider" +import { MarketplacePanelProvider } from "./MarketplacePanelProvider" import { SubAgentViewerProvider } from "./SubAgentViewerProvider" import { EXTENSION_DISPLAY_NAME } from "./constants" import { KiloConnectionService } from "./services/cli-backend" @@ -263,17 +264,18 @@ export function activate(context: vscode.ExtensionContext) { agentManagerHost.setDiffVirtualProvider(diffVirtualProvider) context.subscriptions.push(diffVirtualProvider) - // Create settings/profile editor provider (opens in editor area, not sidebar) + // Create standalone editor providers (open in editor area, not sidebar) const settingsEditorProvider = new SettingsEditorProvider(context.extensionUri, connectionService, context) settingsEditorProvider.setRemoteService(remoteService) - context.subscriptions.push(settingsEditorProvider) + const marketplacePanelProvider = new MarketplacePanelProvider(context.extensionUri, connectionService, context) + context.subscriptions.push(settingsEditorProvider, marketplacePanelProvider) // Create sub-agent viewer provider (read-only editor panel for sub-agent sessions) const subAgentViewerProvider = new SubAgentViewerProvider(context.extensionUri, connectionService, context) context.subscriptions.push(subAgentViewerProvider) - // Register serializers so settings/diff/sub-agent panels restore on restart - const settingsViews = ["settingsPanel", "profilePanel", "marketplacePanel"] as const + // Register serializers so standalone panels restore on restart + const settingsViews = ["settingsPanel", "profilePanel"] as const for (const suffix of settingsViews) { context.subscriptions.push( vscode.window.registerWebviewPanelSerializer(`kilo-code.new.${suffix}`, { @@ -285,6 +287,15 @@ export function activate(context: vscode.ExtensionContext) { ) } + context.subscriptions.push( + vscode.window.registerWebviewPanelSerializer(MarketplacePanelProvider.viewType, { + deserializeWebviewPanel(panel: vscode.WebviewPanel) { + marketplacePanelProvider.deserializePanel(panel) + return Promise.resolve() + }, + }), + ) + context.subscriptions.push( vscode.window.registerWebviewPanelSerializer(DiffViewerProvider.viewType, { deserializeWebviewPanel(panel: vscode.WebviewPanel) { @@ -346,8 +357,8 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.agentManagerOpen", () => { agentManagerProvider.openPanel() }), - vscode.commands.registerCommand("kilo-code.new.marketplaceButtonClicked", (directory?: string) => { - settingsEditorProvider.openPanel("marketplace", undefined, directory) + vscode.commands.registerCommand("kilo-code.new.marketplaceButtonClicked", (directory?: string | null) => { + marketplacePanelProvider.openPanel(directory) }), vscode.commands.registerCommand("kilo-code.new.kiloClawOpen", () => { kiloClawProvider.openPanel() diff --git a/packages/kilo-vscode/src/kilo-provider/remove-config-item.ts b/packages/kilo-vscode/src/kilo-provider/remove-config-item.ts new file mode 100644 index 00000000000..60a18ab0ffc --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/remove-config-item.ts @@ -0,0 +1,39 @@ +import type * as vscode from "vscode" +import type { KiloConnectionService } from "../services/cli-backend" +import { removeMarketplaceItemFromAllScopes, type MarketplaceRemoveContext } from "../services/marketplace/actions" +import { MarketplaceInstaller } from "../services/marketplace/installer" +import { MarketplacePaths } from "../services/marketplace/paths" +import type { MarketplaceItemRef } from "../services/marketplace/types" + +export interface RemoveConfigItemContext { + connection: KiloConnectionService + project: () => string | undefined + directory: () => string + refresh: () => Promise + remove: MarketplaceRemoveContext["remove"] + storage?: vscode.Uri +} + +export function createMarketplaceRemover(): MarketplaceRemoveContext["remove"] { + const installer = new MarketplaceInstaller(new MarketplacePaths()) + return (item, scope, project) => installer.remove(item, scope, project) +} + +export async function removeAgent(ctx: RemoveConfigItemContext, name: string): Promise { + return remove(ctx, { id: name, type: "agent" }) +} + +export async function removeMcp(ctx: RemoveConfigItemContext, name: string): Promise { + return remove(ctx, { id: name, type: "mcp" }) +} + +async function remove(ctx: RemoveConfigItemContext, item: MarketplaceItemRef): Promise { + const actions: MarketplaceRemoveContext = { + connection: ctx.connection, + storage: ctx.storage, + remove: ctx.remove, + } + const removed = await removeMarketplaceItemFromAllScopes(actions, item, ctx.project(), ctx.directory()) + if (removed) await ctx.refresh() + return removed +} diff --git a/packages/kilo-vscode/src/services/marketplace/actions.ts b/packages/kilo-vscode/src/services/marketplace/actions.ts new file mode 100644 index 00000000000..84b1d34fc5c --- /dev/null +++ b/packages/kilo-vscode/src/services/marketplace/actions.ts @@ -0,0 +1,163 @@ +import * as path from "path" +import * as vscode from "vscode" +import type { KiloConnectionService } from "../cli-backend" +import { retry } from "../cli-backend/retry" +import type { MarketplaceService } from "." +import type { + InstallMarketplaceItemOptions, + InstallResult, + MarketplaceDataResponse, + MarketplaceItem, + MarketplaceItemRef, + RemoveResult, +} from "./types" + +export interface MarketplaceActionContext { + connection: KiloConnectionService + marketplace: MarketplaceService + storage?: vscode.Uri +} + +export interface MarketplaceRemoveContext { + connection: KiloConnectionService + storage?: vscode.Uri + remove: (item: MarketplaceItemRef, scope: "project" | "global", project?: string) => Promise +} + +export async function fetchMarketplaceData( + ctx: MarketplaceActionContext, + project: string | undefined, + dir: string | undefined, +): Promise { + const skills = dir ? await fetchSkills(ctx, dir) : undefined + return ctx.marketplace.fetchData(project, skills) +} + +export async function installMarketplaceItem( + ctx: MarketplaceActionContext, + item: MarketplaceItem, + opts: InstallMarketplaceItemOptions, + project: string | undefined, + dir: string, +): Promise { + const scope = opts.target ?? "project" + if (scope === "project" && !project) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope install" } + } + + try { + const result = await ctx.marketplace.install(item, opts, project) + if (result.success) await invalidate(ctx, scope, scope === "project" ? project! : dir) + return result + } catch (err) { + return { success: false, slug: item.id, error: String(err) } + } +} + +export async function removeMarketplaceItem( + ctx: MarketplaceActionContext, + item: MarketplaceItem, + scope: "project" | "global", + project: string | undefined, + dir: string, +): Promise { + if (scope === "project" && !project) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" } + } + + try { + if (item.type === "mcp") await removeLegacyMcp(ctx, item.id, project, scope) + const result = await ctx.marketplace.remove(item, scope, project) + if (result.success) await invalidate(ctx, scope, scope === "project" ? project! : dir) + return result + } catch (err) { + return { success: false, slug: item.id, error: String(err) } + } +} + +export async function removeMarketplaceItemFromAllScopes( + ctx: MarketplaceRemoveContext, + item: MarketplaceItemRef, + project: string | undefined, + dir: string, +): Promise { + try { + if (item.type === "mcp") await removeLegacyMcp(ctx, item.id, project, "all") + const local = project ? await ctx.remove(item, "project", project) : undefined + const global = await ctx.remove(item, "global", project) + if (!local?.success && !global.success) return false + await invalidate(ctx, global.success ? "global" : "project", global.success ? dir : project!) + return true + } catch (err) { + console.warn("[Kilo New] Marketplace removal failed:", err) + return false + } +} + +async function fetchSkills(ctx: MarketplaceActionContext, dir: string) { + try { + const client = await ctx.connection.getClientAsync(dir) + const { data } = await retry(() => client.app.skills({ directory: dir }, { throwOnError: true })) + return data + } catch (err) { + console.warn("[Kilo New] Failed to fetch CLI skills for marketplace:", err) + return undefined + } +} + +async function invalidate( + ctx: { connection: KiloConnectionService }, + scope: "project" | "global", + dir: string, +): Promise { + const client = await ctx.connection.getClientAsync(dir).catch((err: unknown) => { + console.warn("[Kilo New] Marketplace CLI invalidation deferred:", err) + return null + }) + if (!client) return + + if (scope === "global") { + await client.global.config.update({ config: {} }).catch((err: unknown) => { + console.warn("[Kilo New] global.config.update after marketplace change failed:", err) + }) + } + await client.instance.dispose({ directory: dir }).catch((err: unknown) => { + console.warn("[Kilo New] instance.dispose() after marketplace change failed:", err) + }) +} + +async function removeLegacyMcp( + ctx: { storage?: vscode.Uri }, + name: string, + project: string | undefined, + scope: "project" | "global" | "all", +): Promise { + const files: vscode.Uri[] = [] + if (project && scope !== "global") { + files.push(vscode.Uri.file(path.join(project, ".kilo", "mcp.json"))) + files.push(vscode.Uri.file(path.join(project, ".kilocode", "mcp.json"))) + } + + if (ctx.storage && scope !== "project") files.push(vscode.Uri.joinPath(ctx.storage, "settings", "mcp_settings.json")) + + let removed = false + for (const uri of files) { + const bytes = await vscode.workspace.fs.readFile(uri).then( + (data) => data, + () => null, + ) + if (!bytes) continue + + try { + const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as Record + const servers = parsed.mcpServers as Record | undefined + if (!servers?.[name]) continue + delete servers[name] + await vscode.workspace.fs.writeFile(uri, Buffer.from(JSON.stringify(parsed, null, 2), "utf8")) + removed = true + } catch (err) { + console.warn("[Kilo New] Failed to remove legacy MCP from", uri.fsPath, err) + } + } + return removed +} diff --git a/packages/kilo-vscode/src/services/marketplace/installer.ts b/packages/kilo-vscode/src/services/marketplace/installer.ts index 75ed0720449..7f8d1d05cf9 100644 --- a/packages/kilo-vscode/src/services/marketplace/installer.ts +++ b/packages/kilo-vscode/src/services/marketplace/installer.ts @@ -6,6 +6,7 @@ import * as yaml from "yaml" import { exec } from "../../util/process" import type { MarketplaceItem, + MarketplaceItemRef, SkillMarketplaceItem, McpMarketplaceItem, AgentMarketplaceItem, @@ -129,7 +130,7 @@ export class MarketplaceInstaller { } async removeAgent( - item: AgentMarketplaceItem, + item: Pick, scope: "project" | "global", workspace?: string, ): Promise { @@ -250,13 +251,20 @@ export class MarketplaceInstaller { // ── Remove ────────────────────────────────────────────────────────── - async remove(item: MarketplaceItem, scope: "project" | "global", workspace?: string): Promise { + async remove(item: MarketplaceItemRef, scope: "project" | "global", workspace?: string): Promise { + if (scope === "project" && !workspace) { + return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" } + } if (item.type === "skill") return this.removeSkill(item, scope, workspace) if (item.type === "mcp") return this.removeMcp(item, scope, workspace) return this.removeAgent(item, scope, workspace) } - async removeMcp(item: McpMarketplaceItem, scope: "project" | "global", workspace?: string): Promise { + async removeMcp( + item: Pick, + scope: "project" | "global", + workspace?: string, + ): Promise { if (scope === "project" && !workspace) { return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" } } @@ -272,7 +280,7 @@ export class MarketplaceInstaller { } async removeSkill( - item: SkillMarketplaceItem, + item: Pick, scope: "project" | "global", workspace?: string, ): Promise { diff --git a/packages/kilo-vscode/src/services/marketplace/types.ts b/packages/kilo-vscode/src/services/marketplace/types.ts index bfb20a6bd18..81f1c6f031f 100644 --- a/packages/kilo-vscode/src/services/marketplace/types.ts +++ b/packages/kilo-vscode/src/services/marketplace/types.ts @@ -60,6 +60,7 @@ export interface SkillMarketplaceItem extends MarketplaceItemBase { } export type MarketplaceItem = McpMarketplaceItem | AgentMarketplaceItem | SkillMarketplaceItem +export type MarketplaceItemRef = Pick export interface InstallMarketplaceItemOptions { target?: "global" | "project" diff --git a/packages/kilo-vscode/tests/unit/font-size-arch.test.ts b/packages/kilo-vscode/tests/unit/font-size-arch.test.ts index 5a96ef7eb28..36d3a9daea2 100644 --- a/packages/kilo-vscode/tests/unit/font-size-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/font-size-arch.test.ts @@ -18,6 +18,7 @@ const TARGETS = [ path.join(ROOT, "webview-ui/src"), path.join(ROOT, "webview-ui/agent-manager"), path.join(ROOT, "webview-ui/kiloclaw"), + path.join(ROOT, "webview-ui/marketplace"), path.join(ROOT, "webview-ui/diff-viewer"), path.join(ROOT, "webview-ui/diff-virtual"), path.join(REPO, "packages/kilo-ui/src/components"), @@ -28,6 +29,7 @@ const WATCHED_PROVIDERS = [ path.join(ROOT, "src/diff/DiffViewerProvider.ts"), path.join(ROOT, "src/DiffVirtualProvider.ts"), path.join(ROOT, "src/kiloclaw/KiloClawProvider.ts"), + path.join(ROOT, "src/MarketplacePanelProvider.ts"), ] const ALLOWED_DIRS = new Set(["stories"]) diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts new file mode 100644 index 00000000000..d0f5962110b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, mock } from "bun:test" +import * as vscode from "vscode" +import { + removeMarketplaceItem, + removeMarketplaceItemFromAllScopes, + type MarketplaceActionContext, + type MarketplaceRemoveContext, +} from "../../src/services/marketplace/actions" +import type { McpMarketplaceItem } from "../../src/services/marketplace/types" + +const project = "/repo" +const storage = vscode.Uri.file("/storage") +const local = `${project}/.kilo/mcp.json` +const legacy = `${project}/.kilocode/mcp.json` +const global = `${storage.fsPath}/settings/mcp_settings.json` +const item: McpMarketplaceItem = { + id: "memory", + type: "mcp", + name: "Memory", + description: "", + url: "", + content: "", +} +const fs = vscode.workspace.fs as unknown as { + readFile: (uri: vscode.Uri) => Promise + writeFile: (uri: vscode.Uri, data: Uint8Array) => Promise +} +const original = { readFile: fs.readFile, writeFile: fs.writeFile } + +function setup() { + const files = new Map([ + [local, JSON.stringify({ mcpServers: { memory: {}, keep: {} } })], + [legacy, JSON.stringify({ mcpServers: { memory: {}, keep: {} } })], + [global, JSON.stringify({ mcpServers: { memory: {}, keep: {} } })], + ]) + fs.readFile = async (uri) => { + const body = files.get(uri.fsPath) + if (!body) throw new Error("missing file") + return Buffer.from(body) + } + fs.writeFile = async (uri, data) => { + files.set(uri.fsPath, Buffer.from(data).toString("utf8")) + } + return files +} + +function has(files: Map, file: string) { + return !!JSON.parse(files.get(file)!).mcpServers.memory +} + +function connection() { + return { + getClientAsync: mock(async () => ({ + global: { config: { update: mock(async () => {}) } }, + instance: { dispose: mock(async () => {}) }, + })), + } as unknown as MarketplaceActionContext["connection"] +} + +afterEach(() => { + fs.readFile = original.readFile + fs.writeFile = original.writeFile +}) + +describe("Marketplace legacy MCP cleanup", () => { + it("preserves global legacy config during project removal", async () => { + const files = setup() + const ctx = { + connection: connection(), + marketplace: { remove: mock(async () => ({ success: true, slug: item.id })) }, + storage, + } as unknown as MarketplaceActionContext + + await removeMarketplaceItem(ctx, item, "project", project, project) + + expect(has(files, local)).toBe(false) + expect(has(files, legacy)).toBe(false) + expect(has(files, global)).toBe(true) + }) + + it("preserves project legacy config during global removal", async () => { + const files = setup() + const ctx = { + connection: connection(), + marketplace: { remove: mock(async () => ({ success: true, slug: item.id })) }, + storage, + } as unknown as MarketplaceActionContext + + await removeMarketplaceItem(ctx, item, "global", project, project) + + expect(has(files, local)).toBe(true) + expect(has(files, legacy)).toBe(true) + expect(has(files, global)).toBe(false) + }) + + it("removes project and global legacy config during sidebar cleanup", async () => { + const files = setup() + const ctx = { + connection: connection(), + remove: mock(async () => ({ success: true, slug: item.id })), + storage, + } as MarketplaceRemoveContext + + await removeMarketplaceItemFromAllScopes(ctx, item, project, project) + + expect(has(files, local)).toBe(false) + expect(has(files, legacy)).toBe(false) + expect(has(files, global)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts b/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts new file mode 100644 index 00000000000..a2f32985292 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-panel-arch.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const root = path.resolve(import.meta.dir, "../..") +const kilo = fs.readFileSync(path.join(root, "src/KiloProvider.ts"), "utf-8") +const panel = fs.readFileSync(path.join(root, "src/MarketplacePanelProvider.ts"), "utf-8") +const remove = fs.readFileSync(path.join(root, "src/kilo-provider/remove-config-item.ts"), "utf-8") + +describe("standalone Marketplace architecture", () => { + it("keeps Marketplace webview cases out of KiloProvider", () => { + for (const type of [ + "fetchMarketplaceData", + "installMarketplaceItem", + "removeInstalledMarketplaceItem", + "dismissAgentMigrationBanner", + ]) { + expect(kilo).not.toContain(`case \"${type}\"`) + expect(panel).toContain(`case \"${type}\"`) + } + }) + + it("uses a dedicated Marketplace webview bundle", () => { + expect(panel).toContain('"dist", "marketplace.js"') + expect(panel).not.toContain('"dist", "webview.js"') + }) + + it("keeps sidebar removal behind a narrow adapter", () => { + expect(kilo).toContain("removeAgent(this.removeConfigItemCtx, name)") + expect(kilo).toContain("removeMcp(this.removeConfigItemCtx, name)") + expect(remove).toContain("createMarketplaceRemover") + expect(remove).not.toContain("new MarketplaceService()") + expect(remove).not.toContain("AgentMarketplaceItem") + expect(remove).not.toContain("McpMarketplaceItem") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-directory.test.ts b/packages/kilo-vscode/tests/unit/project-directory.test.ts new file mode 100644 index 00000000000..306d5a38d74 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-directory.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import { resolvePanelProjectDirectory, resolveProjectDirectory } from "../../src/project-directory" + +describe("project directory resolution", () => { + const folders = [{ uri: { fsPath: "/repo-a" } }, { uri: { fsPath: "/repo-b" } }] + + it("prefers the active editor project", () => { + expect(resolvePanelProjectDirectory("/repo-b", folders)).toBe("/repo-b") + }) + + it("uses the only open workspace", () => { + expect(resolvePanelProjectDirectory(undefined, [folders[0]])).toBe("/repo-a") + }) + + it("disables project scope when a multi-root workspace is ambiguous", () => { + expect(resolvePanelProjectDirectory(undefined, folders)).toBeNull() + }) + + it("preserves an explicit null project override", () => { + expect(resolveProjectDirectory(null, () => "/repo-a")).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/remove-config-item.test.ts b/packages/kilo-vscode/tests/unit/remove-config-item.test.ts new file mode 100644 index 00000000000..2e40e593c4c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/remove-config-item.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, mock } from "bun:test" +import { removeAgent, removeMcp, type RemoveConfigItemContext } from "../../src/kilo-provider/remove-config-item" + +function context(opts: { + project?: string + remove: ReturnType + refresh: ReturnType +}): RemoveConfigItemContext { + return { + connection: { + getClientAsync: mock(async () => ({ + global: { config: { update: mock(async () => {}) } }, + instance: { dispose: mock(async () => {}) }, + })), + } as unknown as RemoveConfigItemContext["connection"], + project: () => opts.project, + directory: () => "/repo", + refresh: opts.refresh, + remove: opts.remove, + } +} + +describe("remove config item adapter", () => { + it("removes agents from project and global scopes, then refreshes", async () => { + const remove = mock(async () => ({ success: true, slug: "reviewer" })) + const refresh = mock(async () => {}) + const ctx = context({ project: "/repo", remove, refresh }) + + expect(await removeAgent(ctx, "reviewer")).toBe(true) + expect(remove).toHaveBeenCalledTimes(2) + expect(remove).toHaveBeenNthCalledWith(1, { id: "reviewer", type: "agent" }, "project", "/repo") + expect(remove).toHaveBeenNthCalledWith(2, { id: "reviewer", type: "agent" }, "global", "/repo") + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it("removes MCP servers globally when there is no project, then refreshes", async () => { + const remove = mock(async () => ({ success: true, slug: "memory" })) + const refresh = mock(async () => {}) + const ctx = context({ remove, refresh }) + + expect(await removeMcp(ctx, "memory")).toBe(true) + expect(remove).toHaveBeenCalledTimes(1) + expect(remove).toHaveBeenCalledWith({ id: "memory", type: "mcp" }, "global", undefined) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it("does not refresh when removal fails", async () => { + const remove = mock(async () => ({ success: false, slug: "reviewer" })) + const refresh = mock(async () => {}) + const ctx = context({ remove, refresh }) + + expect(await removeAgent(ctx, "reviewer")).toBe(false) + expect(refresh).not.toHaveBeenCalled() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 9dbfaa1b336..fbec718bdc6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -84,7 +84,8 @@ import { WorktreeModeProvider } from "../src/context/worktree-mode" import { ChatView } from "../src/components/chat" import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" -import { LanguageBridge, DataBridge, MermaidDownloadBridge } from "../src/App" +import { DataBridge, MermaidDownloadBridge } from "../src/App" +import { LanguageBridge } from "../src/context/language-bridge" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" import { diff --git a/packages/kilo-vscode/webview-ui/marketplace/MarketplaceApp.tsx b/packages/kilo-vscode/webview-ui/marketplace/MarketplaceApp.tsx new file mode 100644 index 00000000000..ccc6ad4883d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/marketplace/MarketplaceApp.tsx @@ -0,0 +1,29 @@ +import { type Component } from "solid-js" +import { ThemeProvider } from "@kilocode/kilo-ui/theme" +import { DialogProvider } from "@kilocode/kilo-ui/context/dialog" +import { Toast } from "@kilocode/kilo-ui/toast" +import { MarketplaceView } from "../src/components/marketplace" +import { MarketplaceSessionProvider } from "../src/context/marketplace-session" +import { LanguageBridge } from "../src/context/language-bridge" +import { ServerProvider } from "../src/context/server" +import { VSCodeProvider } from "../src/context/vscode" +import "../src/styles/chat.css" + +export const MarketplaceApp: Component = () => { + return ( + + + + + + + + + + + + + + + ) +} diff --git a/packages/kilo-vscode/webview-ui/marketplace/index.tsx b/packages/kilo-vscode/webview-ui/marketplace/index.tsx new file mode 100644 index 00000000000..9a712ea9da8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/marketplace/index.tsx @@ -0,0 +1,7 @@ +import { render } from "solid-js/web" +import "@kilocode/kilo-ui/styles" +import { MarketplaceApp } from "./MarketplaceApp" + +const root = document.getElementById("root") +if (!root) throw new Error("Root element not found") +render(() => , root) diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 9843ad7d7d9..e2a3c40bac9 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -19,9 +19,8 @@ import { ConfigProvider } from "./context/config" import { DisplayProvider } from "./context/display" import { IndexingProvider } from "./context/indexing" import { SessionProvider, useSession } from "./context/session" -import { LanguageProvider } from "./context/language" +import { LanguageBridge } from "./context/language-bridge" import { ChatView } from "./components/chat" -import { MarketplaceView } from "./components/marketplace" import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded" import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides" @@ -38,8 +37,8 @@ import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models" import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" import "./styles/chat.css" -type ViewType = "newTask" | "marketplace" | "history" | "profile" | "settings" | "subAgentViewer" -const VALID_VIEWS = new Set(["newTask", "marketplace", "history", "profile", "settings", "subAgentViewer"]) +type ViewType = "newTask" | "history" | "profile" | "settings" | "subAgentViewer" +const VALID_VIEWS = new Set(["newTask", "history", "profile", "settings", "subAgentViewer"]) /** * Bridge our session store to the DataProvider's expected Data shape. @@ -172,19 +171,6 @@ export const DataBridge: Component<{ children: any }> = (props) => { ) } -/** - * Wraps children in LanguageProvider, passing server-side language info. - * Must be below ServerProvider in the hierarchy. - */ -export const LanguageBridge: Component<{ children: any }> = (props) => { - const server = useServer() - return ( - - {props.children} - - ) -} - type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }> export const MermaidDownloadBridge: Component = () => { @@ -223,9 +209,6 @@ const AppContent: Component = () => { window.dispatchEvent(new CustomEvent("newTaskRequest")) setCurrentView("newTask") break - case "marketplaceButtonClicked": - setCurrentView("marketplace") - break case "historyButtonClicked": setCurrentView("history") break @@ -328,9 +311,6 @@ const AppContent: Component = () => { promptBoxId="sidebar:new-task" /> - - - setCurrentView("newTask")} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/InstallModal.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/InstallModal.tsx index 9f753085533..4a3502d4221 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/InstallModal.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/InstallModal.tsx @@ -9,7 +9,7 @@ import { showToast } from "@kilocode/kilo-ui/toast" import { useVSCode } from "../../context/vscode" import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" -import { useSession } from "../../context/session" +import { useMarketplaceSession } from "../../context/marketplace-session" import type { MarketplaceItem, McpMarketplaceItem, McpInstallationMethod, McpParameter } from "../../types/marketplace" interface ScopeOption { @@ -31,7 +31,7 @@ export const InstallModal = (props: Props) => { const vscode = useVSCode() const server = useServer() const { t } = useLanguage() - const session = useSession() + const session = useMarketplaceSession() const workspace = () => server.workspaceDirectory() const options = (): ScopeOption[] => diff --git a/packages/kilo-vscode/webview-ui/src/context/language-bridge.tsx b/packages/kilo-vscode/webview-ui/src/context/language-bridge.tsx new file mode 100644 index 00000000000..c90b85d3be5 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/language-bridge.tsx @@ -0,0 +1,12 @@ +import { type ParentComponent } from "solid-js" +import { LanguageProvider } from "./language" +import { useServer } from "./server" + +export const LanguageBridge: ParentComponent = (props) => { + const server = useServer() + return ( + + {props.children} + + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/marketplace-session.tsx b/packages/kilo-vscode/webview-ui/src/context/marketplace-session.tsx new file mode 100644 index 00000000000..f7a9e822842 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/marketplace-session.tsx @@ -0,0 +1,41 @@ +import { createContext, createSignal, onCleanup, useContext, type ParentComponent } from "solid-js" +import { useVSCode } from "./vscode" +import type { ExtensionMessage, SessionStatusInfo } from "../types/messages" + +interface MarketplaceSessionContextValue { + allStatusMap: () => Record +} + +const MarketplaceSessionContext = createContext() + +/** + * Tracks backend session statuses without loading the full chat SessionProvider, + * enabling Marketplace busy-session warnings. + */ +export const MarketplaceSessionProvider: ParentComponent = (props) => { + const vscode = useVSCode() + const [statuses, setStatuses] = createSignal>({}) + const unsubscribe = vscode.onMessage((msg: ExtensionMessage) => { + if (msg.type !== "sessionStatus") return + const status: SessionStatusInfo = + msg.status === "retry" + ? { type: "retry", attempt: msg.attempt!, message: msg.message!, next: msg.next! } + : msg.status === "offline" + ? { type: "offline", message: msg.message! } + : { type: msg.status } + setStatuses((current) => ({ ...current, [msg.sessionID]: status })) + }) + onCleanup(unsubscribe) + + return ( + + {props.children} + + ) +} + +export function useMarketplaceSession(): MarketplaceSessionContextValue { + const context = useContext(MarketplaceSessionContext) + if (!context) throw new Error("useMarketplaceSession must be used within MarketplaceSessionProvider") + return context +} diff --git a/packages/kilo-vscode/webview-ui/tsconfig.json b/packages/kilo-vscode/webview-ui/tsconfig.json index 09d505f2455..e3e1ededb8c 100644 --- a/packages/kilo-vscode/webview-ui/tsconfig.json +++ b/packages/kilo-vscode/webview-ui/tsconfig.json @@ -12,5 +12,12 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["src/**/*", "agent-manager/**/*", "kiloclaw/**/*", "diff-viewer/**/*", "diff-virtual/**/*"] + "include": [ + "src/**/*", + "agent-manager/**/*", + "kiloclaw/**/*", + "marketplace/**/*", + "diff-viewer/**/*", + "diff-virtual/**/*" + ] } From bcc0aa3db9808f6dfeb57ff8e72b3ece0bb349de Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Wed, 3 Jun 2026 18:22:16 +0200 Subject: [PATCH 2/2] fix(marketplace): correct fetchData directory arg and add error logging Replace swallowed catch block with console.warn for session status sync failures, and pass `this.directory()` instead of `project` as the second positional argument to `fetchMarketplaceData`. --- packages/kilo-vscode/src/MarketplacePanelProvider.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts index b0d17807270..c38ea033f24 100644 --- a/packages/kilo-vscode/src/MarketplacePanelProvider.ts +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -159,8 +159,8 @@ export class MarketplacePanelProvider implements vscode.Disposable { try { const client = this.connection.getClient() await seedSessionStatuses(client, this.directory(), this.statuses, (msg) => this.post(msg), reconcile) - } catch { - // Connection state above is sufficient while the shared client reconnects. + } catch (err) { + console.warn("[Kilo New] Marketplace session status sync failed:", err) } } @@ -199,7 +199,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { private async fetchData(): Promise { try { const project = this.project ?? undefined - const data = await fetchMarketplaceData(this.marketplaceCtx, project, project) + const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory()) const dismissed = this.context.globalState.get("kilo.agentMigrationBannerDismissed") ?? false this.post({ type: "marketplaceData", ...data, showAgentMigrationBanner: !dismissed }) } catch (err) {