Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/marketplace-match-notification.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
17 changes: 17 additions & 0 deletions packages/kilo-vscode/src/MarketplacePanelProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class MarketplacePanelProvider implements vscode.Disposable {
private generation = 0
private refresh: ReturnType<typeof setTimeout> | undefined
private statuses = new Map<string, SessionStatus["type"]>()
private pendingInstall: MarketplaceItem | undefined
private disposables: vscode.Disposable[] = []
private subscriptions: Array<() => void> = []
private readonly marketplace = new MarketplaceService()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions packages/kilo-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
119 changes: 119 additions & 0 deletions packages/kilo-vscode/src/services/marketplace/notifier.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | undefined
private generation = 0
/** Slugs already shown this session so a single scan burst doesn't re-toast. */
private shown = new Set<string>()

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<string[]>(DISMISSED_KEY, []) ?? []
}

private async dismiss(slug: string): Promise<void> {
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<void> {
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)
Comment thread
markijbema marked this conversation as resolved.

const choice = await showSuggestionNotification(item)
if (generation !== this.generation) return
Comment thread
markijbema marked this conversation as resolved.
Outdated
if (choice?.action === "install") this.install(item)
if (choice?.action === "dismiss") await this.dismiss(slug)
}
}
52 changes: 52 additions & 0 deletions packages/kilo-vscode/src/services/marketplace/notify.ts
Original file line number Diff line number Diff line change
@@ -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<MarketplaceItem, "id" | "type">): 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<string>,
): 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<SuggestionChoice | undefined> {
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
}
49 changes: 49 additions & 0 deletions packages/kilo-vscode/tests/unit/marketplace-notify.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,11 @@ export interface MarketplaceInstallResultMessage {
error?: string
}

export interface OpenInstallModalMessage {
type: "openInstallModal"
mpItem: MarketplaceItem
}

export interface MarketplaceRemoveResultMessage {
type: "marketplaceRemoveResult"
success: boolean
Expand Down Expand Up @@ -1121,6 +1126,7 @@ export type ExtensionMessage =
| MarketplaceDataMessage
| MarketplaceInstallResultMessage
| MarketplaceRemoveResultMessage
| OpenInstallModalMessage
| ProviderOAuthReadyMessage
| ProviderConnectedMessage
| ProviderDisconnectedMessage
Expand Down
Loading