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:
Imanol Maiztegui
2026-06-03 23:07:11 +02:00
committed by GitHub
parent b6ba97a09f
commit b89d4ea029
23 changed files with 882 additions and 245 deletions
+6
View File
@@ -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(),
])
}
+1
View File
@@ -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}",
+20 -203
View File
@@ -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)
+17 -6
View File
@@ -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)
+3 -23
View File
@@ -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/**/*"
]
}