mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Extract Marketplace logic from KiloProvider (#10850)
* feat(marketplace): extract marketplace into standalone webview panel * 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`.
This commit is contained in:
@@ -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(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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<typeof createAutoApproveBridge> | 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<void> {
|
||||
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<void> {
|
||||
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<boolean>("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<Array<{ name: string; location: string }> | 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<void> {
|
||||
// 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<boolean> {
|
||||
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<string, unknown>
|
||||
const servers = parsed.mcpServers as Record<string, unknown> | 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<void> {
|
||||
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<RemoveResult> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, SessionStatus["type"]>()
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>("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 (err) {
|
||||
console.warn("[Kilo New] Marketplace session status sync failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
private async handle(msg: MarketplaceMessage): Promise<void> {
|
||||
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<void> {
|
||||
try {
|
||||
const project = this.project ?? undefined
|
||||
const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory())
|
||||
const dismissed = this.context.globalState.get<boolean>("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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<PanelView, string> = {
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void>
|
||||
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<boolean> {
|
||||
return remove(ctx, { id: name, type: "agent" })
|
||||
}
|
||||
|
||||
export async function removeMcp(ctx: RemoveConfigItemContext, name: string): Promise<boolean> {
|
||||
return remove(ctx, { id: name, type: "mcp" })
|
||||
}
|
||||
|
||||
async function remove(ctx: RemoveConfigItemContext, item: MarketplaceItemRef): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
@@ -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<RemoveResult>
|
||||
}
|
||||
|
||||
export async function fetchMarketplaceData(
|
||||
ctx: MarketplaceActionContext,
|
||||
project: string | undefined,
|
||||
dir: string | undefined,
|
||||
): Promise<MarketplaceDataResponse> {
|
||||
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<InstallResult> {
|
||||
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<RemoveResult> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<string, unknown>
|
||||
const servers = parsed.mcpServers as Record<string, unknown> | 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
|
||||
}
|
||||
@@ -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<AgentMarketplaceItem, "id">,
|
||||
scope: "project" | "global",
|
||||
workspace?: string,
|
||||
): Promise<RemoveResult> {
|
||||
@@ -250,13 +251,20 @@ export class MarketplaceInstaller {
|
||||
|
||||
// ── Remove ──────────────────────────────────────────────────────────
|
||||
|
||||
async remove(item: MarketplaceItem, scope: "project" | "global", workspace?: string): Promise<RemoveResult> {
|
||||
async remove(item: MarketplaceItemRef, scope: "project" | "global", workspace?: string): Promise<RemoveResult> {
|
||||
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<RemoveResult> {
|
||||
async removeMcp(
|
||||
item: Pick<McpMarketplaceItem, "id">,
|
||||
scope: "project" | "global",
|
||||
workspace?: string,
|
||||
): Promise<RemoveResult> {
|
||||
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<SkillMarketplaceItem, "id">,
|
||||
scope: "project" | "global",
|
||||
workspace?: string,
|
||||
): Promise<RemoveResult> {
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface SkillMarketplaceItem extends MarketplaceItemBase {
|
||||
}
|
||||
|
||||
export type MarketplaceItem = McpMarketplaceItem | AgentMarketplaceItem | SkillMarketplaceItem
|
||||
export type MarketplaceItemRef = Pick<MarketplaceItem, "id" | "type">
|
||||
|
||||
export interface InstallMarketplaceItemOptions {
|
||||
target?: "global" | "project"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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<Uint8Array>
|
||||
writeFile: (uri: vscode.Uri, data: Uint8Array) => Promise<void>
|
||||
}
|
||||
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<string, string>, 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)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof mock>
|
||||
refresh: ReturnType<typeof mock>
|
||||
}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<ThemeProvider defaultTheme="kilo-vscode">
|
||||
<DialogProvider>
|
||||
<VSCodeProvider>
|
||||
<ServerProvider>
|
||||
<LanguageBridge>
|
||||
<MarketplaceSessionProvider>
|
||||
<MarketplaceView />
|
||||
</MarketplaceSessionProvider>
|
||||
</LanguageBridge>
|
||||
</ServerProvider>
|
||||
</VSCodeProvider>
|
||||
<Toast.Region />
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
@@ -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(() => <MarketplaceApp />, root)
|
||||
@@ -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<string>(["newTask", "marketplace", "history", "profile", "settings", "subAgentViewer"])
|
||||
type ViewType = "newTask" | "history" | "profile" | "settings" | "subAgentViewer"
|
||||
const VALID_VIEWS = new Set<string>(["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 (
|
||||
<LanguageProvider vscodeLanguage={server.vscodeLanguage} languageOverride={server.languageOverride}>
|
||||
{props.children}
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={currentView() === "marketplace"}>
|
||||
<MarketplaceView />
|
||||
</Match>
|
||||
<Match when={currentView() === "history"}>
|
||||
<HistoryView onSelectSession={handleSelectSession} onBack={() => setCurrentView("newTask")} />
|
||||
</Match>
|
||||
|
||||
@@ -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[] =>
|
||||
|
||||
@@ -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 (
|
||||
<LanguageProvider vscodeLanguage={server.vscodeLanguage} languageOverride={server.languageOverride}>
|
||||
{props.children}
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
@@ -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<string, SessionStatusInfo>
|
||||
}
|
||||
|
||||
const MarketplaceSessionContext = createContext<MarketplaceSessionContextValue>()
|
||||
|
||||
/**
|
||||
* 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<Record<string, SessionStatusInfo>>({})
|
||||
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 (
|
||||
<MarketplaceSessionContext.Provider value={{ allStatusMap: statuses }}>
|
||||
{props.children}
|
||||
</MarketplaceSessionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMarketplaceSession(): MarketplaceSessionContextValue {
|
||||
const context = useContext(MarketplaceSessionContext)
|
||||
if (!context) throw new Error("useMarketplaceSession must be used within MarketplaceSessionProvider")
|
||||
return context
|
||||
}
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user