From 80ed571bd692b49b091a7553d615ef8da951ea33 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 25 Jun 2026 16:59:54 +0200 Subject: [PATCH 1/2] feat(vscode): notify on matching marketplace items Surface a discardable VS Code notification when a marketplace item's suggest_for metadata matches the current workspace. The notification offers a one-click Install button that opens the marketplace install dialog with project scope preselected, plus a per-suggestion "Don't show again" option persisted via a stable type:id slug. --- .changeset/marketplace-match-notification.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 1 + .../src/MarketplacePanelProvider.ts | 17 +++ packages/kilo-vscode/src/extension.ts | 8 ++ .../src/services/marketplace/notifier.ts | 119 ++++++++++++++++++ .../src/services/marketplace/notify.ts | 52 ++++++++ .../tests/unit/marketplace-notify.test.ts | 49 ++++++++ .../marketplace/MarketplaceView.tsx | 4 + .../src/types/messages/extension-messages.ts | 6 + 9 files changed, 261 insertions(+) create mode 100644 .changeset/marketplace-match-notification.md create mode 100644 packages/kilo-vscode/src/services/marketplace/notifier.ts create mode 100644 packages/kilo-vscode/src/services/marketplace/notify.ts create mode 100644 packages/kilo-vscode/tests/unit/marketplace-notify.test.ts diff --git a/.changeset/marketplace-match-notification.md b/.changeset/marketplace-match-notification.md new file mode 100644 index 00000000000..1ea0f8b87db --- /dev/null +++ b/.changeset/marketplace-match-notification.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Notify when a marketplace item matches your workspace, with a one-click install button and a "Don't show again" option per suggestion. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 118ae7243ec..dff7bc1889d 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -3221,6 +3221,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.extensionContext?.globalState.update("recentModels", undefined) await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", undefined) await this.extensionContext?.globalState.update("kilo.agentMigrationBannerDismissed", undefined) + await this.extensionContext?.globalState.update("kilo.marketplace.dismissedSuggestions", undefined) // Re-send all settings to the webview so the UI reflects the reset this.postMessage(buildAutocompleteSettingsMessage()) diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts index bd36dfd8454..0c704ac5805 100644 --- a/packages/kilo-vscode/src/MarketplacePanelProvider.ts +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -36,6 +36,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { private generation = 0 private refresh: ReturnType | undefined private statuses = new Map() + private pendingInstall: MarketplaceItem | undefined private disposables: vscode.Disposable[] = [] private subscriptions: Array<() => void> = [] private readonly marketplace = new MarketplaceService() @@ -83,6 +84,13 @@ export class MarketplacePanelProvider implements vscode.Disposable { this.attach(panel, this.resolveProject()) } + /** Open the panel and surface the install dialog for a specific item, project scope preselected. */ + openInstall(item: MarketplaceItem): void { + this.openPanel() + this.pendingInstall = item + this.flushPendingInstall() + } + dispose(): void { this.panel?.dispose() this.cleanup() @@ -183,6 +191,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { if (this.connection.getConnectionState() === "connected") await this.sync(true) else await this.connect() await this.fetchData() + this.flushPendingInstall() return case "retryConnection": await this.connect() @@ -208,6 +217,14 @@ export class MarketplacePanelProvider implements vscode.Disposable { } } + /** Ask the webview to open the install dialog for a queued suggestion, once it can receive it. */ + private flushPendingInstall(): void { + if (!this.pendingInstall || !this.ready) return + const item = this.pendingInstall + this.pendingInstall = undefined + this.post({ type: "openInstallModal", mpItem: item }) + } + private scheduleRefresh(): void { if (!this.ready) return if (this.refresh) clearTimeout(this.refresh) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 1a22505abd7..27472ec9c51 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -8,6 +8,7 @@ import { DiffSourceCatalog } from "./diff/sources/catalog" import { DiffVirtualProvider } from "./DiffVirtualProvider" import { SettingsEditorProvider } from "./SettingsEditorProvider" import { MarketplacePanelProvider } from "./MarketplacePanelProvider" +import { MarketplaceNotifier } from "./services/marketplace/notifier" import { SubAgentViewerProvider } from "./SubAgentViewerProvider" import { EXTENSION_DISPLAY_NAME } from "./constants" import { KiloConnectionService } from "./services/cli-backend" @@ -271,6 +272,13 @@ export function activate(context: vscode.ExtensionContext) { const marketplacePanelProvider = new MarketplacePanelProvider(context.extensionUri, connectionService, context) context.subscriptions.push(settingsEditorProvider, marketplacePanelProvider) + // Surface a discardable notification when a marketplace item matches the workspace. + const marketplaceNotifier = new MarketplaceNotifier(connectionService, context, (item) => + marketplacePanelProvider.openInstall(item), + ) + context.subscriptions.push(marketplaceNotifier) + marketplaceNotifier.start() + // 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) diff --git a/packages/kilo-vscode/src/services/marketplace/notifier.ts b/packages/kilo-vscode/src/services/marketplace/notifier.ts new file mode 100644 index 00000000000..68f2bb4cd15 --- /dev/null +++ b/packages/kilo-vscode/src/services/marketplace/notifier.ts @@ -0,0 +1,119 @@ +import * as os from "os" +import * as vscode from "vscode" +import { MarketplaceService } from "." +import { fetchMarketplaceData, type MarketplaceActionContext } from "./actions" +import { selectSuggestions, showSuggestionNotification, suggestionSlug } from "./notify" +import type { KiloConnectionService } from "../cli-backend" +import type { MarketplaceItem } from "./types" + +const DISMISSED_KEY = "kilo.marketplace.dismissedSuggestions" +const DEBOUNCE = 1500 + +/** Opens the marketplace install flow for a suggested item. */ +export type InstallHandler = (item: MarketplaceItem) => void + +/** + * Scans the workspace for marketplace items annotated with relevant `suggest_for` + * metadata and surfaces a discardable VS Code notification offering a one-click + * install. Runs in the background, independent of the marketplace panel. + */ +export class MarketplaceNotifier implements vscode.Disposable { + private readonly marketplace = new MarketplaceService() + private disposables: vscode.Disposable[] = [] + private timer: ReturnType | undefined + private generation = 0 + /** Slugs already shown this session so a single scan burst doesn't re-toast. */ + private shown = new Set() + + constructor( + private readonly connection: KiloConnectionService, + private readonly context: vscode.ExtensionContext, + private readonly install: InstallHandler, + ) { + this.disposables.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => this.schedule()), + vscode.extensions.onDidChange(() => this.schedule()), + vscode.workspace.onDidCreateFiles(() => this.schedule()), + ) + } + + /** Begin the first background scan. Safe to call once after activation. */ + start(): void { + this.schedule() + } + + dispose(): void { + if (this.timer) clearTimeout(this.timer) + this.timer = undefined + this.generation++ + for (const disposable of this.disposables) disposable.dispose() + this.disposables = [] + this.marketplace.dispose() + } + + private schedule(): void { + if (this.timer) clearTimeout(this.timer) + this.timer = setTimeout(() => { + this.timer = undefined + void this.scan() + }, DEBOUNCE) + } + + private dismissed(): string[] { + return this.context.globalState.get(DISMISSED_KEY, []) ?? [] + } + + private async dismiss(slug: string): Promise { + const existing = this.dismissed() + if (existing.includes(slug)) return + await this.context.globalState.update(DISMISSED_KEY, [...existing, slug]) + } + + private project(): string | undefined { + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + } + + private directory(): string { + return this.project() ?? os.homedir() + } + + private roots(): vscode.Uri[] { + return vscode.workspace.workspaceFolders?.map((folder) => folder.uri) ?? [] + } + + private get ctx(): MarketplaceActionContext { + return { connection: this.connection, marketplace: this.marketplace, storage: this.context.globalStorageUri } + } + + private async scan(): Promise { + const generation = ++this.generation + const data = await fetchMarketplaceData(this.ctx, this.project(), this.directory(), this.roots()).catch( + (err: unknown) => { + console.warn("[Kilo New] Marketplace suggestion scan failed:", err) + return undefined + }, + ) + if (!data || generation !== this.generation) return + + const installed = new Set([ + ...Object.keys(data.marketplaceInstalledMetadata.project), + ...Object.keys(data.marketplaceInstalledMetadata.global), + ]) + const suggestions = selectSuggestions(data.marketplaceItems, data.marketplaceRelevance, [ + ...this.dismissed(), + ...this.shown, + ...installed, + ]) + if (suggestions.length === 0) return + + // Surface one suggestion at a time to avoid stacking toasts. + const item = suggestions[0] + const slug = suggestionSlug(item) + this.shown.add(slug) + + const choice = await showSuggestionNotification(item) + if (generation !== this.generation) return + if (choice?.action === "install") this.install(item) + if (choice?.action === "dismiss") await this.dismiss(slug) + } +} diff --git a/packages/kilo-vscode/src/services/marketplace/notify.ts b/packages/kilo-vscode/src/services/marketplace/notify.ts new file mode 100644 index 00000000000..0243bbb4a05 --- /dev/null +++ b/packages/kilo-vscode/src/services/marketplace/notify.ts @@ -0,0 +1,52 @@ +import * as vscode from "vscode" +import type { MarketplaceItem, MarketplaceRelevanceMetadata } from "./types" + +/** Stable, discardable identifier for a suggestion. Matches the relevance map key. */ +export function suggestionSlug(item: Pick): string { + return `${item.type}:${item.id}` +} + +/** + * Pick the items worth surfacing as a notification: relevant to the workspace and + * not previously dismissed. Pure so it can be unit tested without VS Code. + */ +export function selectSuggestions( + items: MarketplaceItem[], + relevance: MarketplaceRelevanceMetadata, + dismissed: Iterable, +): MarketplaceItem[] { + const skip = new Set(dismissed) + return items.filter((item) => { + const slug = suggestionSlug(item) + return Boolean(relevance[slug]) && !skip.has(slug) + }) +} + +export interface SuggestionChoice { + action: "install" | "dismiss" + item: MarketplaceItem +} + +function describe(item: MarketplaceItem): string { + if (item.type === "agent") return `the ${item.name} agent` + if (item.type === "skill") return `the ${item.name} skill` + return `the ${item.name} MCP server` +} + +/** + * Show a native VS Code notification for a matched item, offering a direct install + * and a persistent "Don't show again" dismissal. Resolves with the user's choice, + * or `undefined` if the toast was closed without picking an action. + */ +export async function showSuggestionNotification(item: MarketplaceItem): Promise { + const install = "Install" + const dismiss = "Don't show again" + const picked = await vscode.window.showInformationMessage( + `Kilo found ${describe(item)} that matches this workspace. Install it?`, + install, + dismiss, + ) + if (picked === install) return { action: "install", item } + if (picked === dismiss) return { action: "dismiss", item } + return undefined +} diff --git a/packages/kilo-vscode/tests/unit/marketplace-notify.test.ts b/packages/kilo-vscode/tests/unit/marketplace-notify.test.ts new file mode 100644 index 00000000000..7fa082fa4db --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-notify.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test" +import { selectSuggestions, suggestionSlug } from "../../src/services/marketplace/notify" +import type { MarketplaceItem, MarketplaceRelevanceMetadata } from "../../src/services/marketplace/types" + +const agent: MarketplaceItem = { + type: "agent", + id: "angular", + name: "Angular", + description: "Angular specialist", + category: "development", + content: { mode: "all", description: "Angular specialist", prompt: "Help with Angular" }, + suggest_for: { filename: ["*.component.ts"] }, +} + +const mcp: MarketplaceItem = { + type: "mcp", + id: "jupyter", + name: "Jupyter", + description: "Jupyter notebooks", + category: "data", + url: "https://example.com", + content: "{}", + suggest_for: { vscode_extension: ["ms-toolsai.jupyter"] }, +} + +const items = [agent, mcp] + +describe("Marketplace suggestion notification", () => { + it("derives a stable discardable slug from type and id", () => { + expect(suggestionSlug(agent)).toBe("agent:angular") + expect(suggestionSlug(mcp)).toBe("mcp:jupyter") + }) + + it("selects only relevant, non-dismissed items", () => { + const relevance: MarketplaceRelevanceMetadata = { + "agent:angular": { filename: ["*.component.ts"] }, + "mcp:jupyter": { vscodeExtension: ["ms-toolsai.jupyter"] }, + } + + expect(selectSuggestions(items, relevance, [])).toEqual([agent, mcp]) + expect(selectSuggestions(items, relevance, ["agent:angular"])).toEqual([mcp]) + expect(selectSuggestions(items, relevance, ["agent:angular", "mcp:jupyter"])).toEqual([]) + }) + + it("ignores items without a relevance match", () => { + const relevance: MarketplaceRelevanceMetadata = { "mcp:jupyter": { vscodeExtension: ["ms-toolsai.jupyter"] } } + expect(selectSuggestions(items, relevance, [])).toEqual([mcp]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx index 5f11c0ad135..ee98aa08f42 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -49,6 +49,10 @@ export const MarketplaceView = () => { setFetching(false) setShowMigrationBanner(msg.showAgentMigrationBanner ?? false) } + if (msg.type === "openInstallModal") { + const match = items().find((i) => i.type === msg.mpItem.type && i.id === msg.mpItem.id) + handleInstall(match ?? msg.mpItem) + } if (msg.type === "marketplaceRemoveResult") { const removed = pending() setPending(null) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index f35813d6280..8abd4094057 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -921,6 +921,11 @@ export interface MarketplaceInstallResultMessage { error?: string } +export interface OpenInstallModalMessage { + type: "openInstallModal" + mpItem: MarketplaceItem +} + export interface MarketplaceRemoveResultMessage { type: "marketplaceRemoveResult" success: boolean @@ -1121,6 +1126,7 @@ export type ExtensionMessage = | MarketplaceDataMessage | MarketplaceInstallResultMessage | MarketplaceRemoveResultMessage + | OpenInstallModalMessage | ProviderOAuthReadyMessage | ProviderConnectedMessage | ProviderDisconnectedMessage From a18acfc71a61cd15e9f50e87e7f98a9ce848f789 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Fri, 26 Jun 2026 13:13:57 +0200 Subject: [PATCH 2/2] fix(vscode): honor suggestion choice across rescans A background rescan bumped generation while a suggestion toast was open, so a later Install or dismiss click hit the stale-scan early return and was silently dropped. Guard the post-toast path on a disposed flag instead, so only teardown can discard the user's choice. --- packages/kilo-vscode/src/services/marketplace/notifier.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/services/marketplace/notifier.ts b/packages/kilo-vscode/src/services/marketplace/notifier.ts index 68f2bb4cd15..55f6b06f18f 100644 --- a/packages/kilo-vscode/src/services/marketplace/notifier.ts +++ b/packages/kilo-vscode/src/services/marketplace/notifier.ts @@ -22,6 +22,7 @@ export class MarketplaceNotifier implements vscode.Disposable { private disposables: vscode.Disposable[] = [] private timer: ReturnType | undefined private generation = 0 + private disposed = false /** Slugs already shown this session so a single scan burst doesn't re-toast. */ private shown = new Set() @@ -43,6 +44,7 @@ export class MarketplaceNotifier implements vscode.Disposable { } dispose(): void { + this.disposed = true if (this.timer) clearTimeout(this.timer) this.timer = undefined this.generation++ @@ -111,8 +113,10 @@ export class MarketplaceNotifier implements vscode.Disposable { const slug = suggestionSlug(item) this.shown.add(slug) + // A later rescan must never void the user's explicit choice, so only a + // disposed notifier short-circuits here — not a bumped generation. const choice = await showSuggestionNotification(item) - if (generation !== this.generation) return + if (this.disposed) return if (choice?.action === "install") this.install(item) if (choice?.action === "dismiss") await this.dismiss(slug) }