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..55f6b06f18f --- /dev/null +++ b/packages/kilo-vscode/src/services/marketplace/notifier.ts @@ -0,0 +1,123 @@ +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 + private disposed = false + /** 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 { + this.disposed = true + 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) + + // 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 (this.disposed) 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