From 53d0af74f61c1d5760ad10d0fddafde6ddef4bad Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 14:29:53 +0200 Subject: [PATCH 01/34] feat(vscode): add subagent inspector tabs --- .changeset/subagent-inspector-tabs.md | 5 + .../tests/unit/agent-manager-arch.test.ts | 3 + .../agent-manager-terminal-layout.test.ts | 15 +- .../tests/unit/subagent-tabs.test.ts | 84 ++++++ .../agent-manager/AgentManagerApp.tsx | 47 +++- .../webview-ui/agent-manager/ClosableTab.tsx | 155 +++++++++++ .../agent-manager/InspectorTabStrip.tsx | 108 ++++++++ .../agent-manager/SubagentPanel.tsx | 120 +++++++++ .../agent-manager/agent-manager.css | 97 +++++-- .../webview-ui/agent-manager/index.tsx | 5 + .../agent-manager/side-panel-layout.ts | 1 + .../webview-ui/agent-manager/subagent-tabs.ts | 84 ++++++ .../terminal/SideTerminalPanel.tsx | 248 +++++------------ .../terminal/SortableTerminalTab.tsx | 250 ++++++------------ .../agent-manager/terminal/render.tsx | 5 +- .../src/components/chat/SessionTabMenu.tsx | 23 +- .../src/components/chat/TaskToolExpanded.tsx | 15 +- .../webview-ui/src/context/session.tsx | 6 +- 18 files changed, 882 insertions(+), 389 deletions(-) create mode 100644 .changeset/subagent-inspector-tabs.md create mode 100644 packages/kilo-vscode/tests/unit/subagent-tabs.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/ClosableTab.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/InspectorTabStrip.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts diff --git a/.changeset/subagent-inspector-tabs.md b/.changeset/subagent-inspector-tabs.md new file mode 100644 index 0000000000..0ab93ea7e5 --- /dev/null +++ b/.changeset/subagent-inspector-tabs.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Open delegated subagent sessions in Agent Manager inspector tabs alongside terminals. diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 37ad20e207..84c63ad8d3 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -21,6 +21,7 @@ const CSS_FILES = [ ] const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"), + path.join(ROOT, "webview-ui/agent-manager/SubagentPanel.tsx"), path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"), path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"), @@ -51,6 +52,8 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/SidebarBody.tsx"), path.join(ROOT, "webview-ui/agent-manager/Skeleton.tsx"), path.join(ROOT, "webview-ui/agent-manager/TabBar.tsx"), + path.join(ROOT, "webview-ui/agent-manager/ClosableTab.tsx"), + path.join(ROOT, "webview-ui/agent-manager/InspectorTabStrip.tsx"), path.join(ROOT, "webview-ui/agent-manager/ProjectBranchDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/DefaultBaseBranchDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"), diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts index e39170102d..263ac2a399 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -10,6 +10,7 @@ import { const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8") const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8") +const subagent = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/SubagentPanel.tsx"), "utf8") const terminal = readFileSync( resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"), "utf8", @@ -25,13 +26,25 @@ test("xterm owns the padding used by FitAddon", () => { expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/) }) -test("uses one persisted width for the diff and terminal inspector", () => { +test("uses one persisted width for every inspector panel", () => { expect(app).toContain("persisted?.sidePanelWidth") expect(app).toContain("createPanelResize(setPanelWidth") + expect(app).toContain("style={{ width: `${panelWidth()}px` }}") + expect(subagent).toContain("InspectorTabStrip") expect(app).not.toContain("diffWidth") expect(app).not.toContain("terminalWidth") }) +test("hides keyboard hints only in inspector tabs", () => { + const side = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), "utf8") + + expect(subagent).toContain("showKeybind={false}") + expect(side).toContain("showKeybind={false}") + expect(readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/render.tsx"), "utf8")).not.toContain( + "showKeybind={false}", + ) +}) + test("limits inspector layout updates during resize", () => { const frames: ((time: number) => void)[] = [] const widths: number[] = [] diff --git a/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts b/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts new file mode 100644 index 0000000000..5276fac4cb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createSubagentTabs } from "../../webview-ui/agent-manager/subagent-tabs" + +function scene() { + const [current] = createSignal("parent") + const calls = { synced: [] as Array<[string, string | undefined]>, shown: 0, hidden: 0 } + const tabs = createSubagentTabs({ + current, + sync: (id, parent) => calls.synced.push([id, parent]), + show: () => calls.shown++, + hide: () => calls.hidden++, + }) + return { tabs, calls } +} + +describe("Agent Manager subagent tabs", () => { + it("opens multiple child sessions and syncs each to its parent", () => { + createRoot((dispose) => { + const item = scene() + item.tabs.open("child-1", "First", "parent-1") + item.tabs.open("child-2", "Second", "parent-2") + + expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["child-1", "child-2"]) + expect(item.tabs.active()).toBe("child-2") + expect(item.calls.synced).toEqual([ + ["child-1", "parent-1"], + ["child-2", "parent-2"], + ]) + expect(item.calls.shown).toBe(2) + dispose() + }) + }) + + it("closes the active tab onto its nearest survivor and hides when empty", () => { + createRoot((dispose) => { + const item = scene() + item.tabs.open("one", "One") + item.tabs.open("two", "Two") + item.tabs.open("three", "Three") + + item.tabs.close("two") + expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one", "three"]) + expect(item.tabs.active()).toBe("three") + + item.tabs.close("three") + item.tabs.close("one") + expect(item.tabs.tabs()).toEqual([]) + expect(item.tabs.active()).toBeUndefined() + expect(item.calls.hidden).toBe(1) + dispose() + }) + }) + + it("supports Close Others and preserves the selected child", () => { + createRoot((dispose) => { + const item = scene() + item.tabs.open("one") + item.tabs.open("two") + item.tabs.open("three") + + item.tabs.closeOthers("one") + expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one"]) + expect(item.tabs.active()).toBe("one") + expect(item.calls.shown).toBe(4) + dispose() + }) + }) + + it("reorders tabs without changing the active child", () => { + createRoot((dispose) => { + const item = scene() + item.tabs.open("one") + item.tabs.open("two") + item.tabs.open("three") + item.tabs.select("two") + + item.tabs.reorder("three", "one") + expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["three", "one", "two"]) + expect(item.tabs.active()).toBe("two") + dispose() + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 29f979dd05..a1c5cd9ef6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -178,6 +178,8 @@ import { initialMessage, seedInitialVariant } from "./initial-message" import { SidebarToggleButton } from "./SidebarToggleButton" import { setTabWidths } from "./tab-widths" import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout" +import { SubagentPanel } from "./SubagentPanel" +import { createSubagentTabs } from "./subagent-tabs" import { buildShortcutCategories } from "./shortcuts" import { tracker } from "./telemetry" import { createChatFocus, createPromptFocus, hasQuestionOption } from "./focus" @@ -306,8 +308,8 @@ const AgentManagerContent: Component = () => { const diffLoading = diffs.diffLoading const setDiffLoading = diffs.setDiffLoading const diffNotices = diffs.diffNotices - // Diff and terminal views share one inspector width, restored from webview - // state so the user's divider position survives panel reloads. + // Diff, PR, terminal, and subagent views share one inspector width, restored + // from webview state so the user's divider position survives panel reloads. const [panelWidth, setPanelWidth] = createSignal(clampPanelWidth(persisted?.sidePanelWidth, window.innerWidth)) const resizeSide = createPanelResize(setPanelWidth, () => window.innerWidth) const showSideTerminal = () => { @@ -321,6 +323,16 @@ const AgentManagerContent: Component = () => { const reviewComposer = createReviewComposer() const [reviewActive, setReviewActive] = createSignal(false) const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified") + const subagents = createSubagentTabs({ + current: session.currentSessionID, + sync: session.syncSession, + show: () => { + setHistory(false) + setReviewActive(false) + setSidePanel(SidePanel.Subagents) + }, + hide: () => setSidePanel(null), + }) const markdown = createMarkdownRender(vscode) // Per-worktree git stats (diff additions/deletions, commits missing from origin) const worktreeStats = () => registry.active().worktreeStats() @@ -1214,7 +1226,17 @@ const AgentManagerContent: Component = () => { if (match) projectNav.jump(parseInt(match[1]!) - 1) } } + const subagent = (event: Event) => { + const detail = (event as CustomEvent<{ sessionID?: unknown; title?: unknown; parentSessionID?: unknown }>).detail + if (typeof detail?.sessionID !== "string") return + subagents.open( + detail.sessionID, + typeof detail.title === "string" ? detail.title : undefined, + typeof detail.parentSessionID === "string" ? detail.parentSessionID : undefined, + ) + } window.addEventListener("message", handler) + window.addEventListener("agentManager.openSubagent", subagent) // Prevent Cmd/Ctrl shortcuts from triggering native browser actions const preventDefaults = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey)) return @@ -1263,6 +1285,7 @@ const AgentManagerContent: Component = () => { confirmDeleteWorktree(sel) } window.addEventListener("keydown", deleteKeyHandler) + onCleanup(() => window.removeEventListener("agentManager.openSubagent", subagent)) // Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held. // Capture phase so the terminal's key handlers can't swallow them; blur resets state @@ -2157,6 +2180,10 @@ const AgentManagerContent: Component = () => { // Close the currently active tab via keyboard shortcut. // If no tabs remain, fall through to close the selected worktree. const closeActiveTab = () => { + if (sidePanel() === SidePanel.Subagents && subagents.active()) { + subagents.close(subagents.active()!) + return + } // A focused side terminal owns Cmd+W while its panel is visible. // Closing a chat tab out from under the user's cursor would be surprising. if (sidePanel() === SidePanel.Terminal && terms.sideFocusedId()) { @@ -2587,7 +2614,7 @@ const AgentManagerContent: Component = () => { mounted while a side terminal is alive — hidden via .am-side-host-hidden (absolute + opacity), never unmounted, so xterm render loops keep streaming. */} - 0}> + 0 || subagents.tabs().length > 0}>
{ } /> + 0}> + sidePanel() === SidePanel.Subagents} + nextKeybind={kb().nextTab ?? ""} + closeKeybind={kb().closeTab ?? ""} + onSelect={subagents.select} + onClose={subagents.close} + onCloseOthers={subagents.closeOthers} + onReorder={subagents.reorder} + onClosePanel={() => setSidePanel(null)} + /> + = T | (() => T) + +function value(input: Value): T { + return typeof input === "function" ? (input as () => T)() : input +} + +export interface ClosableTabProps { + id?: string + label: Value + tooltip: Value + icon: Value + iconStatus?: Value<"success" | "failure" | undefined> + class?: string + focused?: boolean + active: boolean + closeable?: boolean + showKeybind?: boolean + keybind?: string + closeKeybind?: string + role?: "tab" + selected?: boolean + tabIndex?: number + onKeyDown?: JSX.EventHandlerUnion + onSelect: () => void + onMiddleClick?: (event: MouseEvent) => void + onClose: () => void + trailing?: JSX.Element +} + +export const ClosableTabChrome: Component = (props) => { + const { t } = useLanguage() + const label = () => value(props.label) + const tooltip = () => value(props.tooltip) + const icon = () => value(props.icon) + const status = () => (props.iconStatus ? value(props.iconStatus) : undefined) + const keybind = () => (props.showKeybind === false ? "" : (props.keybind ?? "")) + const closeKeybind = () => (props.showKeybind === false ? "" : (props.closeKeybind ?? "")) + return ( +
+
+ + + + }> + + + + {label()} + + +
+ {props.trailing} + + + { + event.stopPropagation() + props.onClose() + }} + /> + + +
+ ) +} + +export const SortableClosableTab: Component< + ClosableTabProps & { + id: string + onCloseOthers: () => void + } +> = (props) => ( + + + {parseBindingTokens(props.closeKeybind).map((token) => ( + {token} + ))} + + ) : undefined + } + > + + + +) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/InspectorTabStrip.tsx b/packages/kilo-vscode/webview-ui/agent-manager/InspectorTabStrip.tsx new file mode 100644 index 0000000000..5ec8efac2f --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/InspectorTabStrip.tsx @@ -0,0 +1,108 @@ +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" +import { For, Show, createSignal, type Accessor, type Component, type JSX } from "solid-js" +import { ConstrainDragYAxis } from "../src/components/chat/TabDnd" +import { createTabFocus } from "../src/utils/tab-navigation" +import { useTabScroll } from "../src/utils/tab-scroll" +import { setTabWidths } from "../src/utils/tab-widths" + +const TABLIST = ".am-inspector-tablist" + +type InspectorTabFocus = ReturnType + +interface InspectorTabStripApi { + focus: InspectorTabFocus + freeze: () => void + release: () => void +} + +interface Props { + ids: Accessor + active: Accessor + label: string + renderTab: (id: string, api: InspectorTabStripApi) => JSX.Element + overlay: (id: string) => string + onSelect: (id: string) => void + onReorder: (from: string, to: string) => void + action?: (api: InspectorTabStripApi) => JSX.Element +} + +export const InspectorTabStrip: Component = (props) => { + let host!: HTMLDivElement + const scroll = useTabScroll(props.ids, props.active) + const focus = createTabFocus({ ids: props.ids, select: props.onSelect, root: () => host }) + const [dragging, setDragging] = createSignal<{ id: string; width: number }>() + const freeze = () => setTabWidths(true, host, TABLIST) + const release = () => setTabWidths(false, host, TABLIST) + const api = { focus, freeze, release } + const start = (event: DragEvent) => { + const id = event.draggable?.id + if (typeof id !== "string") return + const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width + freeze() + setDragging({ id, width }) + } + const end = () => { + setDragging(undefined) + release() + } + const over = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id + if (typeof from !== "string" || typeof to !== "string") return + props.onReorder(from, to) + } + + return ( +
{ + if (event.target instanceof Element && event.target.closest(".am-tab-close[data-tab-close]")) freeze() + }} + onPointerLeave={() => { + if (!dragging()) release() + }} + > + + + +
+
+
+
{ + scroll.setRef(el) + }} + role={props.ids().length > 0 ? "tablist" : undefined} + aria-label={props.ids().length > 0 ? props.label : undefined} + style={{ "--tab-count": `${props.ids().length}` } as JSX.CSSProperties} + > + + {(id) => props.renderTab(id, api)} + +
+
+
+
+ + + {(tab) => ( +
+ {props.overlay(tab().id)} +
+ )} +
+
+ + {props.action?.(api)} +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx new file mode 100644 index 0000000000..3e8b39c4a2 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -0,0 +1,120 @@ +/** + * Read-only subagent chats for the Agent Manager inspector. + * + * The nested session provider keeps the parent chat selection independent from + * the child transcript while still consuming the same webview event stream. + */ + +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { createEffect, type Accessor, type Component } from "solid-js" +import { DataBridge } from "../src/App" +import { ChatView } from "../src/components/chat" +import { SessionProvider, useSession } from "../src/context/session" +import { SortableClosableTab } from "./ClosableTab" +import { InspectorTabStrip } from "./InspectorTabStrip" +import type { SubagentTab } from "./subagent-tabs" + +interface Props { + tabs: Accessor + active: Accessor + visible: Accessor + nextKeybind: string + closeKeybind: string + onSelect: (id: string) => void + onClose: (id: string) => void + onCloseOthers: (id: string) => void + onReorder: (from: string, to: string) => void + onClosePanel: () => void +} + +const SubagentChat: Component<{ active: Accessor }> = (props) => { + const session = useSession() + + createEffect(() => { + const id = props.active() + if (!id) return + session.selectSession(id) + }) + + return ( + + + + ) +} + +export const SubagentPanel: Component = (props) => { + const ids = () => props.tabs().map((tab) => tab.id) + const title = (id: string) => props.tabs().find((tab) => tab.id === id)?.title ?? "Sub-agent" + const close = (id: string, focus: { restore: () => void }) => { + props.onClose(id) + if (ids().length > 0) focus.restore() + } + + return ( + +
+
+
+ + Subagents + {props.tabs().length} +
+ +
+ { + const label = title(id) + return ( + api.focus.key(id, event)} + onSelect={() => props.onSelect(id)} + onMiddleClick={(event) => { + if (event.button !== 1) return + event.preventDefault() + event.stopPropagation() + close(id, api.focus) + }} + onClose={() => close(id, api.focus)} + onCloseOthers={() => props.onCloseOthers(id)} + /> + ) + }} + /> +
+ +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index e58b9c7b60..3dfbe58ecd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1514,7 +1514,7 @@ html[data-theme="kilo-vscode"] color: var(--vscode-testing-iconFailed, #f87171); } -.am-terminal-tab-spinner { +.am-tab-spinner { width: 12px; height: 12px; } @@ -4841,8 +4841,9 @@ body.vscode-high-contrast-light { } } -/* Experimental terminal tabs (feature-flagged) */ +/* Shared sortable inspector tabs. */ +.am-tab-closable, .am-tab-terminal { display: flex; align-items: center; @@ -4850,12 +4851,13 @@ body.vscode-high-contrast-light { border-left: 1px solid var(--border-weak-base); } +.am-tab-closable [data-component="icon"], .am-tab-terminal [data-component="icon"] { flex-shrink: 0; opacity: 0.7; } -.am-tab-terminal-focused { +.am-tab-focused { background: var(--surface-base-hover); } @@ -4978,15 +4980,14 @@ body.vscode-high-contrast-light { color: var(--text-weak); } -/* Side terminal tab strip — one row of tabs reusing the top bar's - .am-tab chrome, plus the "+" action. Height matches .am-diff-header +/* Inspector tab strip — one row of tabs reusing the top bar's + .am-tab chrome, plus an optional action. Height matches .am-diff-header (32px) so switching inspector modes does not shift the panel chrome. No vertical padding: tabs fill the strip like they fill .am-tab-bar, - which also keeps the "+" optically centered. The strip itself never - scrolls; the tab list does, so a narrow panel never pushes the "+" - action out of view (same split as .am-tab-list-wrap / - .am-tab-add-wrap). */ -.am-side-terminal-tabs { + which also keeps actions optically centered. The strip itself never + scrolls; the tab list does, so a narrow panel never pushes an action out + of view (same split as .am-tab-list-wrap / .am-tab-add-wrap). */ +.am-inspector-tabs { display: flex; align-items: stretch; height: 32px; @@ -5001,10 +5002,10 @@ body.vscode-high-contrast-light { /* Same width model as .am-tab-list: tabs claim an equal share of the strip up to a maximum, and the list itself only grows as wide as its - tabs, so the "+" action stays glued to the last tab instead of + tabs, so an action stays glued to the last tab instead of drifting to the far edge of a wide panel. The cap is smaller than the top bar's 240px because the panel is narrow. */ -.am-side-terminal-tablist { +.am-inspector-tablist { --am-tab-max-width: 180px; --am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width)); display: flex; @@ -5020,17 +5021,16 @@ body.vscode-high-contrast-light { scrollbar-width: none; } -.am-side-terminal-tablist::-webkit-scrollbar { +.am-inspector-tablist::-webkit-scrollbar { display: none; } -.am-side-terminal-tablist[data-tab-widths-frozen] .am-tab-sortable { +.am-inspector-tablist[data-tab-widths-frozen] .am-tab-sortable { transition: none; } -/* The left divider marks a terminal among session tabs in the top bar. - Every tab here is a terminal, so it would just be noise. */ -.am-side-terminal-tablist .am-tab-terminal { +/* The left divider belongs to the top-level mixed tab bar. */ +.am-inspector-tablist .am-tab-closable { border-left-color: transparent; } @@ -5054,6 +5054,69 @@ body.vscode-high-contrast-light { pointer-events: none; } +/* Subagent inspector panel. It remains mounted while another inspector mode + is active so switching back keeps the child transcript and scroll position. */ +.am-subagent-panel { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + opacity: 0; + pointer-events: none; + z-index: 1; + background: var(--surface-base); + will-change: opacity; +} + +.am-subagent-panel-visible { + opacity: 1; + pointer-events: auto; +} + +.am-subagent-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + height: 32px; + padding: 0 4px 0 8px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-weak-base); + background: var(--surface-base); +} + +.am-subagent-heading { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; + color: var(--text-base); + font-size: var(--font-size-small); + font-weight: 600; +} + +.am-subagent-count { + color: var(--text-weak); + font-size: var(--kilo-font-size-10); + font-variant-numeric: tabular-nums; +} + +.am-subagent-chat { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; +} + +.am-subagent-chat > [data-component="data-provider"], +.am-subagent-chat [class~="chat-view"] { + min-width: 0; + min-height: 0; + flex: 1; +} + .am-terminal-host { flex: 1; min-height: 0; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/index.tsx b/packages/kilo-vscode/webview-ui/agent-manager/index.tsx index 4651879992..dd358a2859 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/index.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/index.tsx @@ -5,8 +5,13 @@ import { render } from "solid-js/web" import "@kilocode/kilo-ui/styles" import "../src/styles/chat.css" +import { registerExpandedTaskTool } from "../src/components/chat/TaskToolExpanded" +import { registerVscodeToolOverrides } from "../src/components/chat/VscodeToolOverrides" import { AgentManagerApp } from "./AgentManagerApp" +registerExpandedTaskTool() +registerVscodeToolOverrides() + const root = document.getElementById("root") if (root) { render(() => , root) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts index 2fb492bcfa..d89341e31f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts @@ -9,6 +9,7 @@ export enum SidePanel { Diff = "diff", PR = "pr", Terminal = "terminal", + Subagents = "subagents", } function viewportWidth(viewport: number): number { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts new file mode 100644 index 0000000000..db94aa8bee --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts @@ -0,0 +1,84 @@ +import { batch, createSignal, type Accessor } from "solid-js" +import { reorderTabs } from "../src/utils/tab-order" + +export interface SubagentTab { + id: string + title: string +} + +interface Options { + current: Accessor + sync: (id: string, parentID?: string) => void + show: () => void + hide: () => void +} + +export function createSubagentTabs(opts: Options) { + const [tabs, setTabs] = createSignal([]) + const [active, setActive] = createSignal() + + const open = (id: string, title?: string, parentID?: string) => { + if (!id) return + const label = title?.trim() || "Sub-agent" + batch(() => { + setTabs((prev) => { + const existing = prev.find((tab) => tab.id === id) + if (!existing) return [...prev, { id, title: label }] + if (title?.trim() && existing.title !== label) { + return prev.map((tab) => (tab.id === id ? { ...tab, title: label } : tab)) + } + return prev + }) + setActive(id) + opts.show() + }) + opts.sync(id, parentID ?? opts.current()) + } + + const select = (id: string) => { + if (!tabs().some((tab) => tab.id === id)) return + setActive(id) + opts.show() + } + + const close = (id: string) => { + const current = tabs() + const index = current.findIndex((tab) => tab.id === id) + if (index < 0) return + const next = current.filter((tab) => tab.id !== id) + setTabs(next) + if (active() !== id) return + const replacement = next[Math.min(index, next.length - 1)] + if (replacement) { + setActive(replacement.id) + return + } + setActive(undefined) + opts.hide() + } + + const closeOthers = (id: string) => { + if (!tabs().some((tab) => tab.id === id)) return + setTabs((prev) => prev.filter((tab) => tab.id === id)) + setActive(id) + opts.show() + } + + const reorder = (from: string, to: string) => { + const order = reorderTabs( + tabs().map((tab) => tab.id), + from, + to, + ) + if (!order) return + setTabs((prev) => { + const lookup = new Map(prev.map((tab) => [tab.id, tab])) + return order.flatMap((id) => { + const tab = lookup.get(id) + return tab ? [tab] : [] + }) + }) + } + + return { tabs, active, open, select, close, closeOthers, reorder } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx index b598646346..06fbcebfff 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -1,48 +1,26 @@ /** * Right-side terminal panel for the Agent Manager inspector. * - * Lives inside the shared `.am-diff-panel-wrapper` host next to the diff - * and PR panels, so all three inspector modes share one resize handle - * and one width. + * Lives inside the shared inspector host next to diff, PR, and subagent + * panels, so every mode uses the same persisted resize width. The tab row is + * the shared inspector strip used by subagents as well. * - * A context can own several side terminals. The header is a tab strip - * that reuses the top tab bar's whole chrome: `SortableTerminalTab` - * (icon, title, X close, right-click Close / Close Others), the same - * `@thisbeyond/solid-dnd` reorder stack, the same overflow scrolling - * with edge fades, the same width freeze while tabs close, and the same - * arrow-key tab navigation, so a terminal behaves identically in - * either surface. Reorder state lives in the terminal state, so it is - * preserved across sidebar context switches for the webview's lifetime. - * - * The `+` action sits directly after the last tab (outside the - * scrolling region, like the tab bar's `am-tab-add-wrap`), so it never - * scrolls away and never drifts to the far edge of a wide panel. The - * strip stays visible even when empty so `+` is always reachable. - * - * Visibility is opacity-based, never unmount: the xterm render loop - * dies when its subtree leaves the paint tree (see `render.tsx`). + * Visibility is opacity-based, never unmount: the xterm render loop dies when + * its subtree leaves the paint tree (see `render.tsx`). */ -import type { Accessor, Component, JSX } from "solid-js" -import { For, Show, createEffect, createSignal } from "solid-js" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" -import { IconButton } from "@kilocode/kilo-ui/icon-button" +import type { Accessor, Component } from "solid-js" +import { Show, createEffect } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" +import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" -import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useLanguage } from "../../src/context/language" -import { ConstrainDragYAxis } from "../../src/components/chat/TabDnd" -import { useTabScroll } from "../../src/utils/tab-scroll" -import { setTabWidths } from "../../src/utils/tab-widths" -import { createTabFocus } from "../../src/utils/tab-navigation" +import { InspectorTabStrip } from "../InspectorTabStrip" import { renderSideTerminalLayer } from "./render" import { SortableTerminalTab } from "./SortableTerminalTab" import type { TerminalStateControls } from "./state" -/** Only this strip's tabs freeze; the top tab bar keeps its own widths. */ -const TABLIST = ".am-side-terminal-tablist" - interface Props { state: TerminalStateControls /** Context the panel currently shows (`state.sideKey`). */ @@ -67,65 +45,18 @@ interface Props { export const SideTerminalPanel: Component = (props) => { const { t } = useLanguage() let panel!: HTMLElement - let strip!: HTMLDivElement createEffect(() => { panel.inert = !props.visible() }) - const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>() const sides = () => props.state.sidesForContext(props.contextKey()) const ids = () => sides().map((term) => term.id) const active = () => props.state.sideActiveFor(props.contextKey()) const pending = () => props.state.pendingSide(props.contextKey()) - const scroll = useTabScroll(ids, active) - // Scoped to `strip` so arrow keys and focus restore never jump to a - // tab in the top bar, which uses the same role="tab" markup. - const focus = createTabFocus({ ids, select: props.onSelect, root: () => strip }) - // Only freeze while the pointer is over the strip: the widths must - // survive until the pointer leaves, so the remaining X buttons stay - // put across repeated closes. Releasing on the next frame would undo - // the freeze before it is ever painted (rAF runs before paint). - // "Close others" needs none of this: its context menu is portaled, so - // the pointer is off the strip, and the survivor spans the strip anyway. - const freeze = () => { - if (strip.closest(".am-side-terminal-tabs")?.matches(":hover")) setTabWidths(true, document, TABLIST) - } - const release = () => setTabWidths(false, document, TABLIST) - const close = (id: string) => { - freeze() + const close = (id: string, focus: { restore: () => void }) => { props.onClose(id) - // Restore focus inside the strip only while it still owns a tab. - // Falling through to `focusPrompt` would pull focus into the chat - // composer while the panel is still open on its empty state. if (ids().length > 0) focus.restore() } - // Adding a tab shrinks every tab's equal share, so any freeze left - // over from a close in the same hover has to go first. `+` lives - // inside the strip, so no pointerleave happens between the two - // clicks and the surviving tabs would keep their wider pixel widths. - const start = () => { - release() - props.onStart() - } - const onDragStart = (event: DragEvent) => { - const id = event.draggable?.id - if (typeof id !== "string") return - // Pin the overlay to the tab's width: the overlay container uses - // min-width, so a long OSC title would otherwise overflow it and - // shift the visual center off the cursor (the "drag offset" bug). - const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width - setTabWidths(true, document, TABLIST) - setDragging({ id, width }) - } - const onDragEnd = () => { - setDragging(undefined) - release() - } - const onDragOver = (event: DragEvent) => { - const from = event.draggable?.id - const to = event.droppable?.id - if (typeof from !== "string" || typeof to !== "string") return - props.state.reorderSideDrag(props.contextKey(), from, to) - } + return (
= (props) => { aria-label={t("agentManager.tab.terminal")} aria-hidden={!props.visible()} > -
{ - if (!dragging()) release() - }} - > - - - - {/* Overflow chrome copied from the top tab bar: the list is the - only scrolling element, wrapped by a fade host, so the "+" - action stays pinned next to the last tab. */} -
-
-
- {/* role="tablist" only when tabs exist: axe - aria-required-children rejects an empty tablist. */} -
{ - strip = el - scroll.setRef(el) - }} - role={sides().length > 0 ? "tablist" : undefined} - aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined} - style={{ "--tab-count": `${sides().length}` } as JSX.CSSProperties} - > - - - {(term) => ( - focus.key(term.id, event)} - onSelect={() => props.onSelect(term.id)} - onMiddleClick={(e: MouseEvent) => { - if (e.button !== 1) return - e.preventDefault() - e.stopPropagation() - close(term.id) - }} - onClose={(e: MouseEvent) => { - e.stopPropagation() - close(term.id) - }} - onCloseOthers={() => props.onCloseOthers(term.id)} - onStop={(e: MouseEvent) => { - e.stopPropagation() - props.onStop(term.id) - }} - /> - )} - - -
-
-
-
- {/* Cursor-following clone of the dragged tab (same pattern as - the top tab bar). The overlay is what makes the in-list - original use solid-dnd's slot-compensated transform, so the - dragged tab tracks the cursor without a jump/offset. The - original stays dimmed in its slot via .am-tab-dragging. */} - - - {(tab) => ( -
- {props.state.title(tab().id) ?? t("agentManager.tab.terminal")} -
- )} -
-
- -
- - props.state.title(id) ?? t("agentManager.tab.terminal")} + onSelect={props.onSelect} + onReorder={(from, to) => props.state.reorderSideDrag(props.contextKey(), from, to)} + renderTab={(id, api) => { + const term = sides().find((item) => item.id === id) + if (!term) return null + return ( + api.focus.key(term.id, event)} + onSelect={() => props.onSelect(term.id)} + onMiddleClick={(event) => { + if (event.button !== 1) return + event.preventDefault() + event.stopPropagation() + close(term.id, api.focus) + }} + onClose={() => close(term.id, api.focus)} + onCloseOthers={() => props.onCloseOthers(term.id)} + onStop={(event) => { + event.stopPropagation() + props.onStop(term.id) + }} /> - -
-
+ ) + }} + action={(api) => ( +
+ + { + api.release() + props.onStart() + }} + /> + +
+ )} + /> {renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx index 99b25001d2..25e4ffd0f2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx @@ -1,191 +1,99 @@ /** - * Tab chrome for xterm terminals. + * Terminal-specific adapter for the shared sortable inspector tab. * - * `TerminalTabChrome` is the shared visual tab: console icon, title, - * tooltip/keybinding hints, and the X close button — the same - * `am-tab*` structure the session tabs use. `SortableTerminalTab` - * wraps it with drag-and-drop and a right-click context menu; both the - * top tab bar and the side terminal panel render that wrapper, so a - * terminal tab behaves identically in either surface. + * PTY status determines the icon and whether a Setup tab can be closed. The + * tab chrome, drag wrapper, context menu, and close behavior are shared with + * subagent tabs. */ -import { Component, Show, type JSX } from "solid-js" +import { Show, type Component } from "solid-js" import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Icon } from "@kilocode/kilo-ui/icon" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" -import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { useLanguage } from "../../src/context/language" -import { SortableTabContainer } from "../../src/components/chat/TabDnd" -import { parseBindingTokens } from "../keybind-tokens" +import { SortableClosableTab, type ClosableTabProps } from "../ClosableTab" import { terminalChrome, terminalClosable, terminalStoppable } from "./chrome" import type { ScriptTerminalStatus } from "./state" -export const TerminalTabChrome: Component<{ +interface Props extends Omit { label: string tooltip: string status?: ScriptTerminalStatus - keybind?: string - closeKeybind?: string - focused?: boolean - active: boolean - role?: "tab" - selected?: boolean - tabIndex?: number - onKeyDown?: JSX.EventHandlerUnion - onSelect: () => void - onMiddleClick?: (e: MouseEvent) => void - onClose: (e: MouseEvent) => void - onStop?: (e: MouseEvent) => void -}> = (props) => { + onClose: () => void + onStop?: (event: MouseEvent) => void +} + +const StopButton: Component<{ active: boolean; tabIndex: number; onStop?: (event: MouseEvent) => void }> = (props) => { const { t } = useLanguage() - const chrome = () => terminalChrome(props.tooltip, props.status) - const icon = () => { - const kind = chrome().icon - if (kind === "success") return "check-small" - if (kind === "failure") return "warning" - return "console" - } return ( -
-
+ - - - - }> - - - - {props.label} - - -
- - - - - - - - - - -
+ { + event.stopPropagation() + props.onStop?.(event) + }} + /> + + ) } -export const SortableTerminalTab: Component<{ - id: string - label: string - tooltip: string - status?: ScriptTerminalStatus - keybind?: string - closeKeybind?: string - focused?: boolean - active: boolean - role?: "tab" - selected?: boolean - tabIndex?: number - onKeyDown?: JSX.EventHandlerUnion - onSelect: () => void - onMiddleClick: (e: MouseEvent) => void - onClose: (e: MouseEvent) => void - onCloseOthers: () => void - onStop?: (e: MouseEvent) => void -}> = (props) => { - const { t } = useLanguage() - return ( - - - - - - - - props.onClose(new MouseEvent("click", { bubbles: true, cancelable: true }) as MouseEvent)} - > - - {t("agentManager.tab.close")} - - - {parseBindingTokens(props.closeKeybind ?? "").map((token) => ( - {token} - ))} - - - - - - {t("agentManager.tab.closeOthers")} - - - - - - ) +function icon(status: ScriptTerminalStatus | undefined) { + const value = terminalChrome("", status).icon + if (value === "success") return "check-small" as const + if (value === "failure") return "warning" as const + if (value === "spinner") return "spinner" as const + return "console" as const } + +function iconStatus(status: ScriptTerminalStatus | undefined) { + const value = terminalChrome("", status).icon + if (value === "success") return "success" as const + if (value === "failure") return "failure" as const + return undefined +} + +export const SortableTerminalTab: Component< + Props & { + id: string + onCloseOthers: () => void + } +> = (props) => ( + icon(props.status)} + iconStatus={() => iconStatus(props.status)} + class="am-tab-terminal" + focused={props.focused} + active={props.active} + closeable={terminalClosable(props.status)} + keybind={props.keybind} + closeKeybind={props.closeKeybind} + role={props.role} + selected={props.selected} + tabIndex={props.tabIndex} + onKeyDown={props.onKeyDown} + onSelect={props.onSelect} + onMiddleClick={props.onMiddleClick} + onClose={props.onClose} + onCloseOthers={props.onCloseOthers} + trailing={ + + } + /> +) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx index 611f9d2013..032147fa63 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx @@ -59,10 +59,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element { onKeyDown={deps.onKeyDown} onSelect={() => deps.onSelect(deps.id)} onMiddleClick={(e: MouseEvent) => deps.onMiddleClick(deps.id, e)} - onClose={(e: MouseEvent) => { - e.stopPropagation() - deps.onClose(deps.id) - }} + onClose={() => deps.onClose(deps.id)} onCloseOthers={() => deps.onCloseOthers(deps.id)} /> ) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx index 1e03df08a5..dd03d3e796 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx @@ -8,6 +8,7 @@ export const SessionTabMenu: ParentComponent<{ onFork?: () => void onClose: () => void onCloseOthers?: () => void + closeable?: boolean closeShortcut?: JSX.Element }> = (props) => { const { t } = useLanguage() @@ -23,18 +24,22 @@ export const SessionTabMenu: ParentComponent<{ {t("agentManager.tab.forkSession")} - + + + - - - {t("agentManager.tab.close")} - {props.closeShortcut} - - - props.onCloseOthers?.()}> + + - {t("agentManager.tab.closeOthers")} + {t("agentManager.tab.close")} + {props.closeShortcut} + + props.onCloseOthers?.()}> + + {t("agentManager.tab.closeOthers")} + + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index 5138f071c9..8ad09e3807 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -18,6 +18,7 @@ import { useI18n } from "@kilocode/kilo-ui/context/i18n" import { createAutoScroll } from "@kilocode/kilo-ui/hooks" import { useSession } from "../../context/session" import { useVSCode } from "../../context/vscode" +import { useWorktreeMode } from "../../context/worktree-mode" import { childID } from "../../context/session-utils" import { taskResult, taskRunning, taskVisible } from "./task-tool-state" @@ -26,6 +27,7 @@ const TaskToolRenderer: Component = (props) => { const language = useLanguage() const session = useSession() const vscode = useVSCode() + const worktree = useWorktreeMode() const childSessionId = () => childID({ @@ -115,7 +117,16 @@ const TaskToolRenderer: Component = (props) => { e.stopPropagation() const id = childSessionId() if (!id) return - vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title: description() }) + const title = description() + if (worktree) { + window.dispatchEvent( + new CustomEvent("agentManager.openSubagent", { + detail: { sessionID: id, title, parentSessionID: session.currentSessionID() }, + }), + ) + return + } + vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title }) } const trigger = () => ( @@ -138,7 +149,7 @@ const TaskToolRenderer: Component = (props) => { icon="square-arrow-top-right" size="small" variant="ghost" - aria-label="Open sub-agent in tab" + aria-label={worktree ? "Open sub-agent in panel" : "Open sub-agent in tab"} onClick={openInTab} /> diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index ed5b5d07ff..87b388c9e9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -290,7 +290,7 @@ interface SessionContextValue { deleteSession: (id: string) => void renameSession: (id: string, title: string) => void exportSessionTranscript: (id: string) => void - syncSession: (sessionID: string) => void + syncSession: (sessionID: string, parentSessionID?: string) => void // Cloud session preview cloudPreviewId: Accessor @@ -2822,8 +2822,8 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "deleteMessage", sessionID, messageID }) } - function syncSession(sessionID: string) { - vscode.postMessage({ type: "syncSession", sessionID, parentSessionID: currentSessionID() }) + function syncSession(sessionID: string, parentSessionID = currentSessionID()) { + vscode.postMessage({ type: "syncSession", sessionID, parentSessionID }) } const todos = () => { From d95c5d460fa350d3569310108e314053e367b1f0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 14:37:22 +0200 Subject: [PATCH 02/34] fix(vscode): format subagent inspector files --- .../tests/unit/agent-manager-terminal-layout.test.ts | 11 +++++++---- .../webview-ui/agent-manager/SubagentPanel.tsx | 8 ++++---- .../agent-manager/terminal/SideTerminalPanel.tsx | 8 ++++---- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts index 263ac2a399..c9d0fdeebc 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -36,13 +36,16 @@ test("uses one persisted width for every inspector panel", () => { }) test("hides keyboard hints only in inspector tabs", () => { - const side = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), "utf8") + const side = readFileSync( + resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), + "utf8", + ) expect(subagent).toContain("showKeybind={false}") expect(side).toContain("showKeybind={false}") - expect(readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/render.tsx"), "utf8")).not.toContain( - "showKeybind={false}", - ) + expect( + readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/render.tsx"), "utf8"), + ).not.toContain("showKeybind={false}") }) test("limits inspector layout updates during resize", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx index 3e8b39c4a2..68a6a8515c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -88,10 +88,10 @@ export const SubagentPanel: Component = (props) => { = (props) => { Date: Mon, 17 Aug 2026 14:55:44 +0200 Subject: [PATCH 03/34] fix(vscode): shorten prompt model label --- .changeset/compact-model-trigger-label.md | 5 +++ .../tests/unit/model-selector-utils.test.ts | 36 +++++++++---------- .../src/components/shared/ModelSelector.tsx | 1 - .../components/shared/model-selector-utils.ts | 2 -- 4 files changed, 22 insertions(+), 22 deletions(-) create mode 100644 .changeset/compact-model-trigger-label.md diff --git a/.changeset/compact-model-trigger-label.md b/.changeset/compact-model-trigger-label.md new file mode 100644 index 0000000000..c15c4ac38a --- /dev/null +++ b/.changeset/compact-model-trigger-label.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep provider names out of the compact prompt model selector label while retaining them in the expanded picker. diff --git a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts index 59956de5ef..2f204ed9a1 100644 --- a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -255,69 +255,67 @@ describe("hasByok", () => { describe("buildTriggerLabel", () => { it("returns resolved model name for non-kilo provider unchanged", () => { - expect(buildTriggerLabel("GPT-4o", "openai", undefined, null, false, "", true, labels)).toBe("GPT-4o") + expect(buildTriggerLabel("GPT-4o", "openai", null, false, "", true, labels)).toBe("GPT-4o") }) it("strips sub-provider prefix from resolved name for kilo gateway models", () => { - expect( - buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, undefined, null, false, "", true, labels), - ).toBe("Claude Sonnet") + expect(buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, null, false, "", true, labels)).toBe( + "Claude Sonnet", + ) }) it("does not strip prefix for non-kilo provider even if name contains ': '", () => { - expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", undefined, null, false, "", true, labels)).toBe( + expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", null, false, "", true, labels)).toBe( "Anthropic: Claude Sonnet", ) }) it("returns resolved name as-is when providerID is undefined", () => { - expect(buildTriggerLabel("GPT-4o", undefined, undefined, null, false, "", true, labels)).toBe("GPT-4o") + expect(buildTriggerLabel("GPT-4o", undefined, null, false, "", true, labels)).toBe("GPT-4o") }) - it("returns providerName / resolvedName for non-kilo provider with providerName", () => { - expect(buildTriggerLabel("GPT-4o", "openai", "OpenAI", null, false, "", true, labels)).toBe("OpenAI / GPT-4o") + it("does not add provider name to the compact label", () => { + expect(buildTriggerLabel("GPT-5.6 Luna", "openai", null, false, "", true, labels)).toBe("GPT-5.6 Luna") }) it("returns modelID for kilo gateway raw selection", () => { const raw = { providerID: "kilo", modelID: "kilo-auto/frontier" } - expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier") + expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier") }) it("returns providerID / modelID for non-kilo raw selection", () => { const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" } - expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe( - "anthropic / claude-3-5-sonnet", - ) + expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("anthropic / claude-3-5-sonnet") }) it("returns clearLabel when allowClear and no selection", () => { - expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "None", true, labels)).toBe("None") + expect(buildTriggerLabel(undefined, undefined, null, true, "None", true, labels)).toBe("None") }) it("falls back to labels.notSet when allowClear and clearLabel is empty", () => { - expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "", true, labels)).toBe("Not set") + expect(buildTriggerLabel(undefined, undefined, null, true, "", true, labels)).toBe("Not set") }) it("returns labels.select when providers exist and no selection", () => { - expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", true, labels)).toBe("Select model") + expect(buildTriggerLabel(undefined, undefined, null, false, "", true, labels)).toBe("Select model") }) it("returns labels.noProviders when no providers available", () => { - expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", false, labels)).toBe("No providers") + expect(buildTriggerLabel(undefined, undefined, null, false, "", false, labels)).toBe("No providers") }) it("prefers resolvedName over raw selection", () => { const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" } - expect(buildTriggerLabel("Claude Sonnet", undefined, undefined, raw, false, "", true, labels)).toBe("Claude Sonnet") + expect(buildTriggerLabel("Claude Sonnet", undefined, raw, false, "", true, labels)).toBe("Claude Sonnet") }) it("ignores partial raw selection (only providerID)", () => { const raw = { providerID: "anthropic", modelID: "" } - expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model") + expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model") }) it("ignores partial raw selection (only modelID)", () => { const raw = { providerID: "", modelID: "claude-3-5-sonnet" } - expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model") + expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model") }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 74e43dff55..617e69038f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -776,7 +776,6 @@ export const ModelSelectorBase: Component = (props) => { buildTriggerLabel( activeModel()?.name, activeModel()?.providerID, - activeModel()?.providerName, props.value, props.allowClear ?? false, props.clearLabel ?? "", diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts index 80d70c9cf8..36d09cf68c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts @@ -216,7 +216,6 @@ export function stripSubProviderPrefix(name: string): string { export function buildTriggerLabel( resolvedName: string | undefined, providerID: string | undefined, - providerName: string | undefined, raw: ModelSelection | null, allowClear: boolean, clearLabel: string, @@ -225,7 +224,6 @@ export function buildTriggerLabel( ): string { if (resolvedName) { if (providerID === KILO_GATEWAY_ID) return stripSubProviderPrefix(resolvedName) - if (providerName) return `${providerName} / ${resolvedName}` return resolvedName } if (raw?.providerID && raw?.modelID) { From 385dd143427570ab26d8f5beede016d6c66f3272 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 15:19:55 +0200 Subject: [PATCH 04/34] test(vscode): update compact model label expectation --- .../kilo-vscode/tests/model-selector-accessibility.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts index de54f3b5e0..4c58281388 100644 --- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -202,8 +202,8 @@ test("selected favorite remains selected when its duplicate group is collapsed", test("large catalogs keep the rendered tree bounded and navigate to distant models", async ({ page }) => { await load(page, "shared--model-selector-large-catalog") - await page.getByRole("button", { name: "Select model: Provider 0 / Model 300" }).click() - const combobox = page.getByRole("combobox", { name: "Select model: Provider 0 / Model 300. Search models" }) + await page.getByRole("button", { name: "Select model: Model 300" }).click() + const combobox = page.getByRole("combobox", { name: "Select model: Model 300. Search models" }) const tree = page.getByRole("tree", { name: "Select model" }) // The window mounts before we measure it, yet stays far smaller than the catalog. From 86af8dd7c700fcb6229f28471126b5e7b0f6f654 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 15:22:23 +0200 Subject: [PATCH 05/34] fix(opencode): prompt before sandboxed git writes --- .changeset/sandbox-git-escalation.md | 5 + .../src/commands/toggle-auto-approve.ts | 2 + .../src/components/chat/PermissionDock.tsx | 2 + packages/opencode/src/acp/permission.ts | 13 +- packages/opencode/src/cli/cmd/run.ts | 6 +- .../src/cli/cmd/run/footer.permission.tsx | 7 +- .../src/cli/cmd/run/permission.shared.ts | 27 +++- .../opencode/src/kilocode/permission/drain.ts | 1 + packages/opencode/src/kilocode/sandbox/git.ts | 113 +++++++++++++++ .../opencode/src/kilocode/sandbox/policy.ts | 4 + packages/opencode/src/permission/index.ts | 11 +- packages/opencode/src/session/tools.ts | 130 ++++++++++-------- packages/opencode/src/tool/shell.ts | 51 +++++-- packages/opencode/src/tool/shell/shell.txt | 1 + packages/opencode/src/tool/tool.ts | 5 +- .../kilocode/permission/skill-shell.test.ts | 21 +++ .../test/kilocode/sandbox/git.test.ts | 49 +++++++ .../test/kilocode/tool/shell-unparsed.test.ts | 17 ++- 18 files changed, 371 insertions(+), 94 deletions(-) create mode 100644 .changeset/sandbox-git-escalation.md create mode 100644 packages/opencode/src/kilocode/sandbox/git.ts create mode 100644 packages/opencode/test/kilocode/sandbox/git.test.ts diff --git a/.changeset/sandbox-git-escalation.md b/.changeset/sandbox-git-escalation.md new file mode 100644 index 0000000000..c8eaefbb22 --- /dev/null +++ b/.changeset/sandbox-git-escalation.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prompt for explicit, one-shot approval before mutating Git commands run outside the sandbox. diff --git a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts index a94873fc21..32e12bd610 100644 --- a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts +++ b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts @@ -73,6 +73,7 @@ export function registerToggleAutoApprove( const { data: pending } = await client.permission.list({ directory: dir }, { throwOnError: true }) for (const req of pending) { if (generation !== snapshot) break + if (req.metadata?.["sandboxEscalation"] === true) continue await client.permission .reply({ requestID: req.id, directory: dir, reply: "once" }, { throwOnError: true }) .catch((err) => { @@ -91,6 +92,7 @@ export function registerToggleAutoApprove( if (!active) return false const client = tryGetClient(connectionService) if (!client) return false + if (event.properties.metadata?.["sandboxEscalation"] === true) return false const dir = directory ?? connectionService.getPermissionDirectory(event.properties.id) ?? resolve(event.properties.sessionID) return client.permission diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 879f789281..3b1020c3e4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -62,6 +62,7 @@ export const PermissionDock: Component<{ } const text = (rule: string) => (command() ? label(rule) : describeRule(props.request.toolName, rule, language.t)) const external = () => props.request.toolName === "external_directory" + const sandboxEscalation = () => props.request.toolName === "sandbox_escalation" const cmdDescription = () => { const val = props.request.args?.description return typeof val === "string" && val.length > 0 ? val : undefined @@ -129,6 +130,7 @@ export const PermissionDock: Component<{ } const title = () => { + if (sandboxEscalation()) return "Allow Git operation outside the sandbox?" const skill = props.request.args?.skill if (skillShell() && typeof skill === "string" && skill.length > 0) // Escape the untrusted skill name so bidi/control chars can't reorder the header text. diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index edca6c903c..12568db164 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -60,6 +60,12 @@ export class Handler { } const skillShell = SkillShellPrompt.is(permission.metadata) // kilocode_change - skill batches list commands and never persist + const temporary = skillShell || permission.metadata?.["sandboxEscalation"] === true // kilocode_change + const title = skillShell + ? SkillShellPrompt.title + : temporary + ? "Allow Git operation outside the sandbox" + : undefined // kilocode_change const result = await this.input.connection .requestPermission({ sessionId: permission.sessionID, @@ -67,9 +73,9 @@ export class Handler { toolCallId: permission.tool?.callID ?? permission.id, toolName: permission.permission, input: permission.metadata, - title: skillShell ? SkillShellPrompt.title : undefined, // kilocode_change + title, // kilocode_change }), - options: skillShell ? SkillShellPrompt.options : permissionOptions, // kilocode_change + options: temporary ? SkillShellPrompt.options : permissionOptions, // kilocode_change }) .catch(async () => { await this.reply(permission.id, "reject", session.cwd) @@ -91,7 +97,8 @@ export class Handler { await this.reply(permission.id, reply, session.cwd, true) // kilocode_change - human selected via requestPermission } - private async reply(requestID: string, reply: Reply, directory: string, interactive = false) { // kilocode_change - interactive param + private async reply(requestID: string, reply: Reply, directory: string, interactive = false) { + // kilocode_change - interactive param await this.input.sdk.permission.reply({ requestID, reply, diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0244decdf0..d4cab544c5 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -281,7 +281,9 @@ export const RunCommand = effectCmd({ // kilocode_change start - lazy Kilo implementations (see top-of-file note) const { createKiloClient } = yield* Effect.promise(() => import("@kilocode/sdk/v2")) const { buildRunMessage } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run-message")) - const { importCloudSession, validateCloudFork, reportCloudImportError } = yield* Effect.promise(() => import("@/kilocode/cloud-session")) + const { importCloudSession, validateCloudFork, reportCloudImportError } = yield* Effect.promise( + () => import("@/kilocode/cloud-session"), + ) const { KiloRunAuto } = yield* Effect.promise(() => import("@/kilocode/cli/run-auto")) const { KiloHeadless } = yield* Effect.promise(() => import("@/kilocode/permission/headless")) const { KiloRun, KiloRunDaemon } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run")) @@ -923,7 +925,7 @@ export const RunCommand = effectCmd({ const permission = event.properties // kilocode_change start - skill shell batches need an interactive human decision. The server ignores // non-interactive approvals, so headless runs must reject explicitly rather than leave them pending. - if (permission.metadata?.["skillShell"] === true) { + if (permission.metadata?.["skillShell"] === true || permission.metadata?.["sandboxEscalation"] === true) { await client.permission.reply({ requestID: permission.id, reply: "reject" }) continue } diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx index ab3ea3cc4f..5c53616f82 100644 --- a/packages/opencode/src/cli/cmd/run/footer.permission.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -27,6 +27,7 @@ import { permissionReject, permissionRun, permissionShift, + temporaryPermission, type PermissionOption, } from "./permission.shared" import { footerWidthPolicy } from "./footer.width" @@ -141,8 +142,8 @@ export function RunPermissionBody(props: { const info = createMemo(() => permissionInfo(props.request)) const ft = createMemo(() => toolFiletype(info().file)) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) - const skillShell = createMemo(() => props.request.metadata?.["skillShell"] === true) // kilocode_change - const opts = createMemo(() => permissionOptions(state().stage, skillShell())) // kilocode_change - skillShell-aware options + const temporary = createMemo(() => temporaryPermission(props.request)) // kilocode_change + const opts = createMemo(() => permissionOptions(state().stage, temporary())) // kilocode_change const busy = createMemo(() => state().submitting) const title = createMemo(() => { if (state().stage === "always") { @@ -166,7 +167,7 @@ export function RunPermissionBody(props: { }) const shift = (dir: -1 | 1) => { - setState((prev) => permissionShift(prev, dir, skillShell())) // kilocode_change - skillShell-aware options + setState((prev) => permissionShift(prev, dir, temporary())) // kilocode_change } const submit = async (next: PermissionReply) => { diff --git a/packages/opencode/src/cli/cmd/run/permission.shared.ts b/packages/opencode/src/cli/cmd/run/permission.shared.ts index 8b0f33a241..e2324b8330 100644 --- a/packages/opencode/src/cli/cmd/run/permission.shared.ts +++ b/packages/opencode/src/cli/cmd/run/permission.shared.ts @@ -77,11 +77,10 @@ export function createPermissionBodyState(requestID: string): PermissionBodyStat } } -export function permissionOptions(stage: PermissionStage, skillShell?: boolean): PermissionOption[] { // kilocode_change - skillShell param +export function permissionOptions(stage: PermissionStage, temporary?: boolean): PermissionOption[] { + // kilocode_change if (stage === "permission") { - // kilocode_change start - skill-shell batches are never persisted, so no "Allow always" - return skillShell ? ["once", "reject"] : ["once", "always", "reject"] - // kilocode_change end + return temporary ? ["once", "reject"] : ["once", "always", "reject"] } if (stage === "always") { @@ -99,6 +98,17 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo { return info } + if (request.permission === "sandbox_escalation") { + const command = text(input.command) + return { + icon: "!", + title: "Allow Git operation outside the sandbox", + lines: command + ? [`$ ${command}`, "This approval applies to this command only."] + : ["This approval applies to this command only."], + } + } + if (request.permission === "external_directory") { const meta = dict(request.metadata) const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || "" @@ -125,6 +135,10 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo { } } +export function temporaryPermission(request: PermissionRequest) { + return request.metadata?.["skillShell"] === true || request.metadata?.["sandboxEscalation"] === true +} + export function permissionAlwaysLines(request: PermissionRequest): string[] { if (request.always.length === 1 && request.always[0] === "*") { return [`This will allow ${request.permission} until Kilo is restarted.`] // kilocode_change @@ -153,8 +167,9 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply } } -export function permissionShift(state: PermissionBodyState, dir: -1 | 1, skillShell?: boolean): PermissionBodyState { // kilocode_change - skillShell param - const list = permissionOptions(state.stage, skillShell) // kilocode_change - skillShell-aware options +export function permissionShift(state: PermissionBodyState, dir: -1 | 1, temporary?: boolean): PermissionBodyState { + // kilocode_change + const list = permissionOptions(state.stage, temporary) // kilocode_change if (list.length === 0) { return state } diff --git a/packages/opencode/src/kilocode/permission/drain.ts b/packages/opencode/src/kilocode/permission/drain.ts index 499f4f83e1..16795332f2 100644 --- a/packages/opencode/src/kilocode/permission/drain.ts +++ b/packages/opencode/src/kilocode/permission/drain.ts @@ -35,6 +35,7 @@ export function drainCovered( if (ConfigProtection.isRequest(entry.info) && !skill) continue // Never auto-resolve a skill shell batch; it must get an explicit reply. if (entry.info.metadata?.["skillShell"] === true) continue + if (entry.info.metadata?.["sandboxEscalation"] === true) continue const actions = entry.info.patterns.map((pattern: string) => { const rule = skill ? Permission.evaluate(entry.info.permission, skill, approved) diff --git a/packages/opencode/src/kilocode/sandbox/git.ts b/packages/opencode/src/kilocode/sandbox/git.ts new file mode 100644 index 0000000000..cfccf9fb9f --- /dev/null +++ b/packages/opencode/src/kilocode/sandbox/git.ts @@ -0,0 +1,113 @@ +const READONLY = new Set([ + "cat-file", + "check-attr", + "check-ignore", + "check-mailmap", + "config", + "describe", + "diff", + "for-each-ref", + "grep", + "log", + "ls-files", + "ls-tree", + "ls-remote", + "merge-base", + "name-rev", + "rev-list", + "rev-parse", + "show", + "show-ref", + "status", + "tag", + "whatchanged", +]) + +const MUTATING = new Set([ + "add", + "am", + "apply", + "branch", + "cherry-pick", + "checkout", + "clean", + "clone", + "commit", + "fetch", + "init", + "merge", + "mv", + "pull", + "push", + "rebase", + "reset", + "restore", + "rm", + "stash", + "switch", + "update-index", +]) + +function args(text: string) { + const match = text + .trim() + .match( + /^(?:command\s+|env\s+(?:-[^\s]+\s+|[A-Za-z_][A-Za-z0-9_]*=[^\s]+\s+)*|[A-Za-z_][A-Za-z0-9_]*=[^\s]+\s+)*(?:git|git.exe)(?:\s+|$)(.*)$/i, + ) + if (!match) return + return match[1].trim().split(/\s+/).filter(Boolean) +} + +export function mutates(text: string) { + if (/[;&|<>`\n\r]/.test(text)) return false + const values = args(text) + if (!values) return false + const options = new Set(["-C", "--git-dir", "--work-tree", "--namespace", "-c"]) + while (values[0]?.startsWith("-")) { + const option = values.shift()! + if (options.has(option)) values.shift() + } + const subcommand = values[0]?.toLowerCase() + if (!subcommand) return false + if (subcommand === "branch") { + return ( + values.length > 1 && + !values + .slice(1) + .some((value) => + ["-a", "-r", "-l", "--all", "--list", "--show-current", "--contains", "--merged", "--no-merged"].includes( + value, + ), + ) + ) + } + if (subcommand === "tag") { + return ( + values.length > 1 && + !values + .slice(1) + .some((value) => ["-l", "--list", "--contains", "--points-at", "--merged", "--no-merged"].includes(value)) + ) + } + if (subcommand === "config") { + if (values.length === 1) return false + return !values + .slice(1) + .some((value) => + [ + "--get", + "--get-all", + "--get-regexp", + "--get-urlmatch", + "--list", + "-l", + "--name-only", + "--show-origin", + "--show-names", + ].includes(value), + ) + } + if (READONLY.has(subcommand)) return false + if (MUTATING.has(subcommand)) return true + return true +} diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index d84d40731b..f95f4741ed 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -623,6 +623,10 @@ export function executeTool(sessionID: SessionID, tool: { id: string }, return execute(sessionID, Network.tool(tool, effect)) } +export function executeEscalated(approved: boolean, effect: Effect.Effect) { + return approved ? unrestricted(effect) : effect +} + export function executeMcp(sessionID: SessionID, tool: object, effect: Effect.Effect) { return execute(sessionID, Network.mcp(tool, effect)) } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 2ed1c26e65..a4a5b8a43f 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -146,6 +146,7 @@ function subset(permission: string, ruleset: Ruleset) { function covered(entry: PendingEntry, approved: Ruleset, local: Ruleset) { if (ConfigProtection.isRequest(entry.info)) return false if (entry.info.metadata?.["skillShell"] === true) return false // kilocode_change - skill batch needs an explicit reply + if (entry.info.metadata?.["sandboxEscalation"] === true) return false // kilocode_change - host access needs an explicit reply return entry.info.patterns.every((pattern) => { if (veto(entry.info.permission, pattern, entry.hardRuleset)) return false return resolve(entry.info.permission, pattern, entry.ruleset, approved, local).action === "allow" @@ -212,7 +213,7 @@ const layer = Layer.effect( : false // kilocode_change end - const forceAsk = request.metadata?.["skillShell"] === true // kilocode_change + const forceAsk = request.metadata?.["skillShell"] === true || request.metadata?.["sandboxEscalation"] === true // kilocode_change for (const pattern of request.patterns) { const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) @@ -291,8 +292,12 @@ const layer = Layer.effect( // (auto-approve/YOLO clients omit `interactive`) so the prompt stays pending for a real decision. // Log rather than fail silently: a genuine human client sets `interactive`, so a refused reply here // means an auto-approver tried to answer — the request intentionally stays pending for a human. - if (existing.info.metadata?.["skillShell"] === true && input.reply !== "reject" && input.interactive !== true) { - yield* Effect.logWarning("skill shell approval refused: requires an interactive human reply", { + if ( + (existing.info.metadata?.["skillShell"] === true || existing.info.metadata?.["sandboxEscalation"] === true) && + input.reply !== "reject" && + input.interactive !== true + ) { + yield* Effect.logWarning("sensitive permission approval refused: requires an interactive human reply", { id: input.requestID, }) return diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 1b48fc3304..a07bdbdd7c 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -75,65 +75,79 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // kilocode_change end const flags = yield* RuntimeFlags.Service const restricted = yield* SandboxPolicy.networkRestricted(input.session.id) // kilocode_change - - const context = (args: Record, options: ToolExecutionOptions): Tool.Context => ({ - sessionID: input.session.id, - abort: options.abortSignal!, - messageID: input.processor.message.id, - callID: options.toolCallId, - extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps }, - agent: input.agent.name, - messages: input.messages, - // kilocode_change start - metadata: (val) => input.processor.metadata(options.toolCallId, val), - ask: (req) => - KiloSessionPrompt.askPermission({ - permission, - agents, - sessions, - origins: permissionOrigins, - agent: input.agent, - session: input.session, - request: { - ...req, - sessionID: input.session.id, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - }, - }).pipe( - // record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask - Effect.tap((approval) => - input.processor.metadata(options.toolCallId, { - metadata: { - approval: PermissionProvenance.tagOutsideWorkspace( - approval, - req.permission, - PermissionProvenance.filepathOf(req.metadata), - ), - }, - }), + const sandboxed = (yield* SandboxPolicy.status(input.session.id)).enabled // kilocode_change + const context = (args: Record, options: ToolExecutionOptions): Tool.Context => { + const extra = { + model: input.model, + bypassAgentCheck: input.bypassAgentCheck, + promptOps: input.promptOps, + sandboxed, // kilocode_change + sandboxEscalation: false, + } + return { + sessionID: input.session.id, + abort: options.abortSignal!, + messageID: input.processor.message.id, + callID: options.toolCallId, + extra, + agent: input.agent.name, + messages: input.messages, + // kilocode_change start + metadata: (val) => input.processor.metadata(options.toolCallId, val), + ask: (req) => + KiloSessionPrompt.askPermission({ + permission, + agents, + sessions, + origins: permissionOrigins, + agent: input.agent, + session: input.session, + request: { + ...req, + sessionID: input.session.id, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + }, + }).pipe( + // record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask + Effect.tap((approval) => + Effect.gen(function* () { + if (req.metadata?.["sandboxEscalation"] === true && approval.source === "manual") { + extra.sandboxEscalation = true + } + yield* input.processor.metadata(options.toolCallId, { + metadata: { + approval: PermissionProvenance.tagOutsideWorkspace( + approval, + req.permission, + PermissionProvenance.filepathOf(req.metadata), + ), + }, + }) + }), + ), + // record why the call was denied too, so JSON exports and clients can explain the denial + Effect.tapErrorTag("PermissionDeniedError", (err) => + input.processor.metadata(options.toolCallId, { + metadata: { + approval: PermissionProvenance.tagOutsideWorkspace( + PermissionProvenance.classifyDenial({ + ruleset: err.ruleset, + permission: req.permission, + patterns: req.patterns, + agent: input.agent.name, + origins: permissionOrigins, + }), + req.permission, + PermissionProvenance.filepathOf(req.metadata), + ), + }, + }), + ), + Effect.asVoid, + Effect.orDie, ), - // record why the call was denied too, so JSON exports and clients can explain the denial - Effect.tapErrorTag("PermissionDeniedError", (err) => - input.processor.metadata(options.toolCallId, { - metadata: { - approval: PermissionProvenance.tagOutsideWorkspace( - PermissionProvenance.classifyDenial({ - ruleset: err.ruleset, - permission: req.permission, - patterns: req.patterns, - agent: input.agent.name, - origins: permissionOrigins, - }), - req.permission, - PermissionProvenance.filepathOf(req.metadata), - ), - }, - }), - ), - Effect.asVoid, - Effect.orDie, - ), - }) + } + } // kilocode_change end for (const item of yield* registry.tools({ diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 41d464db5f..4d6fe3bbe0 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -26,6 +26,8 @@ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellPrompt, type Parameters } from "./shell/prompt" import { BashArity } from "@/permission/arity" +import { mutates as mutatesGit } from "@/kilocode/sandbox/git" // kilocode_change +import * as SandboxPolicy from "@/kilocode/sandbox/policy" // kilocode_change export { Parameters } from "./shell/prompt" @@ -327,6 +329,7 @@ type PermissionInput = { cwd: string shell: string description?: string + escalate?: boolean // kilocode_change } export const ShellPermission = Effect.gen(function* () { @@ -430,6 +433,19 @@ export const ShellPermission = Effect.gen(function* () { scan.access = "unknown" } yield* ask(ctx, scan, input.command, metadata, input.description) // kilocode_change + const gitMutation = commands(tree.rootNode).some((node) => mutatesGit(node.text)) + if (input.escalate && gitMutation) { + yield* ctx.ask({ + permission: "sandbox_escalation", // kilocode_change + patterns: [input.command], + always: [], + metadata: { + command: normalizeUrls(input.command), + ...(input.description ? { description: input.description } : {}), + sandboxEscalation: true, + }, + }) + } }), ) }) @@ -729,18 +745,29 @@ export const ShellTool = Tool.define( throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } const timeout = CommandTimeout.clamp(params.timeout ?? defaultTimeoutMs).timeout // kilocode_change - yield* permission.ask(ctx, { command: params.command, cwd, shell, description: params.description }) // kilocode_change - - return yield* run( - { - shell, - command: params.command, - cwd, - env: yield* shellEnv(ctx, cwd), - timeout, - description: params.description ?? params.command, // kilocode_change - }, - ctx, + const sandboxed = ctx.extra?.["sandboxed"] === true + yield* permission.ask(ctx, { + command: params.command, + cwd, + shell, + description: params.description, + escalate: sandboxed, // kilocode_change + }) // kilocode_change + const approved = ctx.extra?.["sandboxEscalation"] === true + if (ctx.extra) ctx.extra["sandboxEscalation"] = false + return yield* SandboxPolicy.executeEscalated( + approved, + run( + { + shell, + command: params.command, + cwd, + env: yield* shellEnv(ctx, cwd), + timeout, + description: params.description ?? params.command, // kilocode_change + }, + ctx, + ), ) }), } diff --git a/packages/opencode/src/tool/shell/shell.txt b/packages/opencode/src/tool/shell/shell.txt index 22bd8f6c1a..6b29baab25 100644 --- a/packages/opencode/src/tool/shell/shell.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -11,6 +11,7 @@ IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO N ${commandSection} # Git and GitHub +- When the sandbox is active, read-only Git commands can run normally. Mutating Git commands such as `git add`, `git commit`, `git merge`, `git checkout`, `git reset`, and `git stash` require a separate confirmation and run unsandboxed only for that command. The confirmation is always one-shot and cannot be saved as an always-allow rule. - Only commit, amend, push, or create PRs when explicitly requested. - Before committing, inspect `git status`, `git diff`, and `git log --oneline -10`; stage only intended files and never commit secrets. - Write a concise commit message that matches the repo style. diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 079627144c..eb30691323 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -120,10 +120,7 @@ function wrap, Result extends Metadat } return Effect.gen(function* () { // kilocode_change start - const decoded = yield* decode( - args, - { errors: "all" }, - ).pipe( + const decoded = yield* decode(args, { errors: "all" }).pipe( Effect.mapError( (error) => new InvalidArgumentsError({ diff --git a/packages/opencode/test/kilocode/permission/skill-shell.test.ts b/packages/opencode/test/kilocode/permission/skill-shell.test.ts index f4aa00bb5c..ca4e1269e1 100644 --- a/packages/opencode/test/kilocode/permission/skill-shell.test.ts +++ b/packages/opencode/test/kilocode/permission/skill-shell.test.ts @@ -79,6 +79,27 @@ it.instance( { git: true }, ) +it.instance( + "sandbox escalation - forces a one-shot interactive prompt", + () => + Effect.gen(function* () { + const fiber = yield* ask({ + sessionID: SessionID.make("session_sandbox"), + permission: "sandbox_escalation", + patterns: ["git commit -m message"], + metadata: { sandboxEscalation: true }, + always: [], + ruleset: [{ permission: "sandbox_escalation", pattern: "*", action: "allow" }], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + expect(pending[0]?.metadata?.sandboxEscalation).toBe(true) + yield* reply({ requestID: pending[0]!.id, reply: "once", interactive: true }) + expect((yield* Fiber.join(fiber)).manual).toBe(true) + }), + { git: true }, +) + it.instance( "skillShell - a deny rule stays terminal (build mode, no hard ruleset)", () => diff --git a/packages/opencode/test/kilocode/sandbox/git.test.ts b/packages/opencode/test/kilocode/sandbox/git.test.ts new file mode 100644 index 0000000000..8d7cbc3aa2 --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/git.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { mutates } from "@/kilocode/sandbox/git" + +describe("sandbox Git mutation classification", () => { + test("allows read-only Git commands to remain sandboxed", () => { + for (const command of [ + "git status", + "git diff --cached", + "git log --oneline -5", + "git show HEAD:file.ts", + "git rev-parse --show-toplevel", + "git branch --all", + "git tag --list", + "git config --get user.name", + "GIT_DIR=/repo/.git git status", + "GIT_INDEX_FILE=/tmp/index git diff", + ]) { + expect(mutates(command)).toBe(false) + } + }) + + test("requires escalation for Git state mutations", () => { + for (const command of [ + "git add src/index.ts", + "git commit -m message", + "git checkout -b feature", + "git merge main", + "git rebase main", + "git stash push", + "git -C /repo reset --hard HEAD", + "git config user.name Agent", + "git unknown-subcommand", + "GIT_INDEX_FILE=/tmp/index git commit -m message", + "KILO_TEST=1 GIT_INDEX_FILE=/tmp/index git add src/index.ts", + ]) { + expect(mutates(command)).toBe(true) + } + }) + + test("does not classify unrelated commands as Git mutations", () => { + expect(mutates("echo git commit -m unsafe")).toBe(false) + expect(mutates("npm run git-status")).toBe(false) + }) + + test("classifies Git mutations inside compound shell commands", () => { + expect(mutates("git add .")).toBe(true) + expect(mutates("git add . && git commit -m message")).toBe(false) + }) +}) diff --git a/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts b/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts index 7044c078aa..fb44dfc120 100644 --- a/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts +++ b/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts @@ -29,11 +29,15 @@ import { SessionID, MessageID } from "../../../src/session/schema" import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdir } from "../../fixture/fixture" import { afterEach } from "bun:test" -const layer = Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), AppNodeBuilder.build(FSUtil.node), testInstanceStoreLayer) +const layer = Layer.mergeAll( + AppNodeBuilder.build(CrossSpawnSpawner.node), + AppNodeBuilder.build(FSUtil.node), + testInstanceStoreLayer, +) type ScanRequest = Omit -async function scan(dir: string, command: string, shell: string) { +async function scan(dir: string, command: string, shell: string, sandbox = false) { const requests: ScanRequest[] = [] const ctx = { sessionID: SessionID.make("ses_test"), @@ -52,7 +56,7 @@ async function scan(dir: string, command: string, shell: string) { provideInstance(dir)( Effect.gen(function* () { const permission = yield* ShellPermission - yield* permission.ask(ctx, { command, cwd: dir, shell, description: "test" }) + yield* permission.ask(ctx, { command, cwd: dir, shell, description: "test", escalate: sandbox }) }), ).pipe(Effect.provide(layer)), ) @@ -80,6 +84,13 @@ afterEach(async () => { }) describe("shell permission scanner fails closed on unparsed commands", () => { + test("asks separately before mutating Git when the session sandbox is enabled", async () => { + await using tmp = await tmpdir({ git: true }) + const requests = await scan(tmp.path, "git add . && git commit -m test", "bash", true) + expect(requests.map((request) => request.permission)).toEqual(["bash", "sandbox_escalation"]) + expect(requests[1]?.metadata?.sandboxEscalation).toBe(true) + }) + test("pwsh: bare '--' git commands now produce a denied pattern", async () => { await using tmp = await tmpdir() for (const command of ["git checkout -- file", "git restore -- file", "git log -- file", "git checkout -- ."]) { From 4cbd411d52027069535cb4f70fb012b4e01462cd Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Mon, 17 Aug 2026 13:23:03 +0000 Subject: [PATCH 06/34] chore: update kilo-vscode visual regression baselines --- .../shared/model-selector-large-catalog-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/shared/model-selector-large-catalog-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-large-catalog-chromium-linux.png index f6ca0c2ec8..a0cb7260ed 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-large-catalog-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-large-catalog-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b197d749f21df8d3061b93d13281a346b52514bec1b6855b474a03cfaeeb117b -size 2500 +oid sha256:4b2de227517c16c9aa90af0245da92f8e532ad29e21de2142e50121dbebf55fc +size 1574 From 2477bde2c25e46a46489be436de0c332f195837b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 15:26:21 +0200 Subject: [PATCH 07/34] fix(agent-manager): default worktree session history --- .changeset/worktree-history-default.md | 5 +++++ packages/kilo-vscode/tests/history-accessibility.spec.ts | 3 +++ .../webview-ui/src/components/history/HistoryView.tsx | 5 ++--- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .changeset/worktree-history-default.md diff --git a/.changeset/worktree-history-default.md b/.changeset/worktree-history-default.md new file mode 100644 index 0000000000..2241a12d90 --- /dev/null +++ b/.changeset/worktree-history-default.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open `/sessions` on a git worktree with the Worktree history filter selected by default. diff --git a/packages/kilo-vscode/tests/history-accessibility.spec.ts b/packages/kilo-vscode/tests/history-accessibility.spec.ts index 230bc7e47f..3840ab30a2 100644 --- a/packages/kilo-vscode/tests/history-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/history-accessibility.spec.ts @@ -105,6 +105,9 @@ test.describe("history session accessibility", () => { const local = page.getByRole("tab", { name: "Local" }) const worktree = page.getByRole("tab", { name: "Worktree" }) + await expect(worktree).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("tabpanel", { name: "Worktree" })).toBeVisible() + await local.focus() await page.keyboard.press("End") await expect(worktree).toBeFocused() diff --git a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx index 9f31a31115..53a111ff2a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx @@ -29,7 +29,8 @@ const HistoryView: Component = (props) => { const dialog = useDialog() const session = useSession() const tabs = useLocalTabs() - const [tab, setTab] = createSignal("local") + const worktreeIds = () => props.worktreeSessionIds?.() + const [tab, setTab] = createSignal(worktreeIds() ? "worktree" : "local") let local: HTMLButtonElement | undefined let cloud: HTMLButtonElement | undefined let worktree: HTMLButtonElement | undefined @@ -37,8 +38,6 @@ const HistoryView: Component = (props) => { let cloudPanel: HTMLDivElement | undefined let worktreePanel: HTMLDivElement | undefined - const worktreeIds = () => props.worktreeSessionIds?.() - createEffect(() => { if (tab() === "worktree" && !worktreeIds()) setTab("local") }) From d563ba2a723e914a25e8c9ea2f52086cf82fdf8c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 15:26:38 +0200 Subject: [PATCH 08/34] fix(vscode): isolate subagent inspector sessions --- packages/kilo-vscode/src/KiloProvider.ts | 73 +++++++-- .../kilo-vscode/src/agent-manager/types.ts | 1 + .../src/kilo-provider/visible-task-streams.ts | 4 + .../tests/unit/subagent-tabs.test.ts | 10 +- .../agent-manager/AgentManagerApp.tsx | 3 +- .../agent-manager/SubagentPanel.tsx | 139 ++++++++++-------- .../webview-ui/agent-manager/subagent-tabs.ts | 8 +- .../src/components/chat/SessionTabMenu.tsx | 14 +- .../src/components/chat/TaskToolExpanded.tsx | 7 + .../webview-ui/src/context/session.tsx | 43 ++++-- .../src/types/messages/webview-messages.ts | 9 ++ 11 files changed, 216 insertions(+), 95 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 144b7fdd61..57dc4279f6 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -379,6 +379,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly openSessionIds = new Set() private modelUsageSessionIds: Set = new Set() private syncedChildSessions: Set = new Set() + private readonly inspectorSessionIds = new Set() private readonly checkpoints = new Map>() private readonly sessionCreations = new Map>() private readonly draftSessions = new Map() @@ -1083,6 +1084,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (await this.handleModelSelectorExpandedMessage(message)) return this.handleWebviewFocusMessage(message) this.visibleTaskStreams.handle(message) + this.handleStreamVisibilityMessage(message) + if (this.handleChildSyncMessage(message)) return if (await this.handleMemoryMessage(message)) return if (this.handleLegacyMigrationMessage(message)) return switch (message.type) { @@ -1171,15 +1174,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // isn't blocked by slow responses for earlier sessions. void this.handleLoadMessages(message.sessionID, { mode: message.mode, + focus: message.focus, before: message.before, limit: message.limit, }) break - case "syncSession": - this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) => - console.error("[Kilo New] handleSyncSession failed:", e), - ) - break case "loadSessions": this.handleLoadSessions().catch((e) => console.error("[Kilo New] handleLoadSessions failed:", e)) break @@ -1572,6 +1571,33 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private handleChildSyncMessage( + message: TypedWebviewMessage & { sessionID?: unknown; parentSessionID?: unknown; scope?: unknown }, + ): boolean { + if (message.type !== "syncSession" && message.type !== "unsyncSession") return false + if (typeof message.sessionID !== "string") return true + if (message.type === "syncSession") { + if (message.scope === "inspector") this.inspectorSessionIds.add(message.sessionID) + const parent = typeof message.parentSessionID === "string" ? message.parentSessionID : undefined + this.handleSyncSession(message.sessionID, parent).catch((e) => + console.error("[Kilo New] handleSyncSession failed:", e), + ) + return true + } + this.inspectorSessionIds.delete(message.sessionID) + this.releaseChildSession(message.sessionID) + return true + } + + private handleStreamVisibilityMessage( + message: TypedWebviewMessage & { sessionID?: unknown; visible?: unknown }, + ): void { + if (message.type !== "streamSessionVisible" || message.visible !== false || typeof message.sessionID !== "string") { + return + } + this.releaseChildSession(message.sessionID) + } + private handleEditorOpenMessage(message: Parameters[0]): boolean { return handleEditorAction(message, { // An explicit sessionID (e.g. from validateFiles) takes precedence over @@ -1976,14 +2002,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async handleLoadMessages( sessionID: string, - options: { mode?: MessageLoadMode; before?: string; limit?: number; preserveStream?: boolean } = {}, + options: { + mode?: MessageLoadMode + focus?: boolean + before?: string + limit?: number + preserveStream?: boolean + } = {}, ): Promise { const mode = options.mode ?? "replace" if (mode === "replace" || mode === "focus") { - this.stopCurrentSessionProcesses(sessionID) this.trackedSessionIds.add(sessionID) - this.focusSession(sessionID) - this.contextSessionID = sessionID + if (options.focus !== false) { + this.stopCurrentSessionProcesses(sessionID) + this.focusSession(sessionID) + this.contextSessionID = sessionID + } } if (!this.client) { this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID }) @@ -2119,6 +2153,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private releaseChildSession(sessionID: string): void { + if ( + this.inspectorSessionIds.has(sessionID) || + this.visibleTaskStreams.has(sessionID) || + this.currentSession?.id === sessionID || + this.openSessionIds.has(sessionID) + ) { + return + } + if (!this.syncedChildSessions.delete(sessionID)) return + this.trackedSessionIds.delete(sessionID) + this.streams.drop(sessionID) + this.visibleTaskStreams.delete(sessionID) + this.sessionDirectories.delete(sessionID) + this.sessionGitDirectories.delete(sessionID) + this.sessionGitRecoveries.delete(sessionID) + this.connectionService.pruneSession(sessionID) + } + /** * Build the context object used by the extracted session-refresh helpers. */ @@ -2252,6 +2305,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.streams.drop(sessionID) this.visibleTaskStreams.delete(sessionID) this.syncedChildSessions.delete(sessionID) + this.inspectorSessionIds.delete(sessionID) this.sessionDirectories.delete(sessionID) this.sessionGitDirectories.delete(sessionID) this.sessionGitRecoveries.delete(sessionID) @@ -5091,6 +5145,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.clear() this.openSessionIds.clear() this.syncedChildSessions.clear() + this.inspectorSessionIds.clear() this.draftSessions.clear() this.sessionDirectories.clear() this.anacondaDesktop.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 1d76a49abf..6df88ab49f 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -817,6 +817,7 @@ interface LoadMessagesIn { type: "loadMessages" sessionID: string mode?: "replace" | "prepend" | "focus" + focus?: boolean before?: string limit?: number } diff --git a/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts b/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts index 83b97c3103..66ad878936 100644 --- a/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts +++ b/packages/kilo-vscode/src/kilo-provider/visible-task-streams.ts @@ -25,6 +25,10 @@ export class VisibleTaskStreams { this.refs.delete(id) } + has(id: string): boolean { + return this.refs.has(id) + } + setActive(active: boolean): void { if (this.active === active) return this.active = active diff --git a/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts b/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts index 5276fac4cb..d5c618b21c 100644 --- a/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts +++ b/packages/kilo-vscode/tests/unit/subagent-tabs.test.ts @@ -4,10 +4,16 @@ import { createSubagentTabs } from "../../webview-ui/agent-manager/subagent-tabs function scene() { const [current] = createSignal("parent") - const calls = { synced: [] as Array<[string, string | undefined]>, shown: 0, hidden: 0 } + const calls = { + synced: [] as Array<[string, string | undefined]>, + unsynced: [] as string[], + shown: 0, + hidden: 0, + } const tabs = createSubagentTabs({ current, sync: (id, parent) => calls.synced.push([id, parent]), + unsync: (id) => calls.unsynced.push(id), show: () => calls.shown++, hide: () => calls.hidden++, }) @@ -47,6 +53,7 @@ describe("Agent Manager subagent tabs", () => { item.tabs.close("one") expect(item.tabs.tabs()).toEqual([]) expect(item.tabs.active()).toBeUndefined() + expect(item.calls.unsynced).toEqual(["two", "three", "one"]) expect(item.calls.hidden).toBe(1) dispose() }) @@ -62,6 +69,7 @@ describe("Agent Manager subagent tabs", () => { item.tabs.closeOthers("one") expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one"]) expect(item.tabs.active()).toBe("one") + expect(item.calls.unsynced).toEqual(["two", "three"]) expect(item.calls.shown).toBe(4) dispose() }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a1c5cd9ef6..10e72057ad 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -325,7 +325,8 @@ const AgentManagerContent: Component = () => { const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified") const subagents = createSubagentTabs({ current: session.currentSessionID, - sync: session.syncSession, + sync: (id, parentID) => session.syncSession(id, parentID, "inspector"), + unsync: (id) => session.unsyncSession(id, "inspector"), show: () => { setHistory(false) setReviewActive(false) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx index 68a6a8515c..ddc20fc7a0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -34,7 +34,7 @@ const SubagentChat: Component<{ active: Accessor }> = (props createEffect(() => { const id = props.active() if (!id) return - session.selectSession(id) + session.selectSession(id, { focus: false }) }) return ( @@ -44,77 +44,88 @@ const SubagentChat: Component<{ active: Accessor }> = (props ) } -export const SubagentPanel: Component = (props) => { +const SubagentContent: Component = (props) => { + const session = useSession() const ids = () => props.tabs().map((tab) => tab.id) const title = (id: string) => props.tabs().find((tab) => tab.id === id)?.title ?? "Sub-agent" const close = (id: string, focus: { restore: () => void }) => { props.onClose(id) + session.releaseSession(id) if (ids().length > 0) focus.restore() } + const closeOthers = (id: string) => { + const gone = ids().filter((item) => item !== id) + props.onCloseOthers(id) + for (const item of gone) session.releaseSession(item) + } return ( - -
-
-
- - Subagents - {props.tabs().length} -
- -
- { - const label = title(id) - return ( - api.focus.key(id, event)} - onSelect={() => props.onSelect(id)} - onMiddleClick={(event) => { - if (event.button !== 1) return - event.preventDefault() - event.stopPropagation() - close(id, api.focus) - }} - onClose={() => close(id, api.focus)} - onCloseOthers={() => props.onCloseOthers(id)} - /> - ) - }} - /> -
- +
+
+
+ + Subagents + {props.tabs().length}
-
- + + + { + const label = title(id) + return ( + api.focus.key(id, event)} + onSelect={() => props.onSelect(id)} + onMiddleClick={(event) => { + if (event.button !== 1) return + event.preventDefault() + event.stopPropagation() + close(id, api.focus) + }} + onClose={() => close(id, api.focus)} + onCloseOthers={() => closeOthers(id)} + /> + ) + }} + /> +
+ +
+
) } + +export const SubagentPanel: Component = (props) => ( + + + +) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts index db94aa8bee..4ab84f8f20 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts @@ -9,6 +9,7 @@ export interface SubagentTab { interface Options { current: Accessor sync: (id: string, parentID?: string) => void + unsync: (id: string) => void show: () => void hide: () => void } @@ -20,6 +21,7 @@ export function createSubagentTabs(opts: Options) { const open = (id: string, title?: string, parentID?: string) => { if (!id) return const label = title?.trim() || "Sub-agent" + const existing = tabs().some((tab) => tab.id === id) batch(() => { setTabs((prev) => { const existing = prev.find((tab) => tab.id === id) @@ -32,7 +34,7 @@ export function createSubagentTabs(opts: Options) { setActive(id) opts.show() }) - opts.sync(id, parentID ?? opts.current()) + if (!existing) opts.sync(id, parentID ?? opts.current()) } const select = (id: string) => { @@ -46,6 +48,7 @@ export function createSubagentTabs(opts: Options) { const index = current.findIndex((tab) => tab.id === id) if (index < 0) return const next = current.filter((tab) => tab.id !== id) + opts.unsync(id) setTabs(next) if (active() !== id) return const replacement = next[Math.min(index, next.length - 1)] @@ -59,6 +62,9 @@ export function createSubagentTabs(opts: Options) { const closeOthers = (id: string) => { if (!tabs().some((tab) => tab.id === id)) return + for (const tab of tabs()) { + if (tab.id !== id) opts.unsync(tab.id) + } setTabs((prev) => prev.filter((tab) => tab.id === id)) setActive(id) opts.show() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx index dd03d3e796..0dd87709cd 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabMenu.tsx @@ -24,7 +24,7 @@ export const SessionTabMenu: ParentComponent<{ {t("agentManager.tab.forkSession")} - + @@ -34,12 +34,12 @@ export const SessionTabMenu: ParentComponent<{ {t("agentManager.tab.close")} {props.closeShortcut} - - props.onCloseOthers?.()}> - - {t("agentManager.tab.closeOthers")} - - + + + props.onCloseOthers?.()}> + + {t("agentManager.tab.closeOthers")} + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index 8ad09e3807..501b3b8434 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -52,11 +52,18 @@ const TaskToolRenderer: Component = (props) => { }), ) + let synced: string | undefined createEffect(() => { const id = taskVisible(open(), childSessionId()) + if (synced === id) return + if (synced) session.unsyncSession(synced) + synced = id if (!id) return session.syncSession(id) }) + onCleanup(() => { + if (synced) session.unsyncSession(synced) + }) const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 87b388c9e9..e0a490f4e6 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -286,11 +286,13 @@ interface SessionContextValue { clearCurrentSession: () => void loadSessions: () => void loadOlderMessages: () => boolean - selectSession: (id: string) => void + selectSession: (id: string, options?: { focus?: boolean }) => void + releaseSession: (id: string) => void deleteSession: (id: string) => void renameSession: (id: string, title: string) => void exportSessionTranscript: (id: string) => void - syncSession: (sessionID: string, parentSessionID?: string) => void + syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void + unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void // Cloud session preview cloudPreviewId: Accessor @@ -2585,9 +2587,9 @@ export const SessionProvider: ParentComponent = (props) => { // Session whose message fetch was deferred because the backend was offline at // selection time. Replayed by the reconnect effect below. - let deferredFetch: string | undefined + let deferredFetch: { id: string; focus: boolean } | undefined - function selectSession(id: string) { + function selectSession(id: string, options: { focus?: boolean } = {}) { // Cloud preview sessions use a separate keyed path (selectCloudSession). if (id.startsWith("cloud:")) { console.warn("[Kilo New] Cannot select cloud preview session via selectSession") @@ -2608,15 +2610,26 @@ export const SessionProvider: ParentComponent = (props) => { // load message is what re-focuses the backend (focusSession, contextSessionID, // SSE tracking, active worktree) and runs the reconcile self-heal, so skipping // it would leave the extension focused on the previously selected session. + const focus = options.focus !== false if (!server.isConnected()) { - deferredFetch = id + deferredFetch = { id, focus } return } deferredFetch = undefined - loadFocusedMessages(id, ready) + loadFocusedMessages(id, ready, focus) } - function loadFocusedMessages(id: string, ready: boolean) { + function loadFocusedMessages(id: string, ready: boolean, focus = true) { + if (!focus) { + vscode.postMessage({ + type: "loadMessages", + sessionID: id, + mode: "replace", + focus: false, + limit: MESSAGE_PAGE_LIMIT, + }) + return + } vscode.postMessage( ready ? { type: "loadMessages", sessionID: id, mode: "focus" } @@ -2631,10 +2644,10 @@ export const SessionProvider: ParentComponent = (props) => { createEffect( on(server.isConnected, (connected) => { if (!connected) return - const id = deferredFetch + const pending = deferredFetch deferredFetch = undefined - if (!id || id !== currentSessionID()) return - loadFocusedMessages(id, loaded().has(id)) + if (!pending || pending.id !== currentSessionID()) return + loadFocusedMessages(pending.id, loaded().has(pending.id), pending.focus) }), ) @@ -2822,8 +2835,12 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "deleteMessage", sessionID, messageID }) } - function syncSession(sessionID: string, parentSessionID = currentSessionID()) { - vscode.postMessage({ type: "syncSession", sessionID, parentSessionID }) + function syncSession(sessionID: string, parentSessionID = currentSessionID(), scope: "task" | "inspector" = "task") { + vscode.postMessage({ type: "syncSession", sessionID, parentSessionID, scope }) + } + + function unsyncSession(sessionID: string, scope: "task" | "inspector" = "task") { + vscode.postMessage({ type: "unsyncSession", sessionID, scope }) } const todos = () => { @@ -3018,10 +3035,12 @@ export const SessionProvider: ParentComponent = (props) => { loadSessions, loadOlderMessages, selectSession, + releaseSession: handleSessionDeleted, deleteSession, renameSession, exportSessionTranscript, syncSession, + unsyncSession, cloudPreviewId, selectCloudSession, draftSessionID, 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 5192242d78..a93d067e3a 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 @@ -82,6 +82,7 @@ export interface LoadMessagesRequest { type: "loadMessages" sessionID: string mode?: MessageLoadMode + focus?: boolean before?: string limit?: number } @@ -591,6 +592,13 @@ export interface SyncSessionRequest { type: "syncSession" sessionID: string parentSessionID?: string + scope?: "task" | "inspector" +} + +export interface UnsyncSessionRequest { + type: "unsyncSession" + sessionID: string + scope?: "task" | "inspector" } // Agent Manager worktree messages @@ -1488,6 +1496,7 @@ export type WebviewMessage = | ResetReadNotificationsRequest | SettingsTabChangedMessage | SyncSessionRequest + | UnsyncSessionRequest | CreateWorktreeSessionRequest | RequestNotificationsMessage | DismissNotificationMessage From 4279750e0ca04231d8a3045228cde386395fbae4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 15:29:51 +0200 Subject: [PATCH 09/34] test(vscode): update session selection contract --- .../tests/unit/session-select-connection.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/session-select-connection.test.ts b/packages/kilo-vscode/tests/unit/session-select-connection.test.ts index 08798e4a23..7fd6a265e9 100644 --- a/packages/kilo-vscode/tests/unit/session-select-connection.test.ts +++ b/packages/kilo-vscode/tests/unit/session-select-connection.test.ts @@ -50,7 +50,7 @@ describe("selectSession keeps the chat in sync with the selection while offline" // Queue a replay unconditionally. The earlier `deferredFetch = ready ? undefined : id` // form skipped cached sessions, so a reconnect never re-sent the focus load that // re-focuses the backend (focusSession/contextSessionID/SSE tracking/reconcile). - expect(body).toContain("deferredFetch = id") + expect(body).toContain("deferredFetch = { id, focus }") expect(body).not.toMatch(/deferredFetch\s*=\s*ready\s*\?/) }) }) @@ -61,12 +61,15 @@ describe("a deferred fetch is replayed on reconnect", () => { const effect = source.slice(source.indexOf("on(server.isConnected")) expect(effect).toContain("deferredFetch") // Replays with the focus/replace choice so cached sessions still re-focus the backend. - expect(effect).toMatch(/loadFocusedMessages\(\s*id,\s*loaded\(\)\.has\(id\)\s*\)/) + expect(effect).toMatch( + /loadFocusedMessages\(\s*pending\.id,\s*loaded\(\)\.has\(pending\.id\),\s*pending\.focus\s*\)/, + ) }) it("the focused load helper sends focus for cached sessions and replace otherwise", () => { const helper = source.slice(source.indexOf("function loadFocusedMessages(")) expect(helper).toMatch(/mode: "focus"/) expect(helper).toMatch(/mode: "replace"/) + expect(helper).toContain("focus: false") }) }) From 0067dbac39447dc27bd36ee1b88431a709e76744 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 15:39:47 +0200 Subject: [PATCH 10/34] fix(vscode): preserve inspector sync scope --- packages/kilo-vscode/src/KiloProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 57dc4279f6..457bdc5355 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1584,7 +1584,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) return true } - this.inspectorSessionIds.delete(message.sessionID) + if (message.scope === "inspector") this.inspectorSessionIds.delete(message.sessionID) this.releaseChildSession(message.sessionID) return true } From a066d0f983c88785f680b5bc8200db7b8d12c9a8 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 15:42:47 +0200 Subject: [PATCH 11/34] fix(permission): cover tui sandbox escalation prompt --- .../src/components/chat/PermissionDock.tsx | 2 +- .../kilo-vscode/webview-ui/src/i18n/en.ts | 1 + .../src/cli/cmd/run/permission.shared.ts | 2 +- .../test/cli/run/permission.shared.test.ts | 16 ++++++++++ .../tui/src/routes/session/permission.tsx | 32 ++++++++++++++++--- 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 3b1020c3e4..025d833d52 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -130,7 +130,7 @@ export const PermissionDock: Component<{ } const title = () => { - if (sandboxEscalation()) return "Allow Git operation outside the sandbox?" + if (sandboxEscalation()) return language.t("notification.permission.titleSandboxEscalation") const skill = props.request.args?.skill if (skillShell() && typeof skill === "string" && skill.length > 0) // Escape the untrusted skill name so bidi/control chars can't reorder the header text. diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 784b0ef7a3..9d9fc986af 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -270,6 +270,7 @@ export const dict = { "notification.permission.title": "Permission required", "notification.permission.titleSubagent": "Permission required (subagent)", "notification.permission.titleSkillShell": 'Run shell commands from skill "{{skill}}"?', + "notification.permission.titleSandboxEscalation": "Allow Git operation outside the sandbox?", "ui.permission.manageAutoApprove": "Manage Auto-Approve Rules", "ui.permission.doomLoop.prompt": "Potential loop detected for the {{tool}} tool. Continue running?", "ui.permission.doomLoop.rule": "Continue {{tool}} calls", diff --git a/packages/opencode/src/cli/cmd/run/permission.shared.ts b/packages/opencode/src/cli/cmd/run/permission.shared.ts index e2324b8330..cde3960b00 100644 --- a/packages/opencode/src/cli/cmd/run/permission.shared.ts +++ b/packages/opencode/src/cli/cmd/run/permission.shared.ts @@ -102,7 +102,7 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo { const command = text(input.command) return { icon: "!", - title: "Allow Git operation outside the sandbox", + title: "Allow Git operation outside the sandbox", // kilocode_change lines: command ? [`$ ${command}`, "This approval applies to this command only."] : ["This approval applies to this command only."], diff --git a/packages/opencode/test/cli/run/permission.shared.test.ts b/packages/opencode/test/cli/run/permission.shared.test.ts index 75eb76f2ba..843652db9d 100644 --- a/packages/opencode/test/cli/run/permission.shared.test.ts +++ b/packages/opencode/test/cli/run/permission.shared.test.ts @@ -139,6 +139,22 @@ describe("run permission shared", () => { expect(permissionOptions("permission", true)).toEqual(["once", "reject"]) expect(permissionOptions("permission")).toEqual(["once", "always", "reject"]) }) + + test("sandbox escalation shows the command and offers only one-shot approval", () => { + expect( + permissionInfo( + req({ + permission: "sandbox_escalation", + metadata: { command: "git add file.txt && git commit -m test", sandboxEscalation: true }, + }), + ), + ).toEqual({ + icon: "!", + title: "Allow Git operation outside the sandbox", + lines: ["$ git add file.txt && git commit -m test", "This approval applies to this command only."], + }) + expect(permissionOptions("permission", true)).toEqual(["once", "reject"]) + }) // kilocode_change end test("formats always-allow copy for wildcard and explicit patterns", () => { diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 17ad94972f..524c8e7547 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -334,6 +334,27 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? } } + // kilocode_change start - show sandbox escalation details and keep approval one-shot + if (permission === "sandbox_escalation") { + const meta = props.request.metadata ?? {} + const command = normalizeUrls( + typeof data.command === "string" ? data.command : typeof meta.command === "string" ? meta.command : "", + ) + return { + icon: "!", + title: "Allow Git operation outside the sandbox?", + body: ( + + + {"$ " + command} + + This approval applies to this command only. + + ), + } + } + // kilocode_change end + if (permission === "task") { const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown" const desc = typeof data.description === "string" ? data.description : "" @@ -463,11 +484,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? ) // kilocode_change start - skill shell batches are never persisted: only Allow / Reject - const options: Record = props.request.metadata?.["skillShell"] - ? { once: "Allow", reject: "Reject" } - : props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY] - ? { once: "Allow once", reject: "Reject" } - : { once: "Allow once", always: "Allow always", reject: "Reject" } + const options: Record = + props.request.metadata?.["skillShell"] || props.request.metadata?.["sandboxEscalation"] + ? { once: "Allow", reject: "Reject" } + : props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY] + ? { once: "Allow once", reject: "Reject" } + : { once: "Allow once", always: "Allow always", reject: "Reject" } // kilocode_change end const body = ( From d7d3ddaf150fe89c423ae1de00f0b55bc97ec803 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 09:35:57 +0200 Subject: [PATCH 12/34] fix(vscode): complete sandbox escalation translations --- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/br.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/da.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/de.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/es.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/fa.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/it.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/no.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/th.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 1 + 20 files changed, 21 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index ddffd33eeb..6e0e95294d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -265,6 +265,7 @@ export const dict = { "notification.permission.title": "مطلوب إذن", "notification.permission.titleSubagent": "مطلوب إذن (وكيل فرعي)", "notification.permission.titleSkillShell": "هل تريد تشغيل أوامر الصدفة من المهارة «{{skill}}»؟", + "notification.permission.titleSandboxEscalation": "السماح بعملية Git خارج البيئة المعزولة؟", "ui.permission.manageAutoApprove": "إدارة قواعد الموافقة التلقائية", "ui.permission.doomLoop.prompt": "تم اكتشاف حلقة محتملة في أداة {{tool}}. هل تريد متابعة التشغيل؟", "ui.permission.doomLoop.rule": "متابعة استدعاءات {{tool}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 76db76b87c..944cba44a1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -275,6 +275,7 @@ export const dict = { "notification.permission.title": "Permissão necessária", "notification.permission.titleSubagent": "Permissão necessária (subagente)", "notification.permission.titleSkillShell": "Executar comandos de shell da skill “{{skill}}”?", + "notification.permission.titleSandboxEscalation": "Permitir operação do Git fora da sandbox?", "ui.permission.manageAutoApprove": "Gerenciar regras de aprovação automática", "ui.permission.doomLoop.prompt": "Possível loop detectado na ferramenta {{tool}}. Continuar executando?", "ui.permission.doomLoop.rule": "Continuar chamadas de {{tool}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 7f6ad63cfa..6cf93af0f9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -273,6 +273,7 @@ export const dict = { "notification.permission.title": "Potrebna dozvola", "notification.permission.titleSubagent": "Potrebna dozvola (podagent)", "notification.permission.titleSkillShell": "Pokrenuti shell komande iz vještine „{{skill}}”?", + "notification.permission.titleSandboxEscalation": "Dozvoliti Git operaciju izvan sandboxa?", "ui.permission.manageAutoApprove": "Upravljanje pravilima automatskog odobravanja", "ui.permission.doomLoop.prompt": "Otkrivena je moguća petlja za alat {{tool}}. Nastaviti izvršavanje?", "ui.permission.doomLoop.rule": "Nastavi pozive alata {{tool}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 2ce0aca4a1..a05db69f91 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -272,6 +272,7 @@ export const dict = { "notification.permission.title": "Tilladelse påkrævet", "notification.permission.titleSubagent": "Tilladelse påkrævet (underagent)", "notification.permission.titleSkillShell": "Kør shell-kommandoer fra færdigheden „{{skill}}“?", + "notification.permission.titleSandboxEscalation": "Tillad Git-handling uden for sandkassen?", "ui.permission.manageAutoApprove": "Administrer regler for automatisk godkendelse", "ui.permission.doomLoop.prompt": "Der blev registreret en mulig løkke for værktøjet {{tool}}. Fortsæt kørslen?", "ui.permission.doomLoop.rule": "Fortsæt {{tool}}-kald", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 81f34ecb80..31147a2c06 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -281,6 +281,7 @@ export const dict = { "notification.permission.title": "Berechtigung erforderlich", "notification.permission.titleSubagent": "Berechtigung erforderlich (Subagent)", "notification.permission.titleSkillShell": "Shell-Befehle aus dem Skill „{{skill}}“ ausführen?", + "notification.permission.titleSandboxEscalation": "Git-Vorgang außerhalb der Sandbox zulassen?", "ui.permission.manageAutoApprove": "Regeln für automatische Genehmigung verwalten", "ui.permission.doomLoop.prompt": "Potenzielle Schleife beim Tool {{tool}} erkannt. Weiter ausführen?", "ui.permission.doomLoop.rule": "{{tool}}-Aufrufe fortsetzen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 2b20d3ef3c..5c14f2f2f4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -276,6 +276,7 @@ export const dict = { "notification.permission.title": "Permiso requerido", "notification.permission.titleSubagent": "Permiso requerido (subagente)", "notification.permission.titleSkillShell": "¿Ejecutar comandos de shell de la habilidad «{{skill}}»?", + "notification.permission.titleSandboxEscalation": "¿Permitir la operación de Git fuera del entorno aislado?", "ui.permission.manageAutoApprove": "Gestionar reglas de aprobación automática", "ui.permission.doomLoop.prompt": "Se detectó un posible bucle en la herramienta {{tool}}. ¿Continuar ejecutando?", "ui.permission.doomLoop.rule": "Continuar llamadas a {{tool}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index f61da4a910..0667bb741d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -270,6 +270,7 @@ export const dict = { "notification.permission.title": "مجوز لازم است", "notification.permission.titleSubagent": "مجوز مورد نیاز است (زیرعامل)", "notification.permission.titleSkillShell": "دستورهای شل از مهارت «{{skill}}» اجرا شود؟", + "notification.permission.titleSandboxEscalation": "اجازه انجام عملیات Git خارج از sandbox داده شود؟", "ui.permission.manageAutoApprove": "مدیریت قوانین تأیید خودکار", "ui.permission.doomLoop.prompt": "حلقه احتمالی برای ابزار {{tool}} شناسایی شد. ادامه می‌دهید؟", "ui.permission.doomLoop.rule": "ادامه فراخوانی‌های {{tool}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index c5f2b49fef..964ae64068 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -275,6 +275,7 @@ export const dict = { "notification.permission.title": "Permission requise", "notification.permission.titleSubagent": "Permission requise (sous-agent)", "notification.permission.titleSkillShell": "Exécuter les commandes shell de la compétence «\u00a0{{skill}}\u00a0» ?", + "notification.permission.titleSandboxEscalation": "Autoriser l’opération Git en dehors du bac à sable ?", "ui.permission.manageAutoApprove": "Gérer les règles d'approbation automatique", "ui.permission.doomLoop.prompt": "Boucle potentielle détectée pour l’outil {{tool}}. Continuer l’exécution ?", "ui.permission.doomLoop.rule": "Continuer les appels à {{tool}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index a83f6ec78b..447725f13c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -187,6 +187,7 @@ export const dict = { "notification.permission.title": "Autorizzazione richiesta", "notification.permission.titleSubagent": "Autorizzazione richiesta (sub-agent)", "notification.permission.titleSkillShell": "Eseguire i comandi shell della skill “{{skill}}”?", + "notification.permission.titleSandboxEscalation": "Consentire l'operazione Git al di fuori della sandbox?", "ui.permission.manageAutoApprove": "Gestisci regole approvazione automatica", "ui.permission.doomLoop.prompt": "Rilevato un potenziale ciclo nello strumento {{tool}}. Continuare l'esecuzione?", "ui.permission.doomLoop.rule": "Continua le chiamate a {{tool}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index d501bffca1..ed7de9a899 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -272,6 +272,7 @@ export const dict = { "notification.permission.title": "権限が必要です", "notification.permission.titleSubagent": "権限が必要です(サブエージェント)", "notification.permission.titleSkillShell": "スキル「{{skill}}」のシェルコマンドを実行しますか?", + "notification.permission.titleSandboxEscalation": "サンドボックス外での Git 操作を許可しますか?", "ui.permission.manageAutoApprove": "自動承認ルールを管理", "ui.permission.doomLoop.prompt": "{{tool}} ツールでループの可能性が検出されました。実行を続行しますか?", "ui.permission.doomLoop.rule": "{{tool}} の呼び出しを続行", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index ec6e36518d..f4c7d5847f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -273,6 +273,7 @@ export const dict = { "notification.permission.title": "권한 필요", "notification.permission.titleSubagent": "권한 필요 (서브에이전트)", "notification.permission.titleSkillShell": '스킬 "{{skill}}"의 셸 명령을 실행할까요?', + "notification.permission.titleSandboxEscalation": "샌드박스 외부에서 Git 작업을 허용할까요?", "ui.permission.manageAutoApprove": "자동 승인 규칙 관리", "ui.permission.doomLoop.prompt": "{{tool}} 도구에서 잠재적인 반복 실행이 감지되었습니다. 계속 실행하시겠습니까?", "ui.permission.doomLoop.rule": "{{tool}} 호출 계속", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index ee80c03158..fb6851d6fe 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -276,6 +276,7 @@ export const dict = { "notification.permission.title": "Toestemming vereist", "notification.permission.titleSubagent": "Toestemming vereist (subagent)", "notification.permission.titleSkillShell": "Shell-opdrachten uit vaardigheid “{{skill}}” uitvoeren?", + "notification.permission.titleSandboxEscalation": "Git-bewerking buiten de sandbox toestaan?", "ui.permission.manageAutoApprove": "Beheer automatisch goedkeuren regels", "ui.permission.doomLoop.prompt": "Mogelijke lus gedetecteerd voor het hulpmiddel {{tool}}. Doorgaan met uitvoeren?", "ui.permission.doomLoop.rule": "Doorgaan met {{tool}}-aanroepen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 5d09c50ecc..7014ffc988 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -279,6 +279,7 @@ export const dict = { "notification.permission.title": "Tillatelse påkrevd", "notification.permission.titleSubagent": "Tillatelse påkrevd (underagent)", "notification.permission.titleSkillShell": "Kjøre skallkommandoer fra ferdigheten «{{skill}}»?", + "notification.permission.titleSandboxEscalation": "Tillate Git-operasjon utenfor sandkassen?", "ui.permission.manageAutoApprove": "Administrer regler for automatisk godkjenning", "ui.permission.doomLoop.prompt": "Mulig løkke oppdaget for verktøyet {{tool}}. Fortsette kjøringen?", "ui.permission.doomLoop.rule": "Fortsett {{tool}}-kall", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index d46f87dd1b..4fea6ceacf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -273,6 +273,7 @@ export const dict = { "notification.permission.title": "Wymagane uprawnienie", "notification.permission.titleSubagent": "Wymagane uprawnienie (podagent)", "notification.permission.titleSkillShell": "Uruchomić polecenia powłoki z umiejętności „{{skill}}”?", + "notification.permission.titleSandboxEscalation": "Zezwolić na operację Git poza piaskownicą?", "ui.permission.manageAutoApprove": "Zarządzaj regułami automatycznego zatwierdzania", "ui.permission.doomLoop.prompt": "Wykryto potencjalną pętlę dla narzędzia {{tool}}. Kontynuować działanie?", "ui.permission.doomLoop.rule": "Kontynuuj wywołania {{tool}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 068fa11fec..7c4fcd02c3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -270,6 +270,7 @@ export const dict = { "notification.permission.title": "Требуется разрешение", "notification.permission.titleSubagent": "Требуется разрешение (субагент)", "notification.permission.titleSkillShell": "Выполнить команды оболочки из навыка «{{skill}}»?", + "notification.permission.titleSandboxEscalation": "Разрешить операцию Git за пределами песочницы?", "ui.permission.manageAutoApprove": "Управление правилами автоодобрения", "ui.permission.doomLoop.prompt": "Обнаружен потенциальный цикл при работе инструмента {{tool}}. Продолжить выполнение?", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index a7114cc672..a203079a8f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -270,6 +270,7 @@ export const dict = { "notification.permission.title": "ต้องการสิทธิ์", "notification.permission.titleSubagent": "ต้องการสิทธิ์ (ตัวแทนย่อย)", "notification.permission.titleSkillShell": 'เรียกใช้คำสั่งเชลล์จากสกิล "{{skill}}" หรือไม่?', + "notification.permission.titleSandboxEscalation": "อนุญาตการดำเนินการ Git นอกแซนด์บ็อกซ์หรือไม่?", "ui.permission.manageAutoApprove": "จัดการกฎการอนุมัติอัตโนมัติ", "ui.permission.doomLoop.prompt": "ตรวจพบการวนซ้ำที่อาจเกิดขึ้นในเครื่องมือ {{tool}} ต้องการดำเนินการต่อหรือไม่", "ui.permission.doomLoop.rule": "เรียกใช้ {{tool}} ต่อไป", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 9532e801a7..d8e013e70a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -271,6 +271,8 @@ export const dict = { "notification.permission.title": "İzin gerekli", "notification.permission.titleSubagent": "İzin gerekli (alt ajan)", "notification.permission.titleSkillShell": "“{{skill}}” becerisindeki kabuk komutları çalıştırılsın mı?", + "notification.permission.titleSandboxEscalation": + "Git işleminin korumalı alan dışında gerçekleştirilmesine izin verilsin mi?", "ui.permission.manageAutoApprove": "Otomatik Onay Kurallarını Yönet", "ui.permission.doomLoop.prompt": "{{tool}} aracında olası bir döngü algılandı. Çalıştırmaya devam edilsin mi?", "ui.permission.doomLoop.rule": "{{tool}} çağrılarına devam et", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index e570d6ceb0..5b631ac1e8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -274,6 +274,7 @@ export const dict = { "notification.permission.title": "Потрібен дозвіл", "notification.permission.titleSubagent": "Потрібен дозвіл (підагент)", "notification.permission.titleSkillShell": "Виконати команди оболонки з навички «{{skill}}»?", + "notification.permission.titleSandboxEscalation": "Дозволити операцію Git за межами пісочниці?", "ui.permission.manageAutoApprove": "Керувати правилами автоматичного схвалення", "ui.permission.doomLoop.prompt": "Виявлено потенційний цикл під час роботи інструмента {{tool}}. Продовжити виконання?", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 0d85a61606..95adff6d8a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -260,6 +260,7 @@ export const dict = { "notification.permission.title": "需要权限", "notification.permission.titleSubagent": "需要权限(子代理)", "notification.permission.titleSkillShell": "要执行技能「{{skill}}」的 shell 命令吗?", + "notification.permission.titleSandboxEscalation": "要允许在沙盒外执行 Git 操作吗?", "ui.permission.manageAutoApprove": "管理自动审批规则", "ui.permission.doomLoop.prompt": "检测到 {{tool}} 工具可能陷入循环。是否继续运行?", "ui.permission.doomLoop.rule": "继续调用 {{tool}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index acb40a48f8..49b1087e0b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -258,6 +258,7 @@ export const dict = { "notification.permission.title": "需要權限", "notification.permission.titleSubagent": "需要權限(子代理)", "notification.permission.titleSkillShell": "要執行技能「{{skill}}」的 shell 指令嗎?", + "notification.permission.titleSandboxEscalation": "要允許在沙盒外執行 Git 操作嗎?", "ui.permission.manageAutoApprove": "管理自動核准規則", "ui.permission.doomLoop.prompt": "偵測到 {{tool}} 工具可能陷入迴圈。是否繼續執行?", "ui.permission.doomLoop.rule": "繼續呼叫 {{tool}}", From 923cf79ee0727595e0dd5c47c7cebc480ee06807 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 09:54:14 +0200 Subject: [PATCH 13/34] fix(cli): clean truncation files by mtime --- packages/opencode/src/tool/truncate.ts | 14 ++++++++------ packages/opencode/test/tool/truncation.test.ts | 10 +++++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 3a48c90a98..c00ba1f881 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -6,7 +6,6 @@ import type { Agent } from "../agent/agent" import { FSUtil } from "@opencode-ai/core/fs-util" import { evaluate } from "@/permission/evaluate" import { Config } from "@/config/config" -import { Identifier } from "../id/id" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" @@ -52,17 +51,20 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const cleanup = Effect.fn("Truncate.cleanup")(function* () { - const cutoff = Identifier.timestamp( - Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)), - ) + // kilocode_change start - use file mtimes because encoded IDs wrap + const cutoff = Date.now() - Duration.toMillis(RETENTION) const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), Effect.catch(() => Effect.succeed([])), ) for (const entry of entries) { - if (Identifier.timestamp(entry) >= cutoff) continue - yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) + const file = path.join(TRUNCATION_DIR, entry) + const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + const mtime = info && Option.getOrUndefined(info.mtime) + if (!mtime || mtime.getTime() >= cutoff) continue + yield* fs.remove(file).pipe(Effect.catch(() => Effect.void)) } + // kilocode_change end }) const write = Effect.fn("Truncate.write")(function* (text: string) { diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index d575a58ffa..ea9092f7ce 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -242,18 +242,22 @@ describe("Truncate", () => { describe("cleanup", () => { const DAY_MS = 24 * 60 * 60 * 1000 - it.live("deletes files older than 7 days and preserves recent files", () => + // kilocode_change start - use IDs across the timestamp wrap and set file times explicitly + it.live("uses file mtime when IDs wrap", () => Effect.gen(function* () { const svc = yield* Truncate.Service const fs = yield* FileSystem.FileSystem yield* fs.makeDirectory(Truncate.DIR, { recursive: true }) - const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 10 * DAY_MS)) - const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 3 * DAY_MS)) + const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", 2 ** 36 - 1)) + const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", 2 ** 36 + 1)) yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(recent, "recent content") + yield* fs.utimes(old, new Date(), new Date(Date.now() - 10 * DAY_MS)) + yield* fs.utimes(recent, new Date(), new Date(Date.now() - 3 * DAY_MS)) + // kilocode_change end yield* svc.cleanup() expect(yield* fs.exists(old)).toBe(false) From f54c215e6ab2f22e055790ccc4a2d122992dba48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=A8=E5=8D=93?= Date: Tue, 18 Aug 2026 15:58:03 +0800 Subject: [PATCH 14/34] fix(cli): persist snapshot disable across restarts The slow-repo prompt wrote snapshot:false outside the Effect fiber, so the project config was skipped and snapshots came back after a VS Code restart. Co-authored-by: Cursor --- .changeset/persist-snapshot-disable.md | 5 +++ .../opencode/src/kilocode/snapshot/track.ts | 31 ++++++-------- .../kilocode/snapshot-track-timeout.test.ts | 42 +++++++++++++++++++ 3 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 .changeset/persist-snapshot-disable.md diff --git a/.changeset/persist-snapshot-disable.md b/.changeset/persist-snapshot-disable.md new file mode 100644 index 0000000000..f433da002e --- /dev/null +++ b/.changeset/persist-snapshot-disable.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Persist disabling snapshots from the slow-repo prompt across restarts. diff --git a/packages/opencode/src/kilocode/snapshot/track.ts b/packages/opencode/src/kilocode/snapshot/track.ts index 4e69a32047..5fcb2a62c6 100644 --- a/packages/opencode/src/kilocode/snapshot/track.ts +++ b/packages/opencode/src/kilocode/snapshot/track.ts @@ -49,9 +49,11 @@ 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 { capture } from "@/kilocode/instance" import { ConfigParse } from "@/config/parse" import * as Log from "@opencode-ai/core/util/log" import { iife } from "@/util/iife" +import { EffectBridge } from "@/effect/bridge" import { makeRuntime } from "@/effect/run-service" import type { Config } from "@/config/config" // Avoid an eager `import { Session }` here: session/index.ts indirectly @@ -473,7 +475,9 @@ export namespace KiloSnapshotTrack { if (answer === "disable") { log.info("user chose to disable snapshot for this project") - yield* Effect.promise(() => + // Restore instance context across the Promise boundary; Effect.promise + // drops it, and persistDisable needs the project directory. + yield* EffectBridge.fromPromise(() => hooks.persistDisable().catch((err) => { log.error("failed to persist snapshot:false to project config", { err }) }), @@ -631,8 +635,11 @@ export namespace KiloSnapshotTrack { }, async persistDisable() { - const directory = await currentDirectory() - if (!directory) return + const ctx = capture() + if (!ctx) { + log.error("persistDisable: no instance directory; snapshot:false was not written to project config") + return + } // Every field on Config.Info is Schema.optional(...), so a single-key // object is structurally a valid Config.Info — no cast needed. const patch: Config.Info = { snapshot: false } @@ -640,8 +647,8 @@ export namespace KiloSnapshotTrack { Effect.gen(function* () { yield* KilocodeConfig.updateProjectConfig({ fs, - directory: directory.directory, - worktree: directory.worktree, + directory: ctx.directory, + worktree: ctx.worktree, config: patch, read: (file) => fs.readFileString(file).pipe( @@ -681,18 +688,4 @@ export namespace KiloSnapshotTrack { return applyEdits(out, edits) }, input) } - - /** - * Resolve the active instance directory/worktree. Runs via `Instance.current` - * when available; returns undefined outside of an instance context (e.g. in - * tests that bypass the runtime). - */ - async function currentDirectory(): Promise<{ directory: string; worktree?: string } | undefined> { - const { Instance } = await import("@/kilocode/instance") - try { - return { directory: Instance.directory, worktree: Instance.worktree } - } catch { - return undefined - } - } } diff --git a/packages/opencode/test/kilocode/snapshot-track-timeout.test.ts b/packages/opencode/test/kilocode/snapshot-track-timeout.test.ts index b18f486fd3..e2a410a101 100644 --- a/packages/opencode/test/kilocode/snapshot-track-timeout.test.ts +++ b/packages/opencode/test/kilocode/snapshot-track-timeout.test.ts @@ -7,9 +7,11 @@ import { describe, expect, test } from "bun:test" import { Deferred, Duration, Effect, Fiber } from "effect" import * as TestClock from "effect/testing/TestClock" +import path from "path" import { PartID, type MessageID, type SessionID } from "../../src/session/schema" import { KiloSnapshotTrack } from "../../src/kilocode/snapshot/track" import { KiloPartLifecycle } from "../../src/kilocode/session/part-lifecycle" +import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, it } from "../lib/effect" const SESSION = "ses_test" as SessionID @@ -949,6 +951,46 @@ describe("KiloSnapshotTrack progress indicator", () => { }) }) +describe("KiloSnapshotTrack persistDisable", () => { + it.instance( + "disable writes snapshot:false to the project config", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const state = KiloSnapshotTrack.makeState() + const hooks: KiloSnapshotTrack.Hooks = { + ...KiloSnapshotTrack.defaultHooks, + async ask() { + return "disable" + }, + async startProgress() {}, + async updateProgress() {}, + async endProgress() {}, + } + + yield* KiloSnapshotTrack.wrap({ + inner: hangInner(), + state, + sessionID: SESSION, + messageID: MESSAGE, + hooks, + timeoutMs: 10, + progressDelayMs: 2, + }) + + const file = path.join(test.directory, ".kilo", "kilo.jsonc") + const text = yield* Effect.tryPromise(() => Bun.file(file).text()) + expect(JSON.parse(text).snapshot).toBe(false) + expect(state.disabledForSession).toBe(true) + }), + { git: true }, + ) + + test("persistDisable without instance context does not throw", async () => { + await KiloSnapshotTrack.defaultHooks.persistDisable() + }) +}) + describe("KiloSnapshotTrack constants", () => { test("TIMEOUT_MS defaults to 10s and respects env override", () => { // The constant is evaluated once at module load, so we can only assert From af158e6156f6906e6e34846afe9bbe6e7679f246 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 10:44:10 +0200 Subject: [PATCH 15/34] fix(agent-manager): ignore subagent sessions in worktree labels --- .changeset/quiet-worktrees-filter.md | 5 ++++ .../src/agent-manager/AgentManagerProvider.ts | 1 + .../src/agent-manager/project/init.ts | 1 + .../tests/unit/agent-project-sessions.test.ts | 12 +++++++++ .../tests/unit/project-session-filter.test.ts | 26 +++++++++++++++++++ .../agent-manager/ProjectSidebarBody.tsx | 4 +-- .../agent-manager/project/session-filter.ts | 6 +++++ 7 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-worktrees-filter.md create mode 100644 packages/kilo-vscode/tests/unit/project-session-filter.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/project/session-filter.ts diff --git a/.changeset/quiet-worktrees-filter.md b/.changeset/quiet-worktrees-filter.md new file mode 100644 index 0000000000..5e4763e59b --- /dev/null +++ b/.changeset/quiet-worktrees-filter.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent subagent descriptions from appearing as Agent Manager worktree titles. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 1214cb7f13..79bf119331 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -321,6 +321,7 @@ export class AgentManagerProvider implements Disposable { // throw here would escape into the SSE dispatch loop and starve the other // listeners (there is no per-listener error isolation). if (!info?.time || !dir) return + if (info.parentID !== undefined && info.parentID !== null) return const ctx = this.contexts.byDirectory(dir) if (!ctx || ctx.lifecycle !== "ready") return const state = ctx.peekState() diff --git a/packages/kilo-vscode/src/agent-manager/project/init.ts b/packages/kilo-vscode/src/agent-manager/project/init.ts index fc428c3a85..307dfc34e4 100644 --- a/packages/kilo-vscode/src/agent-manager/project/init.ts +++ b/packages/kilo-vscode/src/agent-manager/project/init.ts @@ -182,6 +182,7 @@ export async function collectProjectSessions( const out: ProjectSessionView[] = [] for (const { dir, worktreeId, items } of byDir) { for (const s of items) { + if (s.parentID !== undefined && s.parentID !== null) continue if (seen.has(s.id)) continue seen.add(s.id) sessions.setSessionDirectory(s.id, dir) diff --git a/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts b/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts index 7bab778417..6717d6de13 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts @@ -148,6 +148,18 @@ describe("Agent Manager per-project session discovery", () => { expect(out.find((s) => s.id === "ses-root")?.title).toBe("Session ses-root") }) + it("does not expose child sessions as project sidebar sessions", async () => { + const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } + const ctx = makeContext(ROOT, fakeState([wt])) + const rootSession = mkSession("ses-root", WT_PATH) + const child = { ...mkSession("ses-child", WT_PATH), parentID: rootSession.id } + const { listing } = recordingListing({ [ROOT]: [], [WT_PATH]: [child, rootSession] }) + + const out = await collectProjectSessions(ctx, listing) + + expect(out.map((s) => s.id)).toEqual(["ses-root"]) + }) + it("does not list or include sessions from unrelated-project directories", async () => { const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" } const ctx = makeContext(ROOT, fakeState([wt])) diff --git a/packages/kilo-vscode/tests/unit/project-session-filter.test.ts b/packages/kilo-vscode/tests/unit/project-session-filter.test.ts new file mode 100644 index 0000000000..4289e99a22 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-session-filter.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test" +import { rootSessions } from "../../webview-ui/agent-manager/project/session-filter" +import type { ProjectSessionInfo } from "../../webview-ui/src/types/messages" + +const session = (id: string, worktreeId: string | null, parentID: string | null): ProjectSessionInfo => ({ + id, + worktreeId, + parentID, + title: id, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}) + +describe("rootSessions", () => { + it("ignores child sessions when selecting a worktree label", () => { + const sessions = [session("child", "wt-1", "root"), session("root", "wt-1", null), session("other", "wt-2", null)] + + expect(rootSessions(sessions, "wt-1").map((item) => item.id)).toEqual(["root"]) + }) + + it("filters subagents from the local session list too", () => { + const sessions = [session("child", null, "root"), session("root", null, null)] + + expect(rootSessions(sessions, null).map((item) => item.id)).toEqual(["root"]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index 2c56d7fc0a..4908e2616e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -33,6 +33,7 @@ import { ConstrainDragXAxis } from "./constrain-drag-x" import { createProjectStore, type ProjectStore } from "./project/store" import { randomColor } from "./section-colors" import { projectSidebarOrder, projectWorktreeRow } from "./project-local-navigation" +import { rootSessions } from "./project/session-filter" const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) @@ -94,8 +95,7 @@ export const ProjectSidebarBody: Component = (props) => { pendingTimer = setTimeout(() => setPending(undefined), 2500) } const state = () => props.state - const sessions = (worktreeId: string | null) => - (props.sessions ?? []).filter((item) => item.worktreeId === worktreeId) + const sessions = (worktreeId: string | null) => rootSessions(props.sessions ?? [], worktreeId) const active = () => props.selectedProject === props.project.id const runs = () => store.runStatuses() const sections = () => store.sections() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/session-filter.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/session-filter.ts new file mode 100644 index 0000000000..776244e3be --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/session-filter.ts @@ -0,0 +1,6 @@ +import type { ProjectSessionInfo } from "../../src/types/messages" +import { isKnownRootSession } from "../navigate" + +export function rootSessions(sessions: ProjectSessionInfo[], worktreeId: string | null): ProjectSessionInfo[] { + return sessions.filter((session) => session.worktreeId === worktreeId && isKnownRootSession(session)) +} From b4c83878e94be07d2f7b9f7c219635ee76792ee0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 10:54:17 +0200 Subject: [PATCH 16/34] fix(agent-manager): use explicit git fetch refspecs --- .changeset/fix-pr-branch-refspec.md | 5 ++ .../src/agent-manager/WorktreeManager.ts | 21 ++++++-- .../tests/unit/worktree-manager.test.ts | 53 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-pr-branch-refspec.md diff --git a/.changeset/fix-pr-branch-refspec.md b/.changeset/fix-pr-branch-refspec.md new file mode 100644 index 0000000000..abc1e27f9d --- /dev/null +++ b/.changeset/fix-pr-branch-refspec.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Agent Manager PR and base-branch worktrees when a repository uses a restrictive Git fetch refspec. diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index a28e157a4e..a67bacaa74 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -839,6 +839,8 @@ export class WorktreeManager { private async refreshBase(branch: string, requested?: string): Promise { const remote = requested ?? (await this.resolveRemote()) if (!remote) return + validateGitRef(remote, "remote") + validateGitRef(branch, "branch") const key = `${this.root}:${remote}:${branch}` const cached = WorktreeManager.fetchCache.get(key) if (cached && Date.now() - cached < WorktreeManager.FETCH_CACHE_TTL) return @@ -849,7 +851,7 @@ export class WorktreeManager { const env = nonInteractiveEnv() await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } }) .env(env) - .fetch(remote, branch, { "--quiet": null, "--no-tags": null }) + .raw(["fetch", "--quiet", "--no-tags", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]) WorktreeManager.fetchCache.set(key, Date.now()) } @@ -1107,10 +1109,17 @@ export class WorktreeManager { if (!remotes.some((r) => r.name === forkOwner)) { await this.git.addRemote(forkOwner, `https://github.com/${forkOwner}/${parsed.repo}.git`) } - await this.gitExec(["fetch", forkOwner, info.headRefName]) + await this.gitExec([ + "fetch", + "--quiet", + "--no-tags", + forkOwner, + `+refs/heads/${info.headRefName}:refs/remotes/${forkOwner}/${info.headRefName}`, + ]) } else { validateGitRef(info.headRefName, "branch name") - const ok = await this.gitTry(["fetch", "origin", info.headRefName]) + const ref = `+refs/heads/${info.headRefName}:refs/remotes/origin/${info.headRefName}` + const ok = await this.gitTry(["fetch", "--quiet", "--no-tags", "origin", ref]) if (!ok) { await this.gitExec([ "fetch", @@ -1118,6 +1127,12 @@ export class WorktreeManager { `+refs/pull/${parsed.number}/head:refs/remotes/origin/${info.headRefName}`, ]) } + if (!(await this.gitTry(["show-ref", "--verify", "--quiet", `refs/heads/${info.headRefName}`]))) { + const ref = `refs/remotes/origin/${info.headRefName}` + await this.gitExec(["branch", info.headRefName, ref]) + await this.gitExec(["config", `branch.${info.headRefName}.remote`, "origin"]) + await this.gitExec(["config", `branch.${info.headRefName}.merge`, `refs/heads/${info.headRefName}`]) + } } } diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 67174fb21a..60b65bbd6f 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -11,6 +11,7 @@ import { versionedName, } from "../../src/agent-manager/branch-name" import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" +import type { PRInfo } from "../../src/agent-manager/git-import" import simpleGit from "simple-git" // Each test gets its own temp directory -- no shared state, safe to run in parallel. @@ -1138,6 +1139,58 @@ describe("WorktreeManager.createWorktree advanced", () => { const devParams = await git.log(["-1"]) expect(headParams.latest?.hash).toBe(devParams.latest?.hash) }) + + it("creates from a base branch excluded by the remote fetch refspec", async () => { + const { clone } = await createTempRepoWithOrigin() + const git = simpleGit(clone) + await git.checkoutLocalBranch("topic") + await fs.writeFile(path.join(clone, "topic.txt"), "topic") + await git.add(".") + await git.commit("topic commit") + await git.push("origin", "topic") + await git.checkout("main") + + await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"]) + await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"]) + + const result = await createManager(clone).createWorktree({ baseBranch: "topic", prompt: "from topic" }) + const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim() + const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim() + + expect(worktreeHead).toBe(remoteHead) + expect(result.parentBranch).toBe("topic") + }) + + it("creates from a same-repository PR branch excluded by the remote fetch refspec", async () => { + const { clone } = await createTempRepoWithOrigin() + const git = simpleGit(clone) + await git.checkoutLocalBranch("topic") + await fs.writeFile(path.join(clone, "topic.txt"), "topic") + await git.add(".") + await git.commit("topic commit") + await git.push("origin", "topic") + await git.checkout("main") + await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"]) + await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"]) + await git.branch(["-D", "topic"]) + + const manager = createManager(clone) + const internal = manager as unknown as { + fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise + } + internal.fetchPRInfo = async () => ({ + headRefName: "topic", + isCrossRepository: false, + title: "Topic PR", + }) + + const result = await manager.createFromPR("https://github.com/org/repo/pull/1") + const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim() + const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim() + + expect(worktreeHead).toBe(remoteHead) + expect(result.parentBranch).toBe("topic") + }) }) // --------------------------------------------------------------------------- From 35e4dadd1e98731f5a6fd591dc69501c0a29d1c0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 10:55:02 +0200 Subject: [PATCH 17/34] fix(agent-manager): preserve sparse root sessions --- packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts | 3 +-- .../webview-ui/src/stories/agent-manager.stories.tsx | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 79bf119331..86e581263f 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -320,8 +320,7 @@ export class AgentManagerProvider implements Disposable { // Session events from sync or older backends can lack time/directory; a // throw here would escape into the SSE dispatch loop and starve the other // listeners (there is no per-listener error isolation). - if (!info?.time || !dir) return - if (info.parentID !== undefined && info.parentID !== null) return + if (!info?.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return const ctx = this.contexts.byDirectory(dir) if (!ctx || ctx.lifecycle !== "ready") return const state = ctx.peekState() diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 817310e1bb..7c1e92d125 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -1389,6 +1389,7 @@ const projectSession = ( ): ProjectSessionInfo => ({ id, worktreeId, + parentID: null, title, createdAt: "2026-07-19T09:00:00Z", updatedAt, From db0784bd755fb2c3b30b3c0ba0f1763120887f56 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 10:55:33 +0200 Subject: [PATCH 18/34] fix(agent-manager): make tool requests strict --- .../strict-agent-manager-tool-requests.md | 5 + .../src/kilocode/tool/agent-manager.ts | 79 ++++++++------- packages/opencode/src/tool/json-schema.ts | 54 +++++++---- .../test/kilocode/agent-manager-tool.test.ts | 96 ++++++++++++------- 4 files changed, 151 insertions(+), 83 deletions(-) create mode 100644 .changeset/strict-agent-manager-tool-requests.md diff --git a/.changeset/strict-agent-manager-tool-requests.md b/.changeset/strict-agent-manager-tool-requests.md new file mode 100644 index 0000000000..0b7b3cbd78 --- /dev/null +++ b/.changeset/strict-agent-manager-tool-requests.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Make Agent Manager tool requests use a strict operation union so starting sessions and managing existing sessions cannot be confused. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 8c4104e25e..7b2487a32d 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -14,6 +14,18 @@ import { Effect, Schema } from "effect" import { matchesQuery } from "./model-search" import DESCRIPTION from "./agent-manager.txt" +function strict(fields: Fields) { + const target = Schema.Struct(fields) + // Preserve unknown keys long enough for the branch check to reject mixed operations. + const source = Schema.StructWithRest(target, [Schema.Record(Schema.String, Schema.Unknown)]).check( + Schema.makeFilter((value) => { + const extra = Object.keys(value).find((key) => !Object.hasOwn(fields, key)) + return extra === undefined ? undefined : `Unexpected Agent Manager parameter: ${extra}` + }), + ) + return source.pipe(Schema.decodeTo(target)) +} + const Task = Schema.Struct({ prompt: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Initial prompt to send to the new session", @@ -46,7 +58,32 @@ const Task = Schema.Struct({ ), ) -const StartParams = Schema.Struct({ +function wireSchema() { + const schema = ToolJsonSchema.fromSchema(Params, { additionalProperties: false }) + + // llama.cpp rejects the prefix-only SessionID pattern. Keep the runtime brand + // check, but omit that provider-incompatible hint from the advertised schema. + function strip(value: unknown): void { + if (Array.isArray(value)) { + value.forEach(strip) + return + } + if (!value || typeof value !== "object") return + const item = value as Record + if (item.properties && typeof item.properties === "object") { + const properties = item.properties as Record + if (properties.sessionID && typeof properties.sessionID === "object") { + delete (properties.sessionID as Record).pattern + } + } + Object.values(item).forEach(strip) + } + + strip(schema) + return schema +} + +const StartParams = strict({ mode: Schema.Literals(["worktree", "local"]).annotate({ description: "Use worktree for isolated git worktrees, or local for same-directory Agent Manager sessions", }), @@ -59,14 +96,14 @@ const StartParams = Schema.Struct({ .annotate({ description: "Agent Manager sessions to start" }), }) -const ListParams = Schema.Struct({ +const ListParams = strict({ action: Schema.Literal("list").annotate({ description: "Read the current Agent Manager sections, worktrees, and sessions before any assignment. This is the source of truth for section and session IDs.", }), filter: Schema.optional( Schema.NullOr( - Schema.Struct({ + strict({ sectionIDs: Schema.optional(Schema.Array(Schema.String).check(Schema.isMaxLength(100))), states: Schema.optional( Schema.Array(Schema.Literals(["idle", "busy", "retry", "offline", "waiting"])).check(Schema.isMaxLength(5)), @@ -78,7 +115,7 @@ const ListParams = Schema.Struct({ }), }) -const PromptParams = Schema.Struct({ +const PromptParams = strict({ action: Schema.Literal("prompt"), sessionID: SessionID, prompt: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100_000)).check( @@ -86,12 +123,12 @@ const PromptParams = Schema.Struct({ ), }) -const StopParams = Schema.Struct({ +const StopParams = strict({ action: Schema.Literal("stop"), sessionID: SessionID, }) -const MoveParams = Schema.Struct({ +const MoveParams = strict({ action: Schema.Literal("move").annotate({ description: "Move exactly one managed worktree by targeting one of its session IDs returned by action=list.", }), @@ -103,25 +140,7 @@ const MoveParams = Schema.Struct({ }), }) -export const Params = Schema.Union([StartParams, ListParams, PromptParams, StopParams, MoveParams]) - -const WireParams = Schema.Struct({ - mode: Schema.optional(StartParams.fields.mode), - versions: Schema.optional(StartParams.fields.versions), - tasks: Schema.optional(StartParams.fields.tasks), - action: Schema.optional( - Schema.Literals(["list", "prompt", "stop", "move"]).annotate({ - description: - "Use list first to discover IDs and assignments. Use move only after list, once per worktree. Never edit .kilo/agent-manager.json for these operations.", - }), - ), - filter: Schema.optional(ListParams.fields.filter), - sessionID: Schema.optional( - Schema.String.annotate({ description: "For move, use a session ID returned by action=list (IDs start with ses_)." }), - ), - prompt: Schema.optional(PromptParams.fields.prompt), - sectionID: Schema.optional(MoveParams.fields.sectionID), -}) +export const Params = Schema.Union([StartParams, ListParams, PromptParams, MoveParams, StopParams]) type Input = Schema.Schema.Type type Selected = { task?: AgentManagerTask; error?: string } @@ -281,18 +300,10 @@ export const AgentManagerTool = Tool.define< const bus = yield* Bus.Service const host = yield* AgentManager.Service const provider = yield* Provider.Service - const wire = ToolJsonSchema.fromSchema(WireParams) - const section = wire.properties?.sectionID - if (section && typeof section === "object" && wire.properties) { - wire.properties.sectionID = { - anyOf: [{ type: "string", minLength: 1 }, { type: "null" }], - description: "Section ID returned by action=list. Use null to unassign the worktree from its current section.", - } - } return { description: DESCRIPTION, parameters: Params, - jsonSchema: wire, + jsonSchema: wireSchema(), execute: (params, ctx) => Effect.gen(function* () { if ("action" in params) { diff --git a/packages/opencode/src/tool/json-schema.ts b/packages/opencode/src/tool/json-schema.ts index edb43e11ca..c789a94bab 100644 --- a/packages/opencode/src/tool/json-schema.ts +++ b/packages/opencode/src/tool/json-schema.ts @@ -5,28 +5,36 @@ import type * as Tool from "./tool" type JsonObject = Record const cache = new WeakMap() -export function fromSchema(schema: Schema.Top): JSONSchema7 { - const cached = cache.get(schema) +// kilocode_change start - allow Kilo-owned tools to advertise strict object branches +export function fromSchema(schema: Schema.Top, options: { additionalProperties?: boolean } = {}): JSONSchema7 { + const cached = options.additionalProperties === undefined ? cache.get(schema) : undefined if (cached) return cached - const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true }) - const result = normalize({ - $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, - ...document.schema, - ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}), + const document = Schema.toJsonSchemaDocument(schema, { + additionalProperties: options.additionalProperties ?? true, }) + const result = normalize( + { + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + ...document.schema, + ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}), + }, + options, + ) const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result)) if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value") - cache.set(schema, inlined) + if (options.additionalProperties === undefined) cache.set(schema, inlined) return inlined } +// kilocode_change end export function fromTool(tool: Tool.Def): JSONSchema7 { return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top) } -function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown { - if (Array.isArray(value)) return value.map((item) => normalize(item)) +// kilocode_change start - propagate strict object schema options through unions +function normalize(value: unknown, options: { stripNull?: boolean; additionalProperties?: boolean } = {}): unknown { + if (Array.isArray(value)) return value.map((item) => normalize(item, options)) if (!isRecord(value)) return value const required = Array.isArray(value.required) @@ -39,18 +47,29 @@ function normalize(value: unknown, options: { stripNull?: boolean } = {}): unkno ? Object.fromEntries( Object.entries(item).map(([name, property]) => [ name, - normalize(property, { stripNull: !required?.has(name) }), + normalize(property, { + stripNull: !required?.has(name), + additionalProperties: options.additionalProperties, + }), ]), ) - : normalize(item), + : normalize(item, { additionalProperties: options.additionalProperties }), ]), ) if (schema.additionalProperties === true) delete schema.additionalProperties + if ( + options.additionalProperties !== undefined && + schema.type === "object" && + schema.additionalProperties === undefined + ) { + schema.additionalProperties = options.additionalProperties + } + if (options.stripNull && Array.isArray(schema.anyOf)) { const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null") - if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull }) + if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull }, options) } if (Array.isArray(schema.anyOf)) { @@ -61,23 +80,23 @@ function normalize(value: unknown, options: { stripNull?: boolean } = {}): unkno ) if (number && nonFinite.length === withoutNull.length - 1) { const { anyOf: _, ...rest } = schema - return normalize({ ...number, ...rest }) + return normalize({ ...number, ...rest }, options) } if (isEmptyStructUnion(withoutNull)) { const { anyOf: _, ...rest } = schema - return normalize({ type: "object", properties: {}, ...rest }) + return normalize({ type: "object", properties: {}, ...rest }, options) } if (withoutNull.length === 1 && isRecord(withoutNull[0])) { const { anyOf: _, ...rest } = schema - return normalize({ ...withoutNull[0], ...rest }) + return normalize({ ...withoutNull[0], ...rest }, options) } } if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) { const { allOf, ...rest } = schema - return normalize({ ...Object.assign({}, ...allOf), ...rest }) + return normalize({ ...Object.assign({}, ...allOf), ...rest }, options) } if (schema.type === "integer" && schema.maximum === undefined) { @@ -86,6 +105,7 @@ function normalize(value: unknown, options: { stripNull?: boolean } = {}): unkno return schema } +// kilocode_change end function isRecord(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 2f44f3f5c7..e7fdf99234 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -1,6 +1,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" -import { Effect, Layer, ManagedRuntime, Queue, Schema } from "effect" +import { Effect, Layer, ManagedRuntime, Queue, Result, Schema } from "effect" import { MessageID, SessionID } from "../../src/session/schema" import { provideTmpdirInstance } from "../fixture/fixture" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -151,45 +151,77 @@ function publish( } describe("agent_manager tool", () => { - test("uses an object-root input schema without combinators", async () => { + test("advertises each operation as a strict union branch", async () => { const tool = await init() const schema = ToolJsonSchema.fromTool(tool) - expect(schema.type).toBe("object") - expect(schema.anyOf).toBeUndefined() + expect(schema.type).toBeUndefined() + expect(schema.anyOf).toHaveLength(5) expect(schema.oneOf).toBeUndefined() expect(schema.allOf).toBeUndefined() - const action = schema.properties?.action - expect(action && typeof action === "object" ? action.enum : undefined).toEqual(["list", "prompt", "stop", "move"]) - expect(action && typeof action === "object" ? action.description : undefined).toContain("Use list first") - expect(action && typeof action === "object" ? action.description : undefined).toContain("Never edit") - expect(schema.properties?.sessionID).toEqual( - expect.objectContaining({ description: expect.stringContaining("IDs start with ses_") }), - ) - expect(schema.properties?.sessionID).not.toHaveProperty("pattern") - expect(schema.properties?.sectionID).toEqual( - expect.objectContaining({ description: expect.stringContaining("Use null to unassign") }), - ) - expect(schema.properties?.sectionID).toEqual( - expect.objectContaining({ - anyOf: expect.arrayContaining([expect.objectContaining({ type: "string" }), { type: "null" }]), - }), - ) - expect(Object.keys(schema.properties ?? {})).toEqual([ - "mode", - "versions", - "tasks", - "action", - "filter", - "sessionID", - "prompt", - "sectionID", + const branches = schema.anyOf as Array> + const properties = (branch: Record) => branch.properties as Record + expect(branches.map((branch) => branch.required)).toEqual([ + ["mode", "tasks"], + ["action"], + ["action", "sessionID", "prompt"], + ["action", "sessionID", "sectionID"], + ["action", "sessionID"], ]) + expect(branches.every((branch) => branch.additionalProperties === false)).toBe(true) + expect(properties(branches[2]!).sessionID).not.toHaveProperty("pattern") + expect(properties(branches[3]!).sessionID).not.toHaveProperty("pattern") + expect(properties(branches[4]!).sessionID).not.toHaveProperty("pattern") + expect(properties(branches[0]!)).toEqual( + expect.objectContaining({ mode: expect.anything(), tasks: expect.anything() }), + ) + expect(properties(branches[1]!)).toEqual( + expect.objectContaining({ action: expect.objectContaining({ enum: ["list"] }) }), + ) + expect(properties(branches[2]!)).toEqual( + expect.objectContaining({ action: expect.objectContaining({ enum: ["prompt"] }) }), + ) + expect(properties(branches[3]!)).toEqual( + expect.objectContaining({ action: expect.objectContaining({ enum: ["move"] }) }), + ) + expect(properties(branches[4]!)).toEqual( + expect.objectContaining({ action: expect.objectContaining({ enum: ["stop"] }) }), + ) }) - test("keeps session ID validation local", () => { - expect(Schema.is(Params)({ action: "stop", sessionID: "ses_target" })).toBe(true) - expect(Schema.is(Params)({ action: "stop", sessionID: "invalid" })).toBe(false) + test("accepts each operation branch and rejects ambiguous payloads", () => { + const task = { prompt: "Fix the issue" } + const accepts = (input: unknown) => Result.isSuccess(Schema.decodeUnknownResult(Params)(input)) + expect(accepts({ mode: "local", tasks: [task] })).toBe(true) + expect(accepts({ action: "list" })).toBe(true) + expect(accepts({ action: "list", filter: null })).toBe(true) + expect(accepts({ action: "prompt", sessionID: "ses_target", prompt: "Continue" })).toBe(true) + expect(accepts({ action: "stop", sessionID: "ses_target" })).toBe(true) + expect(accepts({ action: "move", sessionID: "ses_target", sectionID: null })).toBe(true) + expect(accepts({ action: "stop", sessionID: "invalid" })).toBe(false) + + expect(accepts({ mode: "local", tasks: [task], action: "list" })).toBe(false) + expect(accepts({ action: "list", mode: "local", tasks: [task] })).toBe(false) + expect(accepts({ action: "prompt", sessionID: "ses_target", prompt: "Continue", mode: "local" })).toBe(false) + expect(accepts({ action: "stop", sessionID: "ses_target", prompt: "Continue" })).toBe(false) + expect(accepts({ action: "move", sessionID: "ses_target", sectionID: null, filter: null })).toBe(false) + }) + + test("rejects mixed payloads before dispatch", async () => { + const tool = await init() + const calls: unknown[] = [] + + await expect( + runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix issue" }], action: "list" }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ), + ).rejects.toThrow("Unexpected Agent Manager parameter") + expect(calls).toEqual([]) }) test("asks for agent_manager permission", async () => { From 6131ed269f37ae8e258c1b91929c3170a4cf2767 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 11:01:00 +0200 Subject: [PATCH 19/34] fix(cli): preserve Kilo upgrade version lookup --- .../restore-kilo-upgrade-version-lookup.md | 5 +++++ packages/opencode/src/installation/index.ts | 16 ++-------------- .../opencode/src/kilocode/installation/latest.ts | 14 ++++++++++++++ .../test/kilocode/installation/upgrade.test.ts | 6 +++++- 4 files changed, 26 insertions(+), 15 deletions(-) create mode 100644 .changeset/restore-kilo-upgrade-version-lookup.md create mode 100644 packages/opencode/src/kilocode/installation/latest.ts diff --git a/.changeset/restore-kilo-upgrade-version-lookup.md b/.changeset/restore-kilo-upgrade-version-lookup.md new file mode 100644 index 0000000000..9bc312c4bc --- /dev/null +++ b/.changeset/restore-kilo-upgrade-version-lookup.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep `kilo upgrade` on the Kilo CLI release channel when GitHub's latest release is a JetBrains release. diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index cc0e7bebd7..82d14ea90d 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -21,6 +21,7 @@ import { Release as KiloRelease, Scoop as KiloScoop, } from "@/kilocode/installation" +import { latest as kiloLatest } from "@/kilocode/installation/latest" // kilocode_change end import { InstallationEvent } from "@opencode-ai/schema/installation-event" @@ -70,7 +71,6 @@ export class UpgradeFailedError extends Schema.TaggedErrorClass { () => "", (request) => { release.push(request.url) + if (request.url === "https://api.github.com/repos/Kilo-Org/kilocode/releases/latest") { + return json({ tag_name: "jetbrains/v7.0.16" }) + } return json({ version: "8.8.8" }) }, ), - ).effect("reads fallback versions from the Kilo npm registry", () => + ).effect("does not use polluted GitHub release tags for fallback versions", () => Effect.gen(function* () { const result = yield* Installation.Service.use((svc) => svc.latest("unknown")) expect(result).toBe("8.8.8") expect(release).toContain(`https://registry.npmjs.org/@kilocode%2fcli/${InstallationChannel}`) + expect(release).not.toContain("https://api.github.com/repos/Kilo-Org/kilocode/releases/latest") }), ) From ee00a4f0a435fa1bacb80f8eeb96be48ab7bd858 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 11:06:34 +0200 Subject: [PATCH 20/34] fix(agent-manager): avoid tracking deleted PR branches --- .../src/agent-manager/WorktreeManager.ts | 10 +++--- .../tests/unit/worktree-manager.test.ts | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index a67bacaa74..7973adc05d 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -1128,10 +1128,12 @@ export class WorktreeManager { ]) } if (!(await this.gitTry(["show-ref", "--verify", "--quiet", `refs/heads/${info.headRefName}`]))) { - const ref = `refs/remotes/origin/${info.headRefName}` - await this.gitExec(["branch", info.headRefName, ref]) - await this.gitExec(["config", `branch.${info.headRefName}.remote`, "origin"]) - await this.gitExec(["config", `branch.${info.headRefName}.merge`, `refs/heads/${info.headRefName}`]) + const start = `refs/remotes/origin/${info.headRefName}` + await this.gitExec(["branch", info.headRefName, start]) + if (ok) { + await this.gitExec(["config", `branch.${info.headRefName}.remote`, "origin"]) + await this.gitExec(["config", `branch.${info.headRefName}.merge`, `refs/heads/${info.headRefName}`]) + } } } } diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 60b65bbd6f..69ff01da58 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -1191,6 +1191,40 @@ describe("WorktreeManager.createWorktree advanced", () => { expect(worktreeHead).toBe(remoteHead) expect(result.parentBranch).toBe("topic") }) + + it("does not track a deleted PR source branch when using the pull ref fallback", async () => { + const { bare, clone } = await createTempRepoWithOrigin() + const git = simpleGit(clone) + await git.checkoutLocalBranch("topic") + await fs.writeFile(path.join(clone, "topic.txt"), "topic") + await git.add(".") + await git.commit("topic commit") + await git.push("origin", "topic") + const head = (await git.revparse(["topic"])).trim() + await git.checkout("main") + await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"]) + await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"]) + gitExec(["git", "--git-dir", bare, "update-ref", "refs/pull/1/head", head]) + gitExec(["git", "--git-dir", bare, "update-ref", "-d", "refs/heads/topic"]) + await git.branch(["-D", "topic"]) + + const manager = createManager(clone) + const internal = manager as unknown as { + fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise + } + internal.fetchPRInfo = async () => ({ + headRefName: "topic", + isCrossRepository: false, + title: "Topic PR", + }) + + const result = await manager.createFromPR("https://github.com/org/repo/pull/1") + const upstream = await git.raw(["config", "--get", "branch.topic.remote"]).catch(() => "") + const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim() + + expect(worktreeHead).toBe(head) + expect(upstream.trim()).toBe("") + }) }) // --------------------------------------------------------------------------- From 59048c44bdd1a443503b416fde6fa81034460642 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 11:06:59 +0200 Subject: [PATCH 21/34] fix(vscode): stabilize inline diff scrolling --- .changeset/calm-diff-scroll.md | 5 ++ packages/kilo-ui/src/components/diff-ssr.tsx | 1 + packages/kilo-ui/src/components/diff.tsx | 34 +++++++++- packages/kilo-ui/src/pierre/index.ts | 3 + .../tests/diff-scroll-preservation.spec.ts | 62 +++++++++++++++++++ .../unit/agent-manager-diff-state.test.ts | 14 ++++- .../webview-ui/agent-manager/DiffPanel.tsx | 3 +- .../diff-viewer/FullScreenDiffView.tsx | 3 +- .../webview-ui/diff-viewer/diff-state.ts | 29 +++++++++ .../src/stories/agent-manager.stories.tsx | 33 ++++++++-- 10 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 .changeset/calm-diff-scroll.md diff --git a/.changeset/calm-diff-scroll.md b/.changeset/calm-diff-scroll.md new file mode 100644 index 0000000000..f33b52455f --- /dev/null +++ b/.changeset/calm-diff-scroll.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the Agent Manager inline diff position stable while scrolling upward through large reviews diff --git a/packages/kilo-ui/src/components/diff-ssr.tsx b/packages/kilo-ui/src/components/diff-ssr.tsx index a233514c87..087bf8e059 100644 --- a/packages/kilo-ui/src/components/diff-ssr.tsx +++ b/packages/kilo-ui/src/components/diff-ssr.tsx @@ -24,6 +24,7 @@ export function Diff(props: SSRDiffProps) { "selectedLines", "commentedLines", "virtualized", + "sizeKey", ]) const workerPool = useWorkerPool(props.diffStyle) diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index cee9fba7a4..fd503eee30 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -23,6 +23,23 @@ const MIN_PLACEHOLDER_HEIGHT = 160 const MAX_PLACEHOLDER_HEIGHT = 1200 type Job = { run: () => void; cancelled: boolean } +const sizes = new WeakMap>() +const WIDTH_LIMIT = 8 + +function remember(key: object | undefined, width: number, height: number) { + if (!key || width <= 0 || height <= 0) return + const widths = sizes.get(key) ?? new Map() + widths.delete(width) + widths.set(width, height) + if (widths.size > WIDTH_LIMIT) widths.delete(widths.keys().next().value!) + sizes.set(key, widths) +} + +function reserved(key: object | undefined, width: number) { + if (!key || width <= 0) return + return sizes.get(key)?.get(width) +} + // A review can contain many expanded diff components. Creating one // IntersectionObserver per diff showed up in profiles, so all deferred diffs // share a single observer and only register their element + render callback. @@ -173,6 +190,7 @@ export function Diff(props: DiffProps) { "commentedLines", "onRendered", "virtualized", + "sizeKey", ]) const mobile = createMediaQuery("(max-width: 640px)") @@ -247,7 +265,7 @@ export function Diff(props: DiffProps) { createEffect(() => { if (visible()) return - container.style.minHeight = `${estimate()}px` + container.style.minHeight = `${reserved(local.sizeKey, container.clientWidth) ?? estimate()}px` }) createEffect(() => { @@ -266,6 +284,17 @@ export function Diff(props: DiffProps) { return root } + createEffect(() => { + if (typeof ResizeObserver === "undefined") return + const resize = new ResizeObserver(() => { + const root = getRoot() + if (!visible() || !current() || !root?.querySelector("[data-line]")) return + remember(local.sizeKey, container.clientWidth, container.offsetHeight) + }) + resize.observe(container) + onCleanup(() => resize.disconnect()) + }) + const applyScheme = () => { const host = container.querySelector("diffs-container") if (!(host instanceof HTMLElement)) return @@ -370,6 +399,7 @@ export function Diff(props: DiffProps) { if (token !== renderToken) return // Clear the height pin now that Pierre has rendered new content. container.style.minHeight = "" + remember(local.sizeKey, container.clientWidth, container.offsetHeight) setSelectedLines(lastSelection) local.onRendered?.() }) @@ -411,6 +441,7 @@ export function Diff(props: DiffProps) { if (typeof MutationObserver === "undefined") { container.style.minHeight = "" if (!root || !isReady(root)) return + remember(local.sizeKey, container.clientWidth, container.offsetHeight) setSelectedLines(lastSelection) local.onRendered?.() return @@ -777,6 +808,7 @@ export function Diff(props: DiffProps) { if (!instance) return instance.setLineAnnotations(annotations ?? []) instance.rerender() + notifyRendered() }, { defer: true }, ), diff --git a/packages/kilo-ui/src/pierre/index.ts b/packages/kilo-ui/src/pierre/index.ts index 7b4b13aabe..988835e6a2 100644 --- a/packages/kilo-ui/src/pierre/index.ts +++ b/packages/kilo-ui/src/pierre/index.ts @@ -58,6 +58,9 @@ type DiffShared = FileDiffOptions & { // files so eager rendering does not expand full before/after content. // Defaults to virtualized. virtualized?: boolean + // Stable rendered-content identity used to preserve deferred height when a + // surrounding row virtualizer unmounts and later re-creates this diff. + sizeKey?: object class?: string classList?: ComponentProps<"div">["classList"] } diff --git a/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts index 735f6832d5..640449e0b0 100644 --- a/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts +++ b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts @@ -2,11 +2,16 @@ import { expect, test, type Page } from "@playwright/test" const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" const STORY_ID = "agentmanager--full-screen-diff-agent-edit-scroll" +const INLINE_STORY_ID = "agentmanager--diff-panel-scroll-up" function storyUrl() { return `/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}` } +function inlineStoryUrl() { + return `/iframe.html?id=${INLINE_STORY_ID}&viewMode=story&globals=${GLOBALS}` +} + async function disableAnimations(page: Page) { await page.addStyleTag({ content: ` @@ -159,3 +164,60 @@ test("resets virtual measurements and scroll when the review context changes", a await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBe(1_200) await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBe(0) }) + +test("keeps the inline diff position stable while scrolling upward", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 760 }) + await page.goto(inlineStoryUrl(), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector(".am-diff-content diffs-container", { state: "attached" }) + + const result = await page.locator(".am-diff-content").evaluate(async (el) => { + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)) + const settle = async (count: number) => { + for (let i = 0; i < count; i++) await frame() + } + const seen = new Set( + Array.from(el.querySelectorAll("[data-file-path]"), (row) => row.getAttribute("data-file-path")), + ) + let remounts = 0 + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (!(node instanceof HTMLElement)) continue + const rows = node.matches("[data-file-path]") ? [node] : Array.from(node.querySelectorAll("[data-file-path]")) + for (const row of rows) { + const file = row.getAttribute("data-file-path") + if (seen.has(file)) remounts++ + seen.add(file) + } + } + } + }) + observer.observe(el, { childList: true, subtree: true }) + + // Materialize every row once, then start from the settled bottom. The bug + // appears when upward scrolling re-creates rows above the viewport. + while (el.scrollTop < el.scrollHeight - el.clientHeight - 1) { + el.scrollTop = Math.min(el.scrollHeight - el.clientHeight, el.scrollTop + 120) + await frame() + } + await settle(30) + + let correction = 0 + let range = 0 + while (el.scrollTop > 0) { + const height = el.scrollHeight + const intended = Math.max(0, el.scrollTop - 80) + el.scrollTop = intended + await settle(2) + correction = Math.max(correction, Math.abs(el.scrollTop - intended)) + range = Math.max(range, Math.abs(el.scrollHeight - height)) + } + observer.disconnect() + return { correction, range, remounts } + }) + + expect(result.remounts).toBeGreaterThan(0) + expect(result.correction).toBeLessThanOrEqual(1) + expect(result.range).toBeLessThanOrEqual(1) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts index 381ac518a3..4f836944df 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { mergeWorktreeDiffs } from "../../webview-ui/diff-viewer/diff-state" +import { diffSizeKey, mergeWorktreeDiffs } from "../../webview-ui/diff-viewer/diff-state" import { EXTREME_DIFF_CHANGED_LINES, allOpenFiles, @@ -28,6 +28,18 @@ function diff(overrides: Partial): WorktreeFileDiff { } } +describe("diffSizeKey", () => { + it("changes with rendered content, style, and review context", () => { + const base = diff({ summarized: false, patch: "@@ -1 +1 @@\n-old\n+new\n" }) + const key = diffSizeKey("review-a", base, "unified") + + expect(diffSizeKey("review-a", base, "unified")).toBe(key) + expect(diffSizeKey("review-b", base, "unified")).not.toBe(key) + expect(diffSizeKey("review-a", base, "split")).not.toBe(key) + expect(diffSizeKey("review-a", { ...base, patch: "@@ -1 +1 @@\n-old\n+newer\n" }, "unified")).not.toBe(key) + }) +}) + describe("agent manager diff state", () => { it("preserves loaded detail and patch when summary metadata is unchanged", () => { const prev = [diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -1 +1 @@\n-old\n+new\n" })] diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index ae15fa3cf6..eff3750a77 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -60,7 +60,7 @@ import { VirtualDiffList } from "../diff-viewer/VirtualDiffList" import { treeOrder } from "../diff-viewer/file-tree-utils" import { isMarkdownFile, MarkdownDiffView } from "../diff-viewer/MarkdownDiffView" import { ImageDiffView } from "../diff-viewer/ImageDiffView" -import { createDiffRows } from "../diff-viewer/diff-state" +import { createDiffRows, diffSizeKey } from "../diff-viewer/diff-state" import { createDiffRequests } from "../diff-viewer/diff-requests" // --- Data model --- @@ -728,6 +728,7 @@ export const DiffPanel: Component = (props) => { after={{ name: diff.file, contents: diff.after }} patch={diff.patch} diffStyle={props.diffStyle ?? "unified"} + sizeKey={diffSizeKey(props.sessionKey, diff, props.diffStyle ?? "unified")} virtualized={shouldVirtualizeDiff(diff)} annotations={annotationsForFile(diff.file)} renderAnnotation={buildAnnotation} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx index 6f708f8bd9..db2373c3e1 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx @@ -61,7 +61,7 @@ import { DiffEndMarker } from "./DiffEndMarker" import { VirtualDiffList } from "./VirtualDiffList" import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" import { ImageDiffView } from "./ImageDiffView" -import { createDiffRows } from "./diff-state" +import { createDiffRows, diffSizeKey } from "./diff-state" import { createDiffRequests } from "./diff-requests" type DiffStyle = "unified" | "split" @@ -802,6 +802,7 @@ export const FullScreenDiffView: Component = (props) => after={{ name: diff.file, contents: diff.after }} patch={diff.patch} diffStyle={props.diffStyle} + sizeKey={diffSizeKey(props.sessionKey, diff, props.diffStyle)} virtualized={shouldVirtualizeDiff(diff)} annotations={annotationsForFile(diff.file)} renderAnnotation={buildAnnotation} diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/diff-state.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-state.ts index 32ebe6db77..379266350b 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/diff-state.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/diff-state.ts @@ -1,6 +1,18 @@ import { createMemo, createSignal } from "solid-js" import type { WorktreeFileDiff } from "../src/types/messages" +const sizeKeys = new WeakMap< + WorktreeFileDiff, + { + context: string | undefined + style: string + patch: string | undefined + before: string + after: string + key: object + } +>() + export function sameDiffMeta(left: WorktreeFileDiff, right: WorktreeFileDiff) { return ( left.file === right.file && @@ -20,6 +32,23 @@ export function diffToken(diff: WorktreeFileDiff) { return diff.stamp ?? parts.join(":") } +export function diffSizeKey(context: string | undefined, diff: WorktreeFileDiff, style: string) { + const cached = sizeKeys.get(diff) + if ( + cached && + cached.context === context && + cached.style === style && + cached.patch === diff.patch && + cached.before === diff.before && + cached.after === diff.after + ) + return cached.key + + const key = {} + sizeKeys.set(diff, { context, style, patch: diff.patch, before: diff.before, after: diff.after, key }) + return key +} + // Keep each rendered row mounted while live detail refreshes replace its data. // Otherwise Solid's keyed remounts the row and deferred rendering swaps a // previously rendered diff above the viewport for a short placeholder. diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 817310e1bb..8a44a8ca8f 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -87,13 +87,13 @@ const foldedDiffs: WorktreeFileDiff[] = [ ] const ROWS = 140 -function edited(seed: string): WorktreeFileDiff { +function edited(seed: string, file = "src/agent-edit.ts"): WorktreeFileDiff { const before = Array.from({ length: ROWS }, (_, i) => `const row${i} = "${seed}-old-${i}"\n`).join("") const after = Array.from({ length: ROWS }, (_, i) => `const row${i} = "${seed}-new-${i}"\n`).join("") const patch = [ - "diff --git a/src/agent-edit.ts b/src/agent-edit.ts", - "--- a/src/agent-edit.ts", - "+++ b/src/agent-edit.ts", + `diff --git a/${file} b/${file}`, + `--- a/${file}`, + `+++ b/${file}`, `@@ -1,${ROWS} +1,${ROWS} @@`, ...before .trimEnd() @@ -107,7 +107,7 @@ function edited(seed: string): WorktreeFileDiff { ].join("\n") return { - file: "src/agent-edit.ts", + file, status: "modified", additions: ROWS, deletions: ROWS, @@ -322,6 +322,29 @@ export const DiffPanelWithDiffs: Story = { ), } +export const DiffPanelScrollUp: Story = { + name: "DiffPanel - scroll upward through large diffs", + render: () => { + const diffs = Array.from({ length: 5 }, (_, i) => edited(`review-${i}`, `src/review-${i}.ts`)) + return ( + +
+ {}} + comments={[]} + onCommentsChange={() => {}} + onClose={() => {}} + /> +
+
+ ) + }, +} + const buttonFixtureStyle: JSX.CSSProperties = { display: "inline-flex", "align-items": "center", From dfd7a487a7cc6d43d9c013d56ee16fc68dddc088 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 11:09:58 +0200 Subject: [PATCH 22/34] docs(cli): explain Kilo upgrade version source --- packages/opencode/src/kilocode/installation/latest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/kilocode/installation/latest.ts b/packages/opencode/src/kilocode/installation/latest.ts index c1a70c6ddb..cc35e15490 100644 --- a/packages/opencode/src/kilocode/installation/latest.ts +++ b/packages/opencode/src/kilocode/installation/latest.ts @@ -3,6 +3,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab const Package = Schema.Struct({ version: Schema.String }) +// GitHub's latest Kilo release can be a JetBrains release, not a CLI release. +// Use the public npm channel so curl installs resolve only Kilo CLI versions. export function latest(http: HttpClient.HttpClient, path: string, channel: string) { return Effect.gen(function* () { const response = yield* http.execute( From 62996f1f30c3bee9c05c818125917de067e3cd23 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 18 Aug 2026 09:10:59 +0000 Subject: [PATCH 23/34] chore: update kilo-vscode visual regression baselines --- .../agentmanager/diff-panel-scroll-up-chromium-linux.png | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-scroll-up-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-scroll-up-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-scroll-up-chromium-linux.png new file mode 100644 index 0000000000..1f434f6638 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-scroll-up-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1115e3b2056f1b00c6b9a657f61c3540e00b14ec0ff4fb04f072157bb5059b3 +size 17935 From 94b426179bbcdc7152c8c39d4d4f9791094af242 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 11:13:20 +0200 Subject: [PATCH 24/34] fix(agent-manager): preserve tool schema guidance --- .../src/kilocode/tool/agent-manager.ts | 13 +++-- packages/opencode/src/tool/json-schema.ts | 54 ++++++------------- .../test/kilocode/agent-manager-tool.test.ts | 14 ++++- 3 files changed, 39 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 7b2487a32d..6e4a89f4de 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -59,7 +59,7 @@ const Task = Schema.Struct({ ) function wireSchema() { - const schema = ToolJsonSchema.fromSchema(Params, { additionalProperties: false }) + const schema = structuredClone(ToolJsonSchema.fromSchema(Params)) // llama.cpp rejects the prefix-only SessionID pattern. Keep the runtime brand // check, but omit that provider-incompatible hint from the advertised schema. @@ -70,6 +70,9 @@ function wireSchema() { } if (!value || typeof value !== "object") return const item = value as Record + if (item.type === "object" && item.additionalProperties === undefined) { + item.additionalProperties = false + } if (item.properties && typeof item.properties === "object") { const properties = item.properties as Record if (properties.sessionID && typeof properties.sessionID === "object") { @@ -117,7 +120,9 @@ const ListParams = strict({ const PromptParams = strict({ action: Schema.Literal("prompt"), - sessionID: SessionID, + sessionID: SessionID.annotate({ + description: "Session ID returned by action=list. Do not use a worktree name, branch, or section name.", + }), prompt: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100_000)).check( Schema.makeFilter((value) => (value.trim() ? undefined : "Prompt must not be empty")), ), @@ -125,7 +130,9 @@ const PromptParams = strict({ const StopParams = strict({ action: Schema.Literal("stop"), - sessionID: SessionID, + sessionID: SessionID.annotate({ + description: "Session ID returned by action=list. Do not use a worktree name, branch, or section name.", + }), }) const MoveParams = strict({ diff --git a/packages/opencode/src/tool/json-schema.ts b/packages/opencode/src/tool/json-schema.ts index c789a94bab..edb43e11ca 100644 --- a/packages/opencode/src/tool/json-schema.ts +++ b/packages/opencode/src/tool/json-schema.ts @@ -5,36 +5,28 @@ import type * as Tool from "./tool" type JsonObject = Record const cache = new WeakMap() -// kilocode_change start - allow Kilo-owned tools to advertise strict object branches -export function fromSchema(schema: Schema.Top, options: { additionalProperties?: boolean } = {}): JSONSchema7 { - const cached = options.additionalProperties === undefined ? cache.get(schema) : undefined +export function fromSchema(schema: Schema.Top): JSONSchema7 { + const cached = cache.get(schema) if (cached) return cached - const document = Schema.toJsonSchemaDocument(schema, { - additionalProperties: options.additionalProperties ?? true, + const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true }) + const result = normalize({ + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + ...document.schema, + ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}), }) - const result = normalize( - { - $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, - ...document.schema, - ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}), - }, - options, - ) const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result)) if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value") - if (options.additionalProperties === undefined) cache.set(schema, inlined) + cache.set(schema, inlined) return inlined } -// kilocode_change end export function fromTool(tool: Tool.Def): JSONSchema7 { return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top) } -// kilocode_change start - propagate strict object schema options through unions -function normalize(value: unknown, options: { stripNull?: boolean; additionalProperties?: boolean } = {}): unknown { - if (Array.isArray(value)) return value.map((item) => normalize(item, options)) +function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown { + if (Array.isArray(value)) return value.map((item) => normalize(item)) if (!isRecord(value)) return value const required = Array.isArray(value.required) @@ -47,29 +39,18 @@ function normalize(value: unknown, options: { stripNull?: boolean; additionalPro ? Object.fromEntries( Object.entries(item).map(([name, property]) => [ name, - normalize(property, { - stripNull: !required?.has(name), - additionalProperties: options.additionalProperties, - }), + normalize(property, { stripNull: !required?.has(name) }), ]), ) - : normalize(item, { additionalProperties: options.additionalProperties }), + : normalize(item), ]), ) if (schema.additionalProperties === true) delete schema.additionalProperties - if ( - options.additionalProperties !== undefined && - schema.type === "object" && - schema.additionalProperties === undefined - ) { - schema.additionalProperties = options.additionalProperties - } - if (options.stripNull && Array.isArray(schema.anyOf)) { const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null") - if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull }, options) + if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull }) } if (Array.isArray(schema.anyOf)) { @@ -80,23 +61,23 @@ function normalize(value: unknown, options: { stripNull?: boolean; additionalPro ) if (number && nonFinite.length === withoutNull.length - 1) { const { anyOf: _, ...rest } = schema - return normalize({ ...number, ...rest }, options) + return normalize({ ...number, ...rest }) } if (isEmptyStructUnion(withoutNull)) { const { anyOf: _, ...rest } = schema - return normalize({ type: "object", properties: {}, ...rest }, options) + return normalize({ type: "object", properties: {}, ...rest }) } if (withoutNull.length === 1 && isRecord(withoutNull[0])) { const { anyOf: _, ...rest } = schema - return normalize({ ...withoutNull[0], ...rest }, options) + return normalize({ ...withoutNull[0], ...rest }) } } if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) { const { allOf, ...rest } = schema - return normalize({ ...Object.assign({}, ...allOf), ...rest }, options) + return normalize({ ...Object.assign({}, ...allOf), ...rest }) } if (schema.type === "integer" && schema.maximum === undefined) { @@ -105,7 +86,6 @@ function normalize(value: unknown, options: { stripNull?: boolean; additionalPro return schema } -// kilocode_change end function isRecord(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index e7fdf99234..d611edd48e 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -179,13 +179,23 @@ describe("agent_manager tool", () => { expect.objectContaining({ action: expect.objectContaining({ enum: ["list"] }) }), ) expect(properties(branches[2]!)).toEqual( - expect.objectContaining({ action: expect.objectContaining({ enum: ["prompt"] }) }), + expect.objectContaining({ + action: expect.objectContaining({ enum: ["prompt"] }), + sessionID: expect.objectContaining({ + description: expect.stringContaining("Session ID returned by action=list"), + }), + }), ) expect(properties(branches[3]!)).toEqual( expect.objectContaining({ action: expect.objectContaining({ enum: ["move"] }) }), ) expect(properties(branches[4]!)).toEqual( - expect.objectContaining({ action: expect.objectContaining({ enum: ["stop"] }) }), + expect.objectContaining({ + action: expect.objectContaining({ enum: ["stop"] }), + sessionID: expect.objectContaining({ + description: expect.stringContaining("Session ID returned by action=list"), + }), + }), ) }) From bee6fb3b237ff5be73d76eba9d69ec7237cebcc5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 18 Aug 2026 11:16:47 +0200 Subject: [PATCH 25/34] fix(vscode): keep permission prompt actions reachable with large diffs --- .changeset/permission-prompt-scroll.md | 5 ++ .../src/components/chat/PermissionDock.tsx | 82 ++++++++++--------- .../webview-ui/src/styles/chat-layout.css | 36 ++++++++ .../webview-ui/src/styles/permission-dock.css | 49 ++++++++++- 4 files changed, 130 insertions(+), 42 deletions(-) create mode 100644 .changeset/permission-prompt-scroll.md diff --git a/.changeset/permission-prompt-scroll.md b/.changeset/permission-prompt-scroll.md new file mode 100644 index 0000000000..0c139a2b60 --- /dev/null +++ b/.changeset/permission-prompt-scroll.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the Allow and Deny buttons reachable when a permission prompt contains a large diff or a long command: the prompt now scrolls its own content and shrinks with the available chat height instead of pushing its buttons out of view diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 879f789281..e9bb781a93 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -287,50 +287,54 @@ export const PermissionDock: Component<{ } > - 0} - fallback={ - <> - {(desc) =>
{desc()}
}
- - {(cmd) => } - + {/* Everything above the buttons scrolls: a long command or a large diff must never + push Allow/Deny out of the clipped chat view. */} +
+ 0} + fallback={ + <> + {(desc) =>
{desc()}
}
+ + {(cmd) => } + - {(() => { - const desc = description() - if (!desc) - return !command() && toolDescription() ? ( -
{toolDescription()}
- ) : null - if (desc.kind === "single") + {(() => { + const desc = description() + if (!desc) + return !command() && toolDescription() ? ( +
{toolDescription()}
+ ) : null + if (desc.kind === "single") + return ( +
+ {desc.text} +
+ ) return ( -
- {desc.text} +
+ {desc.title} + {(path) => {path}}
) - return ( -
- {desc.title} - {(path) => {path}} -
- ) - })()} - - } - > - {/* Verbatim commands (args.commands), control-char/bidi-escaped so the displayed command matches execution. */} - {(cmd) => } - + })()} + + } + > + {/* Verbatim commands (args.commands), control-char/bidi-escaped so the displayed command matches execution. */} + {(cmd) => } + - 0}> -
- {(diff) => } -
-
+ 0}> +
+ {(diff) => } +
+
+