From 45eadab047e820bc020149e0f7f92e7feb09a8d6 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 23 Jun 2026 13:42:24 +0200 Subject: [PATCH 01/17] feat(vscode): unify marketplace browsing --- .changeset/mix-marketplace-categories.md | 5 ++ .../src/services/marketplace/detection.ts | 12 ++- .../src/services/marketplace/types.ts | 3 +- .../tests/unit/marketplace-actions.test.ts | 57 ++++++++++++ .../tests/unit/marketplace-installer.test.ts | 5 ++ .../src/components/marketplace/ItemCard.tsx | 6 ++ .../marketplace/MarketplaceListView.tsx | 89 ++++++++----------- .../marketplace/MarketplaceView.tsx | 89 ++++--------------- .../components/marketplace/marketplace.css | 40 ++++----- .../src/components/marketplace/utils.ts | 35 +++++++- .../src/stories/marketplace.stories.tsx | 88 ++++++++++-------- .../webview-ui/src/types/marketplace.ts | 5 +- 12 files changed, 239 insertions(+), 195 deletions(-) create mode 100644 .changeset/mix-marketplace-categories.md diff --git a/.changeset/mix-marketplace-categories.md b/.changeset/mix-marketplace-categories.md new file mode 100644 index 00000000000..f922d06b6b7 --- /dev/null +++ b/.changeset/mix-marketplace-categories.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Browse skills, agents, and MCP servers together and filter them by category. diff --git a/packages/kilo-vscode/src/services/marketplace/detection.ts b/packages/kilo-vscode/src/services/marketplace/detection.ts index fb2030beb7d..d7a3a20eb84 100644 --- a/packages/kilo-vscode/src/services/marketplace/detection.ts +++ b/packages/kilo-vscode/src/services/marketplace/detection.ts @@ -5,6 +5,10 @@ import { MarketplacePaths } from "./paths" type Entry = [string, { type: string }] +function entry(id: string, type: "agent" | "mcp" | "skill"): Entry { + return [`${type}:${id}`, { type }] +} + export interface CliSkill { name: string location: string @@ -52,7 +56,7 @@ export class InstallationDetector { ? !!workspace && this.isProjectSkill(s.location, workspace) : !workspace || !this.isProjectSkill(s.location, workspace), ) - .map((s) => [s.name, { type: "skill" }]) + .map((skill) => entry(skill.name, "skill")) } /** Scan .kilo/agents/*.md files to detect installed marketplace agents. */ @@ -60,7 +64,7 @@ export class InstallationDetector { const dir = this.paths.agentsDir(scope, workspace) try { const files = await fs.readdir(dir) - return files.filter((f) => f.endsWith(".md")).map((f) => [path.basename(f, ".md"), { type: "agent" }] as Entry) + return files.filter((file) => file.endsWith(".md")).map((file) => entry(path.basename(file, ".md"), "agent")) } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { console.warn(`Failed to detect agent files from ${dir}:`, err) @@ -78,13 +82,13 @@ export class InstallationDetector { if (parsed?.mcp && typeof parsed.mcp === "object") { for (const key of Object.keys(parsed.mcp)) { - entries.push([key, { type: "mcp" }]) + entries.push(entry(key, "mcp")) } } if (parsed?.agent && typeof parsed.agent === "object") { for (const key of Object.keys(parsed.agent)) { - entries.push([key, { type: "agent" }]) + entries.push(entry(key, "agent")) } } diff --git a/packages/kilo-vscode/src/services/marketplace/types.ts b/packages/kilo-vscode/src/services/marketplace/types.ts index 81f1c6f031f..1f544860b01 100644 --- a/packages/kilo-vscode/src/services/marketplace/types.ts +++ b/packages/kilo-vscode/src/services/marketplace/types.ts @@ -16,9 +16,9 @@ export interface MarketplaceItemBase { id: string name: string description: string + category: string author?: string authorUrl?: string - tags?: string[] prerequisites?: string[] } @@ -52,7 +52,6 @@ export interface RawSkill { export interface SkillMarketplaceItem extends MarketplaceItemBase { type: "skill" - category: string githubUrl: string content: string displayName: string diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index d0f5962110b..aacc681a9ff 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -7,6 +7,8 @@ import { type MarketplaceRemoveContext, } from "../../src/services/marketplace/actions" import type { McpMarketplaceItem } from "../../src/services/marketplace/types" +import { filterItems, installedScopes } from "../../webview-ui/src/components/marketplace/utils" +import type { MarketplaceItem } from "../../webview-ui/src/types/marketplace" const project = "/repo" const storage = vscode.Uri.file("/storage") @@ -18,6 +20,7 @@ const item: McpMarketplaceItem = { type: "mcp", name: "Memory", description: "", + category: "development", url: "", content: "", } @@ -62,6 +65,60 @@ afterEach(() => { fs.writeFile = original.writeFile }) +describe("Marketplace installation metadata", () => { + it("tracks colliding IDs independently by item type", () => { + const metadata = { + project: { + "mcp:dbt": { type: "mcp" }, + "skill:dbt": { type: "skill" }, + }, + global: {}, + } + + expect(installedScopes("dbt", "mcp", metadata)).toEqual(["project"]) + expect(installedScopes("dbt", "skill", metadata)).toEqual(["project"]) + expect(installedScopes("dbt", "agent", metadata)).toEqual([]) + }) + + it("filters the mixed list by search, category, and status", () => { + const items: MarketplaceItem[] = [ + { + type: "agent", + id: "reviewer", + name: "Code Reviewer", + description: "Reviews code", + category: "development", + content: { mode: "all", description: "Reviews code", prompt: "Review" }, + }, + { + type: "mcp", + id: "warehouse", + name: "Warehouse", + description: "Queries data", + category: "data", + url: "https://example.com", + content: "{}", + }, + { + type: "skill", + id: "campaign-writer", + name: "Campaign Writer", + displayName: "Campaign Writer", + description: "Writes campaigns", + category: "business", + displayCategory: "Business", + githubUrl: "https://example.com", + content: "https://example.com/skill.tar.gz", + }, + ] + const metadata = { project: { "mcp:warehouse": { type: "mcp" } }, global: {} } + + expect(filterItems(items, metadata, "reviewer", "all", []).map((item) => item.id)).toEqual(["reviewer"]) + expect(filterItems(items, metadata, "", "all", ["business"]).map((item) => item.id)).toEqual(["campaign-writer"]) + expect(filterItems(items, metadata, "", "installed", []).map((item) => item.id)).toEqual(["warehouse"]) + }) +}) + describe("Marketplace legacy MCP cleanup", () => { it("preserves global legacy config during project removal", async () => { const files = setup() diff --git a/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts b/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts index 1991576cece..a576f64d95c 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts @@ -55,6 +55,7 @@ describe("MarketplaceInstaller MCP format normalization", () => { id: "memory", name: "Memory", description: "test", + category: "development", url: "https://example.com", content: JSON.stringify({ command: "npx", @@ -81,6 +82,7 @@ describe("MarketplaceInstaller MCP format normalization", () => { id: "myremote", name: "Remote", description: "test", + category: "development", url: "https://example.com", content: JSON.stringify({ type: "sse", @@ -105,6 +107,7 @@ describe("MarketplaceInstaller MCP format normalization", () => { id: "already", name: "Already Done", description: "test", + category: "development", url: "https://example.com", content: JSON.stringify({ type: "local", @@ -153,6 +156,7 @@ describe("MarketplaceInstaller skills", () => { id: "test-mcp", name: "Test MCP", description: "test", + category: "development", url: "https://example.com", content: "{}", }, @@ -164,6 +168,7 @@ describe("MarketplaceInstaller skills", () => { id: "test-agent", name: "Test Agent", description: "test", + category: "development", content: { mode: "all", description: "test", prompt: "test" }, }, "project", diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/ItemCard.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/ItemCard.tsx index b74e556edeb..c82127b96d1 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/ItemCard.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/ItemCard.tsx @@ -23,6 +23,11 @@ export const ItemCard = (props: Props) => { const scopes = () => installedScopes(props.item.id, props.item.type, props.metadata) const installed = () => scopes().length > 0 const name = () => props.displayName ?? props.item.name + const type = () => { + if (props.item.type === "mcp") return t("marketplace.badge.mcpServer") + if (props.item.type === "agent") return t("marketplace.remove.type.agent") + return t("marketplace.remove.type.skill") + } const [expanded, setExpanded] = createSignal(false) const [clamped, setClamped] = createSignal(false) let ref: HTMLParagraphElement | undefined @@ -44,6 +49,7 @@ export const ItemCard = (props: Props) => { {name()} + {type()} diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx index 8bb615fa386..4bbba1f7e03 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -1,4 +1,4 @@ -import { createSignal, createMemo, For, Show } from "solid-js" +import { createSignal, createMemo, createEffect, For, Show } from "solid-js" import { TextField } from "@kilocode/kilo-ui/text-field" import { Select } from "@kilocode/kilo-ui/select" import { Tag } from "@kilocode/kilo-ui/tag" @@ -10,7 +10,7 @@ import type { MarketplaceInstalledMetadata, } from "../../types/marketplace" import { useLanguage } from "../../context/language" -import { isInstalled } from "./utils" +import { filterItems } from "./utils" import { ItemCard } from "./ItemCard" import { MarketplaceContribute } from "./MarketplaceContribute" @@ -23,7 +23,6 @@ interface Props { items: MarketplaceItem[] metadata: MarketplaceInstalledMetadata fetching: boolean - type: "mcp" | "agent" | "skill" searchPlaceholder: string emptyMessage: string onInstall: (item: MarketplaceItem) => void @@ -34,7 +33,7 @@ export const MarketplaceListView = (props: Props) => { const { t } = useLanguage() const [search, setSearch] = createSignal("") const [status, setStatus] = createSignal({ value: "all", label: t("marketplace.filter.all") }) - const [tags, setTags] = createSignal([]) + const [categories, setCategories] = createSignal([]) const options = (): StatusOption[] => [ { value: "all", label: t("marketplace.filter.all") }, @@ -42,52 +41,33 @@ export const MarketplaceListView = (props: Props) => { { value: "notInstalled", label: t("marketplace.filter.notInstalled") }, ] - const tagsFor = (item: MarketplaceItem): string[] => { - if (item.type === "skill") return [(item as SkillMarketplaceItem).displayCategory] - return item.tags ?? [] - } + const label = (category: string) => + category + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") - const allTags = createMemo(() => { - const counts = new Map() - for (const item of props.items) { - for (const tag of tagsFor(item)) counts.set(tag, (counts.get(tag) ?? 0) + 1) - } - const min = props.type === "mcp" ? 5 : 1 - return Array.from(counts.entries()) - .filter(([, n]) => n >= min) - .map(([tag]) => tag) - .sort() - }) + const allCategories = createMemo(() => Array.from(new Set(props.items.map((item) => item.category))).sort()) - const toggleTag = (tag: string) => { - const current = tags() - if (current.includes(tag)) { - setTags(current.filter((t) => t !== tag)) - } else { - setTags([...current, tag]) - } - } - - const filtered = createMemo(() => { - const q = search().toLowerCase() - const s = status().value - const active = tags() - return props.items.filter((item) => { - if (s === "installed" && !isInstalled(item.id, item.type, props.metadata)) return false - if (s === "notInstalled" && isInstalled(item.id, item.type, props.metadata)) return false - if (active.length > 0 && !active.some((tag) => tagsFor(item).includes(tag))) return false - if (!q) return true - const skill = item.type === "skill" ? (item as SkillMarketplaceItem) : undefined - return ( - item.id.toLowerCase().includes(q) || - item.name.toLowerCase().includes(q) || - item.description.toLowerCase().includes(q) || - (item.author?.toLowerCase().includes(q) ?? false) || - (skill?.displayName.toLowerCase().includes(q) ?? false) - ) + createEffect(() => { + const available = new Set(allCategories()) + setCategories((current) => { + const next = current.filter((category) => available.has(category)) + return next.length === current.length ? current : next }) }) + const toggleCategory = (category: string) => { + const current = categories() + if (current.includes(category)) { + setCategories(current.filter((value) => value !== category)) + return + } + setCategories([...current, category]) + } + + const filtered = createMemo(() => filterItems(props.items, props.metadata, search(), status().value, categories())) + return (
@@ -102,16 +82,17 @@ export const MarketplaceListView = (props: Props) => { onSelect={(v: StatusOption | undefined) => v && setStatus(v)} />
- 0}> -
- - {(tag) => ( + 0}> +
+ + {(category) => ( )} @@ -147,7 +128,7 @@ export const MarketplaceListView = (props: Props) => { linkUrl={skill?.githubUrl ?? mcp?.url} onInstall={props.onInstall} onRemove={props.onRemove} - footer={{(tag) => {tag}}} + footer={{label(item.category)}} /> ) }} diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx index b8deefc54e3..8892ec1aa11 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -1,18 +1,11 @@ -import { createSignal, createMemo, createEffect, onCleanup, onMount, Show } from "solid-js" -import { Tabs } from "@kilocode/kilo-ui/tabs" +import { createSignal, createEffect, onCleanup, onMount, Show } from "solid-js" import { Card } from "@kilocode/kilo-ui/card" import { Button } from "@kilocode/kilo-ui/button" import { useVSCode } from "../../context/vscode" import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { useDialog } from "@kilocode/kilo-ui/context/dialog" -import type { - MarketplaceItem, - McpMarketplaceItem, - AgentMarketplaceItem, - SkillMarketplaceItem, - MarketplaceInstalledMetadata, -} from "../../types/marketplace" +import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../../types/marketplace" import { TelemetryEventName } from "../../../../src/services/telemetry/types" import { MarketplaceListView } from "./MarketplaceListView" import { InstallModal } from "./InstallModal" @@ -31,14 +24,9 @@ export const MarketplaceView = () => { const [metadata, setMetadata] = createSignal(EMPTY_METADATA) const [fetching, setFetching] = createSignal(true) const [errors, setErrors] = createSignal([]) - const [tab, setTab] = createSignal("agent") const [pending, setPending] = createSignal<{ item: MarketplaceItem; scope: "project" | "global" } | null>(null) const [showMigrationBanner, setShowMigrationBanner] = createSignal(false) - const skills = createMemo(() => items().filter((i): i is SkillMarketplaceItem => i.type === "skill")) - const mcps = createMemo(() => items().filter((i): i is McpMarketplaceItem => i.type === "mcp")) - const agents = createMemo(() => items().filter((i): i is AgentMarketplaceItem => i.type === "agent")) - const fetchData = () => { setFetching(true) vscode.postMessage({ type: "fetchMarketplaceData" }) @@ -158,62 +146,23 @@ export const MarketplaceView = () => { ))} - - - {t("marketplace.tab.agents")} - {t("marketplace.tab.mcp")} - {t("marketplace.tab.skills")} - - -
- - - - {t("marketplace.migration.notice")} - - - - - - - - - - - - - -
-
+ + + {t("marketplace.migration.notice")} + + + +
) } diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css index b783ef5b59a..45beaca8006 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css @@ -9,25 +9,12 @@ gap: 8px; } -.marketplace-tabs-root { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; -} - -.marketplace-content { - flex: 1; - overflow: auto; -} - /* List layout */ .marketplace-list { display: flex; flex-direction: column; gap: 12px; - padding-top: 12px; } .marketplace-filters { @@ -40,30 +27,31 @@ flex: 1; } -.marketplace-category-filters { - overflow-x: auto; -} +/* Category filters */ -/* Tag filters */ - -.marketplace-active-tags { +.marketplace-categories { display: flex; flex-wrap: wrap; gap: 4px; } -.marketplace-tag-filter { +.marketplace-category-filter { all: unset; cursor: pointer; opacity: 0.5; transition: opacity 0.15s ease; } -.marketplace-tag-filter:hover { +.marketplace-category-filter:hover { opacity: 0.8; } -.marketplace-tag-filter.active { +.marketplace-category-filter:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.marketplace-category-filter.active { opacity: 1; } @@ -210,6 +198,10 @@ /* Installed badge */ +.marketplace-badge-type { + flex-shrink: 0; +} + .marketplace-badge-installed { color: var(--text-on-success-base) !important; border-color: var(--border-success-base) !important; @@ -326,11 +318,11 @@ body.vscode-high-contrast-light { border: 1px solid var(--vscode-contrastBorder, transparent); } - .marketplace-tag-filter { + .marketplace-category-filter { border: 1px solid var(--vscode-contrastBorder, transparent); } - .marketplace-tag-filter.active { + .marketplace-category-filter.active { border-color: var(--vscode-contrastActiveBorder, var(--vscode-focusBorder)); } diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts index 9504e734f37..66a7910a2aa 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts @@ -1,4 +1,4 @@ -import type { MarketplaceInstalledMetadata } from "../../types/marketplace" +import type { MarketplaceInstalledMetadata, MarketplaceItem } from "../../types/marketplace" export function isInstalled( id: string, @@ -14,7 +14,36 @@ export function installedScopes( metadata: MarketplaceInstalledMetadata, ): ("project" | "global")[] { const scopes: ("project" | "global")[] = [] - if (metadata.project[id]?.type === type) scopes.push("project") - if (metadata.global[id]?.type === type) scopes.push("global") + const key = `${type}:${id}` + if (metadata.project[key]?.type === type) scopes.push("project") + if (metadata.global[key]?.type === type) scopes.push("global") return scopes } + +export function filterItems( + items: MarketplaceItem[], + metadata: MarketplaceInstalledMetadata, + search: string, + status: string, + categories: string[], +): MarketplaceItem[] { + const query = search.trim().toLowerCase() + return items + .filter((item) => { + if (status === "installed" && !isInstalled(item.id, item.type, metadata)) return false + if (status === "notInstalled" && isInstalled(item.id, item.type, metadata)) return false + if (categories.length > 0 && !categories.includes(item.category)) return false + if (!query) return true + const skill = item.type === "skill" ? item : undefined + return ( + item.id.toLowerCase().includes(query) || + item.name.toLowerCase().includes(query) || + item.description.toLowerCase().includes(query) || + item.category.toLowerCase().includes(query) || + item.type.includes(query) || + (item.author?.toLowerCase().includes(query) ?? false) || + (skill?.displayName.toLowerCase().includes(query) ?? false) + ) + }) + .sort((a, b) => a.name.localeCompare(b.name)) +} diff --git a/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx index 62b7f321f1b..498bac128b0 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx @@ -115,7 +115,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [ '{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } }', parameters: [{ name: "GitHub Token", key: "GITHUB_TOKEN", placeholder: "ghp_xxxxxxxxxxxx" }], author: "Anthropic", - tags: ["version-control", "development"], + category: "development", }, { type: "mcp", @@ -143,7 +143,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [ }, ], author: "Anthropic", - tags: ["database", "sql"], + category: "data", }, { type: "mcp", @@ -153,7 +153,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [ url: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", content: '{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "${ALLOWED_DIR}"] }', parameters: [{ name: "Allowed Directory", key: "ALLOWED_DIR", placeholder: "/path/to/directory" }], - tags: ["filesystem", "development"], + category: "development", }, { type: "mcp", @@ -165,7 +165,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [ '{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-slack"], "env": { "SLACK_TOKEN": "${SLACK_TOKEN}" } }', parameters: [{ name: "Slack Bot Token", key: "SLACK_TOKEN", placeholder: "xoxb-xxxxxxxxxxxx" }], author: "Anthropic", - tags: ["communication", "productivity"], + category: "productivity", }, { type: "mcp", @@ -176,7 +176,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [ content: '{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"], "env": { "BRAVE_API_KEY": "${BRAVE_API_KEY}" } }', parameters: [{ name: "API Key", key: "BRAVE_API_KEY", placeholder: "BSA-xxxxxxxxxxxx" }], - tags: ["search", "web"], + category: "search", }, { type: "mcp", @@ -186,7 +186,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [ url: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer", content: '{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-puppeteer"] }', prerequisites: ["Chrome or Chromium must be installed"], - tags: ["browser", "automation", "web"], + category: "web-automation", }, ] @@ -205,7 +205,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [ permission: { read: "allow", edit: "deny", bash: "deny", mcp: "deny", question: "allow" }, }, author: "Kilo", - tags: ["planning", "design"], + category: "development", }, { type: "agent", @@ -221,7 +221,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [ permission: { read: "allow", edit: "deny", bash: "allow", mcp: "deny", question: "allow" }, }, author: "Kilo", - tags: ["review", "quality"], + category: "development", }, { type: "agent", @@ -235,7 +235,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [ options: { displayName: "Documentation Writer" }, permission: { read: "allow", edit: "allow", bash: "allow", mcp: "deny", question: "allow" }, }, - tags: ["documentation", "writing"], + category: "business", }, { type: "agent", @@ -251,7 +251,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [ permission: { read: "allow", edit: "allow", bash: "allow", mcp: "allow", question: "allow" }, }, author: "Community", - tags: ["testing", "methodology"], + category: "development", }, { type: "agent", @@ -265,25 +265,33 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [ options: { displayName: "Debugger" }, permission: { read: "allow", edit: "allow", bash: "allow", mcp: "deny", question: "allow" }, }, - tags: ["debugging", "troubleshooting"], + category: "development", }, ] const EMPTY_METADATA: MarketplaceInstalledMetadata = { project: {}, global: {} } const PARTIAL_INSTALLED_SKILLS: MarketplaceInstalledMetadata = { - project: { "nextjs-developer": { type: "skill" } }, - global: { "python-data-science": { type: "skill" } }, + project: { "skill:nextjs-developer": { type: "skill" } }, + global: { "skill:python-data-science": { type: "skill" } }, } const PARTIAL_INSTALLED_MCPS: MarketplaceInstalledMetadata = { - project: { "github-mcp": { type: "mcp" } }, - global: { "postgres-mcp": { type: "mcp" } }, + project: { "mcp:github-mcp": { type: "mcp" } }, + global: { "mcp:postgres-mcp": { type: "mcp" } }, } const PARTIAL_INSTALLED_AGENTS: MarketplaceInstalledMetadata = { - project: { architect: { type: "agent" } }, - global: { reviewer: { type: "agent" } }, + project: { "agent:architect": { type: "agent" } }, + global: { "agent:reviewer": { type: "agent" } }, +} + +const PARTIAL_INSTALLED_MIXED: MarketplaceInstalledMetadata = { + project: { + "agent:architect": { type: "agent" }, + "mcp:github-mcp": { type: "mcp" }, + }, + global: { "skill:python-data-science": { type: "skill" } }, } const noop = () => {} @@ -292,8 +300,27 @@ const noop = () => {} // Stories // --------------------------------------------------------------------------- +export const MixedListWithItems: Story = { + name: "Mixed list — categories and installed items", + render: () => ( + +
+ +
+
+ ), +} + export const SkillsTabWithItems: Story = { - name: "Skills tab — with items", + name: "Skills list — with items", render: () => (
@@ -301,7 +328,6 @@ export const SkillsTabWithItems: Story = { items={MOCK_SKILLS} metadata={EMPTY_METADATA} fetching={false} - type="skill" searchPlaceholder="Search skills..." emptyMessage="No skills found" onInstall={noop} @@ -313,7 +339,7 @@ export const SkillsTabWithItems: Story = { } export const SkillsTabWithInstalled: Story = { - name: "Skills tab — some installed", + name: "Skills list — some installed", render: () => (
@@ -321,7 +347,6 @@ export const SkillsTabWithInstalled: Story = { items={MOCK_SKILLS} metadata={PARTIAL_INSTALLED_SKILLS} fetching={false} - type="skill" searchPlaceholder="Search skills..." emptyMessage="No skills found" onInstall={noop} @@ -333,7 +358,7 @@ export const SkillsTabWithInstalled: Story = { } export const SkillsTabEmpty: Story = { - name: "Skills tab — empty state", + name: "Skills list — empty state", render: () => (
@@ -341,7 +366,6 @@ export const SkillsTabEmpty: Story = { items={[]} metadata={EMPTY_METADATA} fetching={false} - type="skill" searchPlaceholder="Search skills..." emptyMessage="No skills found" onInstall={noop} @@ -393,7 +417,7 @@ export const InstalledSkillCard: Story = { // --------------------------------------------------------------------------- export const McpTabWithItems: Story = { - name: "MCP tab — with items", + name: "MCP list — with items", render: () => (
@@ -401,7 +425,6 @@ export const McpTabWithItems: Story = { items={MOCK_MCPS} metadata={EMPTY_METADATA} fetching={false} - type="mcp" searchPlaceholder="Search MCP servers..." emptyMessage="No MCP servers found" onInstall={noop} @@ -413,7 +436,7 @@ export const McpTabWithItems: Story = { } export const McpTabWithInstalled: Story = { - name: "MCP tab — some installed", + name: "MCP list — some installed", render: () => (
@@ -421,7 +444,6 @@ export const McpTabWithInstalled: Story = { items={MOCK_MCPS} metadata={PARTIAL_INSTALLED_MCPS} fetching={false} - type="mcp" searchPlaceholder="Search MCP servers..." emptyMessage="No MCP servers found" onInstall={noop} @@ -433,7 +455,7 @@ export const McpTabWithInstalled: Story = { } export const McpTabEmpty: Story = { - name: "MCP tab — empty state", + name: "MCP list — empty state", render: () => (
@@ -441,7 +463,6 @@ export const McpTabEmpty: Story = { items={[]} metadata={EMPTY_METADATA} fetching={false} - type="mcp" searchPlaceholder="Search MCP servers..." emptyMessage="No MCP servers found" onInstall={noop} @@ -491,7 +512,7 @@ export const InstalledMcpCard: Story = { // --------------------------------------------------------------------------- export const AgentsTabWithItems: Story = { - name: "Agents tab — with items", + name: "Agents list — with items", render: () => (
@@ -499,7 +520,6 @@ export const AgentsTabWithItems: Story = { items={MOCK_AGENTS} metadata={EMPTY_METADATA} fetching={false} - type="agent" searchPlaceholder="Search agents..." emptyMessage="No agents found" onInstall={noop} @@ -511,7 +531,7 @@ export const AgentsTabWithItems: Story = { } export const AgentsTabWithInstalled: Story = { - name: "Agents tab — some installed", + name: "Agents list — some installed", render: () => (
@@ -519,7 +539,6 @@ export const AgentsTabWithInstalled: Story = { items={MOCK_AGENTS} metadata={PARTIAL_INSTALLED_AGENTS} fetching={false} - type="agent" searchPlaceholder="Search agents..." emptyMessage="No agents found" onInstall={noop} @@ -531,7 +550,7 @@ export const AgentsTabWithInstalled: Story = { } export const AgentsTabEmpty: Story = { - name: "Agents tab — empty state", + name: "Agents list — empty state", render: () => (
@@ -539,7 +558,6 @@ export const AgentsTabEmpty: Story = { items={[]} metadata={EMPTY_METADATA} fetching={false} - type="agent" searchPlaceholder="Search agents..." emptyMessage="No agents found" onInstall={noop} diff --git a/packages/kilo-vscode/webview-ui/src/types/marketplace.ts b/packages/kilo-vscode/webview-ui/src/types/marketplace.ts index 27f83a2b782..954c4b6c159 100644 --- a/packages/kilo-vscode/webview-ui/src/types/marketplace.ts +++ b/packages/kilo-vscode/webview-ui/src/types/marketplace.ts @@ -16,9 +16,9 @@ export interface MarketplaceItemBase { id: string name: string description: string + category: string author?: string authorUrl?: string - tags?: string[] prerequisites?: string[] } @@ -44,7 +44,6 @@ export interface AgentMarketplaceItem extends MarketplaceItemBase { export interface SkillMarketplaceItem extends MarketplaceItemBase { type: "skill" - category: string githubUrl: string content: string displayName: string @@ -66,5 +65,5 @@ export interface MarketplaceInstalledMetadata { export interface MarketplaceFilters { type?: string search?: string - tags?: string[] + categories?: string[] } From 455848d46b64a4052422a3ab2d68bfd2bf68baf7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 23 Jun 2026 11:46:12 +0000 Subject: [PATCH 02/17] chore: update kilo-vscode visual regression baselines --- .../marketplace/agents-tab-empty-chromium-linux.png | 4 ++-- .../marketplace/agents-tab-with-installed-chromium-linux.png | 4 ++-- .../marketplace/agents-tab-with-items-chromium-linux.png | 4 ++-- .../marketplace/mcp-tab-empty-chromium-linux.png | 4 ++-- .../marketplace/mcp-tab-with-installed-chromium-linux.png | 4 ++-- .../marketplace/mcp-tab-with-items-chromium-linux.png | 4 ++-- .../marketplace/mixed-list-with-items-chromium-linux.png | 3 +++ .../marketplace/skills-tab-with-installed-chromium-linux.png | 4 ++-- .../marketplace/skills-tab-with-items-chromium-linux.png | 4 ++-- 9 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png index fd503e50e61..caa3cb9437a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7338febdf802b5ce501773be164e9da152fb7903e9b16807bd8751fa38f208ed -size 10310 +oid sha256:c73b6d01ced180f3a6175159b0b219c38fb7be40093ca6bbfcf7fda83d19ab2b +size 10365 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png index 071927ae44d..06dcbee6bbd 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7e075b4961d3a62c8deda6b481f64cb8777d05183a9c10d0dd2373b7972d3ab -size 53678 +oid sha256:1e671d8d2ed33d57b30c5dc6d5f704cdc005bb545cda472e2b15c3ebd504654d +size 54302 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png index 158e7e0bdcc..2ee5f00f21c 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0dd1dc26b28d354ecd14c53dd1b9e6158a161f47152f30299ba4c0827ce1674 -size 50904 +oid sha256:58b200204269bee41139d86dc506a71a57c6589e9e95e6e2b4c651a0ba2779df +size 51429 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png index e6db4c0c7e4..45809309bec 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1edca81fb2c946f87df5436684418e63b7a9c761079e44f024971a81316b8263 -size 6549 +oid sha256:532ce502f99b7f88d6b01bedbb8cbda01adb3abdbf0886f1f67b85212de78e35 +size 11008 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png index 59da02c6806..acfb685a306 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce4ba314abb23bb43694d512ade273d99760f250b061e235899d86e170bb5f9a -size 52892 +oid sha256:442822e2b6ca1f759c18c17e0dad396333ba9eff4d0ceaeb836d7b8e9b484977 +size 57102 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png index 62f2ae9b606..a50276b45b6 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:631d41ecf6216249943722a797f75c3aefb2f84ebdb7ee80529662a26fa35af5 -size 50206 +oid sha256:ad0d1aa853a03f280a20074c2233e85857c2d644182d0efb8484d4fb0ce8f00e +size 54311 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png new file mode 100644 index 00000000000..d7bf6d29366 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:099bfba94c6b1e9a345e404fc658b0e60a8fafbc3d4724a7757b618a83f37f40 +size 52577 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png index d304537721d..133c7280543 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c35bc13f78745ab41bf39e732c3945578fc2cb2cc47ef231e51b1357bc0b2817 -size 54160 +oid sha256:d09dfc5fc3adf5d8ea92f011af46cdcc750a4a1880463bda13c7cd9c3b961843 +size 56181 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png index 0bb9c058098..51b6ba8d1b0 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52c6eeef2d393b159fe9e88a9d283308d969b7311fe68ea23667561d21a2ed87 -size 51338 +oid sha256:800271cd7f29ba5e332538ad683215e825b62c5a0676b73b3f081aec7da4ae1b +size 53283 From f69d1cd4be6ba4c7578ca95d7e4602e11d8c56ac Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 17:44:42 +0200 Subject: [PATCH 03/17] fix(cli): bound snapshot stalls during turns --- .changeset/bound-snapshot-turns.md | 5 + .../opencode/src/kilocode/question/index.ts | 11 ++ .../opencode/src/kilocode/session/fork.ts | 2 + .../src/kilocode/session/part-lifecycle.ts | 9 + .../opencode/src/kilocode/snapshot/track.ts | 176 +++++++++++++----- packages/opencode/src/question/index.ts | 8 +- packages/opencode/src/snapshot/index.ts | 36 ++-- .../test/kilocode/question-cancel.test.ts | 59 ++++++ .../test/kilocode/session-fork-remap.test.ts | 45 +++++ .../kilocode/snapshot-track-timeout.test.ts | 95 +++++++++- 10 files changed, 379 insertions(+), 67 deletions(-) create mode 100644 .changeset/bound-snapshot-turns.md create mode 100644 packages/opencode/src/kilocode/session/part-lifecycle.ts create mode 100644 packages/opencode/test/kilocode/question-cancel.test.ts diff --git a/.changeset/bound-snapshot-turns.md b/.changeset/bound-snapshot-turns.md new file mode 100644 index 00000000000..7a2cb9871eb --- /dev/null +++ b/.changeset/bound-snapshot-turns.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep turns responsive when snapshot infrastructure stalls and prevent transient snapshot progress from appearing in forked sessions. diff --git a/packages/opencode/src/kilocode/question/index.ts b/packages/opencode/src/kilocode/question/index.ts index d1cf8741483..eb250ba484e 100644 --- a/packages/opencode/src/kilocode/question/index.ts +++ b/packages/opencode/src/kilocode/question/index.ts @@ -48,6 +48,17 @@ export namespace KiloQuestion { } }) + /** Publishes the terminal event when a pending question effect is interrupted. */ + export const finalize = (input: { + pending: Map + id: ID + publishRejected: () => Effect.Effect + }) => + Effect.gen(function* () { + if (!input.pending.delete(input.id)) return + yield* input.publishRejected() + }) + /** * Auto-dismiss when a newer prompt is already queued on this session — a * tool that calls `Question.ask` after the queue event would otherwise block diff --git a/packages/opencode/src/kilocode/session/fork.ts b/packages/opencode/src/kilocode/session/fork.ts index 3e804f9d39e..14d943e7e2b 100644 --- a/packages/opencode/src/kilocode/session/fork.ts +++ b/packages/opencode/src/kilocode/session/fork.ts @@ -3,6 +3,7 @@ import { SessionID } from "@/session/schema" import { Database } from "@/storage/db" import { SyncEvent } from "@/sync" import { Effect } from "effect" +import { KiloPartLifecycle } from "./part-lifecycle" const task = "task" const stale = /^[ \t]*task_id:[^\r\n]*(?:(?:\r?\n){1,2}|$)/m @@ -17,6 +18,7 @@ export function writer(sessionID: SessionID, sync: SyncEvent.Interface) { return info }, part(part: MessageV2.Part) { + if (KiloPartLifecycle.transient(part)) return items.push({ type: "part", part: structuredClone(detachPart(part)), time: Date.now() }) }, commit() { diff --git a/packages/opencode/src/kilocode/session/part-lifecycle.ts b/packages/opencode/src/kilocode/session/part-lifecycle.ts new file mode 100644 index 00000000000..c518b6f7ab4 --- /dev/null +++ b/packages/opencode/src/kilocode/session/part-lifecycle.ts @@ -0,0 +1,9 @@ +import { MessageV2 } from "@/session/message-v2" + +export namespace KiloPartLifecycle { + export const key = "kilocode.lifecycle" + + export function transient(part: MessageV2.Part) { + return part.type === "text" && part.metadata?.[key] === "transient" + } +} diff --git a/packages/opencode/src/kilocode/snapshot/track.ts b/packages/opencode/src/kilocode/snapshot/track.ts index 1e284a8add3..cc252028be3 100644 --- a/packages/opencode/src/kilocode/snapshot/track.ts +++ b/packages/opencode/src/kilocode/snapshot/track.ts @@ -40,13 +40,14 @@ // All of this is Kilo-specific — the upstream snapshot module remains a thin // shim that calls into here. -import { Duration, Effect, Fiber } from "effect" +import { Duration, Effect, Fiber, Option } from "effect" import { applyEdits, modify } from "jsonc-parser" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Question } from "@/question" import type { MessageID, PartID, SessionID } from "@/session/schema" import { PartID as PartIDSchema } from "@/session/schema" import type { MessageV2 } from "@/session/message-v2" +import { KiloPartLifecycle } from "@/kilocode/session/part-lifecycle" import { KilocodeConfig } from "@/kilocode/config/config" import { ConfigParse } from "@/config/parse" import * as Log from "@opencode-ai/core/util/log" @@ -83,14 +84,18 @@ type SessionRuntime = { export namespace KiloSnapshotTrack { const log = Log.create({ service: "snapshot.track" }) - export const TIMEOUT_MS = iife(() => { - const raw = process.env["KILO_SNAPSHOT_TRACK_TIMEOUT_MS"] - if (raw) { - const parsed = Number(raw) - if (Number.isFinite(parsed) && parsed > 0) return parsed - } - return 10_000 - }) + const duration = (name: string, fallback: number) => + iife(() => { + const raw = process.env[name] + if (raw) { + const parsed = Number(raw) + if (Number.isFinite(parsed) && parsed > 0) return parsed + } + return fallback + }) + + export const TIMEOUT_MS = duration("KILO_SNAPSHOT_TRACK_TIMEOUT_MS", 10_000) + export const TURN_TIMEOUT_MS = duration("KILO_SNAPSHOT_TURN_TIMEOUT_MS", 120_000) // Wire values — also function as i18n keys via `labelKey`/`headerKey`. // The backend matches replies on `label`, so the canonical English strings @@ -140,6 +145,65 @@ export namespace KiloSnapshotTrack { asked: false, }) + export const makeStates = () => { + const states = new Map() + return (directory: string) => { + const found = states.get(directory) + if (found) return found + const state = makeState() + states.set(directory, state) + return state + } + } + + export interface ProtectInput { + readonly inner: Effect.Effect + readonly state: State + readonly fallback: A + readonly operation: "track" | "patch" + readonly timeoutMs?: number + } + + /** + * Enforces the turn-facing snapshot availability budget without waiting for + * cancellation. Snapshot tracking and patching are optional metadata work; + * once either exceeds this budget, later calls in the same directory bypass + * the potentially poisoned lock owner for the lifetime of this service. + */ + export const protect = (input: ProtectInput): Effect.Effect => + Effect.gen(function* () { + if (input.state.disabledForSession) return input.fallback + const timeoutMs = input.timeoutMs ?? TURN_TIMEOUT_MS + return yield* Effect.acquireUseRelease( + Effect.forkDetach(input.inner, { startImmediately: true }), + (fiber) => + Effect.gen(function* () { + const result = yield* Fiber.join(fiber).pipe( + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.catchCause((cause) => { + input.state.disabledForSession = true + log.error("snapshot turn operation failed; bypassing snapshots for this directory", { + cause, + operation: input.operation, + }) + return Effect.succeed(Option.some(input.fallback)) + }), + ) + if (Option.isSome(result)) return result.value + input.state.disabledForSession = true + log.warn("snapshot turn operation exceeded availability budget; bypassing snapshots for this directory", { + operation: input.operation, + timeoutMs, + }) + return input.fallback + }), + (fiber) => + Effect.sync(() => { + setTimeout(() => Effect.runFork(Fiber.interrupt(fiber)), 0) + }), + ) + }) + /** Answer shape returned by `askUser`. Three-valued because dismiss !== disable. */ export type Answer = "continue" | "disable" | "dismissed" @@ -152,7 +216,7 @@ export namespace KiloSnapshotTrack { */ export interface Hooks { /** Ask the user. Returns "dismissed" if the question is rejected. */ - readonly ask: (input: { sessionID: SessionID }) => Promise + readonly ask: (input: { sessionID: SessionID }, signal?: AbortSignal) => Promise /** Persist `"snapshot": false` to the project config without disposing the instance. */ readonly persistDisable: () => Promise /** Publish the synthetic progress part allocated by the wrapper. */ @@ -383,7 +447,7 @@ export namespace KiloSnapshotTrack { input.state.owner = owner const sessionID = input.sessionID - const answer = yield* Effect.promise(() => hooks.ask({ sessionID })) + const answer = yield* Effect.promise((signal) => hooks.ask({ sessionID }, signal)) if (answer === "continue") { log.info("user chose to keep waiting for snapshot; joining fiber") @@ -443,7 +507,7 @@ export namespace KiloSnapshotTrack { } /** Build the synthetic progress part payload so both start/update share one shape. */ - const progressPart = (input: { + export const progressPart = (input: { sessionID: SessionID messageID: MessageID partID: PartID @@ -455,6 +519,7 @@ export namespace KiloSnapshotTrack { type: "text", text: input.text, synthetic: true, + metadata: { [KiloPartLifecycle.key]: "transient" }, }) export const defaultHooks: Hooks = { @@ -503,46 +568,55 @@ export namespace KiloSnapshotTrack { ) }, - async ask(input) { - const answers = await questionRt - .runPromise((svc) => - svc.ask({ - sessionID: input.sessionID, - blocking: true, - questions: [ - { - header: "Snapshot is slow", - headerKey: "snapshot.slowRepo.header", - question: - "It is taking a long time to initialize the snapshot system, likely due to the size of the repository.\n\n" + - "Do you want to disable Snapshots for this repository?", - questionKey: "snapshot.slowRepo.question", - custom: false, - options: [ - { - label: ANSWER_CONTINUE, - labelKey: "snapshot.slowRepo.answer.continue", - description: - "Keep waiting for the snapshot to complete. Subsequent turns are fast once the initial snapshot is built.", - descriptionKey: "snapshot.slowRepo.answer.continue.description", - }, - { - label: ANSWER_DISABLE, - labelKey: "snapshot.slowRepo.answer.disable", - description: - "Turn off Kilo's snapshots for this project. You will lose undo/redo of Kilo file changes, but git still tracks everything.", - descriptionKey: "snapshot.slowRepo.answer.disable.description", - }, - ], - }, - ], - }), + async ask(input, signal) { + return questionRt + .runPromise( + (svc) => + svc.ask({ + sessionID: input.sessionID, + blocking: true, + questions: [ + { + header: "Snapshot is slow", + headerKey: "snapshot.slowRepo.header", + question: + "It is taking a long time to initialize the snapshot system, likely due to the size of the repository.\n\n" + + "Do you want to disable Snapshots for this repository?", + questionKey: "snapshot.slowRepo.question", + custom: false, + options: [ + { + label: ANSWER_CONTINUE, + labelKey: "snapshot.slowRepo.answer.continue", + description: + "Keep waiting for the snapshot to complete. Subsequent turns are fast once the initial snapshot is built.", + descriptionKey: "snapshot.slowRepo.answer.continue.description", + }, + { + label: ANSWER_DISABLE, + labelKey: "snapshot.slowRepo.answer.disable", + description: + "Turn off Kilo's snapshots for this project. You will lose undo/redo of Kilo file changes, but git still tracks everything.", + descriptionKey: "snapshot.slowRepo.answer.disable.description", + }, + ], + }, + ], + }), + { signal }, ) - .catch(() => undefined) - const pick = answers?.[0]?.[0] - if (pick === ANSWER_CONTINUE) return "continue" - if (pick === ANSWER_DISABLE) return "disable" - return "dismissed" + .then((answers): Answer => { + const pick = answers[0]?.[0] + if (pick === ANSWER_CONTINUE) return "continue" + if (pick === ANSWER_DISABLE) return "disable" + return "dismissed" + }) + .catch((err): Answer => { + if (!signal?.aborted && !(err instanceof Question.RejectedError)) { + log.warn("snapshot question failed; treating as dismissed", { err }) + } + return "dismissed" + }) }, async persistDisable() { diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 5da7e21918c..ed473ea7cd6 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -210,9 +210,13 @@ export const layer = Layer.effect( return yield* Effect.ensuring( Deferred.await(deferred), - Effect.sync(() => { - pending.delete(id) + // kilocode_change start - every asked question gets a terminal event when its waiter is interrupted + KiloQuestion.finalize({ + pending, + id, + publishRejected: () => bus.publish(Event.Rejected, { sessionID: info.sessionID, requestID: info.id }), }), + // kilocode_change end ) }) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 622e8254909..1d90f5eb62c 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -873,7 +873,7 @@ export const layer: Layer.Layer = ) // kilocode_change start - service-local state and cache avoid leaking across Snapshot layer instances - const trackState = KiloSnapshotTrack.makeState() + const trackState = KiloSnapshotTrack.makeStates() const cache = new Map>() const max = 100 // kilocode_change end @@ -885,20 +885,34 @@ export const layer: Layer.Layer = cleanup: Effect.fn("Snapshot.cleanup")(function* () { return yield* InstanceState.useEffect(state, (s) => s.cleanup()) }), - // kilocode_change start - guard slow snapshots and surface progress to the active session + // kilocode_change start - isolate turn-facing snapshot work from poisoned locks track: Effect.fn("Snapshot.track")(function* (opts) { - return yield* KiloSnapshotTrack.wrap({ - inner: InstanceState.useEffect(state, (s) => s.track(opts)), - state: trackState, - snapshotInitialization: opts?.snapshotInitialization, - sessionID: opts?.sessionID, - messageID: opts?.messageID, + const ctx = yield* InstanceState.context + const guard = trackState(ctx.worktree) + return yield* KiloSnapshotTrack.protect({ + inner: KiloSnapshotTrack.wrap({ + inner: InstanceState.useEffect(state, (s) => s.track(opts)), + state: guard, + snapshotInitialization: opts?.snapshotInitialization, + sessionID: opts?.sessionID, + messageID: opts?.messageID, + }), + state: guard, + fallback: undefined, + operation: "track", + }) + }), + patch: Effect.fn("Snapshot.patch")(function* (hash: string) { + const ctx = yield* InstanceState.context + const guard = trackState(ctx.worktree) + return yield* KiloSnapshotTrack.protect({ + inner: InstanceState.useEffect(state, (s) => s.patch(hash)), + state: guard, + fallback: { hash, files: [] }, + operation: "patch", }) }), // kilocode_change end - patch: Effect.fn("Snapshot.patch")(function* (hash: string) { - return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) - }), restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) }), diff --git a/packages/opencode/test/kilocode/question-cancel.test.ts b/packages/opencode/test/kilocode/question-cancel.test.ts new file mode 100644 index 00000000000..e41edba708a --- /dev/null +++ b/packages/opencode/test/kilocode/question-cancel.test.ts @@ -0,0 +1,59 @@ +import { afterEach, expect } from "bun:test" +import { Effect, Fiber, Layer, Queue } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Question } from "../../src/question" +import { Bus } from "../../src/bus" +import { QuestionID } from "../../src/question/schema" +import { SessionID } from "../../src/session/schema" +import { disposeAllInstances } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect( + Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(Bus.layer)), CrossSpawnSpawner.defaultLayer), +) + +afterEach(async () => { + await disposeAllInstances() +}) + +it.instance( + "publishes rejection when a pending question is interrupted", + () => + Effect.gen(function* () { + const question = yield* Question.Service + const bus = yield* Bus.Service + const asked = yield* Queue.unbounded<{ properties: Question.Request }>() + const rejected = yield* Queue.unbounded<{ + properties: { sessionID: SessionID; requestID: QuestionID } + }>() + const offAsked = yield* bus.subscribeCallback(Question.Event.Asked, (event) => Queue.offerUnsafe(asked, event)) + const offRejected = yield* bus.subscribeCallback(Question.Event.Rejected, (event) => + Queue.offerUnsafe(rejected, event), + ) + yield* Effect.addFinalizer(() => Effect.sync(() => [offAsked(), offRejected()])) + + const fiber = yield* question + .ask({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + header: "Snapshot", + question: "Keep waiting?", + options: [{ label: "Continue", description: "Keep waiting" }], + }, + ], + }) + .pipe(Effect.forkChild) + const request = yield* Queue.take(asked).pipe(Effect.timeout("2 seconds")) + + yield* Fiber.interrupt(fiber) + + const event = yield* Queue.take(rejected).pipe(Effect.timeout("2 seconds")) + expect(event.properties).toEqual({ + sessionID: request.properties.sessionID, + requestID: request.properties.id, + }) + expect(yield* question.list()).toEqual([]) + }), + { git: true }, +) diff --git a/packages/opencode/test/kilocode/session-fork-remap.test.ts b/packages/opencode/test/kilocode/session-fork-remap.test.ts index 2ff0eeba502..8af0bb8b3a6 100644 --- a/packages/opencode/test/kilocode/session-fork-remap.test.ts +++ b/packages/opencode/test/kilocode/session-fork-remap.test.ts @@ -11,6 +11,7 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Database, eq } from "../../src/storage/db" import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { Flag } from "@opencode-ai/core/flag/flag" +import { KiloPartLifecycle } from "../../src/kilocode/session/part-lifecycle" Log.init({ print: false }) @@ -381,4 +382,48 @@ describe("Session.fork task detachment", () => { }, { timeout: 30000 }, ) + + test( + "drops transient UI parts while preserving durable synthetic context", + async () => { + await using tmp = await tmpdir({ git: true }) + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const parent = await sessions.create({ title: "parent" }) + const user = await userMsg(parent.id) + const parts = [ + { text: "Initializing snapshot... but durable", synthetic: true }, + { text: "durable context", synthetic: true }, + { + text: "arbitrary live status", + synthetic: true, + metadata: { [KiloPartLifecycle.key]: "transient" }, + }, + ] + for (const part of parts) { + await sessions.updatePart({ + id: PartID.ascending(), + messageID: user, + sessionID: parent.id, + type: "text", + ...part, + } as MessageV2.TextPart) + } + + const forked = await Session.fork({ sessionID: parent.id }) + const source = await sessions.messages({ sessionID: parent.id }) + const copy = await sessions.messages({ sessionID: forked.id }) + const texts = copy.flatMap((msg) => msg.parts).flatMap((part) => (part.type === "text" ? [part.text] : [])) + + expect(source.flatMap((msg) => msg.parts)).toHaveLength(3) + expect(texts).toEqual([ + "Initializing snapshot... but durable", + "durable context", + ]) + }, + }) + }, + { timeout: 30000 }, + ) }) diff --git a/packages/opencode/test/kilocode/snapshot-track-timeout.test.ts b/packages/opencode/test/kilocode/snapshot-track-timeout.test.ts index abd3a74deb5..b18f486fd33 100644 --- a/packages/opencode/test/kilocode/snapshot-track-timeout.test.ts +++ b/packages/opencode/test/kilocode/snapshot-track-timeout.test.ts @@ -5,10 +5,12 @@ // touch the real Question module or write to the filesystem. import { describe, expect, test } from "bun:test" -import { Duration, Effect } from "effect" -import type { MessageID, SessionID } from "../../src/session/schema" +import { Deferred, Duration, Effect, Fiber } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { PartID, type MessageID, type SessionID } from "../../src/session/schema" import { KiloSnapshotTrack } from "../../src/kilocode/snapshot/track" -import { awaitWithTimeout } from "../lib/effect" +import { KiloPartLifecycle } from "../../src/kilocode/session/part-lifecycle" +import { awaitWithTimeout, it } from "../lib/effect" const SESSION = "ses_test" as SessionID const MESSAGE = "msg_test" as MessageID @@ -64,6 +66,81 @@ const makeHooks = ( return { hooks, calls } } +describe("KiloSnapshotTrack.protect", () => { + it.effect("returns at the availability deadline without waiting for cancellation", () => + Effect.gen(function* () { + const state = KiloSnapshotTrack.makeState() + const started = yield* Deferred.make() + const fallback = { hash: "base", files: [] as string[] } + let finalized = false + const inner = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring( + Effect.sync(() => { + finalized = true + }), + ), + ) + const fiber = yield* KiloSnapshotTrack.protect({ + inner, + state, + fallback, + operation: "patch", + timeoutMs: 100, + }).pipe(Effect.forkChild) + + yield* Deferred.await(started) + yield* TestClock.adjust(100) + + expect(yield* Fiber.join(fiber)).toEqual(fallback) + expect(finalized).toBe(false) + expect(state.disabledForSession).toBe(true) + }), + ) + + it.effect("bypasses later operations after a deadline opens the directory circuit", () => + Effect.gen(function* () { + const state = KiloSnapshotTrack.makeState() + const started = yield* Deferred.make() + let calls = 0 + const first = yield* KiloSnapshotTrack.protect({ + inner: Effect.sync(() => { + calls += 1 + }).pipe(Effect.andThen(Deferred.succeed(started, undefined)), Effect.andThen(Effect.never)), + state, + fallback: undefined, + operation: "track", + timeoutMs: 100, + }).pipe(Effect.forkChild) + + yield* Deferred.await(started) + yield* TestClock.adjust(100) + expect(yield* Fiber.join(first)).toBeUndefined() + + const second = yield* KiloSnapshotTrack.protect({ + inner: Effect.sync(() => { + calls += 1 + return "unexpected" + }), + state, + fallback: undefined, + operation: "track", + }) + expect(second).toBeUndefined() + expect(calls).toBe(1) + }), + ) + + test("keeps circuit state isolated by directory", () => { + const states = KiloSnapshotTrack.makeStates() + const first = states("/repo/a") + first.disabledForSession = true + + expect(states("/repo/a")).toBe(first) + expect(states("/repo/b").disabledForSession).toBe(false) + }) +}) + describe("KiloSnapshotTrack.wrap", () => { test("returns the hash when inner resolves before the timeout", async () => { const state = KiloSnapshotTrack.makeState() @@ -483,6 +560,18 @@ describe("KiloSnapshotTrack.wrap", () => { }) describe("KiloSnapshotTrack progress indicator", () => { + test("classifies persisted progress as transient", () => { + const part = KiloSnapshotTrack.progressPart({ + sessionID: SESSION, + messageID: MESSAGE, + partID: PartID.make("prt_test"), + text: "arbitrary status", + }) + + expect(part.synthetic).toBe(true) + expect(KiloPartLifecycle.transient(part)).toBe(true) + }) + // Strip the braille spinner frame (first Unicode codepoint, plus the // trailing space) so tests can assert on the stable descriptive text // without caring which animation frame landed. From b9439700bd5a1c2d2fe78bfa147a60eb37ee46a5 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 23 Jun 2026 18:45:20 +0200 Subject: [PATCH 04/17] feat(vscode): support Jupyter notebook autocomplete --- .changeset/bright-notebooks-complete.md | 5 + .../THIRD_PARTY_LICENSES/continue.txt | 201 ++++++++++++++++++ packages/kilo-vscode/src/KiloProvider.ts | 47 ++-- .../AutocompleteServiceManager.ts | 33 ++- .../AutocompleteServiceManager.spec.ts | 31 ++- .../AutocompleteInlineCompletionProvider.ts | 91 ++++---- .../__tests__/AutocompleteTelemetry.test.ts | 1 + .../classic-auto-complete/inline-utils.ts | 17 +- .../continuedev/core/autocomplete/notebook.ts | 99 +++++++++ .../src/services/autocomplete/types.ts | 2 + .../kilo-vscode/tests/setup/vscode-mock.ts | 7 + .../unit/autocomplete-inline-utils.test.ts | 51 +++-- .../tests/unit/autocomplete-selector.test.ts | 18 ++ .../unit/autocomplete-telemetry-utils.test.ts | 14 +- .../tests/unit/notebook-context.test.ts | 188 ++++++++++++++++ 15 files changed, 721 insertions(+), 84 deletions(-) create mode 100644 .changeset/bright-notebooks-complete.md create mode 100644 packages/kilo-vscode/THIRD_PARTY_LICENSES/continue.txt create mode 100644 packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts create mode 100644 packages/kilo-vscode/tests/unit/autocomplete-selector.test.ts create mode 100644 packages/kilo-vscode/tests/unit/notebook-context.test.ts diff --git a/.changeset/bright-notebooks-complete.md b/.changeset/bright-notebooks-complete.md new file mode 100644 index 00000000000..afe6eb43733 --- /dev/null +++ b/.changeset/bright-notebooks-complete.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Enable autocomplete in Jupyter notebooks. diff --git a/packages/kilo-vscode/THIRD_PARTY_LICENSES/continue.txt b/packages/kilo-vscode/THIRD_PARTY_LICENSES/continue.txt new file mode 100644 index 00000000000..9aea05f9b72 --- /dev/null +++ b/packages/kilo-vscode/THIRD_PARTY_LICENSES/continue.txt @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all other +commercial damages or losses), even if such Contributor has been +advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright 2023 Continue Dev, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 18ef1427c09..820c8769ecf 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -15,6 +15,7 @@ import { previewSound } from "./services/attention" import type { EditorContext, IndexingStatus } from "./services/cli-backend/types" import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController" import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete" +import { notebookUri } from "./services/autocomplete/continuedev/core/autocomplete/notebook" import { buildWebviewHtml, getWebviewFontSize } from "./utils" import { saveImage } from "./kilo-provider/save-image" import { handleEditorAction } from "./kilo-provider/editor-actions" @@ -3432,14 +3433,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const result = new Set() for (const group of vscode.window.tabGroups.all) { for (const tab of group.tabs) { - if (tab.input instanceof vscode.TabInputText) { - const uri = tab.input.uri - if (uri.scheme === "file") { - const rel = path.relative(dir, uri.fsPath) - if (!rel.startsWith("..") && !path.isAbsolute(rel) && controller.validateAccess(uri.fsPath)) { - result.add(rel.replaceAll("\\", "/")) - } - } + const uri = + tab.input instanceof vscode.TabInputText || tab.input instanceof vscode.TabInputNotebook + ? tab.input.uri + : undefined + if (uri?.scheme !== "file") continue + + const rel = path.relative(dir, uri.fsPath) + if (!rel.startsWith("..") && !path.isAbsolute(rel) && controller.validateAccess(uri.fsPath)) { + result.add(rel.replaceAll("\\", "/")) } } } @@ -3477,21 +3479,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // Visible files (capped to avoid bloating context, filtered through .kilocodeignore) - const visibleFiles = vscode.window.visibleTextEditors - .map((e) => e.document.uri) - .filter((uri) => uri.scheme === "file") - .map((uri) => toRelative(uri.fsPath)) - .filter((p): p is string => p !== undefined && controller.validateAccess(path.resolve(workspaceDir, p))) - .slice(0, 200) + const visibleFiles = [ + ...new Set( + [ + ...vscode.window.visibleTextEditors.map((editor) => notebookUri(editor.document.uri)), + ...vscode.window.visibleNotebookEditors.map((editor) => editor.notebook.uri), + ] + .filter((uri): uri is vscode.Uri => uri?.scheme === "file") + .map((uri) => toRelative(uri.fsPath)) + .filter( + (file): file is string => file !== undefined && controller.validateAccess(path.resolve(workspaceDir, file)), + ), + ), + ].slice(0, 200) - // Open tabs — use instanceof TabInputText to exclude notebooks, diffs, custom editors + // Open tabs — text and notebook files only; exclude diffs and custom editors const openTabs = [...(await this.getOpenTabPaths(workspaceDir))].slice(0, 20) // Active file (also filtered through .kilocodeignore) const activeEditor = vscode.window.activeTextEditor - const activeRel = - activeEditor?.document.uri.scheme === "file" ? toRelative(activeEditor.document.uri.fsPath) : undefined - const activeFile = activeRel && controller.validateAccess(activeEditor!.document.uri.fsPath) ? activeRel : undefined + const activeUri = activeEditor + ? notebookUri(activeEditor.document.uri) + : vscode.window.activeNotebookEditor?.notebook.uri + const activeRel = activeUri ? toRelative(activeUri.fsPath) : undefined + const activeFile = activeRel && activeUri && controller.validateAccess(activeUri.fsPath) ? activeRel : undefined // Shell const shell = vscode.env.shell || undefined diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 7a2eeaebbaf..4e33d81992d 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -12,10 +12,24 @@ import { NextEditSuggestionManager } from "./next-edit/NextEditSuggestionManager import { toAllowedMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter" import type { KiloConnectionService } from "../cli-backend" import { hasValidCredentials } from "./fim" -import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" +import { + DEFAULT_AUTOCOMPLETE_MODEL, + getAutocompleteModel, + getAutocompleteModelById, +} from "../../shared/autocomplete-models" const CONFIG_SECTION = "kilo-code.new.autocomplete" +export function selector(kind: "classic" | "next-edit"): vscode.DocumentSelector { + return kind === "classic" ? [{ scheme: "file" }, { scheme: "vscode-notebook-cell" }] : [{ scheme: "file" }] +} + +export function notebookModel(provider?: string, model?: string) { + const info = getAutocompleteModel(provider, model) + if (info.kind !== "edit") return info + return getAutocompleteModelById(info.fimModelID) +} + export interface AutocompleteServiceSettings { enableAutoTrigger?: boolean enableSmartInlineTaskKeybinding?: boolean @@ -68,6 +82,7 @@ export class AutocompleteServiceManager { public readonly nextEditProvider: NextEditInlineCompletionProvider public readonly nextEditSuggestionManager: NextEditSuggestionManager private inlineCompletionProviderDisposable: vscode.Disposable | null = null + private notebookCompletionProviderDisposable: vscode.Disposable | null = null private inlineCompletionProviderKind: "classic" | "next-edit" | null = null private unsubscribeState: (() => void) | null = null private unsubscribeEvent: (() => void) | null = null @@ -177,7 +192,7 @@ export class AutocompleteServiceManager { public async load() { this.settings = readSettings() - this.inlineCompletionProvider.setModel(getAutocompleteModel(this.settings.provider, this.settings.model).id) + this.inlineCompletionProvider.setModel(notebookModel(this.settings.provider, this.settings.model).id) await this.updateGlobalContext() this.updateStatusBar() @@ -204,7 +219,9 @@ export class AutocompleteServiceManager { ) { if (this.inlineCompletionProviderKind === "next-edit") this.nextEditSuggestionManager.clear() this.inlineCompletionProviderDisposable?.dispose() + this.notebookCompletionProviderDisposable?.dispose() this.inlineCompletionProviderDisposable = null + this.notebookCompletionProviderDisposable = null this.inlineCompletionProviderKind = null } @@ -216,7 +233,9 @@ export class AutocompleteServiceManager { if (!shouldBeRegistered) { this.inlineCompletionProviderDisposable!.dispose() + this.notebookCompletionProviderDisposable?.dispose() this.inlineCompletionProviderDisposable = null + this.notebookCompletionProviderDisposable = null this.inlineCompletionProviderKind = null return } @@ -224,9 +243,15 @@ export class AutocompleteServiceManager { const provider: vscode.InlineCompletionItemProvider = desiredKind === "next-edit" ? this.nextEditProvider : this.inlineCompletionProvider this.inlineCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider( - { scheme: "file" }, + selector(desiredKind), provider, ) + if (desiredKind === "next-edit") { + this.notebookCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider( + [{ scheme: "vscode-notebook-cell" }], + this.inlineCompletionProvider, + ) + } this.inlineCompletionProviderKind = desiredKind } @@ -459,6 +484,8 @@ export class AutocompleteServiceManager { this.inlineCompletionProviderDisposable = null this.inlineCompletionProviderKind = null } + this.notebookCompletionProviderDisposable?.dispose() + this.notebookCompletionProviderDisposable = null // Dispose inline completion provider resources this.inlineCompletionProvider.dispose() diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts index 5fc37a76bf8..d5e93a6969a 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts @@ -251,12 +251,41 @@ describe("AutocompleteServiceManager (less mocked logic)", () => { await (manager as any).ensureInlineCompletionProviderRegistration() expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenCalledWith( - { scheme: "file" }, + [{ scheme: "file" }, { scheme: "vscode-notebook-cell" }], manager.inlineCompletionProvider, ) expect((manager as any).inlineCompletionProviderDisposable).toBe(disposable) }) + it("registers classic notebook fallback when Next Edit is selected", async () => { + const manager = await createManager() + + const primary = { dispose: vi.fn() } + const notebook = { dispose: vi.fn() } + vi.mocked(vscode.languages.registerInlineCompletionItemProvider) + .mockReturnValueOnce(primary as any) + .mockReturnValueOnce(notebook as any) + ;(manager as any).settings = { + enableAutoTrigger: true, + provider: "kilo", + model: "inception/mercury-next-edit", + } + + await (manager as any).ensureInlineCompletionProviderRegistration() + + expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenNthCalledWith( + 1, + [{ scheme: "file" }], + manager.nextEditProvider, + ) + expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenNthCalledWith( + 2, + [{ scheme: "vscode-notebook-cell" }], + manager.inlineCompletionProvider, + ) + expect((manager as any).notebookCompletionProviderDisposable).toBe(notebook) + }) + it("does not register the provider when snoozed", async () => { const manager = await createManager() diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index cc38a899e43..b3fd6a1121b 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -15,6 +15,7 @@ import { } from "../types" import { findMatchingSuggestion as _findMatchingSuggestion, + findCoveringPendingRequest, applyFirstLineOnly as _applyFirstLineOnly, calcDebounceDelay, MatchingSuggestionWithFillIn as _MatchingSuggestionWithFillIn, @@ -32,10 +33,21 @@ import { postprocessAutocompleteSuggestion } from "./uselessSuggestionFilter" import { shouldSkipAutocomplete } from "./contextualSkip" import { FileIgnoreController } from "../shims/FileIgnoreController" import { AutocompleteTelemetry } from "./AutocompleteTelemetry" +import { + autocompleteScope, + getNotebookContext, + notebookUri, + supportsNotebook, +} from "../continuedev/core/autocomplete/notebook" import { ErrorBackoff } from "./ErrorBackoff" const MAX_SUGGESTIONS_HISTORY = 20 +export function accessible(controller: FileIgnoreController, document: vscode.TextDocument): boolean { + const uri = notebookUri(document.uri) + return controller.validateAccess(uri?.fsPath ?? document.fileName) +} + /** * Minimum debounce delay in milliseconds. * The adaptive debounce delay will never go below this value, even when @@ -70,11 +82,12 @@ export type { CostTrackingCallback, AutocompletePrompt, MatchingSuggestionResult export type MatchingSuggestionWithFillIn = _MatchingSuggestionWithFillIn export function findMatchingSuggestion( + scope: string, prefix: string, suffix: string, suggestionsHistory: FillInAtCursorSuggestion[], ): MatchingSuggestionWithFillIn | null { - return _findMatchingSuggestion(prefix, suffix, suggestionsHistory) + return _findMatchingSuggestion(scope, prefix, suffix, suggestionsHistory) } export function applyFirstLineOnly( @@ -178,6 +191,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple public updateSuggestions(fillInAtCursor: FillInAtCursorSuggestion): void { const isDuplicate = this.suggestionsHistory.some( (existing) => + existing.scope === fillInAtCursor.scope && existing.text === fillInAtCursor.text && existing.prefix === fillInAtCursor.prefix && existing.suffix === fillInAtCursor.suffix, @@ -211,7 +225,16 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple recentlyEditedRanges, } - const autocompleteInput = contextToAutocompleteInput(context) + const input = contextToAutocompleteInput(context) + const notebook = getNotebookContext(document, position) + const autocompleteInput = notebook + ? { + ...input, + filepath: notebook.filepath, + pos: { line: notebook.position.line, character: notebook.position.character }, + manuallyPassFileContents: notebook.contents, + } + : input const { prefix, suffix } = extractPrefixSuffix(document, position) const languageId = document.languageId @@ -235,6 +258,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple private processSuggestion( suggestionText: string, + scope: string, prefix: string, suffix: string, telemetryContext: AutocompleteContext, @@ -242,7 +266,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple ): FillInAtCursorSuggestion { if (!suggestionText) { this.telemetry?.captureSuggestionFiltered("empty_response", telemetryContext) - return { text: "", prefix, suffix } + return { text: "", scope, prefix, suffix } } const processedText = postprocessAutocompleteSuggestion({ @@ -254,11 +278,11 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple }) if (processedText) { - return { text: processedText, prefix, suffix } + return { text: processedText, scope, prefix, suffix } } this.telemetry?.captureSuggestionFiltered("filtered_by_postprocessing", telemetryContext) - return { text: "", prefix, suffix } + return { text: "", scope, prefix, suffix } } private async disposeIgnoreController(): Promise { @@ -336,6 +360,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple _token: vscode.CancellationToken, ): Promise { vscode.commands.executeCommand("setContext", "kilo-code.new.autocomplete.hasSuggestions", false) + if (!supportsNotebook(document)) return [] // Build telemetry context const telemetryContext: AutocompleteContext = { @@ -389,8 +414,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple return [] } - const isAccessible = controller.validateAccess(document.fileName) - if (!isAccessible) { + if (!accessible(controller, document)) { return [] } } catch { @@ -399,10 +423,14 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple } } + const scope = autocompleteScope(document) const { prefix, suffix } = extractPrefixSuffix(document, position) // Check cache first - allow mid-word lookups from cache - const matchingResult = applyFirstLineOnly(findMatchingSuggestion(prefix, suffix, this.suggestionsHistory), prefix) + const matchingResult = applyFirstLineOnly( + findMatchingSuggestion(scope, prefix, suffix, this.suggestionsHistory), + prefix, + ) if (matchingResult !== null) { this.lastSuggestion = { @@ -425,9 +453,12 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple const { prompt, prefix: promptPrefix, suffix: promptSuffix } = await this.getPrompt(document, position) - await this.debouncedFetchAndCacheSuggestion(prompt, promptPrefix, promptSuffix, document.languageId) + await this.debouncedFetchAndCacheSuggestion(scope, prompt, promptPrefix, promptSuffix, document.languageId) - const cachedResult = applyFirstLineOnly(findMatchingSuggestion(prefix, suffix, this.suggestionsHistory), prefix) + const cachedResult = applyFirstLineOnly( + findMatchingSuggestion(scope, prefix, suffix, this.suggestionsHistory), + prefix, + ) if (cachedResult) { this.lastSuggestion = { ...telemetryContext, @@ -448,31 +479,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple } } - /** - * Find a pending request that covers the current prefix/suffix. - * A request covers the current position if: - * 1. The suffix matches (user hasn't changed text after cursor) - * 2. The current prefix either equals or extends the pending prefix - * (user is typing forward, not backspacing or editing earlier) - * - * @returns The covering pending request, or null if none found - */ - private findCoveringPendingRequest(prefix: string, suffix: string): PendingRequest | null { - for (const pendingRequest of this.pendingRequests) { - // Suffix must match exactly (text after cursor unchanged) - if (suffix !== pendingRequest.suffix) { - continue - } - - // Current prefix must start with the pending prefix (user typed more) - // or be exactly equal (same position) - if (prefix.startsWith(pendingRequest.prefix)) { - return pendingRequest - } - } - return null - } - /** * Remove a pending request from the list when it completes. */ @@ -499,13 +505,14 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple * - If a pending request covers the current prefix/suffix, reuse it instead of starting a new one */ private debouncedFetchAndCacheSuggestion( + scope: string, prompt: AutocompletePrompt, prefix: string, suffix: string, languageId: string, ): Promise { // Check if any existing pending request covers this one - const coveringRequest = this.findCoveringPendingRequest(prefix, suffix) + const coveringRequest = findCoveringPendingRequest(scope, prefix, suffix, this.pendingRequests) if (coveringRequest) { // Wait for the existing request to complete - no need to start a new one return coveringRequest.promise @@ -515,8 +522,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple // but still track it as a pending request so subsequent calls can reuse it if (this.isFirstCall && this.debounceTimer === null) { this.isFirstCall = false - const promise = this.fetchAndCacheSuggestion(prompt, prefix, suffix, languageId) - const leading: PendingRequest = { prefix, suffix, promise } + const promise = this.fetchAndCacheSuggestion(scope, prompt, prefix, suffix, languageId) + const leading: PendingRequest = { scope, prefix, suffix, promise } promise.finally(() => this.removePendingRequest(leading)) this.pendingRequests.push(leading) return promise @@ -533,6 +540,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple // Create the pending request object first so we can reference it in the cleanup const pendingRequest: PendingRequest = { + scope, prefix, suffix, promise: null!, // Will be set immediately below @@ -545,7 +553,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple this.debouncedPendingRequest = null this.isFirstCall = true // Reset for next sequence try { - await this.fetchAndCacheSuggestion(prompt, prefix, suffix, languageId) + await this.fetchAndCacheSuggestion(scope, prompt, prefix, suffix, languageId) } finally { this.removePendingRequest(pendingRequest) resolve() @@ -566,6 +574,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple } public async fetchAndCacheSuggestion( + scope: string, prompt: AutocompletePrompt, prefix: string, suffix: string, @@ -592,9 +601,9 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple } try { - // Curry processSuggestion with prefix, suffix, telemetry context, and languageId + // Curry processSuggestion with request context const curriedProcessSuggestion = (text: string) => - this.processSuggestion(text, prefix, suffix, telemetryContext, languageId) + this.processSuggestion(text, scope, prefix, suffix, telemetryContext, languageId) const result = await this.fimPromptBuilder.getFromFIM( this.connectionService, diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteTelemetry.test.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteTelemetry.test.ts index aa5752aed70..e93b4fb50db 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteTelemetry.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteTelemetry.test.ts @@ -16,6 +16,7 @@ describe("AutocompleteTelemetry", () => { function createSuggestion(index: number): FillInAtCursorSuggestion { return { text: `text-${index}`, + scope: `scope-${index}`, prefix: `prefix-${index}`, suffix: `suffix-${index}`, } diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts index b570605e744..74dc958e961 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts @@ -1,4 +1,4 @@ -import type { FillInAtCursorSuggestion, MatchingSuggestionResult } from "../types" +import type { FillInAtCursorSuggestion, MatchingSuggestionResult, PendingRequest } from "../types" export interface MatchingSuggestionWithFillIn extends MatchingSuggestionResult { fillInAtCursor: FillInAtCursorSuggestion @@ -12,12 +12,14 @@ const MAX_DEBOUNCE_DELAY_MS = 1000 * Searches from most recent to least recent. */ export function findMatchingSuggestion( + scope: string, prefix: string, suffix: string, suggestionsHistory: FillInAtCursorSuggestion[], ): MatchingSuggestionWithFillIn | null { for (let i = suggestionsHistory.length - 1; i >= 0; i--) { const fillInAtCursor = suggestionsHistory[i]! + if (scope !== fillInAtCursor.scope) continue if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) { return { text: fillInAtCursor.text, matchType: "exact", fillInAtCursor } @@ -42,6 +44,19 @@ export function findMatchingSuggestion( return null } +export function findCoveringPendingRequest( + scope: string, + prefix: string, + suffix: string, + requests: PendingRequest[], +): PendingRequest | null { + for (const request of requests) { + if (scope !== request.scope || suffix !== request.suffix) continue + if (prefix.startsWith(request.prefix)) return request + } + return null +} + /** * Counts the number of lines in a text string. * A single trailing newline does not count as an additional line. diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts new file mode 100644 index 00000000000..2dca033b18e --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/notebook.ts @@ -0,0 +1,99 @@ +// Copied from Continue's VS Code autocomplete provider: +// https://github.com/continuedev/continue/blob/d0a3c0b626b5bebc3bef4742eec05a0242be0bab/extensions/vscode/src/autocomplete/completionProvider.ts#L226-L263 +// Copyright 2023 Continue +// Licensed under the Apache License, Version 2.0. +// Modified by Kilo Code for notebook paths, cursor positions, and cache scoping. + +import * as vscode from "vscode" + +export interface NotebookContext { + contents: string + filepath: string + position: vscode.Position +} + +interface NotebookResolution { + notebook: vscode.NotebookDocument + cells: vscode.NotebookCell[] + cell: vscode.NotebookCell + index: number + version: number +} + +const resolutions = new WeakMap() + +function resolveNotebook(uri: vscode.Uri): NotebookResolution | undefined { + const id = uri.toString() + const cached = resolutions.get(uri) + if ( + cached && + cached.notebook.version === cached.version && + vscode.workspace.notebookDocuments.includes(cached.notebook) + ) { + return cached + } + + resolutions.delete(uri) + for (const notebook of vscode.workspace.notebookDocuments) { + const cells = notebook.getCells() + const index = cells.findIndex((cell) => cell.document.uri.toString() === id) + if (index < 0) continue + const resolved = { notebook, cells, cell: cells[index]!, index, version: notebook.version } + resolutions.set(uri, resolved) + return resolved + } +} + +export function notebookUri(uri: vscode.Uri): vscode.Uri | undefined { + if (uri.scheme === "file") return uri + if (uri.scheme !== "vscode-notebook-cell") return + return resolveNotebook(uri)?.notebook.uri +} + +export function supportsNotebook(document: vscode.TextDocument): boolean { + if (document.uri.scheme !== "vscode-notebook-cell") return true + const resolved = resolveNotebook(document.uri) + return resolved?.cell.kind === vscode.NotebookCellKind.Code && document.languageId === "python" +} + +export function autocompleteScope(document: vscode.TextDocument): string { + const id = document.uri.toString() + const resolved = resolveNotebook(document.uri) + if (!resolved) return id + + const siblings = resolved.cells + .filter((_, index) => index !== resolved.index) + .map((cell) => [cell.document.uri.toString(), cell.kind, cell.document.languageId, cell.document.version]) + return JSON.stringify([id, resolved.notebook.uri.toString(), resolved.index, siblings]) +} + +export function getNotebookContext( + document: vscode.TextDocument, + position: vscode.Position, +): NotebookContext | undefined { + if (document.uri.scheme !== "vscode-notebook-cell" || !supportsNotebook(document)) return + + const resolved = resolveNotebook(document.uri) + if (!resolved) return + + const cells = resolved.cells + const contents = cells + .map((cell) => { + const text = cell.document.getText() + if (cell.kind === vscode.NotebookCellKind.Markup) { + return `"""${text}"""` + } + return text + }) + .join("\n\n") + + const line = cells + .slice(0, resolved.index) + .reduce((line, cell) => line + cell.document.getText().split("\n").length + 1, position.line) + + return { + contents, + filepath: resolved.notebook.uri.fsPath, + position: new vscode.Position(line, position.character), + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/types.ts b/packages/kilo-vscode/src/services/autocomplete/types.ts index 765f1d6f4b9..b7a88433828 100644 --- a/packages/kilo-vscode/src/services/autocomplete/types.ts +++ b/packages/kilo-vscode/src/services/autocomplete/types.ts @@ -52,6 +52,7 @@ export interface FillInAtCursorSuggestion { text: string prefix: string suffix: string + scope: string } export interface MatchingSuggestionResult { @@ -105,6 +106,7 @@ export interface LastSuggestionInfo extends AutocompleteContext { } export interface PendingRequest { + scope: string prefix: string suffix: string promise: Promise diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index ee234232d41..4b2b2845d7d 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -47,6 +47,7 @@ const mockVscode = { workspace: { workspaceFolders: [{ uri: { fsPath: "/repo" } }], textDocuments: [] as Array, + notebookDocuments: [] as Array, onDidOpenTextDocument: () => ({ dispose: noop }), onDidChangeTextDocument: () => ({ dispose: noop }), onDidCloseTextDocument: () => ({ dispose: noop }), @@ -73,7 +74,9 @@ const mockVscode = { }, window: { activeTextEditor: undefined, + activeNotebookEditor: undefined, visibleTextEditors: [], + visibleNotebookEditors: [], tabGroups: { all: [] }, showTextDocument: async () => {}, showWarningMessage: async () => undefined, @@ -132,6 +135,10 @@ const mockVscode = { TabInputText: class { constructor(public uri: { scheme: string; fsPath: string }) {} }, + TabInputNotebook: class { + constructor(public uri: { scheme: string; fsPath: string }) {} + }, + NotebookCellKind: { Markup: 1, Code: 2 }, Position: class { constructor( public line: number, diff --git a/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts index 7f171f84b78..4ccae8102b8 100644 --- a/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts @@ -6,11 +6,14 @@ import { shouldShowOnlyFirstLine, getFirstLine, calcDebounceDelay, + findCoveringPendingRequest, } from "../../src/services/autocomplete/classic-auto-complete/inline-utils" -import type { FillInAtCursorSuggestion } from "../../src/services/autocomplete/types" +import type { FillInAtCursorSuggestion, PendingRequest } from "../../src/services/autocomplete/types" -function makeSuggestion(prefix: string, text: string, suffix = ""): FillInAtCursorSuggestion { - return { prefix, suffix, text } +const scope = "file:///workspace/file.ts" + +function makeSuggestion(prefix: string, text: string, suffix = "", value = scope): FillInAtCursorSuggestion { + return { scope: value, prefix, suffix, text } } describe("countLines", () => { @@ -89,46 +92,68 @@ describe("shouldShowOnlyFirstLine", () => { describe("findMatchingSuggestion", () => { it("returns null for empty history", () => { - expect(findMatchingSuggestion("prefix", "suffix", [])).toBeNull() + expect(findMatchingSuggestion(scope, "prefix", "suffix", [])).toBeNull() }) it("returns exact match", () => { const hist = [makeSuggestion("hello ", "world")] - const result = findMatchingSuggestion("hello ", "", hist) + const result = findMatchingSuggestion(scope, "hello ", "", hist) expect(result?.matchType).toBe("exact") expect(result?.text).toBe("world") }) it("returns partial_typing match when user typed beginning of suggestion", () => { const hist = [makeSuggestion("he", "llo world")] - const result = findMatchingSuggestion("hell", "", hist) + const result = findMatchingSuggestion(scope, "hell", "", hist) expect(result?.matchType).toBe("partial_typing") expect(result?.text).toBe("o world") }) it("returns backward_deletion match when user deleted chars", () => { const hist = [makeSuggestion("hello world", "more", "suffix")] - const result = findMatchingSuggestion("hello", "suffix", hist) + const result = findMatchingSuggestion(scope, "hello", "suffix", hist) expect(result?.matchType).toBe("backward_deletion") expect(result?.text).toBe(" worldmore") }) it("prefers most recent suggestion (searches from end)", () => { const hist = [makeSuggestion("prefix", "old suggestion"), makeSuggestion("prefix", "new suggestion")] - const result = findMatchingSuggestion("prefix", "", hist) + const result = findMatchingSuggestion(scope, "prefix", "", hist) expect(result?.text).toBe("new suggestion") }) it("returns null when no match found", () => { const hist = [makeSuggestion("different", "no match")] - expect(findMatchingSuggestion("unrelated", "suffix", hist)).toBeNull() + expect(findMatchingSuggestion(scope, "unrelated", "suffix", hist)).toBeNull() }) it("does not match empty suggestion text for partial_typing", () => { const hist = [makeSuggestion("prefix", "")] - const result = findMatchingSuggestion("prefix more", "", hist) + const result = findMatchingSuggestion(scope, "prefix more", "", hist) expect(result).toBeNull() }) + + it("does not reuse suggestions from another scope", () => { + const hist = [makeSuggestion("value = ", "first()", "", "vscode-notebook-cell:notebook.ipynb#cell-a")] + const result = findMatchingSuggestion("vscode-notebook-cell:notebook.ipynb#cell-b", "value = ", "", hist) + expect(result).toBeNull() + }) +}) + +describe("findCoveringPendingRequest", () => { + function request(value: string): PendingRequest { + return { scope: value, prefix: "pri", suffix: "", promise: Promise.resolve() } + } + + it("reuses forward-typing requests within the same scope", () => { + const pending = request(scope) + expect(findCoveringPendingRequest(scope, "print", "", [pending])).toBe(pending) + }) + + it("does not reuse requests from another scope", () => { + const pending = request("vscode-notebook-cell:notebook.ipynb#cell-a") + expect(findCoveringPendingRequest("vscode-notebook-cell:notebook.ipynb#cell-b", "print", "", [pending])).toBeNull() + }) }) describe("applyFirstLineOnly", () => { @@ -138,21 +163,21 @@ describe("applyFirstLineOnly", () => { it("returns empty result unchanged", () => { const hist = [makeSuggestion("p", "")] - const result = findMatchingSuggestion("p", "", hist)! + const result = findMatchingSuggestion(scope, "p", "", hist)! const applied = applyFirstLineOnly(result, "p") expect(applied?.text).toBe("") }) it("truncates to first line when mid-line suggestion", () => { const hist = [makeSuggestion("function foo() { return ", "x\n const y = 1\n}")] - const result = findMatchingSuggestion("function foo() { return ", "", hist)! + const result = findMatchingSuggestion(scope, "function foo() { return ", "", hist)! const applied = applyFirstLineOnly(result, "function foo() { return ") expect(applied?.text).toBe("x") }) it("preserves full multi-line suggestion when starting with newline", () => { const hist = [makeSuggestion("foo", "\n const x = 1\n const y = 2")] - const result = findMatchingSuggestion("foo", "", hist)! + const result = findMatchingSuggestion(scope, "foo", "", hist)! const applied = applyFirstLineOnly(result, "foo") expect(applied?.text).toBe("\n const x = 1\n const y = 2") }) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-selector.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-selector.test.ts new file mode 100644 index 00000000000..2293a531cce --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-selector.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test" +import { notebookModel, selector } from "../../src/services/autocomplete/AutocompleteServiceManager" + +describe("autocomplete document selector", () => { + it("registers classic autocomplete for files and notebook cells", () => { + expect(selector("classic")).toEqual([{ scheme: "file" }, { scheme: "vscode-notebook-cell" }]) + }) + + it("keeps Next Edit limited to files", () => { + expect(selector("next-edit")).toEqual([{ scheme: "file" }]) + }) + + it("uses the matching FIM model for notebook fallback", () => { + expect(notebookModel("kilo", "inception/mercury-next-edit").id).toBe("kilo/inception/mercury-edit-2") + expect(notebookModel("inception", "mercury-next-edit").id).toBe("inception/mercury-edit-2") + expect(notebookModel("kilo", "mistralai/codestral-2508").id).toBe("kilo/mistralai/codestral-2508") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts index 9848c21c9e0..3c5ad8d7ab4 100644 --- a/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts @@ -6,29 +6,29 @@ import { describe("getSuggestionKey", () => { it("combines prefix, suffix, and text with pipe separators", () => { - const key = getSuggestionKey({ prefix: "hello ", suffix: "\n}", text: "world" }) + const key = getSuggestionKey({ scope: "", prefix: "hello ", suffix: "\n}", text: "world" }) expect(key).toBe("hello |\n}|world") }) it("produces unique keys for different suggestions", () => { - const k1 = getSuggestionKey({ prefix: "a", suffix: "c", text: "b" }) - const k2 = getSuggestionKey({ prefix: "a", suffix: "c", text: "x" }) + const k1 = getSuggestionKey({ scope: "", prefix: "a", suffix: "c", text: "b" }) + const k2 = getSuggestionKey({ scope: "", prefix: "a", suffix: "c", text: "x" }) expect(k1).not.toBe(k2) }) it("same content produces same key (stable)", () => { - const s = { prefix: "const x = ", suffix: ";", text: "42" } + const s = { scope: "", prefix: "const x = ", suffix: ";", text: "42" } expect(getSuggestionKey(s)).toBe(getSuggestionKey(s)) }) it("different prefix produces different key", () => { - const k1 = getSuggestionKey({ prefix: "a", suffix: "", text: "t" }) - const k2 = getSuggestionKey({ prefix: "b", suffix: "", text: "t" }) + const k1 = getSuggestionKey({ scope: "", prefix: "a", suffix: "", text: "t" }) + const k2 = getSuggestionKey({ scope: "", prefix: "b", suffix: "", text: "t" }) expect(k1).not.toBe(k2) }) it("handles empty strings", () => { - const key = getSuggestionKey({ prefix: "", suffix: "", text: "" }) + const key = getSuggestionKey({ scope: "", prefix: "", suffix: "", text: "" }) expect(key).toBe("||") }) }) diff --git a/packages/kilo-vscode/tests/unit/notebook-context.test.ts b/packages/kilo-vscode/tests/unit/notebook-context.test.ts new file mode 100644 index 00000000000..c4f2742936c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/notebook-context.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { + autocompleteScope, + getNotebookContext, + notebookUri, + supportsNotebook, +} from "../../src/services/autocomplete/continuedev/core/autocomplete/notebook" +import { accessible } from "../../src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider" +import type { FileIgnoreController } from "../../src/services/autocomplete/shims/FileIgnoreController" + +function uri(scheme: string, path: string, fragment = ""): vscode.Uri { + const value = `${scheme}:${path}${fragment ? `#${fragment}` : ""}` + return { + scheme, + fsPath: path, + toString: () => value, + } as vscode.Uri +} + +function document(id: string, text: string, languageId = "python", version = 1): vscode.TextDocument { + return { + uri: uri("vscode-notebook-cell", "/workspace/example.ipynb", id), + fileName: `/workspace/${id}.py`, + languageId, + version, + getText: () => text, + } as vscode.TextDocument +} + +function notebooks(value: vscode.NotebookDocument[]): void { + Object.defineProperty(vscode.workspace, "notebookDocuments", { + configurable: true, + value, + }) +} + +describe("notebook context", () => { + beforeEach(() => notebooks([])) + + it("flattens notebook cells and translates the cursor", () => { + const markdown = document("markdown", "# Title\nNotes") + const code = document("code", "const value = 1\nvalue += 1", "javascript") + const current = document("current", "print(value)\nprint('done')") + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + getCells: () => [ + { kind: vscode.NotebookCellKind.Markup, document: markdown }, + { kind: vscode.NotebookCellKind.Code, document: code }, + { kind: vscode.NotebookCellKind.Code, document: current }, + ], + } as vscode.NotebookDocument + notebooks([notebook]) + + const context = getNotebookContext(current, new vscode.Position(1, 5)) + + expect(context).toEqual({ + contents: `"""# Title\nNotes"""\n\nconst value = 1\nvalue += 1\n\nprint(value)\nprint('done')`, + filepath: "/workspace/example.ipynb", + position: new vscode.Position(7, 5), + }) + }) + + it("limits notebook completion to Python code cells", () => { + const python = document("python", "value = 1") + const javascript = document("javascript", "const value = 1", "javascript") + const markdown = document("markdown", "# Heading", "markdown") + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + getCells: () => [ + { kind: vscode.NotebookCellKind.Code, document: python }, + { kind: vscode.NotebookCellKind.Code, document: javascript }, + { kind: vscode.NotebookCellKind.Markup, document: markdown }, + ], + } as vscode.NotebookDocument + notebooks([notebook]) + + expect(supportsNotebook(python)).toBe(true) + expect(supportsNotebook(javascript)).toBe(false) + expect(supportsNotebook(markdown)).toBe(false) + expect(getNotebookContext(javascript, new vscode.Position(0, 0))).toBeUndefined() + expect(getNotebookContext(markdown, new vscode.Position(0, 0))).toBeUndefined() + expect(supportsNotebook({ uri: uri("file", "/workspace/file.ts") } as vscode.TextDocument)).toBe(true) + }) + + it("resolves file and notebook cell URIs", () => { + const file = uri("file", "/workspace/file.ts") + const cell = document("code", "value = 1") + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + getCells: () => [{ kind: vscode.NotebookCellKind.Code, document: cell }], + } as vscode.NotebookDocument + notebooks([notebook]) + + expect(notebookUri(file)).toBe(file) + expect(notebookUri(cell.uri)).toBe(notebook.uri) + expect(notebookUri(uri("untitled", "Untitled-1"))).toBeUndefined() + }) + + it("scopes autocomplete cache to the cell and sibling versions", () => { + const current = document("current", "value = 1") + const sibling = document("sibling", "other = 1") + const cells = [ + { kind: vscode.NotebookCellKind.Code, document: current }, + { kind: vscode.NotebookCellKind.Code, document: sibling }, + ] as vscode.NotebookCell[] + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + version: 1, + getCells: () => cells, + } as vscode.NotebookDocument + notebooks([notebook]) + + const initial = autocompleteScope(current) + Object.assign(current, { version: 2 }) + Object.assign(notebook, { version: 2 }) + expect(autocompleteScope(current)).toBe(initial) + + Object.assign(sibling, { version: 2 }) + Object.assign(notebook, { version: 3 }) + expect(autocompleteScope(current)).not.toBe(initial) + expect(autocompleteScope(current)).not.toBe(autocompleteScope(sibling)) + }) + + it("changes autocomplete scope when sibling order changes", () => { + const current = document("current", "value = 1") + const first = document("first", "first = 1") + const second = document("second", "second = 1") + const cells = [ + { kind: vscode.NotebookCellKind.Code, document: first }, + { kind: vscode.NotebookCellKind.Code, document: second }, + { kind: vscode.NotebookCellKind.Code, document: current }, + ] as vscode.NotebookCell[] + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + version: 1, + getCells: () => cells, + } as vscode.NotebookDocument + notebooks([notebook]) + + const initial = autocompleteScope(current) + cells.splice(0, 2, cells[1]!, cells[0]!) + Object.assign(notebook, { version: 2 }) + + expect(autocompleteScope(current)).not.toBe(initial) + }) + + it("reuses notebook resolution within the same notebook version", () => { + const current = document("current", "value = 1") + const cells = [{ kind: vscode.NotebookCellKind.Code, document: current }] as vscode.NotebookCell[] + let calls = 0 + const notebook = { + uri: uri("file", "/workspace/example.ipynb"), + version: 1, + getCells: () => { + calls++ + return cells + }, + } as vscode.NotebookDocument + notebooks([notebook]) + + expect(supportsNotebook(current)).toBe(true) + expect(notebookUri(current.uri)).toBe(notebook.uri) + expect(getNotebookContext(current, new vscode.Position(0, 0))).toBeDefined() + expect(calls).toBe(1) + }) + + it("validates notebook parent paths regardless of URI scheme", () => { + for (const scheme of ["file", "untitled", "memfs"]) { + const cell = document(scheme, "value = 1") + const notebook = { + uri: uri(scheme, `/workspace/${scheme}.ipynb`), + getCells: () => [{ kind: vscode.NotebookCellKind.Code, document: cell }], + } as vscode.NotebookDocument + const paths: string[] = [] + const controller = { + validateAccess: (path: string) => { + paths.push(path) + return false + }, + } as FileIgnoreController + notebooks([notebook]) + + expect(accessible(controller, cell)).toBe(false) + expect(paths).toEqual([`/workspace/${scheme}.ipynb`]) + } + }) +}) From 8dc96eff6ac1f03ddedde49767d10b737db0951b Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 24 Jun 2026 06:50:27 +0200 Subject: [PATCH 05/17] fix(vscode): scope notebook autocomplete aborts --- .../AutocompleteInlineCompletionProvider.ts | 20 ++-- .../unit/autocomplete-abort-scope.test.ts | 112 ++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/autocomplete-abort-scope.test.ts diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index b3fd6a1121b..f1e8e86ffd1 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -131,8 +131,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple private debouncedPendingRequest: PendingRequest | null = null private isFirstCall: boolean = true public readonly ignoreController: Promise - /** Abort controller for the current in-flight FIM request */ - private fimAbortController: AbortController | null = null + /** Abort controllers for in-flight FIM requests, scoped by file/notebook context. */ + private fimAbortControllers = new Map() private acceptedCommand: vscode.Disposable | null = null private contextService: ContextRetrievalService | null = null private debounceDelayMs: number = INITIAL_DEBOUNCE_DELAY_MS @@ -323,8 +323,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple } this.settleDebouncedPendingRequest() this.pendingRequests.length = 0 - this.fimAbortController?.abort() - this.fimAbortController = null + for (const controller of this.fimAbortControllers.values()) { + controller.abort() + } + this.fimAbortControllers.clear() this.telemetry?.dispose() this.contextService?.dispose() this.contextService = null @@ -580,10 +582,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple suffix: string, languageId: string, ): Promise { - // Abort any previous in-flight FIM request before starting a new one - this.fimAbortController?.abort() + // Abort only the request superseded within this file/notebook scope. + this.fimAbortControllers.get(scope)?.abort() const controller = new AbortController() - this.fimAbortController = controller + this.fimAbortControllers.set(scope, controller) const startTime = performance.now() @@ -657,6 +659,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple this.fatalNotified = true this.onFatalError?.(this.backoff.getFatalStatus()) } + } finally { + if (this.fimAbortControllers.get(scope) === controller) { + this.fimAbortControllers.delete(scope) + } } } diff --git a/packages/kilo-vscode/tests/unit/autocomplete-abort-scope.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-abort-scope.test.ts new file mode 100644 index 00000000000..43ec508d4bf --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-abort-scope.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "bun:test" +import * as vscode from "vscode" +import { AutocompleteInlineCompletionProvider } from "../../src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider" + +function createProvider() { + ;(vscode.window as any).onDidChangeActiveTextEditor = () => ({ dispose: () => {} }) + ;(vscode.window as any).onDidChangeTextEditorSelection = () => ({ dispose: () => {} }) + return new AutocompleteInlineCompletionProvider( + {} as any, + "kilo/mistralai/codestral-2508", + { getConnectionState: () => "connected" } as any, + () => {}, + () => ({ enableAutoTrigger: true }), + "/repo", + ) +} + +function prompt() { + return { + prefix: "", + suffix: "", + modelName: "codestral", + completionOptions: {}, + selectedCompletionInfo: undefined, + } as any +} + +async function tick() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe("autocomplete FIM abort scope", () => { + it("does not abort in-flight requests from another scope", async () => { + const provider = createProvider() + const signals: AbortSignal[] = [] + const resolvers: Array<() => void> = [] + ;(provider as any).fimPromptBuilder = { + getFromFIM: async ( + _connection: unknown, + _model: string, + _prompt: unknown, + process: (text: string) => unknown, + signal: AbortSignal, + ) => { + signals.push(signal) + return new Promise((resolve) => { + resolvers.push(() => { + resolve({ + suggestion: process("value"), + cost: 0, + inputTokens: 0, + outputTokens: 0, + }) + }) + }) + }, + } + + const first = provider.fetchAndCacheSuggestion("scope-a", prompt(), "", "", "python") + await tick() + + const second = provider.fetchAndCacheSuggestion("scope-b", prompt(), "", "", "python") + await tick() + + expect(signals[0]?.aborted).toBe(false) + expect(signals[1]?.aborted).toBe(false) + + resolvers.forEach((resolve) => resolve()) + await Promise.all([first, second]) + provider.dispose() + }) + + it("aborts older in-flight requests in the same scope", async () => { + const provider = createProvider() + const signals: AbortSignal[] = [] + const resolvers: Array<() => void> = [] + ;(provider as any).fimPromptBuilder = { + getFromFIM: async ( + _connection: unknown, + _model: string, + _prompt: unknown, + process: (text: string) => unknown, + signal: AbortSignal, + ) => { + signals.push(signal) + return new Promise((resolve) => { + resolvers.push(() => { + resolve({ + suggestion: process("value"), + cost: 0, + inputTokens: 0, + outputTokens: 0, + }) + }) + }) + }, + } + + const first = provider.fetchAndCacheSuggestion("scope-a", prompt(), "", "", "python") + await tick() + + const second = provider.fetchAndCacheSuggestion("scope-a", prompt(), "", "", "python") + await tick() + + expect(signals[0]?.aborted).toBe(true) + expect(signals[1]?.aborted).toBe(false) + + resolvers.forEach((resolve) => resolve()) + await Promise.all([first, second]) + provider.dispose() + }) +}) From 92cd9d283889e07089202a958f0372d9c8cc6a2b Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 24 Jun 2026 08:17:42 +0200 Subject: [PATCH 06/17] fix(vscode): avoid stale autocomplete abort controllers --- .../AutocompleteInlineCompletionProvider.ts | 12 ++++++------ .../unit/autocomplete-abort-scope.test.ts | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index f1e8e86ffd1..1fd3128145d 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -582,6 +582,12 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple suffix: string, languageId: string, ): Promise { + // Defense-in-depth: credentials may become invalid between the provider gate and the actual + // debounced execution. In that case, do not attempt an LLM call at all. + if (!hasValidCredentials(this.connectionService)) { + return + } + // Abort only the request superseded within this file/notebook scope. this.fimAbortControllers.get(scope)?.abort() const controller = new AbortController() @@ -596,12 +602,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple provider: getAutocompleteModelById(this.contextProvider.modelId).provider, } - // Defense-in-depth: credentials may become invalid between the provider gate and the actual - // debounced execution. In that case, do not attempt an LLM call at all. - if (!hasValidCredentials(this.connectionService)) { - return - } - try { // Curry processSuggestion with request context const curriedProcessSuggestion = (text: string) => diff --git a/packages/kilo-vscode/tests/unit/autocomplete-abort-scope.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-abort-scope.test.ts index 43ec508d4bf..18d6b37ce8c 100644 --- a/packages/kilo-vscode/tests/unit/autocomplete-abort-scope.test.ts +++ b/packages/kilo-vscode/tests/unit/autocomplete-abort-scope.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from "bun:test" import * as vscode from "vscode" import { AutocompleteInlineCompletionProvider } from "../../src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider" -function createProvider() { +function createProvider(state = "connected") { ;(vscode.window as any).onDidChangeActiveTextEditor = () => ({ dispose: () => {} }) ;(vscode.window as any).onDidChangeTextEditorSelection = () => ({ dispose: () => {} }) return new AutocompleteInlineCompletionProvider( {} as any, "kilo/mistralai/codestral-2508", - { getConnectionState: () => "connected" } as any, + { getConnectionState: () => state } as any, () => {}, () => ({ enableAutoTrigger: true }), "/repo", @@ -109,4 +109,18 @@ describe("autocomplete FIM abort scope", () => { await Promise.all([first, second]) provider.dispose() }) + + it("does not retain controllers when credentials are invalid", async () => { + const provider = createProvider("disconnected") + ;(provider as any).fimPromptBuilder = { + getFromFIM: () => { + throw new Error("should not fetch") + }, + } + + await provider.fetchAndCacheSuggestion("scope-a", prompt(), "", "", "python") + + expect((provider as any).fimAbortControllers.size).toBe(0) + provider.dispose() + }) }) From 34540a959f896c7100e3dc8893727e889fc6c67a Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 24 Jun 2026 08:21:35 +0200 Subject: [PATCH 07/17] feat(vscode): add marketplace type filters --- .../tests/unit/marketplace-actions.test.ts | 9 +++-- .../src/components/marketplace/ItemCard.tsx | 2 +- .../marketplace/MarketplaceListView.tsx | 40 ++++++++++++++++++- .../components/marketplace/marketplace.css | 29 +++++++++++++- .../src/components/marketplace/utils.ts | 26 +++++++----- 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index aacc681a9ff..c305e05c047 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -113,9 +113,12 @@ describe("Marketplace installation metadata", () => { ] const metadata = { project: { "mcp:warehouse": { type: "mcp" } }, global: {} } - expect(filterItems(items, metadata, "reviewer", "all", []).map((item) => item.id)).toEqual(["reviewer"]) - expect(filterItems(items, metadata, "", "all", ["business"]).map((item) => item.id)).toEqual(["campaign-writer"]) - expect(filterItems(items, metadata, "", "installed", []).map((item) => item.id)).toEqual(["warehouse"]) + expect(filterItems(items, metadata, "reviewer", "all", [], []).map((item) => item.id)).toEqual(["reviewer"]) + expect(filterItems(items, metadata, "", "all", ["business"], []).map((item) => item.id)).toEqual([ + "campaign-writer", + ]) + expect(filterItems(items, metadata, "", "installed", [], []).map((item) => item.id)).toEqual(["warehouse"]) + expect(filterItems(items, metadata, "", "all", [], ["mcp"]).map((item) => item.id)).toEqual(["warehouse"]) }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/ItemCard.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/ItemCard.tsx index c82127b96d1..bfb4a9aaf8d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/ItemCard.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/ItemCard.tsx @@ -49,7 +49,7 @@ export const ItemCard = (props: Props) => { {name()} - {type()} + {type()}
diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx index 4bbba1f7e03..6142c6c94ed 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -33,6 +33,7 @@ export const MarketplaceListView = (props: Props) => { const { t } = useLanguage() const [search, setSearch] = createSignal("") const [status, setStatus] = createSignal({ value: "all", label: t("marketplace.filter.all") }) + const [types, setTypes] = createSignal([]) const [categories, setCategories] = createSignal([]) const options = (): StatusOption[] => [ @@ -47,8 +48,18 @@ export const MarketplaceListView = (props: Props) => { .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" ") + const allTypes = createMemo(() => { + const available = new Set(props.items.map((item) => item.type)) + return (["agent", "mcp", "skill"] as const).filter((type) => available.has(type)) + }) const allCategories = createMemo(() => Array.from(new Set(props.items.map((item) => item.category))).sort()) + const typeLabel = (type: MarketplaceItem["type"]) => { + if (type === "mcp") return t("marketplace.badge.mcpServer") + if (type === "agent") return t("marketplace.remove.type.agent") + return t("marketplace.remove.type.skill") + } + createEffect(() => { const available = new Set(allCategories()) setCategories((current) => { @@ -57,6 +68,15 @@ export const MarketplaceListView = (props: Props) => { }) }) + const toggleType = (type: MarketplaceItem["type"]) => { + const current = types() + if (current.includes(type)) { + setTypes(current.filter((value) => value !== type)) + return + } + setTypes([...current, type]) + } + const toggleCategory = (category: string) => { const current = categories() if (current.includes(category)) { @@ -66,7 +86,9 @@ export const MarketplaceListView = (props: Props) => { setCategories([...current, category]) } - const filtered = createMemo(() => filterItems(props.items, props.metadata, search(), status().value, categories())) + const filtered = createMemo(() => + filterItems(props.items, props.metadata, search(), status().value, categories(), types()), + ) return (
@@ -82,6 +104,22 @@ export const MarketplaceListView = (props: Props) => { onSelect={(v: StatusOption | undefined) => v && setStatus(v)} />
+ 1}> +
+ + {(type) => ( + + )} + +
+
0}>
diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css index 45beaca8006..fdcdacb753b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css @@ -27,14 +27,16 @@ flex: 1; } -/* Category filters */ +/* Type and category filters */ +.marketplace-types, .marketplace-categories { display: flex; flex-wrap: wrap; gap: 4px; } +.marketplace-type-filter, .marketplace-category-filter { all: unset; cursor: pointer; @@ -42,15 +44,18 @@ transition: opacity 0.15s ease; } +.marketplace-type-filter:hover, .marketplace-category-filter:hover { opacity: 0.8; } +.marketplace-type-filter:focus-visible, .marketplace-category-filter:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; } +.marketplace-type-filter.active, .marketplace-category-filter.active { opacity: 1; } @@ -200,6 +205,28 @@ .marketplace-badge-type { flex-shrink: 0; + letter-spacing: 0.4px; +} + +.marketplace-type-agent [data-component="tag"], +.marketplace-badge-type.marketplace-type-agent { + color: var(--text-on-success-base) !important; + background-color: color-mix(in srgb, var(--border-success-base) 14%, transparent) !important; + border-color: var(--border-success-base) !important; +} + +.marketplace-type-mcp [data-component="tag"], +.marketplace-badge-type.marketplace-type-mcp { + color: var(--text-on-info-base) !important; + background-color: color-mix(in srgb, var(--border-info-base) 14%, transparent) !important; + border-color: var(--border-info-base) !important; +} + +.marketplace-type-skill [data-component="tag"], +.marketplace-badge-type.marketplace-type-skill { + color: var(--text-on-warning-base) !important; + background-color: color-mix(in srgb, var(--border-warning-base) 14%, transparent) !important; + border-color: var(--border-warning-base) !important; } .marketplace-badge-installed { diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts index 66a7910a2aa..6d9e6f0f9c3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts @@ -20,30 +20,36 @@ export function installedScopes( return scopes } +function matches(item: MarketplaceItem, query: string) { + const skill = item.type === "skill" ? item : undefined + return ( + item.id.toLowerCase().includes(query) || + item.name.toLowerCase().includes(query) || + item.description.toLowerCase().includes(query) || + item.category.toLowerCase().includes(query) || + item.type.includes(query) || + (item.author?.toLowerCase().includes(query) ?? false) || + (skill?.displayName.toLowerCase().includes(query) ?? false) + ) +} + export function filterItems( items: MarketplaceItem[], metadata: MarketplaceInstalledMetadata, search: string, status: string, categories: string[], + types: MarketplaceItem["type"][], ): MarketplaceItem[] { const query = search.trim().toLowerCase() return items .filter((item) => { if (status === "installed" && !isInstalled(item.id, item.type, metadata)) return false if (status === "notInstalled" && isInstalled(item.id, item.type, metadata)) return false + if (types.length > 0 && !types.includes(item.type)) return false if (categories.length > 0 && !categories.includes(item.category)) return false if (!query) return true - const skill = item.type === "skill" ? item : undefined - return ( - item.id.toLowerCase().includes(query) || - item.name.toLowerCase().includes(query) || - item.description.toLowerCase().includes(query) || - item.category.toLowerCase().includes(query) || - item.type.includes(query) || - (item.author?.toLowerCase().includes(query) ?? false) || - (skill?.displayName.toLowerCase().includes(query) ?? false) - ) + return matches(item, query) }) .sort((a, b) => a.name.localeCompare(b.name)) } From cf07116fde14f97622cee65a1612d58517951ebb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Jun 2026 06:24:20 +0000 Subject: [PATCH 08/17] chore: update kilo-vscode visual regression baselines --- .../marketplace/mixed-list-with-items-chromium-linux.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png index d7bf6d29366..9556137ca56 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:099bfba94c6b1e9a345e404fc658b0e60a8fafbc3d4724a7757b618a83f37f40 -size 52577 +oid sha256:4a7bcef3102838812625393f0cead3a10067ae32e67dd3f174d0e74d03116c8c +size 54735 From 1568a43e602ae052678cb98f4cd52a5f901ee92c Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 24 Jun 2026 11:56:18 +0200 Subject: [PATCH 09/17] test(vscode): trim marketplace visual coverage --- .../agents-tab-empty-chromium-linux.png | 3 - ...ents-tab-with-installed-chromium-linux.png | 3 - .../agents-tab-with-items-chromium-linux.png | 3 - .../mcp-tab-empty-chromium-linux.png | 3 - .../mcp-tab-with-installed-chromium-linux.png | 3 - .../mcp-tab-with-items-chromium-linux.png | 3 - .../skills-tab-empty-chromium-linux.png | 3 - ...ills-tab-with-installed-chromium-linux.png | 3 - .../skills-tab-with-items-chromium-linux.png | 3 - .../kilo-vscode/tests/accessibility.spec.ts | 3 +- .../src/stories/marketplace.stories.tsx | 162 +----------------- 11 files changed, 6 insertions(+), 186 deletions(-) delete mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png delete mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png delete mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png delete mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png delete mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png delete mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png delete mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-empty-chromium-linux.png delete mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png delete mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png deleted file mode 100644 index caa3cb9437a..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-empty-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c73b6d01ced180f3a6175159b0b219c38fb7be40093ca6bbfcf7fda83d19ab2b -size 10365 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png deleted file mode 100644 index 06dcbee6bbd..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-installed-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1e671d8d2ed33d57b30c5dc6d5f704cdc005bb545cda472e2b15c3ebd504654d -size 54302 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png deleted file mode 100644 index 2ee5f00f21c..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/agents-tab-with-items-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:58b200204269bee41139d86dc506a71a57c6589e9e95e6e2b4c651a0ba2779df -size 51429 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png deleted file mode 100644 index 45809309bec..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:532ce502f99b7f88d6b01bedbb8cbda01adb3abdbf0886f1f67b85212de78e35 -size 11008 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png deleted file mode 100644 index acfb685a306..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:442822e2b6ca1f759c18c17e0dad396333ba9eff4d0ceaeb836d7b8e9b484977 -size 57102 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png deleted file mode 100644 index a50276b45b6..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ad0d1aa853a03f280a20074c2233e85857c2d644182d0efb8484d4fb0ce8f00e -size 54311 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-empty-chromium-linux.png deleted file mode 100644 index 9903c56a8c5..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-empty-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9f722d2782f69170a3efca6ed64ab02d9140f845bb47059ba193e238c768d0a5 -size 4852 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png deleted file mode 100644 index 133c7280543..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d09dfc5fc3adf5d8ea92f011af46cdcc750a4a1880463bda13c7cd9c3b961843 -size 56181 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png deleted file mode 100644 index 51b6ba8d1b0..00000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:800271cd7f29ba5e332538ad683215e825b62c5a0676b73b3f081aec7da4ae1b -size 53283 diff --git a/packages/kilo-vscode/tests/accessibility.spec.ts b/packages/kilo-vscode/tests/accessibility.spec.ts index fead3c81f38..153eeb1c34e 100644 --- a/packages/kilo-vscode/tests/accessibility.spec.ts +++ b/packages/kilo-vscode/tests/accessibility.spec.ts @@ -11,8 +11,7 @@ const STORIES = [ { id: "profile--logged-in-personal", name: "Profile / personal account" }, { id: "profile--logged-in", name: "Profile / organization account" }, { id: "settings--providers-configure", name: "Settings / providers empty state" }, - { id: "marketplace--skills-tab-empty", name: "Marketplace / skills empty state" }, - { id: "marketplace--agents-tab-empty", name: "Marketplace / agents empty state" }, + { id: "marketplace--empty-list", name: "Marketplace / empty state" }, { id: "agentmanager--sidebar-search-open", name: "Agent Manager / sidebar search" }, ] diff --git a/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx index 498bac128b0..b8c0d692b67 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/marketplace.stories.tsx @@ -319,55 +319,17 @@ export const MixedListWithItems: Story = { ), } -export const SkillsTabWithItems: Story = { - name: "Skills list — with items", +export const EmptyList: Story = { + name: "Mixed list — empty state", render: () => ( -
- -
-
- ), -} - -export const SkillsTabWithInstalled: Story = { - name: "Skills list — some installed", - render: () => ( - -
- -
-
- ), -} - -export const SkillsTabEmpty: Story = { - name: "Skills list — empty state", - render: () => ( - -
+
@@ -416,63 +378,6 @@ export const InstalledSkillCard: Story = { // MCP Stories // --------------------------------------------------------------------------- -export const McpTabWithItems: Story = { - name: "MCP list — with items", - render: () => ( - -
- -
-
- ), -} - -export const McpTabWithInstalled: Story = { - name: "MCP list — some installed", - render: () => ( - -
- -
-
- ), -} - -export const McpTabEmpty: Story = { - name: "MCP list — empty state", - render: () => ( - -
- -
-
- ), -} - export const SingleMcpCard: Story = { name: "ItemCard — single MCP not installed", render: () => ( @@ -511,63 +416,6 @@ export const InstalledMcpCard: Story = { // Mode Stories // --------------------------------------------------------------------------- -export const AgentsTabWithItems: Story = { - name: "Agents list — with items", - render: () => ( - -
- -
-
- ), -} - -export const AgentsTabWithInstalled: Story = { - name: "Agents list — some installed", - render: () => ( - -
- -
-
- ), -} - -export const AgentsTabEmpty: Story = { - name: "Agents list — empty state", - render: () => ( - -
- -
-
- ), -} - export const SingleAgentCard: Story = { name: "ItemCard — single agent not installed", render: () => ( From 47dfd5e68883cff27f50068c7c91d20560d7e07e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Jun 2026 09:59:06 +0000 Subject: [PATCH 10/17] chore: update kilo-vscode visual regression baselines --- .../marketplace/empty-list-chromium-linux.png | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png new file mode 100644 index 00000000000..9bf8b91cdb8 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6eb9d7670f55c5529dce90d11fceb5c9da7a0d3a5f7270fdfd27e66ca1f3d72f +size 9851 From ebcf81f58f7a91f51780c83cf1fe67ae2712254a Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:03:28 +0000 Subject: [PATCH 11/17] fix(vscode): match marketplace display labels in search Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../kilo-vscode/tests/unit/marketplace-actions.test.ts | 6 +++++- .../src/components/marketplace/MarketplaceListView.tsx | 6 +++++- .../webview-ui/src/components/marketplace/utils.ts | 8 +++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index c305e05c047..0a12943fb33 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -95,7 +95,7 @@ describe("Marketplace installation metadata", () => { id: "warehouse", name: "Warehouse", description: "Queries data", - category: "data", + category: "web-automation", url: "https://example.com", content: "{}", }, @@ -114,6 +114,10 @@ describe("Marketplace installation metadata", () => { const metadata = { project: { "mcp:warehouse": { type: "mcp" } }, global: {} } expect(filterItems(items, metadata, "reviewer", "all", [], []).map((item) => item.id)).toEqual(["reviewer"]) + expect(filterItems(items, metadata, "web automation", "all", [], []).map((item) => item.id)).toEqual(["warehouse"]) + expect( + filterItems(items, metadata, "servidor mcp", "all", [], [], { mcp: "Servidor MCP" }).map((item) => item.id), + ).toEqual(["warehouse"]) expect(filterItems(items, metadata, "", "all", ["business"], []).map((item) => item.id)).toEqual([ "campaign-writer", ]) diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx index 6142c6c94ed..9fa87889057 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -87,7 +87,11 @@ export const MarketplaceListView = (props: Props) => { } const filtered = createMemo(() => - filterItems(props.items, props.metadata, search(), status().value, categories(), types()), + filterItems(props.items, props.metadata, search(), status().value, categories(), types(), { + agent: typeLabel("agent"), + mcp: typeLabel("mcp"), + skill: typeLabel("skill"), + }), ) return ( diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts index 6d9e6f0f9c3..be2e93948a8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts @@ -20,14 +20,15 @@ export function installedScopes( return scopes } -function matches(item: MarketplaceItem, query: string) { +function matches(item: MarketplaceItem, query: string, labels: Partial>) { const skill = item.type === "skill" ? item : undefined return ( item.id.toLowerCase().includes(query) || item.name.toLowerCase().includes(query) || item.description.toLowerCase().includes(query) || - item.category.toLowerCase().includes(query) || + item.category.replaceAll("-", " ").toLowerCase().includes(query) || item.type.includes(query) || + (labels[item.type]?.toLowerCase().includes(query) ?? false) || (item.author?.toLowerCase().includes(query) ?? false) || (skill?.displayName.toLowerCase().includes(query) ?? false) ) @@ -40,6 +41,7 @@ export function filterItems( status: string, categories: string[], types: MarketplaceItem["type"][], + labels: Partial> = {}, ): MarketplaceItem[] { const query = search.trim().toLowerCase() return items @@ -49,7 +51,7 @@ export function filterItems( if (types.length > 0 && !types.includes(item.type)) return false if (categories.length > 0 && !categories.includes(item.category)) return false if (!query) return true - return matches(item, query) + return matches(item, query, labels) }) .sort((a, b) => a.name.localeCompare(b.name)) } From 233fb0d126ee38368a71a1c2c0b02851a6f68ed3 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:03:52 +0000 Subject: [PATCH 12/17] fix(vscode): expose type filters in high contrast Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../webview-ui/src/components/marketplace/marketplace.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css index fdcdacb753b..e5f8cdcb8b8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css @@ -345,10 +345,12 @@ body.vscode-high-contrast-light { border: 1px solid var(--vscode-contrastBorder, transparent); } + .marketplace-type-filter, .marketplace-category-filter { border: 1px solid var(--vscode-contrastBorder, transparent); } + .marketplace-type-filter.active, .marketplace-category-filter.active { border-color: var(--vscode-contrastActiveBorder, var(--vscode-focusBorder)); } From 588b8c899690581bc69d0bed83305b8d598ed1a3 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:07:38 +0000 Subject: [PATCH 13/17] fix(vscode): prune unavailable marketplace filters Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../kilo-vscode/tests/unit/marketplace-actions.test.ts | 6 +++++- .../src/components/marketplace/MarketplaceListView.tsx | 9 +++------ .../webview-ui/src/components/marketplace/utils.ts | 6 ++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index 0a12943fb33..f778f9b1f0f 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -7,7 +7,7 @@ import { type MarketplaceRemoveContext, } from "../../src/services/marketplace/actions" import type { McpMarketplaceItem } from "../../src/services/marketplace/types" -import { filterItems, installedScopes } from "../../webview-ui/src/components/marketplace/utils" +import { filterItems, installedScopes, retain } from "../../webview-ui/src/components/marketplace/utils" import type { MarketplaceItem } from "../../webview-ui/src/types/marketplace" const project = "/repo" @@ -80,6 +80,10 @@ describe("Marketplace installation metadata", () => { expect(installedScopes("dbt", "agent", metadata)).toEqual([]) }) + it("removes filters that are no longer available", () => { + expect(retain(["agent", "mcp"], ["mcp", "skill"])).toEqual(["mcp"]) + }) + it("filters the mixed list by search, category, and status", () => { const items: MarketplaceItem[] = [ { diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx index 9fa87889057..c3fe6136a65 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -10,7 +10,7 @@ import type { MarketplaceInstalledMetadata, } from "../../types/marketplace" import { useLanguage } from "../../context/language" -import { filterItems } from "./utils" +import { filterItems, retain } from "./utils" import { ItemCard } from "./ItemCard" import { MarketplaceContribute } from "./MarketplaceContribute" @@ -61,11 +61,8 @@ export const MarketplaceListView = (props: Props) => { } createEffect(() => { - const available = new Set(allCategories()) - setCategories((current) => { - const next = current.filter((category) => available.has(category)) - return next.length === current.length ? current : next - }) + setTypes((current) => retain(current, allTypes())) + setCategories((current) => retain(current, allCategories())) }) const toggleType = (type: MarketplaceItem["type"]) => { diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts index be2e93948a8..0f616cc0c42 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts @@ -1,5 +1,11 @@ import type { MarketplaceInstalledMetadata, MarketplaceItem } from "../../types/marketplace" +export function retain(selected: T[], available: T[]): T[] { + const values = new Set(available) + const next = selected.filter((value) => values.has(value)) + return next.length === selected.length ? selected : next +} + export function isInstalled( id: string, type: string, From 1bddb20a0b15cc303f2ee3824bc7dcb952434d89 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:19:37 +0000 Subject: [PATCH 14/17] test(cli): stabilize websocket idle timeout coverage Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/test/plugin/openai-ws.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index dcb31199c41..21b8b2d6496 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -376,7 +376,7 @@ describe("plugin.openai.ws-pool", () => { }) const fetch = OpenAIWebSocketPool.createWebSocketFetch({ url: server.url, - idleTimeout: 20, + idleTimeout: 100, // kilocode_change - leave enough time for WebSocket callbacks on loaded CI runners streamRetries: 1, }) From ef50772460c98d3c39470f197565d981c21d133c Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:24:21 +0000 Subject: [PATCH 15/17] docs: narrow merge minimizer skill trigger Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .kilo/skills/kilocode-merge-minimizer/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.kilo/skills/kilocode-merge-minimizer/SKILL.md b/.kilo/skills/kilocode-merge-minimizer/SKILL.md index 84dfaf4917b..9c4798de73b 100644 --- a/.kilo/skills/kilocode-merge-minimizer/SKILL.md +++ b/.kilo/skills/kilocode-merge-minimizer/SKILL.md @@ -1,12 +1,14 @@ --- name: kilocode-merge-minimizer -description: Use when changing shared upstream-owned files, editing or reviewing `kilocode_change` markers, or moving additive Kilo-specific behavior into Kilo-owned code to reduce future merge conflicts. +description: Use when changing shared upstream-owned files to add Kilo-specific behavior, editing `kilocode_change` markers in shared code, or moving additive behavior out of shared code to reduce upstream merge conflicts. Do not use for changes confined to Kilo-owned paths such as `packages/kilo-vscode/` or `packages/kilo-ui/`. --- # Kilo Merge Minimizer Use this skill whenever a normal development task touches shared upstream-owned code and includes Kilo-specific behavior, especially for marker cleanup, extraction work, or `kilocode_change` annotations. +Do not use this skill when all changes are confined to Kilo-owned paths, including `packages/kilo-vscode/`, `packages/kilo-ui/`, and paths with `kilocode` in their name. Those files are not merged from upstream and do not need merge-minimization guidance. If a task also touches shared upstream-owned code, use this skill for the shared portion only. + Do not use this as the primary guide for upstream merge resolution. Upstream merges have their own instructions and should not duplicate that workflow here. ## Goal From 2638e06ffbeff598672b671837380ef282f9f34c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 24 Jun 2026 12:29:44 +0200 Subject: [PATCH 16/17] feat(sandbox): add session sandbox controls --- .changeset/toggle-tui-sandbox.md | 7 + packages/kilo-sandbox/src/context.ts | 4 + packages/kilo-sandbox/src/index.ts | 4 +- packages/kilo-vscode/src/KiloProvider.ts | 201 +++++++++++++-- .../src/agent-manager/AgentManagerProvider.ts | 8 +- .../kilo-vscode/src/agent-manager/types.ts | 10 + .../kilo-vscode/src/kilo-provider-utils.ts | 32 ++- .../services/cli-backend/connection-utils.ts | 1 + .../tests/unit/connection-utils.test.ts | 10 + .../unit/kilo-provider-load-messages.test.ts | 160 +++++++++++- .../tests/unit/kilo-provider-utils.test.ts | 18 ++ .../prompt-input-connection-guard.test.ts | 34 ++- .../tests/unit/prompt-input-utils.test.ts | 33 +++ .../src/components/chat/PromptInput.tsx | 173 ++++++++++++- .../src/components/chat/prompt-input-utils.ts | 19 ++ .../src/types/messages/extension-messages.ts | 23 ++ .../src/types/messages/webview-messages.ts | 16 ++ .../src/cli/cmd/tui/plugin/internal.ts | 2 + .../opencode/src/kilocode/plugins/sandbox.tsx | 131 ++++++++++ .../opencode/src/kilocode/sandbox/event.ts | 15 ++ .../opencode/src/kilocode/sandbox/policy.ts | 116 ++++++++- .../kilocode/server/httpapi/groups/sandbox.ts | 63 +++++ .../server/httpapi/handlers/sandbox.ts | 21 ++ .../src/kilocode/server/httpapi/server.ts | 2 + .../src/server/routes/instance/httpapi/api.ts | 2 + packages/opencode/src/session/session.ts | 34 ++- packages/opencode/src/session/tools.ts | 3 +- .../kilocode/sandbox/config-network.test.ts | 7 +- .../test/kilocode/sandbox/sdk-config.test.ts | 16 ++ .../test/kilocode/sandbox/session.test.ts | 52 ++++ .../test/kilocode/sandbox/state.test.ts | 190 ++++++++++++++ .../test/kilocode/sandbox/tui.test.ts | 33 +++ .../server/httpapi-exercise-scenarios.ts | 27 ++ packages/sdk/js/src/gen/types.gen.ts | 8 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 75 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 95 +++++++ packages/sdk/openapi.json | 236 ++++++++++++++++++ script/check-model-tool-network.ts | 8 +- 38 files changed, 1814 insertions(+), 75 deletions(-) create mode 100644 .changeset/toggle-tui-sandbox.md create mode 100644 packages/opencode/src/kilocode/plugins/sandbox.tsx create mode 100644 packages/opencode/src/kilocode/sandbox/event.ts create mode 100644 packages/opencode/src/kilocode/server/httpapi/groups/sandbox.ts create mode 100644 packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts create mode 100644 packages/opencode/test/kilocode/sandbox/sdk-config.test.ts create mode 100644 packages/opencode/test/kilocode/sandbox/session.test.ts create mode 100644 packages/opencode/test/kilocode/sandbox/state.test.ts create mode 100644 packages/opencode/test/kilocode/sandbox/tui.test.ts diff --git a/.changeset/toggle-tui-sandbox.md b/.changeset/toggle-tui-sandbox.md new file mode 100644 index 00000000000..a616acbc636 --- /dev/null +++ b/.changeset/toggle-tui-sandbox.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": minor +"@kilocode/sdk": minor +"kilo-code": patch +--- + +Add session-local macOS sandbox controls, show the effective active state, and confirm toggles in the CLI and VS Code extension. diff --git a/packages/kilo-sandbox/src/context.ts b/packages/kilo-sandbox/src/context.ts index 9cb589ca68c..71609d12a97 100644 --- a/packages/kilo-sandbox/src/context.ts +++ b/packages/kilo-sandbox/src/context.ts @@ -22,6 +22,10 @@ export function run( }) } +export function unrestricted(effect: Effect.Effect) { + return effect.pipe(Effect.provideService(CurrentProfile, undefined)) +} + function denied(path: string, method: string) { return PlatformError.systemError({ _tag: "PermissionDenied", diff --git a/packages/kilo-sandbox/src/index.ts b/packages/kilo-sandbox/src/index.ts index 4f8649386c0..cf942e3e7a9 100644 --- a/packages/kilo-sandbox/src/index.ts +++ b/packages/kilo-sandbox/src/index.ts @@ -1,7 +1,7 @@ export type { Profile } from "./profile" -export { assertWrite, enabled, run } from "./context" +export { assertWrite, enabled, run, unrestricted } from "./context" export { decorateFileSystem, ensureDirectory } from "./filesystem" export { assertNetwork, decorateHttpClient, httpLayer as networkHttpLayer } from "./network" export { batchMutations, mutate, withRunner, type Runner as MutationRunner } from "./mutation" export type { Request as MutationRequest } from "./mutation-protocol" -export { prepareCommand } from "./backend" +export { backendSupport, prepareCommand } from "./backend" diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 18ef1427c09..22f0b732341 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -293,6 +293,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Remembers the last selected session so /new can stay in the same worktree after clearSession. */ private contextSessionID: string | undefined private connectionState: "connecting" | "connected" | "disconnected" | "error" = "connecting" + private connectionGeneration = 0 private loginAttempt = 0 private isWebviewReady = false private readonly extensionVersion = @@ -309,6 +310,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private providersRefresh: Promise | null = null private providersQueued = false private providersGeneration = 0 + private sandboxRevision = 0 private cachedAgentsMessage: unknown = null /** Cached skillsLoaded payload so requestSkills can be served before client is ready */ private cachedSkillsMessage: unknown = null @@ -336,6 +338,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private trackedSessionIds: Set = new Set() private syncedChildSessions: Set = new Set() private readonly checkpoints = new Map>() + private readonly sessionCreations = new Map>() + private readonly sandboxTransitions = new Map>() private readonly revisions = new Map() private readonly refreshes = new Map() private sessionStatusMap = new Map() // Latest status used for destructive config warnings. @@ -712,11 +716,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper public setSessionDirectory(sessionId: string, directory: string): void { this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId)) this.sessionDirectories.set(sessionId, directory) + if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId) } public clearSessionDirectory(sessionId: string): void { this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId)) this.sessionDirectories.delete(sessionId) + if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId) } /** Exposes the session→directory map so callers outside the webview can resolve worktree paths. */ @@ -1077,6 +1083,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.pendingFollowup = null await handleQuestionReject(this.questionCtx, message.requestID, message.sessionID) break + case "requestSandboxStatus": + await this.fetchAndSendSandboxStatus(message.sessionID) + break + case "toggleSandbox": + await this.handleToggleSandbox(message) + break case "requestConfig": this.fetchAndSendConfig().catch((e) => console.error("[Kilo New] fetchAndSendConfig failed:", e)) break @@ -1337,6 +1349,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.log("[Kilo New] KiloProvider: 🔧 Starting initializeConnection...") this.connectionState = "connecting" + this.connectionGeneration++ this.postMessage({ type: "connectionState", state: "connecting" }) // Clean up any existing subscriptions (e.g., sidebar re-shown) @@ -1389,6 +1402,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Subscribe to connection state changes this.unsubscribeState = this.connectionService.onStateChange(async (state, error) => { + if (this.connectionState !== state) this.connectionGeneration++ this.connectionState = state this.postConnectionState(error) @@ -2455,6 +2469,142 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(getWorkStylePayload()) } + private postSandboxError(sessionID: string, error: unknown, revision: number, requestID?: string): void { + this.postMessage({ + type: "sandboxStatusError", + sessionID, + directory: this.getWorkspaceDirectory(sessionID), + message: getErrorMessage(error) || "Failed to update sandbox", + requestID, + revision, + }) + } + + private async fetchAndSendSandboxStatus(sessionID: string, requestID?: string): Promise { + const revision = ++this.sandboxRevision + const generation = this.connectionGeneration + const client = this.client + const sandbox = client?.sandbox + if (!sandbox?.status) return + if (this.connectionState !== "connected") { + this.postSandboxError(sessionID, "Not connected to CLI backend", revision, requestID) + return + } + try { + const directory = this.getWorkspaceDirectory(sessionID) + const { data } = await sandbox.status({ sessionID, directory }, { throwOnError: true }) + if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client) + return + if (!sameDirectory(data.directory, this.getWorkspaceDirectory(sessionID))) { + if (requestID) void this.fetchAndSendSandboxStatus(sessionID, requestID) + return + } + this.postMessage({ type: "sandboxStatus", sessionID, revision, ...data, requestID }) + } catch (error) { + if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client) + return + this.postSandboxError(sessionID, error, revision, requestID) + } + } + + private sandboxKey(input: { + sessionID?: string + draftID?: string + agentManagerContext?: string + contextDirectory?: string + }): string { + if (input.sessionID) return `session:${input.sessionID}` + if (input.draftID) return `draft:${input.draftID}` + return `context:${input.agentManagerContext ?? ""}:${input.contextDirectory ?? this.getRootDirectory()}` + } + + private handleToggleSandbox(input: { + sessionID?: string + draftID?: string + requestID: string + agentManagerContext?: string + contextDirectory?: string + }): Promise { + const key = this.sandboxKey(input) + const pending = this.sandboxTransitions.get(key) + if (pending) return pending.catch(() => undefined) + const operation = this.runToggleSandbox(input, key) + this.sandboxTransitions.set(key, operation) + return operation + .catch(() => undefined) + .finally(() => { + for (const [id, active] of this.sandboxTransitions) { + if (active === operation) this.sandboxTransitions.delete(id) + } + }) + } + + private async runToggleSandbox( + input: { + sessionID?: string + draftID?: string + requestID: string + agentManagerContext?: string + contextDirectory?: string + }, + key: string, + ): Promise { + const revision = ++this.sandboxRevision + const generation = this.connectionGeneration + const client = this.client + const sandbox = client?.sandbox + if (!sandbox?.toggle || this.connectionState !== "connected") { + const error = new Error("Not connected to CLI backend") + this.postSandboxError(input.sessionID ?? "", error, revision, input.requestID) + throw error + } + const resolved = await this.resolveSession( + input.sessionID, + input.draftID, + input.agentManagerContext, + input.contextDirectory, + ).catch((error) => { + this.postSandboxError(input.sessionID ?? "", error, revision, input.requestID) + throw error + }) + if (!resolved) { + const error = new Error("Failed to resolve sandbox session") + this.postSandboxError(input.sessionID ?? "", error, revision, input.requestID) + throw error + } + const operation = this.sandboxTransitions.get(key) + if (operation) this.sandboxTransitions.set(`session:${resolved.sid}`, operation) + if (this.connectionGeneration !== generation || this.client !== client) { + throw new Error("Sandbox connection changed") + } + try { + const { data } = await sandbox.toggle( + { sessionID: resolved.sid, directory: resolved.dir }, + { throwOnError: true }, + ) + if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client) { + throw new Error("Sandbox connection changed") + } + if (!data.available) throw new Error(data.reason ?? "Sandbox backend is unavailable") + if (!sameDirectory(data.directory, this.getWorkspaceDirectory(resolved.sid))) { + throw new Error("Session directory changed during sandbox toggle") + } + this.postMessage({ + type: "sandboxStatus", + sessionID: resolved.sid, + revision, + ...data, + requestID: input.requestID, + }) + } catch (error) { + if (this.connectionState === "connected" && this.connectionGeneration === generation && this.client === client) { + this.postSandboxError(resolved.sid, error, revision, input.requestID) + void this.fetchAndSendSandboxStatus(resolved.sid) + } + throw error + } + } + private async handleUpdateConfig( partial: Partial, project: Partial = {}, @@ -2569,21 +2719,29 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) if (!sessionID && !this.currentSession) { - const { data: session } = await this.client.session.create( - { directory: dir, platform: this.opts.platform }, - { throwOnError: true }, - ) - this.stopCurrentSessionProcesses(session.id) - this.setCurrentSession(session) - this.contextSessionID = session.id - this.focusSession(session.id) - this.trackDirectory(session.id, dir) - this.trackedSessionIds.add(session.id) - this.postMessage({ - type: "sessionCreated", - session: this.sessionToWebview(session), - draftID, - }) + const key = `${draftID ?? context ?? "new"}\0${dir}` + const pending = this.sessionCreations.get(key) + if (pending) return pending + const creation = (async () => { + const { data: session } = await this.client!.session.create( + { directory: dir, platform: this.opts.platform }, + { throwOnError: true }, + ) + this.stopCurrentSessionProcesses(session.id) + this.setCurrentSession(session) + this.contextSessionID = session.id + this.focusSession(session.id) + this.trackDirectory(session.id, dir) + this.trackedSessionIds.add(session.id) + this.postMessage({ + type: "sessionCreated", + session: this.sessionToWebview(session), + draftID, + }) + return { sid: session.id, dir } + })().finally(() => this.sessionCreations.delete(key)) + this.sessionCreations.set(key, creation) + return creation } const sid = sessionID || this.currentSession?.id @@ -2697,7 +2855,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper let resolved: { sid: string; dir: string } | undefined try { + const sandbox = this.sandboxTransitions.get( + this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }), + ) resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory) + if (sandbox) await sandbox const parts: Array = [] if (files) { @@ -2778,7 +2940,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper let resolved: { sid: string; dir: string } | undefined try { + const sandbox = this.sandboxTransitions.get( + this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }), + ) resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory) + if (sandbox) await sandbox if (messageID) { this.connectionService.recordMessageSessionId(messageID, resolved!.sid) @@ -3341,6 +3507,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } const next = msg.type === "messageCreated" ? { ...msg, message: this.slimInfo(msg.message) } : msg + if (next.type === "sandboxStatus") { + if (!sameDirectory(next.directory, this.getWorkspaceDirectory(next.sessionID))) return + this.postMessage({ ...next, revision: ++this.sandboxRevision }) + return + } if (next.type === "indexingStatusLoaded") { this.cachedIndexingStatusMessage = next } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 81dbc54dabf..3e2b59e592f 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -439,7 +439,7 @@ export class AgentManagerProvider implements Disposable { return null } - if ((m.type === "sendMessage" || m.type === "sendCommand") && !m.sessionID) { + if ((m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") && !m.sessionID) { const ctx = typeof m.agentManagerContext === "string" ? m.agentManagerContext : undefined const worktree = ctx && ctx !== "local" ? this.getStateManager()?.getWorktree(ctx) : undefined if (worktree) { @@ -448,7 +448,11 @@ export class AgentManagerProvider implements Disposable { } } - if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) { + if ( + (m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") && + m.draftID && + !m.sessionID + ) { this.activeSessionId = m.draftID return msg } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index ed766104333..e48b9fa7a4f 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -639,6 +639,15 @@ interface SendCommandIn { contextDirectory?: string } +interface ToggleSandboxIn { + type: "toggleSandbox" + sessionID?: string + draftID?: string + requestID: string + agentManagerContext?: string + contextDirectory?: string +} + interface RequestTerminalContextIn { type: "requestTerminalContext" requestId: string @@ -783,6 +792,7 @@ export type AgentManagerInMessage = | LoadMessagesIn | SendMessageIn | SendCommandIn + | ToggleSandboxIn | RequestTerminalContextIn | ClearSessionIn | AbortIn diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index b862887edf6..ac41b677480 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -518,6 +518,15 @@ export type WebviewMessage = | { type: "sessionDeleted"; sessionID: string } | { type: "messageRemoved"; sessionID: string; messageID: string } | { type: "sessionError"; sessionID?: string; error?: unknown } + | { + type: "sandboxStatus" + sessionID: string + directory: string + enabled: boolean + available: boolean + reason?: string + version: number + } | null type PartEvent = @@ -554,6 +563,12 @@ function mapPartEvent(event: PartEvent, sessionID: string | undefined): WebviewM } } +function statusExtra(info: Extract["properties"]["status"]) { + if (info.type === "retry") return { attempt: info.attempt, message: info.message, next: info.next } + if (info.type === "offline") return { message: info.message } + return {} +} + export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: string | undefined): WebviewMessage { if (event.type === "sync") { switch (event.name) { @@ -595,12 +610,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin case "session.status": { const info = event.properties.status const status = info.type - const extra = - info.type === "retry" - ? { attempt: info.attempt, message: info.message, next: info.next } - : info.type === "offline" - ? { message: info.message } - : {} + const extra = statusExtra(info) return { type: "sessionStatus" as const, sessionID: event.properties.sessionID, @@ -681,6 +691,16 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin error: event.properties.error, } } + case "sandbox.status.changed": + return { + type: "sandboxStatus", + sessionID: event.properties.sessionID, + directory: event.properties.directory, + enabled: event.properties.enabled, + available: event.properties.available, + reason: event.properties.reason, + version: event.properties.version, + } case "indexing.status": return { type: "indexingStatusLoaded", diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index 308beb15bcc..3a2b431b722 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -20,6 +20,7 @@ export function resolveEventSessionId( } void lookupMessageSessionId + if (event.type === "sandbox.status.changed") return event.properties.sessionID return resolveTransientSessionId(event) } diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index 46d74e8a231..2970f9eb05e 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -158,6 +158,16 @@ describe("resolveEventSessionId", () => { expect(resolveEventSessionId(suggestion, noLookup)).toBe("s10") }) + it("routes sandbox status events", () => { + const event = { + id: "e12", + type: "sandbox.status.changed", + properties: { sessionID: "s11", directory: "/repo", enabled: true, available: true, version: 1 }, + } satisfies Payload + + expect(resolveEventSessionId(event, noLookup)).toBe("s11") + }) + it("returns undefined for global events", () => { const event = { id: "e12", type: "server.connected", properties: {} } satisfies Payload diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index a11283907e2..1fd5d68478c 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -61,20 +61,31 @@ function createClient(options?: { sessionData?: unknown sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }> abortFailures?: string[] + createDeferred?: Deferred<{ data: unknown }> + sandboxDeferred?: Deferred<{ data: unknown }> + sandboxStarted?: Deferred }) { const calls: { before?: string; limit?: number }[] = [] const stopped: { sessionID: string; directory?: string }[] = [] const aborted: { sessionID: string; directory?: string }[] = [] const prompted: Array> = [] const reverted: Array> = [] + const created: Array> = [] + const sandboxed: Array> = [] return { calls, stopped, aborted, prompted, reverted, + created, + sandboxed, session: { list: async () => ({ data: [] }), + create: async (params: Record) => { + created.push(params) + return options?.createDeferred?.promise ?? { data: mkSession() } + }, get: async (params: { sessionID: string; directory?: string }) => { if (options?.sessionGet) return options.sessionGet(params) return { data: options?.sessionData ?? null } @@ -104,6 +115,17 @@ function createClient(options?: { return { data: {} } }, }, + sandbox: { + toggle: async (params: Record) => { + sandboxed.push(params) + options?.sandboxStarted?.resolve(undefined) + return ( + options?.sandboxDeferred?.promise ?? { + data: { directory: "/repo", enabled: true, available: true, version: 1 }, + } + ) + }, + }, backgroundProcess: { stopSession: async (params: { sessionID: string; directory?: string }) => { stopped.push(params) @@ -163,7 +185,8 @@ type ProviderInternals = { handleEvent: (event: unknown, directory?: string) => void handleAbort: (sid?: string) => Promise handleRevertSession: (sid: string, messageID: string) => Promise - handleSendMessage: (text: string, messageID?: string, sessionID?: string) => Promise + handleSendMessage: (text: string, messageID?: string, sessionID?: string, draftID?: string) => Promise + handleToggleSandbox: (input: { sessionID?: string; draftID?: string; requestID: string }) => Promise handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise handleDeleteSession: (sid: string) => Promise } @@ -246,6 +269,141 @@ describe("KiloProvider.handleAbort", () => { }) }) +describe("KiloProvider sandbox status", () => { + it("ignores events from another directory for the same session", () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + internal.sessionDirectories.set("s1", "/repo") + internal.trackedSessionIds.add("s1") + + internal.handleEvent({ + type: "sandbox.status.changed", + properties: { sessionID: "s1", directory: "/other", enabled: true, available: true, version: 1 }, + }) + expect(sent.some((message) => (message as { type?: string }).type === "sandboxStatus")).toBe(false) + + internal.handleEvent({ + type: "sandbox.status.changed", + properties: { sessionID: "s1", directory: "/repo", enabled: true, available: true, version: 1 }, + }) + expect(sent).toContainEqual(expect.objectContaining({ type: "sandboxStatus", sessionID: "s1", directory: "/repo" })) + }) +}) + +describe("KiloProvider sandbox toggle", () => { + it("creates a session before toggling from the empty composer", async () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + + await internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) + + expect(client.created).toEqual([expect.objectContaining({ directory: "/repo" })]) + expect(client.sandboxed).toEqual([{ sessionID: "s1", directory: "/repo" }]) + expect(sent.findIndex((message) => (message as { type?: string }).type === "sessionCreated")).toBeLessThan( + sent.findIndex((message) => (message as { type?: string }).type === "sandboxStatus"), + ) + expect(sent).toContainEqual( + expect.objectContaining({ + type: "sandboxStatus", + sessionID: "s1", + requestID: "sandbox-1", + enabled: true, + }), + ) + }) + + it("shares session creation and finishes the toggle before a prompt", async () => { + const create = defer<{ data: unknown }>() + const sandbox = defer<{ data: unknown }>() + const started = defer() + const client = createClient({ createDeferred: create, sandboxDeferred: sandbox, sandboxStarted: started }) + const { internal } = makeProvider(client) + internal.gatherEditorContext = async () => ({}) + + const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) + await Promise.resolve() + const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1") + await Promise.resolve() + + expect(client.created).toHaveLength(1) + expect(client.prompted).toHaveLength(0) + + create.resolve({ data: mkSession() }) + await started.promise + expect(client.sandboxed).toHaveLength(1) + expect(client.prompted).toHaveLength(0) + + sandbox.resolve({ data: { directory: "/repo", enabled: true, available: true, version: 1 } }) + await Promise.all([toggle, send]) + expect(client.created).toHaveLength(1) + expect(client.prompted).toHaveLength(1) + }) + + it("does not send a queued prompt when the sandbox toggle fails", async () => { + const log = spyOn(console, "error").mockImplementation(() => {}) + const sandbox = defer<{ data: unknown }>() + const started = defer() + const client = createClient({ sandboxDeferred: sandbox, sandboxStarted: started }) + const { internal, sent } = makeProvider(client) + internal.gatherEditorContext = async () => ({}) + + const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) + await started.promise + const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1") + sandbox.reject(new Error("toggle failed")) + await Promise.all([toggle, send]) + + expect(client.prompted).toHaveLength(0) + expect(sent).toContainEqual(expect.objectContaining({ type: "sandboxStatusError", requestID: "sandbox-1" })) + expect(sent).toContainEqual( + expect.objectContaining({ type: "sendMessageFailed", sessionID: "s1", messageID: "message-1" }), + ) + log.mockRestore() + }) + + it("does not send a queued prompt when the sandbox backend is unavailable", async () => { + const log = spyOn(console, "error").mockImplementation(() => {}) + const sandbox = defer<{ data: unknown }>() + const started = defer() + const client = createClient({ sandboxDeferred: sandbox, sandboxStarted: started }) + const { internal, sent } = makeProvider(client) + internal.gatherEditorContext = async () => ({}) + + const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) + await started.promise + const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1") + sandbox.resolve({ + data: { directory: "/repo", enabled: false, available: false, reason: "unsupported", version: 0 }, + }) + await Promise.all([toggle, send]) + + expect(client.prompted).toHaveLength(0) + expect(sent).toContainEqual(expect.objectContaining({ type: "sandboxStatusError", message: "unsupported" })) + expect(sent).toContainEqual( + expect.objectContaining({ type: "sendMessageFailed", sessionID: "s1", messageID: "message-1" }), + ) + log.mockRestore() + }) + + it("keeps prompts queued after the draft is promoted", async () => { + const sandbox = defer<{ data: unknown }>() + const started = defer() + const client = createClient({ sandboxDeferred: sandbox, sandboxStarted: started }) + const { internal } = makeProvider(client) + internal.gatherEditorContext = async () => ({}) + + const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" }) + await started.promise + const send = internal.handleSendMessage("hello", "message-1", "s1", "draft-1") + await Promise.resolve() + expect(client.prompted).toHaveLength(0) + + sandbox.resolve({ data: { directory: "/repo", enabled: true, available: true, version: 1 } }) + await Promise.all([toggle, send]) + expect(client.prompted).toHaveLength(1) + }) +}) + describe("KiloProvider revert ordering", () => { it("unwraps the nested sync payload emitted by the live SSE endpoint", () => { const event = unwrapSyncEvent({ diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index d3b710d7805..9df288c6730 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -24,6 +24,7 @@ import type { SyncEventMessageUpdated, EventSessionStatus, EventSessionTurnClose, + EventSandboxStatusChanged, EventPermissionAsked, EventPermissionReplied, EventTodoUpdated, @@ -448,6 +449,23 @@ describe("mapSSEEventToWebviewMessage", () => { } }) + it("maps sandbox status changes to effective button state", () => { + const event: EventSandboxStatusChanged = { + type: "sandbox.status.changed", + properties: { sessionID: "sess-1", directory: "/tmp", enabled: true, available: true, version: 3 }, + } + + expect(mapSSEEventToWebviewMessage(event, "sess-1")).toEqual({ + type: "sandboxStatus", + sessionID: "sess-1", + directory: "/tmp", + enabled: true, + available: true, + reason: undefined, + version: 3, + }) + }) + it("maps session.turn.close to its terminal reason", () => { const event: EventSessionTurnClose = { id: "evt-turn", diff --git a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts index 9fa283f73ef..f1743be91e1 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts @@ -20,17 +20,35 @@ describe("PromptInput connection guard", () => { }) describe("PromptInput sandbox toggle", () => { - it("writes only the sandbox patch without saving pending settings drafts", () => { - const start = src.indexOf("") - const end = src.indexOf("", start) + it("toggles or creates the runtime session instead of writing config", () => { + const start = src.indexOf("const toggleSandbox = () =>") + const end = src.indexOf("let enhanceCounter", start) const toggle = src.slice(start, end) expect(start).toBeGreaterThan(-1) expect(end).toBeGreaterThan(start) - expect(toggle).toContain("vscode.postMessage({") - expect(toggle).toContain('type: "updateConfig"') - expect(toggle).toContain("config: { experimental: { sandbox: !sandbox() } }") - expect(toggle).not.toContain("saveConfig") - expect(toggle).not.toContain("updateConfig(") + expect(toggle).toContain("const sessionID = sandboxID()") + expect(toggle).toContain('type: "toggleSandbox"') + expect(toggle).toContain("sessionID,") + expect(toggle).toContain("draftID: props.pendingSessionID ?? session.draftSessionID()") + expect(toggle).toContain("requestID,") + expect(toggle).toContain("setSandboxTarget(sessionID ?? null)") + expect(toggle).not.toContain('type: "updateConfig"') + }) + + it("uses the internal flag for visibility and effective runtime state for the button", () => { + expect(src).toContain("features().sandboxControls") + expect(src).toContain("") + expect(src).toContain('message.type === "sandboxStatus"') + expect(src).toContain("message.sessionID !== sandboxID() && !matching") + expect(src).toContain("setSandboxState(state)") + expect(src).toContain("message.requestID === sandboxRequest()") + expect(src).toContain("const target = untrack(sandboxTarget)") + expect(src).toContain("if (target && target !== sessionID) clearSandboxRequest()") + expect(src).toContain("sandbox()?.enabled ?? (!sandboxID() && config().experimental?.sandbox === true)") + expect(src).toContain("aria-pressed={sandboxEnabled()}") + expect(src).toContain("!sandboxReady()") + expect(src).toContain("if (sandboxRequest() && target === null) return") + expect(src).not.toContain("if (state === current) return true") }) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts index f38a45a1793..258e14f4b27 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts @@ -10,8 +10,41 @@ import { isSuggesting, isQuestioning, isPathMention, + applySandboxState, } from "../../webview-ui/src/components/chat/prompt-input-utils" +describe("applySandboxState", () => { + const state = (enabled: boolean, revision: number, sessionID = "ses_1", directory = "/repo") => ({ + sessionID, + directory, + enabled, + available: true, + version: enabled ? 1 : 0, + revision, + }) + + it("ignores an HTTP response with an older backend version", () => { + const latest = { ...state(true, 1), version: 2 } + const stale = { ...state(false, 2), version: 1 } + expect(applySandboxState(latest, stale)).toEqual(latest) + }) + + it("uses provider revision to order equal backend versions", () => { + const current = { ...state(false, 2), version: 4 } + const older = { ...state(true, 1), version: 4 } + const newer = { ...state(true, 3), version: 4 } + expect(applySandboxState(current, older)).toEqual(current) + expect(applySandboxState(current, newer)).toEqual(newer) + }) + + it("keeps global provider ordering across sessions and directories", () => { + expect(applySandboxState(state(true, 5, "ses_1"), state(false, 1, "ses_2"))).toEqual(state(true, 5, "ses_1")) + expect(applySandboxState(state(true, 5), state(false, 6, "ses_1", "/worktree"))).toEqual( + state(false, 6, "ses_1", "/worktree"), + ) + }) +}) + describe("fileName", () => { it("extracts the last segment of a unix path", () => { expect(fileName("src/components/chat/PromptInput.tsx")).toBe("PromptInput.tsx") diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 351b1e7216f..8415d853cd4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -43,8 +43,10 @@ import { insertSpacedText, isPromptBusy, isPathMention, + applySandboxState, + type SandboxState, } from "./prompt-input-utils" -import type { ReviewComment, SendMessageFailedMessage, TextPart } from "../../types/messages" +import type { ExtensionMessage, ReviewComment, SendMessageFailedMessage, TextPart } from "../../types/messages" import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown" import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts" import { ReviewComments } from "./ReviewComments" @@ -143,10 +145,82 @@ export const PromptInput: Component = (props) => { const [reviewComments, setReviewComments] = createSignal([]) const [enhancing, setEnhancing] = createSignal(false) const [autoApprove, setAutoApprove] = createSignal(false) - const sandbox = () => config().experimental?.sandbox ?? false + const [sandboxState, setSandboxState] = createSignal() + const [sandboxRequest, setSandboxRequest] = createSignal() + const [sandboxTarget, setSandboxTarget] = createSignal() + let sandboxRetry: ReturnType | undefined + let sandboxAttempts = 0 + const sandboxID = () => { + const id = session.currentSessionID() + return id?.startsWith("cloud:") ? undefined : id + } + const sandboxVisible = () => { + const id = session.currentSessionID() + return features().sandboxControls && !id?.startsWith("cloud:") + } + const sandbox = () => { + const state = sandboxState() + return state?.sessionID === sandboxID() ? state : undefined + } + const sandboxEnabled = () => sandbox()?.enabled ?? (!sandboxID() && config().experimental?.sandbox === true) + const sandboxReady = () => !sandboxID() || sandbox() !== undefined + const requestSandbox = () => { + const sessionID = sandboxID() + if (!sessionID || server.connectionState() !== "connected") return + vscode.postMessage({ type: "requestSandboxStatus", sessionID }) + } + const toggleSandbox = () => { + const sessionID = sandboxID() + const state = sandbox() + if ((sessionID && !state) || state?.available === false || sandboxRequest() || !server.isConnected()) return + const requestID = crypto.randomUUID() + setSandboxRequest(requestID) + setSandboxTarget(sessionID ?? null) + vscode.postMessage({ + type: "toggleSandbox", + sessionID, + draftID: props.pendingSessionID ?? session.draftSessionID(), + requestID, + agentManagerContext: ctx(), + }) + } + const clearSandboxRequest = () => { + setSandboxRequest(undefined) + setSandboxTarget(undefined) + } + const retrySandbox = (sessionID: string) => { + if (sandboxAttempts >= 2) return + sandboxAttempts++ + if (sandboxRetry) clearTimeout(sandboxRetry) + sandboxRetry = setTimeout(() => { + sandboxRetry = undefined + if (sandboxID() === sessionID) requestSandbox() + }, 1000) + } let enhanceCounter = 0 let preEnhanceText: string | null = null + createEffect(() => { + const sessionID = sandboxID() + const connected = server.connectionState() === "connected" + const target = untrack(sandboxTarget) + if (sandboxRetry) clearTimeout(sandboxRetry) + sandboxRetry = undefined + sandboxAttempts = 0 + if (!connected) { + clearSandboxRequest() + setSandboxState(undefined) + return + } + if (target && target !== sessionID) clearSandboxRequest() + if (!sessionID) { + setSandboxState(undefined) + return + } + if (sandboxRequest() && target === null) return + requestSandbox() + }) + const ghost = useGhostText(vscode, text, () => server.isConnected()) const speech = useSpeechToText(vscode, server, language) @@ -378,7 +452,74 @@ export const PromptInput: Component = (props) => { imageDrafts.set(target, images) } + const handleSandboxMessage = (message: ExtensionMessage) => { + if (message.type === "sandboxStatus") { + const matching = message.requestID !== undefined && message.requestID === sandboxRequest() + if (message.sessionID !== sandboxID() && !matching) return false + if (!server.isConnected()) return true + const current = sandboxState() + if (matching) clearSandboxRequest() + const state = applySandboxState(current, message) + if (state !== current) setSandboxState(state) + sandboxAttempts = 0 + if (sandboxRetry) clearTimeout(sandboxRetry) + sandboxRetry = undefined + if (matching) { + if (!state.available) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: state.reason, + }) + return true + } + showToast({ + variant: "success", + title: language.t("settings.experimental.sandbox.title"), + description: language.t(state.enabled ? "prompt.action.sandbox.enabled" : "prompt.action.sandbox.disabled"), + }) + } + return true + } + + if (message.type === "sandboxStatusError") { + const matching = message.requestID !== undefined && message.requestID === sandboxRequest() + if (message.sessionID !== sandboxID() && !matching) return false + if (!server.isConnected()) return true + const current = sandboxState() + if (matching) clearSandboxRequest() + if ((current?.revision ?? -1) > message.revision) return true + if (!message.requestID) { + const same = current?.sessionID === message.sessionID && current.directory === message.directory + setSandboxState({ + sessionID: message.sessionID, + directory: message.directory, + enabled: same ? current.enabled : false, + available: false, + reason: message.message, + version: same ? current.version : 0, + revision: message.revision, + }) + retrySandbox(message.sessionID) + } + if (matching) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: message.message, + }) + } + return true + } + + if (message.type !== "configUpdated") return false + requestSandbox() + return true + } + const unsubscribe = vscode.onMessage((message) => { + if (handleSandboxMessage(message)) return + if (message.type === "setChatBoxMessage") { setText(message.text) mention.seedFromText(message.text) @@ -470,6 +611,7 @@ export const PromptInput: Component = (props) => { onCleanup(() => { // Persist current draft before unmounting saveDraft(draftKey(), text(), reviewComments(), imageAttach.images()) + if (sandboxRetry) clearTimeout(sandboxRetry) unsubAutoApprove() unsubscribe() }) @@ -1073,27 +1215,34 @@ export const PromptInput: Component = (props) => { - + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts index b78b97624c6..6251efee295 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts @@ -1,3 +1,22 @@ +export type SandboxState = { + sessionID: string + enabled: boolean + available: boolean + reason?: string + version: number + directory: string + revision: number +} + +export function applySandboxState(current: SandboxState | undefined, next: SandboxState) { + if (!current) return next + const same = current.sessionID === next.sessionID && current.directory === next.directory + if (same && current.version > next.version) return current + if (same && current.version === next.version && current.revision > next.revision) return current + if (!same && current.revision > next.revision) return current + return next +} + export function fileName(path: string): string { const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "") return normalized.split("/").pop() ?? normalized diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index ac59d37caaa..330da8f633b 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -644,6 +644,27 @@ export interface AutoApproveStateMessage { active: boolean } +export interface SandboxStatusMessage { + type: "sandboxStatus" + sessionID: string + enabled: boolean + available: boolean + reason?: string + version: number + directory: string + revision: number + requestID?: string +} + +export interface SandboxStatusErrorMessage { + type: "sandboxStatusError" + sessionID: string + directory: string + message: string + revision: number + requestID?: string +} + // Multi-version creation progress (extension → webview) export interface AgentManagerMultiVersionProgressMessage { type: "agentManager.multiVersionProgress" @@ -1045,6 +1066,8 @@ export type ExtensionMessage = | AgentManagerRunStatusMessage | AgentManagerKeybindingsMessage | AutoApproveStateMessage + | SandboxStatusMessage + | SandboxStatusErrorMessage | AgentManagerMultiVersionProgressMessage | AgentManagerSetSessionModelMessage | AgentManagerSendInitialMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 1ce95dc9f6f..781df064c8e 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -924,6 +924,20 @@ export interface ToggleAutoApproveMessage { type: "toggleAutoApprove" } +export interface RequestSandboxStatusMessage { + type: "requestSandboxStatus" + sessionID: string +} + +export interface ToggleSandboxMessage { + type: "toggleSandbox" + sessionID?: string + draftID?: string + requestID: string + agentManagerContext?: string + contextDirectory?: string +} + export interface ToggleRemoteMessage { type: "toggleRemote" } @@ -1257,6 +1271,8 @@ export type WebviewMessage = | AgentManagerOpenSessionsMessage | RequestAutoApproveStateMessage | ToggleAutoApproveMessage + | RequestSandboxStatusMessage + | ToggleSandboxMessage | FetchMarketplaceDataMessage | FilterMarketplaceItemsMessage | InstallMarketplaceItemMessage diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 5b1d2349c71..1a15b39c82a 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -10,6 +10,7 @@ import KiloSidebarBackgroundProcesses from "@/kilocode/plugins/sidebar-backgroun import KiloSidebarIndexing from "@/kilocode/plugins/sidebar-indexing" import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr" import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage" +import KiloSandbox from "@/kilocode/plugins/sandbox" // kilocode_change end import SidebarContext from "../feature-plugins/sidebar/context" import SidebarMcp from "../feature-plugins/sidebar/mcp" @@ -44,6 +45,7 @@ export function internalTuiPlugins(flags: Pick> + load: (sessionID: string, force?: boolean) => Promise +}) { + createEffect( + on( + () => props.api.state.config.experimental?.sandbox, + () => void props.load(props.sessionID, true), + ), + ) + return ( + + {indicator(props.status().get(props.sessionID)) ?? ""} + + ) +} + +const tui: TuiPlugin = async (api) => { + const [status, setStatus] = createSignal>(new Map()) + const pending = new Set() + const loads = new Map() + const commit = (sessionID: string, value: Status) => { + const next = new Map(status()) + next.set(sessionID, value) + setStatus(next) + } + const set = (sessionID: string, value: Status) => { + loads.delete(sessionID) + commit(sessionID, value) + } + const load = async (sessionID: string, force = false) => { + if (!force && status().has(sessionID)) return + const token = Symbol() + loads.set(sessionID, token) + try { + const result = await api.client.sandbox.status({ sessionID }, { throwOnError: true }) + if (result.data && loads.get(sessionID) === token) commit(sessionID, result.data) + } catch (err) { + api.ui.toast({ message: String(err), variant: "error", duration: 5000 }) + } finally { + if (loads.get(sessionID) === token) loads.delete(sessionID) + } + } + const changed = api.event.on("sandbox.status.changed", (event) => set(event.properties.sessionID, event.properties)) + api.lifecycle.onDispose(changed) + + api.keymap.registerLayer({ + commands: [ + { + namespace: "palette", + name: "session.toggle.sandbox", + title: "Toggle sandbox", + category: "Session", + slashName: "sandbox", + async run() { + const sessionID = await ensureSession(api) + if (!sessionID || pending.has(sessionID)) return + pending.add(sessionID) + try { + const result = await api.client.sandbox.toggle({ sessionID }, { throwOnError: true }) + const value = result.data + if (!value) return + set(sessionID, value) + if (!value.enabled && !value.available) { + api.ui.toast({ + message: value.reason ?? "Sandbox backend is unavailable", + variant: "error", + duration: 5000, + }) + return + } + api.ui.toast({ message: `Sandbox ${value.enabled ? "enabled" : "disabled"}`, variant: "success" }) + api.ui.dialog.clear() + } catch (err) { + api.ui.toast({ message: String(err), variant: "error", duration: 5000 }) + } finally { + pending.delete(sessionID) + } + }, + }, + ], + }) + + api.slots.register({ + order: 50, + slots: { + session_prompt_right(_ctx, props) { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { id, tui } + +export default plugin diff --git a/packages/opencode/src/kilocode/sandbox/event.ts b/packages/opencode/src/kilocode/sandbox/event.ts new file mode 100644 index 00000000000..afc1ef537ab --- /dev/null +++ b/packages/opencode/src/kilocode/sandbox/event.ts @@ -0,0 +1,15 @@ +import { Schema } from "effect" +import { BusEvent } from "@/bus/bus-event" +import { SessionID } from "@/session/schema" + +export const Changed = BusEvent.define( + "sandbox.status.changed", + Schema.Struct({ + sessionID: SessionID, + directory: Schema.String, + enabled: Schema.Boolean, + available: Schema.Boolean, + reason: Schema.optional(Schema.String), + version: Schema.Int, + }), +) diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index afe7167e845..9783a891047 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -1,13 +1,40 @@ import { readFileSync, statSync } from "node:fs" import path from "node:path" -import { Effect } from "effect" +import { Effect, Semaphore } from "effect" import { Global } from "@opencode-ai/core/global" -import { run as runSandbox, type Profile } from "@kilocode/sandbox" +import { backendSupport, run as runSandbox, unrestricted, type Profile } from "@kilocode/sandbox" +import { Bus } from "@/bus" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" import type { InstanceContext } from "@/project/instance-context" +import type { SessionID } from "@/session/schema" +import { Changed } from "./event" import * as Network from "./network" +const overrides = new Map() +const locks = new Map() + +function key(directory: string, sessionID: SessionID) { + return directory + "\0" + sessionID +} + +function locked(sessionID: SessionID, effect: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const entry = locks.get(sessionID) ?? { semaphore: Semaphore.makeUnsafe(1), refs: 0 } + entry.refs++ + locks.set(sessionID, entry) + return entry + }), + (entry) => entry.semaphore.withPermits(1)(effect), + (entry) => + Effect.sync(() => { + entry.refs-- + if (entry.refs === 0 && locks.get(sessionID) === entry) locks.delete(sessionID) + }), + ) +} + function root(path: string) { return { path, kind: "subtree" as const } } @@ -81,20 +108,91 @@ export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] = } } -export function execute(effect: Effect.Effect) { +export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) { + const config = yield* Config.Service + const cfg = yield* config.get() + const directory = yield* InstanceState.directory + const override = overrides.get(key(directory, sessionID)) + const enabled = override?.enabled ?? cfg.experimental?.sandbox ?? false + return { + directory, + enabled: enabled && backendSupport.available, + available: backendSupport.available, + reason: backendSupport.reason, + version: override?.version ?? 0, + } +}) + +function change(sessionID: SessionID, guard: Effect.Effect) { return Effect.gen(function* () { + const directory = yield* InstanceState.directory + const id = key(directory, sessionID) + return yield* locked( + sessionID, + Effect.gen(function* () { + yield* guard + const current = yield* status(sessionID) + if (!current.enabled && !current.available) return current + const value = { ...current, enabled: !current.enabled, version: current.version + 1 } + overrides.set(id, { enabled: value.enabled, version: value.version }) + yield* (yield* Bus.Service).publish(Changed, { sessionID, ...value }) + return value + }), + ) + }) +} + +export const toggle = Effect.fn("SandboxPolicy.toggle")((sessionID: SessionID) => change(sessionID, Effect.void)) + +export function toggleGuarded(sessionID: SessionID, guard: Effect.Effect) { + return change(sessionID, guard) +} + +export const clear = Effect.fn("SandboxPolicy.clear")(function* (sessionID: SessionID) { + yield* retire(sessionID, yield* InstanceState.directory, Effect.void) +}) + +export function retire( + sessionID: SessionID, + directory: string, + effect: Effect.Effect, +): Effect.Effect { + return locked( + sessionID, + Effect.gen(function* () { + overrides.delete(key(directory, sessionID)) + return yield* effect + }), + ) +} + +export function dispose(sessionID: SessionID, effect: Effect.Effect): Effect.Effect { + return locked( + sessionID, + Effect.gen(function* () { + const suffix = "\0" + sessionID + for (const id of overrides.keys()) { + if (id.endsWith(suffix)) overrides.delete(id) + } + return yield* effect + }), + ) +} + +function execute(sessionID: SessionID, effect: Effect.Effect) { + return Effect.gen(function* () { + if (!(yield* status(sessionID)).enabled) return yield* unrestricted(effect) const config = yield* Config.Service const cfg = yield* config.get() - if (!cfg.experimental?.sandbox) return yield* effect - const mode = cfg.experimental.sandbox_restrict_network === false ? "allow" : "deny" + const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny" return yield* runSandbox(profile(yield* InstanceState.context, mode), effect) }) } -export function executeTool(tool: { id: string }, effect: Effect.Effect) { - return execute(Network.tool(tool, effect)) +export function executeTool(sessionID: SessionID, tool: { id: string }, effect: Effect.Effect) { + return execute(sessionID, Network.tool(tool, effect)) } -export function executeMcp(tool: object, effect: Effect.Effect) { - return execute(Network.mcp(tool, effect)) +export function executeMcp(sessionID: SessionID, tool: object, effect: Effect.Effect) { + return execute(sessionID, Network.mcp(tool, effect)) } diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/sandbox.ts b/packages/opencode/src/kilocode/server/httpapi/groups/sandbox.ts new file mode 100644 index 00000000000..9a0571c899f --- /dev/null +++ b/packages/opencode/src/kilocode/server/httpapi/groups/sandbox.ts @@ -0,0 +1,63 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { SessionID } from "@/session/schema" +import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" +import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { described } from "@/server/routes/instance/httpapi/groups/metadata" +import { ApiNotFoundError } from "@/server/routes/instance/httpapi/errors" + +const root = "/session/:sessionID/sandbox" + +export const SandboxStatus = Schema.Struct({ + directory: Schema.String, + enabled: Schema.Boolean, + available: Schema.Boolean, + reason: Schema.optional(Schema.String), + version: Schema.Int, +}) + +export const SandboxApi = HttpApi.make("sandbox") + .add( + HttpApiGroup.make("sandbox") + .add( + HttpApiEndpoint.get("status", root, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + success: described(SandboxStatus, "Session sandbox status"), + error: ApiNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sandbox.status", + summary: "Get session sandbox status", + description: "Get the effective sandbox state for one session.", + }), + ), + HttpApiEndpoint.post("toggle", `${root}/toggle`, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + success: described(SandboxStatus, "Updated session sandbox status"), + error: ApiNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sandbox.toggle", + summary: "Toggle session sandbox", + description: "Toggle the ephemeral sandbox override for one session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "sandbox", description: "Kilo session sandbox routes." })) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "kilo HttpApi", + version: "0.0.1", + description: "Kilo HttpApi surface.", + }), + ) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts new file mode 100644 index 00000000000..dc756f2fdc9 --- /dev/null +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import * as SandboxPolicy from "@/kilocode/sandbox/policy" +import { Session } from "@/session/session" +import type { SessionID } from "@/session/schema" +import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" +import * as SessionError from "@/server/routes/instance/httpapi/handlers/session-errors" + +export const sandboxHandlers = HttpApiBuilder.group(InstanceHttpApi, "sandbox", (handlers) => + Effect.gen(function* () { + const session = yield* Session.Service + const exists = (sessionID: SessionID) => SessionError.mapStorageNotFound(session.get(sessionID)) + return handlers + .handle("status", (ctx: { params: { sessionID: SessionID } }) => + exists(ctx.params.sessionID).pipe(Effect.andThen(SandboxPolicy.status(ctx.params.sessionID))), + ) + .handle("toggle", (ctx: { params: { sessionID: SessionID } }) => + SandboxPolicy.toggleGuarded(ctx.params.sessionID, exists(ctx.params.sessionID)), + ) + }), +) diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts index 3c6078e9d17..3acf145386d 100644 --- a/packages/opencode/src/kilocode/server/httpapi/server.ts +++ b/packages/opencode/src/kilocode/server/httpapi/server.ts @@ -16,6 +16,7 @@ import { kiloGatewayHandlers } from "./handlers/kilo-gateway" import { kilocodeHandlers } from "./handlers/kilocode" import { networkHandlers } from "./handlers/network" import { remoteHandlers } from "./handlers/remote" +import { sandboxHandlers } from "./handlers/sandbox" import { sessionImportHandlers } from "./handlers/session-import" import { suggestionHandlers } from "./handlers/suggestion" import { telemetryHandlers } from "./handlers/telemetry" @@ -31,6 +32,7 @@ export const provide = Layer.provide([ kilocodeHandlers, networkHandlers, remoteHandlers, + sandboxHandlers, sessionImportHandlers, suggestionHandlers, telemetryHandlers, diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 1d4c2436129..d9749b63931 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -31,6 +31,7 @@ import { KiloGatewayApi } from "@/kilocode/server/httpapi/groups/kilo-gateway" import { KilocodeApi } from "@/kilocode/server/httpapi/groups/kilocode" import { NetworkApi } from "@/kilocode/server/httpapi/groups/network" import { RemoteApi } from "@/kilocode/server/httpapi/groups/remote" +import { SandboxApi } from "@/kilocode/server/httpapi/groups/sandbox" import { SessionImportApi } from "@/kilocode/server/httpapi/groups/session-import" import { SuggestionApi } from "@/kilocode/server/httpapi/groups/suggestion" import { TelemetryApi } from "@/kilocode/server/httpapi/groups/telemetry" @@ -75,6 +76,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(KilocodeApi) .addHttpApi(NetworkApi) .addHttpApi(RemoteApi) + .addHttpApi(SandboxApi) .addHttpApi(SessionImportApi) .addHttpApi(SuggestionApi) .addHttpApi(TelemetryApi) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index bb56e2b7262..d40a9b5d1de 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -33,6 +33,7 @@ import { Global } from "@opencode-ai/core/global" import { BackgroundProcess } from "@/kilocode/background-process" import { KiloSession, kiloSessionFork } from "@/kilocode/session" import { SessionExport } from "@/kilocode/session-export" +import * as SandboxPolicy from "@/kilocode/sandbox/policy" import { baseKey, cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" // kilocode_change // kilocode_change end import { Effect, Layer, Option, Context, Schema, Types } from "effect" @@ -658,20 +659,27 @@ export const layer: Layer.Layer< } // kilocode_change start - yield* Effect.promise(() => KiloSession.removeSession(sessionID)).pipe(Effect.ignore) - KiloSession.clearPlatformOverride(sessionID) - if (hasInstance) { - yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)).pipe(Effect.ignore) - void Promise.all([import("@/effect/app-runtime"), import("./run-state")]).then(([app, run]) => - app.AppRuntime.runPromise(run.SessionRunState.Service.use((svc) => svc.cancel(sessionID))).catch(() => {}), - ) - } + yield* SandboxPolicy.dispose( + sessionID, + Effect.gen(function* () { + yield* Effect.promise(() => KiloSession.removeSession(sessionID)).pipe(Effect.ignore) + KiloSession.clearPlatformOverride(sessionID) + if (hasInstance) { + yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)).pipe(Effect.ignore) + void Promise.all([import("@/effect/app-runtime"), import("./run-state")]).then(([app, run]) => + app.AppRuntime.runPromise(run.SessionRunState.Service.use((svc) => svc.cancel(sessionID))).catch( + () => {}, + ), + ) + } + yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) + // kilocode_change - capture final session-export workspace delta on close/delete + const workspaceKey = hasInstance ? yield* InstanceState.directory : undefined // kilocode_change + yield* Effect.promise(() => SessionExport.onSessionClose(sessionID, workspaceKey)) // kilocode_change + yield* sync.remove(sessionID) + }), + ) // kilocode_change end - yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) - // kilocode_change - capture final session-export workspace delta on close/delete - const workspaceKey = hasInstance ? yield* InstanceState.directory : undefined // kilocode_change - yield* Effect.promise(() => SessionExport.onSessionClose(sessionID, workspaceKey)) // kilocode_change - yield* sync.remove(sessionID) } catch (e) { log.error(e) } diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 63756fb4adf..478a3afdc58 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -90,7 +90,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { { args }, ) // kilocode_change start - const result = yield* SandboxPolicy.executeTool(item, item.execute(args, ctx)) + const result = yield* SandboxPolicy.executeTool(ctx.sessionID, item, item.execute(args, ctx)) // kilocode_change end const output = { ...result, @@ -134,6 +134,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ) // kilocode_change start const result: Awaited>> = yield* SandboxPolicy.executeMcp( + ctx.sessionID, item, Effect.gen(function* () { yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) diff --git a/packages/opencode/test/kilocode/sandbox/config-network.test.ts b/packages/opencode/test/kilocode/sandbox/config-network.test.ts index c42a9815c5b..83e73a66e92 100644 --- a/packages/opencode/test/kilocode/sandbox/config-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/config-network.test.ts @@ -5,9 +5,12 @@ import { ProjectID } from "@/project/schema" import { InstanceRef } from "@/effect/instance-ref" import * as SandboxPolicy from "@/kilocode/sandbox/policy" import * as ToolNetwork from "@/kilocode/sandbox/network" +import { SessionID } from "@/session/schema" import { TestConfig } from "../../fixture/config" import { testEffect } from "../../lib/effect" +const sessionID = SessionID.make("ses_sandbox_config_network") +const tool = ToolNetwork.builtin({ id: "webfetch" }) const ctx = { directory: process.cwd(), worktree: process.cwd(), @@ -55,7 +58,7 @@ restricted.live("keeps network restriction enabled by default", () => { const target = server() return Effect.gen(function* () { const http = yield* HttpClient.HttpClient - const exit = yield* SandboxPolicy.execute(http.get(target.server.url)).pipe( + const exit = yield* SandboxPolicy.executeTool(sessionID, tool, http.get(target.server.url)).pipe( Effect.provideService(InstanceRef, ctx), Effect.exit, ) @@ -69,7 +72,7 @@ open.live("allows tool network traffic when network restriction is disabled", () const target = server() return Effect.gen(function* () { const http = yield* HttpClient.HttpClient - const response = yield* SandboxPolicy.execute(http.get(target.server.url)).pipe( + const response = yield* SandboxPolicy.executeTool(sessionID, tool, http.get(target.server.url)).pipe( Effect.provideService(InstanceRef, ctx), ) expect(yield* response.text).toBe("sandbox-config-ok") diff --git a/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts b/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts new file mode 100644 index 00000000000..15af3fe8ae6 --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test" +import type { Config as ConfigV1 } from "@kilocode/sdk" +import type { Config as ConfigV2 } from "@kilocode/sdk/v2" + +const value = { + experimental: { + sandbox: true, + sandbox_restrict_network: false, + }, +} + +test("both public SDK Config types expose sandbox policy fields", () => { + const legacy = value satisfies ConfigV1 + const current = value satisfies ConfigV2 + expect(legacy.experimental).toEqual(current.experimental) +}) diff --git a/packages/opencode/test/kilocode/sandbox/session.test.ts b/packages/opencode/test/kilocode/sandbox/session.test.ts new file mode 100644 index 00000000000..d36f2399557 --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/session.test.ts @@ -0,0 +1,52 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { BackgroundJob } from "@/background/job" +import { Bus } from "@/bus" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import * as SandboxPolicy from "@/kilocode/sandbox/policy" +import { Session } from "@/session/session" +import { Storage } from "@/storage/storage" +import { SyncEvent } from "@/sync" +import { provideInstance, tmpdirScoped } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const it = testEffect( + Layer.mergeAll( + Session.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), + Layer.provide(BackgroundJob.defaultLayer), + ), + Bus.layer, + Config.defaultLayer, + CrossSpawnSpawner.defaultLayer, + ), +) + +describe("sandbox session cleanup", () => { + it.live("clears every directory override when removing outside instance context", () => + Effect.gen(function* () { + const session = yield* Session.Service + const dir = yield* tmpdirScoped({ git: true }) + const worktree = yield* tmpdirScoped({ git: true }) + const info = yield* provideInstance(dir)(session.create({ title: "sandbox-cleanup" })) + const support = yield* provideInstance(dir)(SandboxPolicy.status(info.id)) + if (!support.available) { + yield* session.remove(info.id) + return + } + + yield* provideInstance(dir)(SandboxPolicy.toggle(info.id)) + yield* provideInstance(worktree)(SandboxPolicy.toggle(info.id)) + expect((yield* provideInstance(dir)(SandboxPolicy.status(info.id))).enabled).toBe(true) + expect((yield* provideInstance(worktree)(SandboxPolicy.status(info.id))).enabled).toBe(true) + yield* session.remove(info.id) + expect((yield* provideInstance(dir)(SandboxPolicy.status(info.id))).enabled).toBe(false) + expect((yield* provideInstance(worktree)(SandboxPolicy.status(info.id))).enabled).toBe(false) + }), + ) +}) diff --git a/packages/opencode/test/kilocode/sandbox/state.test.ts b/packages/opencode/test/kilocode/sandbox/state.test.ts new file mode 100644 index 00000000000..730e16050e9 --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/state.test.ts @@ -0,0 +1,190 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { expect } from "bun:test" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { enabled as sandboxed } from "@kilocode/sandbox" +import { Bus } from "@/bus" +import { Config } from "@/config/config" +import * as Network from "@/kilocode/sandbox/network" +import * as SandboxPolicy from "@/kilocode/sandbox/policy" +import { SessionID } from "@/session/schema" +import { TestInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const it = testEffect(Layer.mergeAll(Bus.layer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const tool = Network.builtin({ id: "read" }) + +function execute(sessionID: SessionID, effect: Effect.Effect) { + return SandboxPolicy.executeTool(sessionID, tool, effect) +} + +it.instance( + "uses config as the default without persisting session toggles", + () => + Effect.gen(function* () { + const id = SessionID.make("ses_sandbox_config") + const initial = yield* SandboxPolicy.status(id) + expect(initial.enabled).toBe(initial.available) + expect(initial.version).toBe(0) + if (!initial.available) return + + const disabled = yield* SandboxPolicy.toggle(id) + expect(disabled.enabled).toBe(false) + expect(disabled.version).toBe(1) + expect((yield* (yield* Config.Service).get()).experimental?.sandbox).toBe(true) + + yield* SandboxPolicy.clear(id) + expect((yield* SandboxPolicy.status(id)).enabled).toBe(true) + }), + { config: { experimental: { sandbox: true } } }, +) + +it.instance("runs unrestricted when config is off and no override exists", () => + Effect.gen(function* () { + const id = SessionID.make("ses_sandbox_default_off") + expect((yield* SandboxPolicy.status(id)).enabled).toBe(false) + expect(yield* execute(id, sandboxed)).toBe(false) + }), +) + +it.instance( + "runs sandboxed when config is on and no override exists", + () => + Effect.gen(function* () { + const id = SessionID.make("ses_sandbox_default_on") + const status = yield* SandboxPolicy.status(id) + expect(status.enabled).toBe(status.available) + expect(yield* execute(id, sandboxed)).toBe(status.available) + }), + { config: { experimental: { sandbox: true } } }, +) + +it.instance( + "overrides config off for only one session", + () => + Effect.gen(function* () { + const first = SessionID.make("ses_sandbox_override_off") + const second = SessionID.make("ses_sandbox_config_stays_on") + if (!(yield* SandboxPolicy.status(first)).available) return + + expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false) + expect(yield* execute(first, sandboxed)).toBe(false) + expect(yield* execute(second, sandboxed)).toBe(true) + }), + { config: { experimental: { sandbox: true } } }, +) + +it.instance("overrides config off to sandbox only one session", () => + Effect.gen(function* () { + const first = SessionID.make("ses_sandbox_override_on") + const second = SessionID.make("ses_sandbox_default_remains_off") + if (!(yield* SandboxPolicy.status(first)).available) return + + expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(true) + expect(yield* execute(first, sandboxed)).toBe(true) + expect(yield* execute(second, sandboxed)).toBe(false) + }), +) + +it.instance("isolates concurrent session overrides and clears them", () => + Effect.gen(function* () { + const first = SessionID.make("ses_sandbox_first") + const second = SessionID.make("ses_sandbox_second") + const support = yield* SandboxPolicy.status(first) + if (!support.available) { + expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false) + return + } + + expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(true) + expect((yield* SandboxPolicy.status(second)).enabled).toBe(false) + expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(true) + expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(false) + expect((yield* SandboxPolicy.status(first)).enabled).toBe(true) + yield* SandboxPolicy.clear(first) + expect((yield* SandboxPolicy.status(first)).enabled).toBe(false) + expect((yield* SandboxPolicy.status(second)).enabled).toBe(false) + }), +) + +it.instance("does not activate an unavailable backend", () => + Effect.gen(function* () { + const id = SessionID.make("ses_sandbox_support") + const result = yield* SandboxPolicy.toggle(id) + if (result.available) return + expect(result.enabled).toBe(false) + expect(result.reason?.length).toBeGreaterThan(0) + }), +) + +it.instance("serializes concurrent toggles for a session", () => + Effect.gen(function* () { + const id = SessionID.make("ses_sandbox_concurrent") + if (!(yield* SandboxPolicy.status(id)).available) return + yield* Effect.all([SandboxPolicy.toggle(id), SandboxPolicy.toggle(id)], { concurrency: "unbounded" }) + expect((yield* SandboxPolicy.status(id)).enabled).toBe(false) + }), +) + +it.instance("prevents a queued toggle from restoring a retired override", () => + Effect.gen(function* () { + const test = yield* TestInstance + const id = SessionID.make("ses_sandbox_retire_race") + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const removal = yield* SandboxPolicy.retire( + id, + test.directory, + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + }), + ).pipe(Effect.forkChild) + yield* Deferred.await(entered) + const pending = yield* SandboxPolicy.toggleGuarded(id, Effect.fail("deleted")).pipe(Effect.exit, Effect.forkChild) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(removal) + expect(Exit.isFailure(yield* Fiber.join(pending))).toBe(true) + expect((yield* SandboxPolicy.status(id)).enabled).toBe(false) + }), +) + +it.instance("uses nested session state instead of inheriting a parent profile", () => + Effect.gen(function* () { + const parent = SessionID.make("ses_sandbox_parent") + const child = SessionID.make("ses_sandbox_child") + if (!(yield* SandboxPolicy.status(parent)).available) return + yield* SandboxPolicy.toggle(parent) + expect(yield* execute(parent, execute(child, sandboxed))).toBe(false) + expect(yield* execute(child, execute(parent, sandboxed))).toBe(true) + }), +) + +it.instance("enforces writes only while the macOS session override is active", () => + Effect.gen(function* () { + if (process.platform !== "darwin") return + const test = yield* TestInstance + const id = SessionID.make("ses_sandbox_process") + if (!(yield* SandboxPolicy.status(id)).available) return + const outside = path.join(path.dirname(test.directory), `outside-${path.basename(test.directory)}`) + const inside = path.join(test.directory, "allowed.txt") + const git = path.join(test.directory, ".git", "denied.txt") + const external = path.join(outside, "denied.txt") + yield* Effect.promise(() => fs.mkdir(path.dirname(git), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(outside, { recursive: true })) + yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))) + const run = (file: string) => + ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.spawn(ChildProcess.make("/usr/bin/touch", [file])).pipe(Effect.flatMap((child) => child.exitCode)), + ) + + expect((yield* SandboxPolicy.toggle(id)).enabled).toBe(true) + expect(Number(yield* execute(id, run(inside)))).toBe(0) + expect(Number(yield* execute(id, run(external)))).not.toBe(0) + expect(Number(yield* execute(id, run(git)))).not.toBe(0) + expect((yield* SandboxPolicy.toggle(id)).enabled).toBe(false) + expect(Number(yield* execute(id, run(external)))).toBe(0) + }), +) diff --git a/packages/opencode/test/kilocode/sandbox/tui.test.ts b/packages/opencode/test/kilocode/sandbox/tui.test.ts new file mode 100644 index 00000000000..4834c27b3eb --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/tui.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs" +import path from "node:path" +import { indicator } from "@/kilocode/plugins/sandbox" + +const source = path.resolve(import.meta.dir, "../../../src/kilocode/plugins/sandbox.tsx") + +describe("sandbox TUI", () => { + test("shows an indicator only for an active available sandbox", () => { + expect(indicator({ directory: "/repo", enabled: true, available: true, version: 1 })).toBe("◆ Sandbox on") + expect(indicator({ directory: "/repo", enabled: false, available: true, version: 2 })).toBeUndefined() + expect( + indicator({ directory: "/repo", enabled: false, available: false, reason: "unavailable", version: 0 }), + ).toBeUndefined() + }) + + test("keeps the prompt status contribution mounted while inactive", () => { + const content = fs.readFileSync(source, "utf8") + expect(content).toContain("") + expect(content).toContain('indicator(props.status().get(props.sessionID)) ?? ""') + }) + + test("keeps the session override available independently of the persistent default", () => { + const content = fs.readFileSync(source, "utf8") + expect(content).not.toContain("enabled: () =>") + expect(content).toContain("await ensureSession(api)") + expect(content).toContain("api.client.session.create") + expect(content).toContain('api.route.navigate("session", { sessionID })') + expect(content).toContain("props.api.state.config.experimental?.sandbox") + expect(content).toContain("void props.load(props.sessionID, true)") + expect(content).toContain('api.event.on("sandbox.status.changed"') + }) +}) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 6ac923569fa..c566e21a504 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -189,6 +189,33 @@ export const kiloScenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, (body) => check(body === true, "missing network reject should remain a no-op success")), + http.protected + .get("/session/{sessionID}/sandbox", "sandbox.status") + .seeded((ctx) => ctx.session({ title: "Sandbox status" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/sandbox", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, (body) => { + object(body) + check(typeof body.enabled === "boolean", "sandbox status should report enabled state") + check(typeof body.available === "boolean", "sandbox status should report backend availability") + check(typeof body.version === "number", "sandbox status should report its revision") + }), + http.protected + .post("/session/{sessionID}/sandbox/toggle", "sandbox.toggle") + .mutating() + .seeded((ctx) => ctx.session({ title: "Sandbox toggle" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/sandbox/toggle", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, (body) => { + object(body) + check(typeof body.enabled === "boolean", "sandbox toggle should report enabled state") + check(typeof body.available === "boolean", "sandbox toggle should report backend availability") + check(typeof body.version === "number", "sandbox toggle should report its revision") + }), http.protected.get("/remote/status", "remote.status").json(200, (body) => { object(body) check(body.enabled === false && body.connected === false, "remote should start disabled") diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 065541638e4..0ccb02e9b5d 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1373,6 +1373,14 @@ export type Config = { * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) */ openTelemetry?: boolean + /** + * Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access + */ + sandbox?: boolean + /** + * Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true) + */ + sandbox_restrict_network?: boolean /** * Tools that should only be available to primary agents. */ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 58d1e10fc48..6cb4f75f0d4 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -257,6 +257,10 @@ import type { RemoteEnableResponses, RemoteStatusErrors, RemoteStatusResponses, + SandboxStatusErrors, + SandboxStatusResponses, + SandboxToggleErrors, + SandboxToggleResponses, SessionAbortErrors, SessionAbortResponses, SessionChildrenErrors, @@ -7620,6 +7624,72 @@ export class Remote extends HeyApiClient { } } +export class Sandbox extends HeyApiClient { + /** + * Get session sandbox status + * + * Get the effective sandbox state for one session. + */ + public status( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/sandbox", + ...options, + ...params, + }) + } + + /** + * Toggle session sandbox + * + * Toggle the ephemeral sandbox override for one session. + */ + public toggle( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/sandbox/toggle", + ...options, + ...params, + }) + } +} + export class Suggestion extends HeyApiClient { /** * List pending suggestions @@ -7993,6 +8063,11 @@ export class KiloClient extends HeyApiClient { return (this._remote ??= new Remote({ client: this.client })) } + private _sandbox?: Sandbox + get sandbox(): Sandbox { + return (this._sandbox ??= new Sandbox({ client: this.client })) + } + private _suggestion?: Suggestion get suggestion(): Suggestion { return (this._suggestion ??= new Suggestion({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8e0e8c55bf6..3015f68a3ee 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -12,6 +12,7 @@ export type Event = | EventTuiCommandExecute | EventTuiToastShow1 | EventTuiSessionSelect + | EventSandboxStatusChanged | EventKilocodeAgentManagerStart | EventIndexingStatus | EventIndexingWarning @@ -930,6 +931,7 @@ export type GlobalEvent = { | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + | EventSandboxStatusChanged | EventKilocodeAgentManagerStart | EventIndexingStatus | EventIndexingWarning @@ -2989,6 +2991,19 @@ export type EventGlobalConfigUpdated = { } } +export type EventSandboxStatusChanged = { + id: string + type: "sandbox.status.changed" + properties: { + sessionID: string + directory: string + enabled: boolean + available: boolean + reason?: string + version: number + } +} + export type EventKilocodeAgentManagerStart = { id: string type: "kilocode.agent_manager.start" @@ -10720,6 +10735,86 @@ export type RemoteStatusResponses = { export type RemoteStatusResponse = RemoteStatusResponses[keyof RemoteStatusResponses] +export type SandboxStatusData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/sandbox" +} + +export type SandboxStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SandboxStatusError = SandboxStatusErrors[keyof SandboxStatusErrors] + +export type SandboxStatusResponses = { + /** + * Session sandbox status + */ + 200: { + directory: string + enabled: boolean + available: boolean + reason?: string + version: number + } +} + +export type SandboxStatusResponse = SandboxStatusResponses[keyof SandboxStatusResponses] + +export type SandboxToggleData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/sandbox/toggle" +} + +export type SandboxToggleErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SandboxToggleError = SandboxToggleErrors[keyof SandboxToggleErrors] + +export type SandboxToggleResponses = { + /** + * Updated session sandbox status + */ + 200: { + directory: string + enabled: boolean + available: boolean + reason?: string + version: number + } +} + +export type SandboxToggleResponse = SandboxToggleResponses[keyof SandboxToggleResponses] + export type KilocodeSessionImportProjectData = { body?: { id: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index eb2f9326c61..a7de036e880 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -15179,6 +15179,192 @@ ] } }, + "/session/{sessionID}/sandbox": { + "get": { + "tags": ["sandbox"], + "operationId": "sandbox.status", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Session sandbox status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "available": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "version": { + "type": "integer" + } + }, + "required": ["directory", "enabled", "available", "version"], + "additionalProperties": false, + "description": "Session sandbox status" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "NotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Get the effective sandbox state for one session.", + "summary": "Get session sandbox status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sandbox.status({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/sandbox/toggle": { + "post": { + "tags": ["sandbox"], + "operationId": "sandbox.toggle", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Updated session sandbox status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "available": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "version": { + "type": "integer" + } + }, + "required": ["directory", "enabled", "available", "version"], + "additionalProperties": false, + "description": "Updated session sandbox status" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "NotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Toggle the ephemeral sandbox override for one session.", + "summary": "Toggle session sandbox", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sandbox.toggle({\n ...\n})" + } + ] + } + }, "/kilocode/session-import/project": { "post": { "tags": ["session-import"], @@ -16488,6 +16674,9 @@ { "$ref": "#/components/schemas/Event.tui.session.select" }, + { + "$ref": "#/components/schemas/EventSandboxStatusChanged" + }, { "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" }, @@ -19271,6 +19460,9 @@ { "$ref": "#/components/schemas/Event.tui.session.select" }, + { + "$ref": "#/components/schemas/EventSandboxStatusChanged" + }, { "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" }, @@ -25601,6 +25793,46 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSandboxStatusChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["sandbox.status.changed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "directory": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "available": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "version": { + "type": "integer" + } + }, + "required": ["sessionID", "directory", "enabled", "available", "version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventKilocodeAgent_managerStart": { "type": "object", "properties": { @@ -30144,6 +30376,10 @@ "name": "remote", "description": "Kilo remote connection routes." }, + { + "name": "sandbox", + "description": "Kilo session sandbox routes." + }, { "name": "session-import", "description": "Kilo legacy session import routes." diff --git a/script/check-model-tool-network.ts b/script/check-model-tool-network.ts index 5135dc9d572..be679b62378 100644 --- a/script/check-model-tool-network.ts +++ b/script/check-model-tool-network.ts @@ -112,14 +112,14 @@ const structure = [ ...(!registry.includes("ToolNetwork.builtin(result)") ? [" tool/registry.ts must distinguish built-in tools from untrusted custom tools"] : []), - ...(!session.includes("SandboxPolicy.executeTool(item,") - ? [" session/tools.ts must route built-in and custom tools through executeTool"] + ...(!/SandboxPolicy\.executeTool\(\s*ctx\.sessionID,\s*item,/.test(session) + ? [" session/tools.ts must route built-in and custom tools through session-aware executeTool"] : []), ...(!mcp.includes("SandboxNetwork.remote(tool)") ? [" mcp/index.ts must classify remote MCP delegated authority"] : []), - ...(!session.includes("SandboxPolicy.executeMcp(") - ? [" session/tools.ts must route MCP delegated authority through executeMcp"] + ...(!/SandboxPolicy\.executeMcp\(\s*ctx\.sessionID,\s*item,/.test(session) + ? [" session/tools.ts must route MCP delegated authority through session-aware executeMcp"] : []), ] From 01b098889e030b5da9ad72295f36cf8df97326c7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 24 Jun 2026 12:43:58 +0200 Subject: [PATCH 17/17] test(sandbox): handle unavailable network backends --- .../opencode/test/kilocode/sandbox/config-network.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/kilocode/sandbox/config-network.test.ts b/packages/opencode/test/kilocode/sandbox/config-network.test.ts index 83e73a66e92..0b5f1fb5881 100644 --- a/packages/opencode/test/kilocode/sandbox/config-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/config-network.test.ts @@ -1,6 +1,7 @@ import { Cause, Effect, Exit, Layer } from "effect" import { expect } from "bun:test" import { HttpClient } from "effect/unstable/http" +import { backendSupport } from "@kilocode/sandbox" import { ProjectID } from "@/project/schema" import { InstanceRef } from "@/effect/instance-ref" import * as SandboxPolicy from "@/kilocode/sandbox/policy" @@ -54,7 +55,7 @@ function server() { const restricted = testEffect(layer()) const open = testEffect(layer(false)) -restricted.live("keeps network restriction enabled by default", () => { +restricted.live("keeps network restriction enabled by default when the sandbox is available", () => { const target = server() return Effect.gen(function* () { const http = yield* HttpClient.HttpClient @@ -62,6 +63,11 @@ restricted.live("keeps network restriction enabled by default", () => { Effect.provideService(InstanceRef, ctx), Effect.exit, ) + if (!backendSupport.available) { + expect(Exit.isSuccess(exit)).toBe(true) + expect(target.requests()).toBe(1) + return + } expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Sandbox denied outbound network access") expect(target.requests()).toBe(0)