Merge remote-tracking branch 'origin/main' into puddle-barometer

This commit is contained in:
kirillk
2026-07-30 10:46:10 -04:00
233 changed files with 7068 additions and 882 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Apply the Agent Manager base branch picker selection to the active diff immediately. Changing the base branch now refreshes the diff against the new base instead of keeping the previous comparison until the scope or session changed.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make Cmd/Ctrl+/ toggle the Agent Manager terminal even when the webview keybinding forwarding drops the key while the prompt input is focused, and stop it from triggering the Agent Manager terminal while the Kilo sidebar is focused.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Match the Agent Manager terminal shortcut fallback to the platform modifier (Cmd on macOS, Ctrl elsewhere) and consume the extension echo once per keypress so unrelated invocations are no longer swallowed.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Run Agent Manager project scripts in the terminal selected by the existing toolbar dropdown. Agent Manager panel uses the named side terminal, while VS Code terminal retains the integrated task flow.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-telemetry": patch
---
Reduce CLI startup time by deferring Kilo-specific module loading until commands actually run, caching the telemetry profile lookup across invocations, and uploading telemetry in the background so process exit is not delayed by a network round trip
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix settings snapping back to their previous value after being cleared to "Not set" when multiple config files exist (e.g. both `kilo.json` and `kilo.jsonc`)
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Allow users to enable web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Navigate long conversations from a compact prompt rail that loads earlier history as you scroll.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix the `@` "Past chats" picker in Agent Manager showing only the current session's directory. It now lists previous sessions across the whole worktree family — the local workspace and every Agent Manager worktree — each labeled with its worktree name, matching the Agent Manager session search. Any listed session can be attached as context, including chats from other worktrees of the same repository.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Open an embedded terminal automatically when switching to a worktree without one.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Include the underlying reason in search execution failures instead of showing a bare "ripgrep execution failed" message.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make Agent Manager panel terminals behave like session tabs: right-click Close and Close Others, arrow-key tab navigation, overflow scrolling with edge fades, and stable tab widths while closing. The new-terminal button now sits directly next to the last terminal tab instead of the far edge of the panel.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running.
@@ -0,0 +1,179 @@
import { spawn } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
import type { Proc } from "../../pty/pty"
import { Log } from "../../util/log"
const log = Log.create({ service: "pty.termination" })
const GRACE_MS = 200
const SPAWN_TIMEOUT_MS = 5_000
export type Process = Pick<Proc, "pid" | "onExit" | "kill">
export type Runtime = {
readonly platform: NodeJS.Platform
readonly taskkill: (
file: string,
args: string[],
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
) => Promise<boolean>
readonly tree: () => Promise<Array<{ pid: number; parent: number }>>
readonly alive: (pid: number) => boolean
readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void
readonly sleep: (ms: number) => Promise<void>
}
const runtime: Runtime = {
platform: process.platform,
taskkill,
tree,
alive: (pid) => {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
},
signal: (pid, signal) => process.kill(pid, signal),
sleep,
}
function direct(proc: Process, signal?: "SIGTERM" | "SIGKILL") {
try {
proc.kill(signal)
} catch (err) {
log.warn("failed to kill PTY directly", { err, pid: proc.pid, signal })
}
}
function descendants(root: number, rows: Array<{ pid: number; parent: number }>) {
const children = new Map<number, number[]>()
for (const row of rows) {
const list = children.get(row.parent) ?? []
list.push(row.pid)
children.set(row.parent, list)
}
const seen = new Set<number>()
const collect = (pid: number): number[] => {
const result: number[] = []
for (const child of children.get(pid) ?? []) {
if (seen.has(child)) continue
seen.add(child)
result.push(...collect(child), child)
}
return result
}
return collect(root)
}
async function family(root: number, input: Runtime) {
const rows = await input.tree().catch((err) => {
log.debug("failed to inspect PTY process tree", { err, pid: root })
return []
})
return [...descendants(root, rows), root]
}
function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", input: Runtime) {
for (const pid of pids) {
let sent = false
for (const target of [-pid, pid]) {
try {
input.signal(target, value)
sent = true
} catch (err) {
log.debug("failed to signal PTY process", { err, pid: target, signal: value })
}
}
if (pid === proc.pid && !sent) direct(proc, value)
}
}
async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) {
return await new Promise<Array<{ pid: number; parent: number }>>((resolve) => {
try {
const child = spawn(file, args, {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
timeout: SPAWN_TIMEOUT_MS,
killSignal: "SIGKILL",
})
const chunks: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk))
child.once("error", () => resolve([]))
child.once("close", (code) => {
if (code !== 0) return resolve([])
const rows = Buffer.concat(chunks)
.toString("utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => line.trim().split(/\s+/).map(Number))
.filter(([pid, parent]) => Number.isSafeInteger(pid) && Number.isSafeInteger(parent))
.map(([pid, parent]) => ({ pid: pid!, parent: parent! }))
resolve(rows)
})
} catch {
resolve([])
}
})
}
async function taskkill(
file: string,
args: string[],
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
) {
return await new Promise<boolean>((resolve) => {
try {
const child = spawn(file, args, opts)
child.once("exit", (code) => resolve(code === 0))
child.once("error", (err) => {
log.warn("taskkill failed", { err })
resolve(false)
})
} catch (err) {
log.warn("failed to start taskkill", { err })
resolve(false)
}
})
}
export async function terminate(proc: Process, input: Runtime = runtime): Promise<void> {
const state = { exited: false }
const listener = proc.onExit(() => {
state.exited = true
})
try {
if (!proc.pid) {
direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
return
}
if (input.platform === "win32") {
const killed = await input.taskkill("taskkill", ["/pid", String(proc.pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
timeout: SPAWN_TIMEOUT_MS,
})
if (!killed && !state.exited) direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
return
}
const initial = await family(proc.pid, input)
signal(proc, initial, "SIGTERM", input)
await input.sleep(GRACE_MS)
const remaining = new Set(initial.filter(input.alive))
if (input.alive(proc.pid)) for (const pid of await family(proc.pid, input)) remaining.add(pid)
if (remaining.size > 0) {
signal(proc, [...remaining], "SIGKILL", input)
await input.sleep(GRACE_MS)
}
} finally {
listener.dispose()
}
}
export * as KiloPtyTermination from "./termination"
+37 -24
View File
@@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema" // kilocode_change
import { Shell } from "./shell"
import { lazy } from "./util/lazy"
import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change
import { KiloPtyTermination } from "./kilocode/pty/termination" // kilocode_change
const BUFFER_LIMIT = 1024 * 1024 * 2
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
@@ -35,6 +36,7 @@ type Active = {
cursor: number
subscribers: Map<object, Subscriber>
listeners: Disp[]
stopping: boolean // kilocode_change
}
export const Info = Schema.Struct({
@@ -83,6 +85,8 @@ export type AttachInput = {
readonly onData: (chunk: string) => void
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
readonly onEnd: (event: { exitCode?: number }) => void
// Canonical routes can replay retained output after exit; legacy callers retain the former error.
readonly allowExited?: boolean // kilocode_change
}
export type Attachment = {
@@ -147,23 +151,25 @@ export const layer = Layer.effect(
session.subscribers.clear()
}
function teardown(session: Active) {
// kilocode_change start - terminate the complete PTY tree before reporting removal.
async function teardown(session: Active) {
session.stopping = true
if (session.info.status === "running") await KiloPtyTermination.terminate(session.process)
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
if (session.info.status === "running") {
try {
session.process.kill()
} catch {}
}
notifyEnd(session, {})
notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {})
}
// kilocode_change end
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) teardown(session)
sessions.clear()
exitOrder.length = 0
}),
yield* Effect.addFinalizer(
() =>
// kilocode_change start - wait for process-tree termination during async service teardown.
Effect.promise(async () => {
await Promise.all(Array.from(sessions.values()).map(teardown))
sessions.clear()
exitOrder.length = 0
}),
// kilocode_change end
)
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
@@ -173,14 +179,18 @@ export const layer = Layer.effect(
})
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
// kilocode_change start - removal and its deleted event are one uninterruptible lifecycle transition.
yield* Effect.gen(function* () {
const session = sessions.get(id)
if (!session) return
yield* Effect.logInfo("removing session", { id })
yield* Effect.promise(() => teardown(session))
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* events.publish(Event.Deleted, { id: session.info.id })
}).pipe(Effect.uninterruptible)
// kilocode_change end
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
@@ -204,9 +214,10 @@ export const layer = Layer.effect(
args: input.args ? [...input.args] : undefined,
cwd: input.cwd,
})
const implicit = !resolved.command
const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
const base = resolved.args ?? []
const args = Shell.login(command) ? [...base, "-l"] : [...base]
const args = implicit && Shell.login(command) ? [...base, "-l"] : [...base]
const cwd = resolved.cwd || location.directory
// kilocode_change end
const env = {
@@ -246,6 +257,7 @@ export const layer = Layer.effect(
cursor: 0,
subscribers: new Map(),
listeners: [],
stopping: false, // kilocode_change
}
sessions.set(id, session)
session.listeners.push(
@@ -269,7 +281,7 @@ export const layer = Layer.effect(
session.bufferCursor += excess
}),
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
if (session.info.status === "exited" || session.stopping) return // kilocode_change
session.info.status = "exited"
session.info.exitCode = exitCode
notifyEnd(session, { exitCode })
@@ -309,7 +321,7 @@ export const layer = Layer.effect(
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
if (session.info.status !== "running" && !input.allowExited) return yield* new ExitedError({ ptyID: id }) // kilocode_change
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
const token = {}
const subscriber: Subscriber = {
@@ -318,6 +330,7 @@ export const layer = Layer.effect(
active: false,
detached: false,
pending: [],
end: session.info.status === "exited" ? { exitCode: session.info.exitCode } : undefined, // kilocode_change
}
session.subscribers.set(token, subscriber)
const start = session.bufferCursor
+7 -5
View File
@@ -165,11 +165,13 @@ export const layer = Layer.effect(
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
return abortable.pipe(
Effect.mapError((cause) =>
cause instanceof Error || cause instanceof InvalidPatternError
? cause
: failure("ripgrep execution failed", cause),
),
// kilocode_change start - surface the underlying reason instead of a bare wrapper message
Effect.mapError((cause) => {
if (cause instanceof Error || cause instanceof InvalidPatternError) return cause
const detail = cause instanceof globalThis.Error && cause.message.trim() ? `: ${cause.message.trim()}` : ""
return failure(`ripgrep execution failed${detail}`, cause)
}),
// kilocode_change end
)
}
+3
View File
@@ -229,6 +229,9 @@ export const Info = Schema.Struct({
layout: Schema.optional(ConfigLayoutV1.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(ConfigPermissionV1.Info),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
web_search: Schema.optional(Schema.Boolean).annotate({
description: "Make web search available to models from all providers (default: false)",
}), // kilocode_change
attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({
description: "Attachment processing configuration, including image size limits and resizing behavior",
}),
+2
View File
@@ -45,6 +45,8 @@ export type Reply = typeof Reply.Type
export const ReplyBody = Schema.Struct({
reply: Reply,
message: Schema.String.pipe(Schema.optional),
// kilocode_change - set by clients when a human answered the prompt; the server refuses machine approvals of skill-shell batches
interactive: Schema.Boolean.pipe(Schema.optional),
}).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = typeof ReplyBody.Type
@@ -0,0 +1,108 @@
import { describe, expect, test } from "bun:test"
import { KiloPtyTermination } from "../../src/kilocode/pty/termination"
function fake(pid = 123) {
const calls: Array<string | undefined> = []
const proc: KiloPtyTermination.Process = {
pid,
onExit: () => ({ dispose() {} }),
kill: (signal) => calls.push(signal),
}
return { proc, calls }
}
function runtime(
platform: NodeJS.Platform,
input: {
taskkill?: boolean
signal?: "throw"
tree?: Array<{ pid: number; parent: number }>
} = {},
) {
const tasks: Array<{
file: string
args: string[]
opts: { stdio: "ignore"; windowsHide: true; timeout: number }
}> = []
const signals: Array<{ pid: number; signal: "SIGTERM" | "SIGKILL" }> = []
const sleeps: number[] = []
const value: KiloPtyTermination.Runtime = {
platform,
taskkill: async (file, args, opts) => {
tasks.push({ file, args, opts })
return input.taskkill ?? true
},
tree: async () => input.tree ?? [],
alive: () => true,
signal: (pid, signal) => {
signals.push({ pid, signal })
if (input.signal === "throw") throw new Error("process group unavailable")
},
sleep: async (ms) => {
sleeps.push(ms)
},
}
return { value, tasks, signals, sleeps }
}
describe("pty process-tree termination", () => {
test("uses hidden taskkill for Windows process trees", async () => {
const item = fake(42)
const input = runtime("win32")
await KiloPtyTermination.terminate(item.proc, input.value)
expect(input.tasks).toEqual([
{
file: "taskkill",
args: ["/pid", "42", "/f", "/t"],
opts: { stdio: "ignore", windowsHide: true, timeout: 5_000 },
},
])
expect(input.signals).toEqual([])
expect(item.calls).toEqual([])
expect(input.sleeps).toEqual([200])
})
test("signals POSIX process groups before escalating", async () => {
const item = fake(42)
const input = runtime("linux")
await KiloPtyTermination.terminate(item.proc, input.value)
expect(input.signals).toEqual([
{ pid: -42, signal: "SIGTERM" },
{ pid: 42, signal: "SIGTERM" },
{ pid: -42, signal: "SIGKILL" },
{ pid: 42, signal: "SIGKILL" },
])
expect(item.calls).toEqual([])
expect(input.sleeps).toEqual([200, 200])
})
test("falls back to direct PTY signals when a process group is unavailable", async () => {
const item = fake(42)
const input = runtime("darwin", { signal: "throw" })
await KiloPtyTermination.terminate(item.proc, input.value)
expect(item.calls).toEqual(["SIGTERM", "SIGKILL"])
})
test("signals descendants that run in separate process groups", async () => {
const item = fake(42)
const input = runtime("linux", {
tree: [
{ pid: 43, parent: 42 },
{ pid: 44, parent: 43 },
],
})
await KiloPtyTermination.terminate(item.proc, input.value)
expect(input.signals).toContainEqual({ pid: -44, signal: "SIGTERM" })
expect(input.signals).toContainEqual({ pid: 44, signal: "SIGKILL" })
expect(input.signals).toContainEqual({ pid: -43, signal: "SIGTERM" })
expect(input.signals).toContainEqual({ pid: 43, signal: "SIGKILL" })
})
})
@@ -127,6 +127,43 @@ describe("pty", () => {
}),
)
// kilocode_change start - explicit commands must not acquire implicit login-shell arguments.
ptyTest("preserves explicit command arguments", () =>
Effect.gen(function* () {
const args = ["-c", 'printf "<%s>" "$0"; sleep 5']
const info = yield* createPty("sh", args)
expect(info.args).toEqual(args)
const attached = yield* attachCollecting(info.id)
expect(yield* waitForOutput(attached.output, "<sh>")).toContain("<sh>")
}),
)
ptyTest("terminates background descendants outside the shell process group", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("sh", ["-c", 'sleep 30 & printf "<CHILD:%s>" "$!"; wait'])
const attached = yield* attachCollecting(info.id)
const output = yield* waitForOutput(attached.output, ">")
const match = output.match(/<CHILD:(\d+)>/)
expect(match?.[1]).toBeDefined()
const pid = Number(match?.[1])
yield* pty.remove(info.id)
yield* Effect.sleep("100 millis")
const alive = yield* Effect.sync(() => {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
})
expect(alive).toBe(false)
}),
)
// kilocode_change end
ptyTest("replays buffered output and streams live output to attachments", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
@@ -201,6 +238,31 @@ describe("pty", () => {
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id })
}),
)
// kilocode_change start - canonical attachments replay retained exited output, then end without accepting input.
ptyTest("replays exited output and ends when enabled", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const events = yield* subscribePtyEvents()
const info = yield* createPty("sh", ["-c", 'printf "replayed"; exit 7'])
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"])
const ended = yield* Deferred.make<{ exitCode?: number }>()
const attachment = yield* pty.attach(info.id, {
allowExited: true,
onData: () => {},
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
})
expect(attachment.replay).toContain("replayed")
attachment.write("ignored")
yield* pty.remove(info.id)
attachment.activate()
expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 })
attachment.detach()
}),
)
// kilocode_change end
})
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
+14
View File
@@ -61,4 +61,18 @@ describe("Ripgrep", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
// kilocode_change start - surfaced error keeps the underlying reason
it.live("includes the underlying reason in execution failures", () =>
Effect.gen(function* () {
const ripgrep = yield* Ripgrep.Service
const controller = new AbortController()
controller.abort()
const error = yield* ripgrep
.find({ cwd: process.cwd(), pattern: "*", limit: 1, signal: controller.signal })
.pipe(Effect.flip)
expect(error.message).toMatch(/^ripgrep execution failed: .+/)
}),
)
// kilocode_change end
})
@@ -1,14 +1,18 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { SearchField } from "../../components/SearchField"
import { useConfig } from "../../context/config"
import { toolCapabilities, toolName } from "../../shared/utils"
import { ConfigCountTag as CountTag, ConfigPage } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
export function ToolsRoute() {
const ctx = useConfig()
const [search, setSearch] = createSignal("")
const snap = () => ctx.data()
const websearch = createMemo(() => snap()?.overlay.fields.web_search)
const searchEnabled = createMemo(() => websearch()?.value === true)
const rows = createMemo(() => {
const data = snap()
if (!data) return []
@@ -52,6 +56,48 @@ export function ToolsRoute() {
}
description="Built-in tools available to agents, including file access, terminal execution, search, fetch, and orchestration tools."
>
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Web search</h2>
<p>Control web search availability for models from providers that do not enable it by default.</p>
</div>
<Show when={ctx.query()?.scope === "project" && websearch()?.overridden}>
<Button
variant="secondary"
disabled={Boolean(ctx.saving())}
onClick={() => ctx.unset([["web_search"]])}
>
Revert
</Button>
</Show>
</header>
<div class="ui-form">
<button
class="ui-toggle"
classList={{ selected: searchEnabled() }}
type="button"
aria-pressed={searchEnabled()}
disabled={Boolean(ctx.saving()) || websearch()?.editable === false}
onClick={() => ctx.save({ web_search: !searchEnabled() })}
>
<span>
<strong>Enable for all providers</strong>
<small>Search requests use Exa or Parallel.</small>
<Show when={websearch()?.reason}>{(reason) => <small>{reason()}</small>}</Show>
</span>
<span class="tags">
<SourceBadge
source={websearch()?.source}
inherited={websearch()?.inherited}
overridden={websearch()?.overridden}
/>
<Tag tone={searchEnabled() ? "success" : "neutral"}>{searchEnabled() ? "On" : "Off"}</Tag>
</span>
</button>
</div>
</Card>
<SearchField
label="Filter tools"
value={search()}
@@ -46,6 +46,7 @@ export const AiProvidersNav: NavSection[] = [
{ href: "/ai-providers/groq", children: "Groq" },
{ href: "/ai-providers/cerebras", children: "Cerebras" },
{ href: "/ai-providers/fireworks", children: "Fireworks AI" },
{ href: "/ai-providers/mixlayer", children: "Mixlayer" },
],
},
{
@@ -0,0 +1,79 @@
---
title: "Using Mixlayer with Kilo Code | Fast Open-Model Inference"
description: "Run open models like GLM and Qwen on Mixlayer's OpenAI-compatible API in Kilo Code. Setup guide for VS Code and the CLI."
---
# Using Mixlayer With Kilo Code
Mixlayer is an inference platform for open models such as GLM and Qwen, with a serving stack built from scratch by core contributors to Candle. It exposes an OpenAI-compatible API and is available as a built-in provider in Kilo Code.
**Website:** [https://mixlayer.com/](https://mixlayer.com/)
## Getting an API Key
1. **Sign Up/Sign In:** Go to [Mixlayer](https://mixlayer.com/) and create an account or sign in.
2. **Navigate to API Keys:** Open the [Mixlayer console](https://console.mixlayer.com/) and go to the API Keys page.
3. **Create a Key:** Click **New Key**, give it a descriptive name (e.g., "Kilo Code"), and copy it. You will not be able to view it again.
## Configuration in Kilo Code
Mixlayer is available as a **built-in provider** in Kilo Code, so you can connect it directly — no custom provider setup needed.
{% tabs %}
{% tab label="VSCode" %}
1. Open **Settings** (gear icon) and go to the **Providers** tab.
2. Click **Connect provider**, search for **Mixlayer**, and select it.
3. Enter your Mixlayer API key.
4. Pick a model — Kilo Code fetches the available models automatically.
{% /tab %}
{% tab label="CLI" %}
**Method 1 — `/connect` (recommended)**
Run `kilo`, then use the `/connect` command, select **Mixlayer**, and paste your API key when prompted:
```bash
kilo
# then, inside Kilo, run:
/connect
```
**Method 2 — config file**
Set your API key and add Mixlayer in your `kilo.json` config file (`~/.config/kilo/kilo.json` or `./kilo.json`):
```bash
export MIXLAYER_API_KEY="your-api-key"
```
```jsonc
{
"provider": {
"mixlayer": {
"env": ["MIXLAYER_API_KEY"],
},
},
"model": "mixlayer/z-ai/glm-5.2",
}
```
{% /tab %}
{% /tabs %}
## Models
Mixlayer serves open models including:
- `z-ai/glm-5.2` — 256K context
- `qwen/qwen3.5-397b-a17b` and the Qwen 3.5 / 3.6 line (vision-capable)
- `moonshotai/kimi-k2.7-code`
Tool calling and reasoning are supported across the model line. See the [Mixlayer docs](https://docs.mixlayer.com) for the full, current model list and supported parameters.
## Tips and Notes
- **Model list:** Kilo Code auto-detects available models from Mixlayer's `/v1/models` endpoint, so the picker stays current with your account.
- **Pricing:** See the [Mixlayer console](https://console.mixlayer.com/) for current per-model pricing.
- **Reasoning:** Qwen models support a thinking mode; reasoning tokens count against the output budget, so give responses enough room when reasoning is enabled.
@@ -341,10 +341,12 @@ Two extra variables are injected into the script's environment:
### Using the run button
- **Run:** Click the play button in the toolbar or press `Cmd+E` (macOS) / `Ctrl+E` (Windows/Linux). Output appears in a dedicated VS Code task panel.
- **Run:** Click the play button in the toolbar or press `Cmd+E` (macOS) / `Ctrl+E` (Windows/Linux). Output appears in a named `Run` tab in the Agent Manager terminal panel and remains available after the script exits.
- **Stop:** Click the stop button (same position) or press `Cmd+E` again while running.
- **Configure:** Click the dropdown arrow next to the run button and select "Configure run script" to open the script in your editor.
The terminal destination dropdown in the Agent Manager toolbar also controls where the script runs. **Agent Manager panel** uses the named side terminal, while **VS Code terminal** runs it as a task in the integrated terminal. The integrated terminal option is kept for comparison and will be removed in a future release.
## Session State and Persistence
Agent Manager state is persisted in `.kilo/agent-manager.json`. It stores worktrees, sections, session tabs, ordering, collapsed state, diff preferences, and cached PR metadata. Git branches and worktree directories remain on disk separately.
@@ -36,6 +36,16 @@ Before using Cloud Agents:
Your work is always pushed to GitHub, ensuring nothing is lost.
## Starting Tasks from the CLI
Use the `kilo cloud` command to run Cloud Agent tasks without opening the browser:
```bash
kilo cloud start --prompt "Fix the flaky login test" --repo Kilo-Org/kilocode
```
`kilo cloud` can start tasks, send follow-up prompts, and check task status and results. Repository, branch, model, mode, and organization are inferred from your local checkout and CLI defaults unless you pass the matching flags. Add `--stream` to `kilo cloud start` to print task events as JSONL until the task completes. See the [CLI reference](/docs/code-with-ai/platforms/cli-reference#kilo-cloud) for all commands and options.
## How Cloud Agents Work
- Each user receives an **isolated Linux container** with common dev tools preinstalled (Node.js, git, gh CLI, glab CLI, etc.).
@@ -96,7 +106,7 @@ You can customize each Cloud Agent session by also defining env vars and startup
## Skills
Cloud Agents support project-level [skills](/docs/code-with-ai/platforms/cli#skills) stored in your repository. When your repo is cloned, any skills in `.kilocode/skills/` are automatically available.
Cloud Agents support project-level [skills](/docs/code-with-ai/platforms/cli#skills) stored in your repository. When your repo is cloned, any skills in `.kilocode/skills/` are automatically available. Skill folders are uploaded as `.zip` archives, with up to 40 companion files per skill.
{% callout type="note" %}
Global skills (`~/.kilocode/skills/`) are not available in Cloud Agents since there is no persistent user home directory.
@@ -97,6 +97,7 @@ BYOK lets you use your own provider API keys with the Kilo AI Gateway. When a BY
| Kimi Code | `kimi-coding` |
| Martian | `martian` |
| Neuralwatt | `neuralwatt` |
| NVIDIA | `nvidia-byok` |
| Ollama Cloud | `ollama-cloud` |
| OpenCode Go | `opencode-go` |
| OrcaRouter | `orcarouter` |
@@ -49,6 +49,7 @@ These providers offer coding-focused subscriptions or dedicated endpoints. Bring
- Martian
- Mistral Codestral
- Neuralwatt
- NVIDIA
- Ollama Cloud
- OpenCode Go
- OrcaRouter
@@ -29,6 +29,8 @@ This is especially useful for complex configuration like custom model definition
Kilo reads JSONC config from a **global** location (`~/.config/kilo/kilo.jsonc`) and from your **project** (`kilo.jsonc`, or `.kilo/kilo.jsonc`). All clients — CLI, VS Code, and JetBrains — read the same files.
If `kilo.json` or the legacy `opencode.json`, `opencode.jsonc`, or `config.json` files exist in the same locations, Kilo reads and deep-merges them as well. Clearing a setting in the Settings UI (for example, setting a model back to "Not set") removes it from every config file that contains it.
{% callout type="warning" %}
**Migrating from opencode?** Kilo no longer falls back to opencode configuration stored in `.opencode` directories (such as `~/.config/opencode` or a project `./.opencode/`). To keep using it, move your global config into `~/.config/kilo/` and any project config into `./.kilo/`.
{% /callout %}
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a7c800d169ca92674fc9f7c7d83032ca17e7d7937bdcbea5c72cb921a7fdb89e
size 51975
oid sha256:2a74c7f53a5d9afa743ee49d298ce8fb7e357c5fcc46cad45de353694a7540dc
size 49476
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812
size 11204
oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc
size 27159
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d
size 11950
oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233
size 27302
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e
size 11202
oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3
size 29709
@@ -1579,6 +1579,7 @@ object KiloCliDataParser {
sb.append("""{"reply":${escape(reply.reply)}""")
val msg = reply.message
if (msg != null) sb.append(""","message":${escape(msg)}""")
if (reply.interactive) sb.append(""","interactive":true""")
sb.append("}")
return sb.toString()
}
@@ -722,7 +722,10 @@ class SessionController(
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" }
cs.launch {
try {
if (!autoApprove) {
// Skill-shell batches must be answered by a human: the server refuses
// non-interactive approvals, so auto-approve must show the card (whose
// manual reply sets interactive=true) rather than send a machine reply.
if (!autoApprove || restore().meta.raw["skillShell"] == "true") {
edt {
if (disposed) return@edt
model.setState(SessionState.AwaitingPermission(restore()))
@@ -759,9 +762,16 @@ class SessionController(
try {
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip }
val count = replyAll(permissions)
if (count == 0) return@launch
// Skill-shell requests are skipped by replyAll; surface one as a card so it
// isn't stranded (never machine-approved, never shown).
val card = skillShellCard(permissions)?.let { toPermission(it) }
if (count == 0 && card == null) return@launch
runEdt {
if (disposed) return@runEdt
if (card != null) {
updateModel { model.setState(SessionState.AwaitingPermission(card)) }
return@runEdt
}
val current = model.state
if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
@@ -777,6 +787,8 @@ class SessionController(
var count = 0
for (request in permissions) {
if (!autoApprove) return count
// Skill-shell batches need a human; skip them here (callers surface the card).
if (request.metadata["skillShell"] == "true") continue
sessions.replyPermission(request.id, directory, PermissionReplyDto("once"))
capture("Permission Auto Approved", sessionProps(request.sessionID) + mapOf("tool" to request.permission, "source" to "drain"))
count++
@@ -784,6 +796,11 @@ class SessionController(
return count
}
// A skill-shell request is never machine-approved (the server refuses non-interactive
// approvals); after draining, callers must surface one as a card so a human can answer.
private fun skillShellCard(permissions: List<PermissionRequestDto>): PermissionRequestDto? =
permissions.lastOrNull { it.metadata["skillShell"] == "true" }
private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) {
assertEdt()
val current = model.state
@@ -1156,11 +1173,15 @@ class SessionController(
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child }
if (permissions.isEmpty()) return
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" }
if (autoApprove) {
// A skill-shell request must surface as a card even under auto-approve (replyAll
// skips it); prefer it over the last pending so a human can answer.
val show = if (autoApprove) {
replyAll(permissions)
return
skillShellCard(permissions) ?: return
} else {
skillShellCard(permissions) ?: permissions.last()
}
val last = toPermission(permissions.last())
val last = toPermission(show)
runEdt {
if (disposed) return@runEdt
if (child !in childIds) return@runEdt
@@ -1201,9 +1222,12 @@ class SessionController(
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id }
val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id }
val status = sessions.statuses.value[id]
// replyAll auto-approves the ordinary permissions and skips skill-shell ones. A
// skill-shell request must then fall through to a human card rather than go Busy.
val skillCard = skillShellCard(permissions)
if (permissions.isNotEmpty() && autoApprove) {
val count = replyAll(permissions)
if (count > 0) {
if (count > 0 && skillCard == null) {
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
@@ -1226,7 +1250,8 @@ class SessionController(
if (sid != id) return@runEdt
updateModel {
if (permissions.isNotEmpty()) {
model.setState(SessionState.AwaitingPermission(toPermission(permissions.last())))
// Prefer a skill-shell request (needs a human) over the last pending.
model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last())))
} else if (questions.isNotEmpty()) {
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
} else if (status != null) {
@@ -356,7 +356,7 @@ class PermissionView(
card.setActionEnabled(ID_RUN, false)
card.setActionEnabled(ID_DENY, false)
rules.setControlsEnabled(false)
reply(id, PermissionReplyDto(reply = "once"), rulePayload())
reply(id, PermissionReplyDto(reply = "once", interactive = true), rulePayload())
}
@RequiresEdt
@@ -254,6 +254,23 @@ class PromptLifecycleTest : SessionControllerTestBase() {
)
}
fun `test auto approve does not machine-reply a skill shell batch`() {
val (m, _, _) = prompted()
edt { m.setAutoApprove(true) }
// skill-shell batches must be answered by a human; auto-approve must show the card
// instead of sending a non-interactive reply the server would refuse.
emit(
ChatEventDto.PermissionAsked(
"ses_test",
permission("perm1").copy(metadata = mapOf("skillShell" to "true")),
),
)
assertTrue(rpc.permissionReplies.isEmpty())
assertTrue(m.model.state is SessionState.AwaitingPermission)
}
fun `test disabling auto approve before reply restores awaiting permission`() {
val (m, _, _) = prompted()
@@ -310,6 +327,31 @@ class PromptLifecycleTest : SessionControllerTestBase() {
assertEquals("once", rpc.permissionReplies[0].third.reply)
}
fun `test enabling auto approve surfaces a pending skill shell as a card`() {
val (m, _, _) = prompted()
rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true")))
edt { m.setAutoApprove(true) }
flush()
// skill-shell must not be machine-approved; it surfaces as a human card instead
assertTrue(rpc.permissionReplies.isEmpty())
assertTrue(m.model.state is SessionState.AwaitingPermission)
}
fun `test recovery surfaces a pending skill shell as a card under auto approve`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true")))
edt { KiloPluginSettings.setAutoApprove(true) }
val m = controller("ses_test")
flush()
assertTrue(rpc.permissionReplies.isEmpty())
assertTrue(m.model.state is SessionState.AwaitingPermission)
}
fun `test auto approve drains pending permissions during recovery`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
@@ -328,6 +328,8 @@ data class ToolRefDto(
data class PermissionReplyDto(
val reply: String,
val message: String? = null,
// Set when a human answered the prompt; the CLI ignores machine approvals of skill-shell batches.
val interactive: Boolean = false,
)
@Serializable
@@ -0,0 +1,89 @@
import { mkdtempSync, readFileSync, existsSync, statSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test"
import { createHash } from "node:crypto"
let profileCalls = 0
mock.module("@kilocode/kilo-gateway", () => ({
fetchProfile: async (token: string) => {
profileCalls++
if (token === "bad-token") return null
return { email: `user-${token}@example.com` }
},
}))
const { Identity } = await import("../identity.js")
function digest(token: string) {
return createHash("sha256").update(token).digest("hex")
}
let dir: string
beforeEach(() => {
profileCalls = 0
dir = mkdtempSync(path.join(tmpdir(), "kilo-telemetry-identity-"))
Identity.reset()
Identity.setDataPath(dir)
})
afterEach(() => {
Identity.setDataPath("")
})
describe("Identity.updateFromKiloAuth profile cache", () => {
test("fetches profile and writes cache keyed by token hash", async () => {
await Identity.updateFromKiloAuth("token-a")
expect(Identity.getUserId()).toBe("user-token-a@example.com")
expect(profileCalls).toBe(1)
const file = path.join(dir, "telemetry-profile.json")
expect(existsSync(file)).toBe(true)
const cache = JSON.parse(readFileSync(file, "utf8"))
expect(cache.token).toBe(digest("token-a"))
expect(cache.email).toBe("user-token-a@example.com")
expect(cache.token).not.toBe("token-a")
// The cache stores an email and a token verifier, so it must be owner-only.
// POSIX only: Windows reports default mode bits and enforces access via ACLs.
if (process.platform !== "win32") expect(statSync(file).mode & 0o777).toBe(0o600)
})
test("uses cached email without a network request on later invocations", async () => {
await Identity.updateFromKiloAuth("token-a")
expect(profileCalls).toBe(1)
// Simulate a fresh process: identity state resets, cache file persists.
Identity.reset()
await Identity.updateFromKiloAuth("token-a")
expect(Identity.getUserId()).toBe("user-token-a@example.com")
expect(profileCalls).toBe(1)
})
test("refetches when the token changes", async () => {
await Identity.updateFromKiloAuth("token-a")
Identity.reset()
await Identity.updateFromKiloAuth("token-b")
expect(Identity.getUserId()).toBe("user-token-b@example.com")
expect(profileCalls).toBe(2)
})
test("clears identity when token is null", async () => {
await Identity.updateFromKiloAuth("token-a")
Identity.reset()
await Identity.updateFromKiloAuth(null)
expect(Identity.getUserId()).toBeNull()
expect(profileCalls).toBe(1)
})
test("ignores a cache file for a different token", async () => {
await Identity.updateFromKiloAuth("token-a")
const file = path.join(dir, "telemetry-profile.json")
const cache = JSON.parse(readFileSync(file, "utf8"))
expect(cache.token).toBe(digest("token-a"))
Identity.reset()
await Identity.updateFromKiloAuth("token-b")
expect(Identity.getUserId()).toBe("user-token-b@example.com")
})
})
+21
View File
@@ -82,4 +82,25 @@ export namespace Client {
}
}
}
// Flush queued events in the background without blocking the caller. The
// flush is delayed slightly so commands that exit immediately pay only the
// single shutdown() flush instead of an in-flight flush plus a follow-up
// flush for CLI_EXIT. For commands that outlive the delay, the upload
// overlaps with execution, so by the time shutdown() runs the queue is
// usually empty (or the connection is still warm) and process exit is not
// delayed by a network round trip. The unref'd timer never keeps a process
// alive on its own. The authoritative, error-handled flush still happens in
// shutdown(); failures here are retried there, so they are only surfaced
// when debug logging is on.
export function flushInBackground(delayMs = 300): void {
if (!enabled || !client) return
const timer = setTimeout(() => {
if (!client) return
client.flush().catch((err) => {
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry background flush failed", err)
})
}, delayMs)
timer.unref?.()
}
}
+69
View File
@@ -1,4 +1,6 @@
import * as path from "path"
import { createHash } from "crypto"
import { writeFile, chmod, rename, rm } from "fs/promises"
import { fetchProfile } from "@kilocode/kilo-gateway"
export namespace Identity {
@@ -7,6 +9,21 @@ export namespace Identity {
let organizationId: string | null = null
let dataPath = ""
// Cache the email resolved from the auth token so CLI startup does not block on
// a profile request for every invocation. Keyed by token hash; refreshed when
// the token changes. Stale entries (older than a week) are still used for the
// current run and refreshed on a best-effort basis for a later run: the
// background refresh is not awaited, so short-lived invocations may exit before
// it completes and simply retry next time.
const CACHE_FILE = "telemetry-profile.json"
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000
interface Cache {
token: string
email: string
fetchedAt: number
}
export function setDataPath(p: string) {
dataPath = p
}
@@ -51,6 +68,45 @@ export namespace Identity {
organizationId = orgId
}
function digest(token: string): string {
return createHash("sha256").update(token).digest("hex")
}
async function read(): Promise<Cache | null> {
if (!dataPath) return null
const file = Bun.file(path.join(dataPath, CACHE_FILE))
if (!(await file.exists())) return null
const parsed = await file.json().catch(() => null)
if (!parsed || typeof parsed.token !== "string" || typeof parsed.email !== "string") return null
if (typeof parsed.fetchedAt !== "number") return null
return parsed as Cache
}
async function write(cache: Cache): Promise<void> {
if (!dataPath) return
const filepath = path.join(dataPath, CACHE_FILE)
// The cache stores the user's email and a token verifier, so keep it
// readable only by the owner, including when replacing an existing file.
// Write to a temp file and rename so concurrent invocations or a mid-write
// kill cannot leave a truncated cache behind (POSIX rename is atomic).
const tmp = `${filepath}.${process.pid}.tmp`
try {
await writeFile(tmp, JSON.stringify(cache), { mode: 0o600 })
await chmod(tmp, 0o600)
await rename(tmp, filepath)
} catch (err) {
await rm(tmp, { force: true }).catch((rmErr) => {
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache temp cleanup failed", rmErr)
})
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache write failed", err)
}
}
async function refresh(token: string, tokenHash: string): Promise<void> {
const profile = await fetchProfile(token).catch(() => null)
if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() })
}
export async function updateFromKiloAuth(token: string | null, accountId?: string): Promise<void> {
organizationId = accountId || null
@@ -59,8 +115,21 @@ export namespace Identity {
return
}
const tokenHash = digest(token)
const cached = await read()
if (cached && cached.token === tokenHash) {
userId = cached.email
if (Date.now() - cached.fetchedAt > CACHE_TTL) {
refresh(token, tokenHash).catch((err) => {
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile refresh failed", err)
})
}
return
}
const profile = await fetchProfile(token).catch(() => null)
userId = profile?.email || null
if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() })
}
export function reset() {
+6
View File
@@ -135,6 +135,12 @@ export namespace Telemetry {
track(TelemetryEvent.CLI_START)
}
// Upload queued events without blocking. Call after bootstrap so the flush
// overlaps with command execution and shutdown() stays fast (#10242).
export function flushInBackground() {
Client.flushInBackground()
}
export function trackCliExit(exitCode?: number) {
track(TelemetryEvent.CLI_EXIT, {
duration: Date.now() - startTime,
+1 -1
View File
@@ -678,7 +678,7 @@
"command": "kilo-code.new.agentManager.showTerminal",
"key": "ctrl+/",
"mac": "cmd+/",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !kilo-code.new.sidebarFocused"
},
{
"command": "kilo-code.new.agentManager.runScript",
@@ -4,10 +4,12 @@ import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import type { KiloConnectionService } from "../services/cli-backend"
import { getErrorMessage } from "../kilo-provider-utils"
import { resolveLocalDiffTarget } from "../diff/shared/target"
import { DiffSourceCatalog } from "../diff/sources/catalog"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
import { composeDiffId, normalizeScope } from "./diff-scope"
import { handleSection } from "./section-handler"
import { normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
@@ -22,9 +24,9 @@ import { SessionTerminalManager } from "./SessionTerminalManager"
import { createTerminalHost } from "./terminal-host"
import { TerminalRouter } from "./terminal-routing"
import { executeVscodeTask } from "./task-runner"
import { startVscodeRunTask } from "./run/task"
import { RunController } from "./run/controller"
import { handleRunMessage } from "./run/message"
import { createRunController, createScriptTerminalRuntime } from "./script-terminal-runtime"
import { forkSession } from "./fork-session"
import { AgentManagerVisiblePresence } from "./am-visible-presence"
import { continueInWorktree } from "./continue-in-worktree"
@@ -64,6 +66,7 @@ export class AgentManagerProvider implements Disposable {
private importer: WorktreeImporter
private terminalManager: SessionTerminalManager
private terminalRouter: TerminalRouter
private scripts: ReturnType<typeof createScriptTerminalRuntime>
private run: RunController
private stateReady: Promise<void> | undefined
private statsPoller: GitStatsPoller
@@ -71,6 +74,7 @@ export class AgentManagerProvider implements Disposable {
private orchestration: AgentManagerOrchestrationBridge
private gitOps: GitOps
private diffs: WorktreeDiffController
private diffCatalog: DiffSourceCatalog
private naming: BranchNamingController
private staleWorktreeIds = new Set<string>()
private toolRequests = new Set<string>()
@@ -111,19 +115,25 @@ export class AgentManagerProvider implements Disposable {
post: (msg) => this.postToWebview(msg),
getTerminalFont: () => readTerminalFont(),
})
this.scripts = createScriptTerminalRuntime({
connection: this.connectionService,
output: this.outputChannel,
post: (message) => this.postToWebview(message),
})
this.unsubFont = watchTerminalFont((font) => {
this.postToWebview({ type: "agentManager.terminal.fontChanged", font })
this.scripts.manager.snapshot()
})
this.unsubDestination = watchTerminalDestination((destination) => {
this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination })
})
this.run = new RunController({
this.run = createRunController({
manager: this.scripts.manager,
root: () => this.getRoot(),
state: () => this.getStateManager(),
open: (file) => this.host.openDocument(file),
start: startVscodeRunTask,
post: (status) => this.postToWebview({ type: "agentManager.runStatus", ...status }),
error: (message) => this.postToWebview({ type: "error", message }),
trusted: () => this.host.isTrusted(),
post: (message) => this.postToWebview(message),
log: (msg) => this.outputChannel.appendLine(`[RunScript] ${msg}`),
refresh: () => this.pushState(),
})
@@ -149,12 +159,13 @@ export class AgentManagerProvider implements Disposable {
log: (msg) => this.log(msg),
})
const local = createLocalDiff(this.gitOps, (...args) => this.log(...args))
this.diffCatalog = new DiffSourceCatalog(this.connectionService)
this.diffs = new WorktreeDiffController({
getState: () => this.getStateManager(),
getRoot: () => this.getRoot(),
getStateReady: () => this.stateReady,
catalog: this.diffCatalog,
git: this.gitOps,
localDiff: local.summary,
localDiffFile: local.file,
post: (msg) => this.postToWebview(msg),
log: (...args) => this.log(...args),
@@ -412,6 +423,7 @@ export class AgentManagerProvider implements Disposable {
if (diff !== undefined) return diff
const bridge = this.onBridgeMessage(m)
if (bridge !== undefined) return bridge
if (this.scripts.manager.intercept(m)) return null
if (this.terminalRouter.handle(m)) return null
return msg
@@ -684,11 +696,11 @@ export class AgentManagerProvider implements Disposable {
private onDiffMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
if (m.type === "agentManager.requestWorktreeDiff") {
void this.diffs.request(m.sessionId)
void this.diffs.request(composeDiffId(m.sessionId, normalizeScope(m.scope)))
return null
}
if (m.type === "agentManager.requestWorktreeDiffFile") {
void this.diffs.requestFile(m.sessionId, m.file)
void this.diffs.requestFile(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file)
return null
}
if (m.type === "agentManager.applyWorktreeDiff") {
@@ -696,23 +708,53 @@ export class AgentManagerProvider implements Disposable {
return null
}
if (m.type === "agentManager.revertWorktreeFile") {
void this.diffs.revert(m.sessionId, m.file)
void this.diffs.revert(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file)
return null
}
if (m.type === "agentManager.startDiffWatch") {
this.diffs.start(m.sessionId)
this.diffs.start(composeDiffId(m.sessionId, normalizeScope(m.scope)))
return null
}
if (m.type === "agentManager.stopDiffWatch") {
this.diffs.stop()
return null
}
if (m.type === "agentManager.requestDiffBranches") {
void this.sendDiffBranches(m.sessionId, m.scope)
return null
}
if (m.type === "agentManager.setDiffBaseBranch") {
void this.diffs
.setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch)
.catch((err) => this.log("Failed to set diff base:", err instanceof Error ? err.message : String(err)))
.then(() => void this.sendDiffBranches(m.sessionId, m.scope))
return null
}
if (m.type === "agentManager.openFile") {
this.openWorktreeFile(m.sessionId, m.filePath, m.line, m.column)
return null
}
}
private async sendDiffBranches(sessionId: string, scope?: string): Promise<void> {
const id = composeDiffId(sessionId, normalizeScope(scope))
const result = await this.diffs.branches(id).catch((err) => {
this.log("Failed to list diff branches:", err instanceof Error ? err.message : String(err))
return undefined
})
if (!result) return
this.postToWebview({
type: "agentManager.diffBranches",
sessionId: id,
branches: result.branches,
defaultBranch: result.defaultBranch,
autoBase: result.autoBase,
currentBase: result.currentBase,
isAuto: result.isAuto,
currentBranch: result.currentBranch,
})
}
private onBridgeMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
if (m.type !== "openFile") return undefined
@@ -732,6 +774,7 @@ export class AgentManagerProvider implements Disposable {
// the panel itself is disposed. In-flight creates from the dying
// instance are reaped by the router's generation guard.
void this.terminalRouter.dispose()
this.scripts.manager.snapshot()
void this.stateReady
?.then(() => {
// When the folder is not a git repo (or has no folder open),
@@ -1023,11 +1066,16 @@ export class AgentManagerProvider implements Disposable {
this.log(`Worktree ${worktreeId} not found in state`)
return null
}
this.statsPoller.skipWorktree(worktreeId)
await this.run.remove(worktreeId)
if (!(await this.scripts.manager.clear("run", worktreeId))) {
this.statsPoller.unskipWorktree(worktreeId)
this.postToWebview({ type: "error", message: "Failed to stop the Run script before deleting the worktree" })
return null
}
// Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree.
// Pre-emptive skip covers any in-flight poll that already captured getWorktrees().
this.statsPoller.skipWorktree(worktreeId)
this.prBridge.remove(worktreeId)
this.run.remove(worktreeId)
this.naming.forget(worktreeId)
const orphaned = state.removeWorktree(worktreeId)
if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) {
@@ -1062,6 +1110,11 @@ export class AgentManagerProvider implements Disposable {
return null
}
await this.run.remove(worktreeId)
if (!(await this.scripts.manager.clear("run", worktreeId))) {
this.postToWebview({ type: "error", message: "Failed to stop the Run script before removing the worktree" })
return null
}
this.naming.forget(worktreeId)
const orphaned = state.removeWorktree(worktreeId)
if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) {
@@ -1924,9 +1977,11 @@ export class AgentManagerProvider implements Disposable {
this.unsubStatus?.()
this.unsubFont?.()
this.unsubDestination?.()
await this.scripts.dispose()
this.orchestration.dispose()
this.visiblePresence.clear()
this.diffs.stop()
this.diffCatalog.dispose()
this.naming.dispose()
this.statsPoller.stop()
this.gitOps.dispose()
@@ -102,6 +102,10 @@ export class GitStatsPoller {
this.skipWorktreeIds.add(id)
}
unskipWorktree(id: string): void {
this.skipWorktreeIds.delete(id)
}
setEnabled(enabled: boolean): void {
if (enabled) {
if (this.active) return
@@ -0,0 +1,395 @@
import type { KiloClient } from "@kilocode/sdk/v2/client"
import type { TerminalFont } from "./terminal-font"
import type { RunHandle } from "./run/manager"
type ScriptTerminalKind = "run"
type ScriptTerminalState = "running" | "stopping" | "exited" | "failed"
interface ScriptTerminalConfig {
worktreeId: string
command: string
args: string[]
cwd: string
env: Record<string, string>
}
interface ScriptTerminalExit {
exitCode?: number
stopped?: boolean
error?: string
}
export interface ScriptTerminalView {
terminalId: string
/** null for the LOCAL workspace; RunController retains its internal "local" key. */
worktreeId: string | null
kind: ScriptTerminalKind
title: "Run"
wsUrl: string
state: ScriptTerminalState
exitCode?: number
font: TerminalFont
}
interface ScriptTerminalDeps {
getClient(): KiloClient
getClientAsync(directory: string): Promise<KiloClient>
buildWsUrl(ptyID: string, cwd: string): string
getTerminalFont(): TerminalFont
emit(terminals: ScriptTerminalView[]): void
closed(terminalId: string): void
log(msg: string): void
}
interface Entry {
key: string
kind: ScriptTerminalKind
terminalId: string
ptyID: string
worktreeId: string
cwd: string
wsUrl: string
state: ScriptTerminalState
exitCode?: number
done: (exit: ScriptTerminalExit) => void
finished: boolean
closing?: Promise<void>
}
interface TerminalMessage {
type: string
terminalId?: unknown
cols?: unknown
rows?: unknown
}
function message(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
function missing(error: unknown): boolean {
if (!error || typeof error !== "object") return false
const value = error as Record<string, unknown>
if (value.status === 404 || value._tag === "PtyNotFoundError") return true
if (!value.data || typeof value.data !== "object") return false
const data = value.data as Record<string, unknown>
return data.status === 404 || data._tag === "PtyNotFoundError"
}
function key(kind: ScriptTerminalKind, worktreeId: string): string {
return `${kind}:${worktreeId}`
}
function terminalId(): string {
return `script:${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
/**
* Owns extension-host script PTYs independently from webview terminal routing.
* Exited records stay available for output replay until the user closes them.
*/
export class ScriptTerminalManager {
private readonly entries = new Map<string, Entry>()
private readonly terminals = new Map<string, Entry>()
private readonly ptys = new Map<string, Entry>()
constructor(private readonly deps: ScriptTerminalDeps) {}
async start(
kind: ScriptTerminalKind,
config: ScriptTerminalConfig,
done: (exit: ScriptTerminalExit) => void,
): Promise<RunHandle> {
const id = key(kind, config.worktreeId)
const prior = this.entries.get(id)
if (prior) {
if (prior.state === "running" || prior.state === "stopping") throw new Error("Run terminal is already active")
await this.remove(prior, false)
if (this.entries.has(id)) throw new Error("Failed to remove previous Run terminal")
}
const client = await this.deps.getClientAsync(config.cwd).catch((error) => {
const detail = message(error)
this.deps.log(`Run terminal create failed: ${detail}`)
throw new Error(detail)
})
const created = await client.v2.pty
.create({
location: { directory: config.cwd },
command: config.command,
args: config.args,
cwd: config.cwd,
env: config.env,
title: "Run",
})
.catch((error) => {
const detail = message(error)
this.deps.log(`Run terminal create failed: ${detail}`)
throw new Error(detail)
})
const pty = created.data?.data
if (created.error || !pty) {
const detail = message(created.error ?? "unknown error")
this.deps.log(`Run terminal create failed: ${detail}`)
throw new Error(`Failed to create Run terminal: ${detail}`)
}
const wsUrl = await this.url(client, pty.id, config.cwd)
const entry: Entry = {
key: id,
kind,
terminalId: terminalId(),
ptyID: pty.id,
worktreeId: config.worktreeId,
cwd: config.cwd,
wsUrl,
state: "running",
done,
finished: false,
}
this.entries.set(entry.key, entry)
this.terminals.set(entry.terminalId, entry)
this.ptys.set(entry.ptyID, entry)
this.emit()
await this.reconcile(entry, client)
return {
stop: () => this.stop(entry),
}
}
/** Return true only for close/resize messages owned by a script terminal. */
intercept(msg: TerminalMessage): boolean {
const id = msg.terminalId
if (typeof id !== "string" || !this.terminals.has(id)) return false
if (msg.type === "agentManager.terminal.close") {
void this.close(id).then((closed) => {
if (closed) this.deps.closed(id)
})
return true
}
if (msg.type !== "agentManager.terminal.resize") return false
if (typeof msg.cols !== "number" || typeof msg.rows !== "number") return true
void this.resize(id, msg.cols, msg.rows)
return true
}
exited(ptyID: string, exitCode: number): void {
const entry = this.ptys.get(ptyID)
if (!entry) return
this.finishExited(entry, exitCode)
}
deleted(ptyID: string): void {
const entry = this.ptys.get(ptyID)
if (!entry) return
const state = entry.state
this.drop(entry)
this.emit()
if (state === "stopping") {
this.done(entry, { stopped: true })
return
}
if (state === "running") this.done(entry, { error: "Run terminal was removed before it exited" })
}
snapshot(): void {
this.emit()
}
owns(ptyID: string): boolean {
return this.ptys.has(ptyID)
}
async sync(): Promise<void> {
await Promise.all(
[...this.entries.values()].map(async (entry) => {
const client = await this.deps.getClientAsync(entry.cwd).catch((error) => {
this.deps.log(`Failed to reconnect Run terminal: ${message(error)}`)
return undefined
})
if (client) await this.reconcile(entry, client)
}),
)
}
async clear(kind: ScriptTerminalKind, worktreeId: string): Promise<boolean> {
const entry = this.entries.get(key(kind, worktreeId))
if (!entry) return true
return this.close(entry.terminalId)
}
async close(terminalId: string): Promise<boolean> {
const entry = this.terminals.get(terminalId)
if (!entry) return true
if (entry.state === "running") {
await this.stop(entry)
return !this.terminals.has(terminalId)
}
if (entry.state === "stopping") {
await entry.closing
return !this.terminals.has(terminalId)
}
await this.remove(entry, false)
return !this.terminals.has(terminalId)
}
async resize(terminalId: string, cols: number, rows: number): Promise<void> {
const entry = this.terminals.get(terminalId)
if (!entry) return
try {
const client = this.deps.getClient()
const result = await client.v2.pty.update({
ptyID: entry.ptyID,
location: { directory: entry.cwd },
size: { cols, rows },
})
if (!result.error) return
this.deps.log(`Run terminal resize failed (${terminalId}): ${message(result.error)}`)
} catch (error) {
this.deps.log(`Run terminal resize failed (${terminalId}): ${message(error)}`)
}
}
async dispose(): Promise<void> {
await Promise.all([...this.terminals.keys()].map((terminalId) => this.close(terminalId)))
}
private async reconcile(entry: Entry, client: KiloClient): Promise<void> {
if (!this.current(entry)) return
try {
const result = await client.v2.pty.get({ ptyID: entry.ptyID, location: { directory: entry.cwd } })
const pty = result.data?.data
if (result.error || !pty) {
this.missing(entry, `Run terminal is no longer available: ${message(result.error ?? "unknown error")}`)
return
}
if (pty.status === "exited") this.finishExited(entry, pty.exitCode ?? 0)
} catch (error) {
this.deps.log(`Failed to read Run terminal: ${message(error)}`)
}
}
private async stop(entry: Entry): Promise<void> {
if (!this.current(entry)) return
if (entry.state === "stopping") {
await entry.closing
return
}
if (entry.state === "exited" || entry.state === "failed") {
await this.remove(entry, false)
return
}
entry.state = "stopping"
this.emit()
await this.remove(entry, true)
}
private remove(entry: Entry, stopped: boolean): Promise<void> {
if (entry.closing) return entry.closing
const task = this.removeEntry(entry, stopped)
entry.closing = task
void task.finally(() => {
if (this.current(entry) && entry.closing === task) entry.closing = undefined
})
return task
}
private async removeEntry(entry: Entry, stopped: boolean): Promise<void> {
try {
const client = await this.deps.getClientAsync(entry.cwd)
const result = await client.v2.pty.remove({ ptyID: entry.ptyID, location: { directory: entry.cwd } })
if (result.error) {
if (missing(result.error)) {
this.drop(entry)
this.emit()
if (stopped) this.done(entry, { stopped: true })
return
}
this.failed(entry, `Failed to remove Run terminal: ${message(result.error)}`)
return
}
this.drop(entry)
this.emit()
if (stopped) this.done(entry, { stopped: true })
} catch (error) {
this.failed(entry, `Failed to remove Run terminal: ${message(error)}`)
}
}
private async url(client: KiloClient, ptyID: string, cwd: string): Promise<string> {
try {
return this.deps.buildWsUrl(ptyID, cwd)
} catch (error) {
this.deps.log(`Failed to build Run terminal URL: ${message(error)}`)
try {
const result = await client.v2.pty.remove({ ptyID, location: { directory: cwd } })
if (result.error) this.deps.log(`Failed to remove Run terminal after URL failure: ${message(result.error)}`)
} catch (cleanup) {
this.deps.log(`Failed to remove Run terminal after URL failure: ${message(cleanup)}`)
}
throw error
}
}
private finishExited(entry: Entry, exitCode: number): void {
if (!this.current(entry) || entry.state === "exited") return
entry.state = "exited"
entry.exitCode = exitCode
this.emit()
this.done(entry, { exitCode })
}
private failed(entry: Entry, error: string): void {
if (!this.current(entry)) return
this.deps.log(error)
entry.state = "failed"
this.emit()
this.done(entry, { error })
}
private missing(entry: Entry, error: string): void {
if (!this.current(entry)) return
this.deps.log(error)
this.drop(entry)
this.emit()
this.done(entry, { error })
}
private done(entry: Entry, exit: ScriptTerminalExit): void {
if (entry.finished) return
entry.finished = true
entry.done(exit)
}
private drop(entry: Entry): void {
if (!this.current(entry)) return
this.entries.delete(entry.key)
this.terminals.delete(entry.terminalId)
this.ptys.delete(entry.ptyID)
}
private current(entry: Entry): boolean {
return this.entries.get(entry.key) === entry
}
private emit(): void {
const terminals: ScriptTerminalView[] = []
for (const entry of this.entries.values()) {
const terminal: ScriptTerminalView = {
terminalId: entry.terminalId,
worktreeId: entry.worktreeId === "local" ? null : entry.worktreeId,
kind: entry.kind,
title: "Run",
wsUrl: entry.wsUrl,
state: entry.state,
font: this.deps.getTerminalFont(),
}
if (entry.exitCode !== undefined) terminal.exitCode = entry.exitCode
terminals.push(terminal)
}
this.deps.emit(terminals)
}
}
@@ -71,6 +71,7 @@ function createMockHost(): Host {
return {
openPanel: vi.fn(),
workspacePath: () => "/repo",
isTrusted: () => true,
autoBranchNaming: () => ({ enabled: true, prefix: "" }),
showError: vi.fn(),
openDocument: vi.fn().mockResolvedValue(undefined),
@@ -78,7 +79,10 @@ function createMockHost(): Host {
openFolder: vi.fn(),
createOutput: () => ({ appendLine: vi.fn(), dispose: vi.fn() }) as OutputHandle,
extensionKeybindings: () => [],
copyToClipboard: vi.fn(),
capture: vi.fn(),
openExternal: vi.fn(),
refreshGit: vi.fn(),
dispose: vi.fn(),
}
}
@@ -102,6 +106,7 @@ function createHarness() {
prBridge: { handleMessage: ReturnType<typeof vi.fn> }
activeSessionId: string | undefined
naming: { prompt: ReturnType<typeof vi.fn> }
scripts: { intercept: ReturnType<typeof vi.fn>; snapshot: ReturnType<typeof vi.fn> }
terminalRouter: { handle: ReturnType<typeof vi.fn> }
stateReady: Promise<void> | undefined
contextTarget: ReturnType<typeof vi.fn>
@@ -125,6 +130,7 @@ function createHarness() {
manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) }
manager.activeSessionId = undefined
manager.naming = { prompt: vi.fn() }
manager.scripts = { intercept: vi.fn().mockReturnValue(false), snapshot: vi.fn() }
manager.terminalRouter = { handle: vi.fn().mockReturnValue(false) }
manager.stateReady = Promise.resolve()
manager.contextTarget = vi.fn()
@@ -0,0 +1,57 @@
/**
* Composite diff-source keying for Agent Manager.
*
* Agent Manager keys diff sources by *context* (a session id, or the `local`
* workspace pseudo-context) while the standalone Changes viewer keys by
* *scope* (branch / staged / unstaged / session). To expose scopes in Agent
* Manager we compose the two into a single id the SourceController can build.
*
* ctx = "local" | "<sessionId>"
* scope = "branch" | "staged" | "unstaged" | "session"
* id = `${ctx}#${scope}`
*
* `ctx#branch` is the default and reproduces the pre-scope behavior exactly.
*/
export type DiffScope = "branch" | "staged" | "unstaged" | "session"
export const DEFAULT_DIFF_SCOPE: DiffScope = "branch"
const SEP = "#"
export function composeDiffId(ctx: string, scope: DiffScope): string {
return `${ctx}${SEP}${scope}`
}
/**
* Split a composite id back into context and scope. Tolerates a bare context
* id (no separator) by assuming the default branch scope, which keeps the
* pre-scope messages working unchanged.
*/
export function parseDiffId(id: string): { ctx: string; scope: DiffScope } {
const idx = id.lastIndexOf(SEP)
if (idx === -1) return { ctx: id, scope: DEFAULT_DIFF_SCOPE }
const scope = id.slice(idx + SEP.length)
if (isDiffScope(scope)) return { ctx: id.slice(0, idx), scope }
return { ctx: id, scope: DEFAULT_DIFF_SCOPE }
}
export function isDiffScope(value: string): value is DiffScope {
return value === "branch" || value === "staged" || value === "unstaged" || value === "session"
}
export function normalizeScope(value: unknown): DiffScope {
return typeof value === "string" && isDiffScope(value) ? value : DEFAULT_DIFF_SCOPE
}
/**
* Map a scope to the underlying standalone-viewer source id the catalog knows
* how to build. `branch` maps to the workspace source; `session` is handled
* separately because it needs the session id embedded in the source id.
*/
export function scopeToSourceId(scope: DiffScope, ctx: string): string {
if (scope === "staged") return "staged"
if (scope === "unstaged") return "unstaged"
if (scope === "session") return `session:${ctx}`
return "workspace"
}
@@ -104,6 +104,9 @@ export interface Host {
/** Get the workspace/project root path. */
workspacePath(): string | undefined
/** Whether the workspace permits executing configured scripts. */
isTrusted(): boolean
/** Read the user's automatic branch naming preferences. */
autoBranchNaming(): { enabled: boolean; prefix: string }
@@ -4,8 +4,10 @@ import { getShellEnvironment } from "../shell-env"
import { RunScriptManager, type RunHandle, type RunStatus } from "./manager"
import { RunScriptService } from "./service"
import type { WorktreeStateManager } from "../WorktreeStateManager"
import type { RunTerminalDestination } from "./destination"
export interface RunTaskConfig {
destination: RunTerminalDestination
worktreeId: string
branch: string
command: string
@@ -14,11 +16,13 @@ export interface RunTaskConfig {
env: Record<string, string>
}
interface TaskExit {
export interface RunTaskExit {
exitCode?: number
stopped?: boolean
error?: string
}
type StartTask = (config: RunTaskConfig, done: (exit: TaskExit) => void) => Promise<RunHandle>
export type StartTask = (config: RunTaskConfig, done: (exit: RunTaskExit) => void) => Promise<RunHandle>
interface Options {
root: () => string | undefined
@@ -60,7 +64,7 @@ export class RunController {
this.opts.refresh?.()
}
async run(worktreeId: string): Promise<void> {
async run(worktreeId: string, destination: RunTerminalDestination): Promise<void> {
const status = this.manager.status(worktreeId)
if (status.state !== "idle") {
this.stop(worktreeId)
@@ -109,18 +113,19 @@ export class RunController {
}
const start = () =>
this.opts.start({ worktreeId, branch, command: script.command, args: script.args, cwd, env }, (exit) =>
this.manager.finish(worktreeId, { exitCode: exit.exitCode }),
this.opts.start(
{ destination, worktreeId, branch, command: script.command, args: script.args, cwd, env },
(exit) => this.manager.finish(worktreeId, exit),
)
await this.manager.start(worktreeId, start)
}
stop(worktreeId: string): void {
this.manager.stop(worktreeId)
void this.manager.stop(worktreeId)
}
remove(worktreeId: string): void {
this.manager.remove(worktreeId)
remove(worktreeId: string): Promise<void> {
return this.manager.remove(worktreeId)
}
dispose(): void {
@@ -0,0 +1,16 @@
/**
* Where the Agent Manager Run button executes the project run script.
*
* The Agent Manager terminal dropdown owns this choice per panel.
* "agentManager" runs through the canonical PTY service in the embedded
* side terminal. "vscode" is the legacy integrated terminal task path,
* kept for comparison while the embedded path proves itself. Remove the
* "vscode" dropdown option, `run/task.ts`, and the integrated branch below
* together once the embedded path is the only one.
*/
export type RunTerminalDestination = "agentManager" | "vscode"
export function pickRunStart<T>(destination: RunTerminalDestination, embedded: T, integrated: T): T {
return destination === "vscode" ? integrated : embedded
}
@@ -4,6 +4,7 @@ export interface RunStatus {
worktreeId: string
state: RunState
exitCode?: number
stopped?: boolean
signal?: string
startedAt?: string
finishedAt?: string
@@ -11,17 +12,21 @@ export interface RunStatus {
}
export interface RunHandle {
stop(): void
stop(): void | Promise<void>
dispose?(): void
}
interface Entry {
status: RunStatus
handle?: RunHandle
task?: Promise<RunHandle>
released?: boolean
stopping?: Promise<void>
}
interface FinishOptions {
exitCode?: number
stopped?: boolean
signal?: string
error?: string
}
@@ -42,6 +47,7 @@ export class RunScriptManager {
) {}
async start(worktreeId: string, start: () => Promise<RunHandle>): Promise<boolean> {
this.removed.delete(worktreeId)
const current = this.entries.get(worktreeId)
if (current && current.status.state !== "idle") return false
@@ -56,21 +62,25 @@ export class RunScriptManager {
this.emit(entry.status)
try {
const handle = await start()
const task = start()
entry.task = task
const handle = await task
const latest = this.entries.get(worktreeId)
if (latest !== entry) {
handle.dispose?.()
await this.release(worktreeId, entry, handle, this.removed.has(worktreeId))
return true
}
entry.handle = handle
if (entry.status.state === "stopping") handle.stop()
if (entry.status.state === "stopping") {
void this.halt(worktreeId, entry, handle)
}
} catch (error) {
this.finish(worktreeId, { error: message(error) })
}
return true
}
stop(worktreeId: string): void {
async stop(worktreeId: string): Promise<void> {
const entry = this.entries.get(worktreeId)
if (!entry || entry.status.state === "idle" || entry.status.state === "stopping") return
@@ -81,11 +91,7 @@ export class RunScriptManager {
this.emit(entry.status)
if (!entry.handle) return
try {
entry.handle.stop()
} catch (error) {
this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`)
}
await this.halt(worktreeId, entry, entry.handle)
}
finish(worktreeId: string, opts: FinishOptions = {}): void {
@@ -100,6 +106,7 @@ export class RunScriptManager {
}
if (entry?.status.startedAt) status.startedAt = entry.status.startedAt
if (opts.exitCode !== undefined) status.exitCode = opts.exitCode
if (opts.stopped) status.stopped = true
if (opts.signal) status.signal = opts.signal
if (opts.error) status.error = opts.error
@@ -115,24 +122,57 @@ export class RunScriptManager {
return [...this.entries.values()].map((entry) => entry.status)
}
remove(worktreeId: string): void {
async remove(worktreeId: string): Promise<void> {
const entry = this.entries.get(worktreeId)
if (entry?.status.state !== "idle") this.stop(worktreeId)
this.entries.delete(worktreeId)
this.removed.add(worktreeId)
this.entries.delete(worktreeId)
const handle =
entry?.handle ??
(entry?.task
? await entry.task.catch((error) => {
this.log(`Failed to start removed run script for ${worktreeId}: ${message(error)}`)
return undefined
})
: undefined)
if (!entry || !handle) return
if (entry.status.state !== "idle") {
await this.release(worktreeId, entry, handle, true)
return
}
await this.release(worktreeId, entry, handle, false)
}
dispose(): void {
for (const entry of this.entries.values()) {
if (entry.status.state !== "idle") {
try {
entry.handle?.stop()
} catch (error) {
this.log(`Failed to stop run script during dispose: ${message(error)}`)
}
}
entry.handle?.dispose?.()
for (const [id, entry] of this.entries) {
this.removed.add(id)
if (!entry.handle || entry.released) continue
entry.released = true
if (entry.status.state !== "idle") void this.halt(id, entry, entry.handle)
entry.handle.dispose?.()
}
this.entries.clear()
}
private async release(worktreeId: string, entry: Entry, handle: RunHandle, stop: boolean): Promise<void> {
if (entry.released) return
entry.released = true
if (stop) await this.halt(worktreeId, entry, handle)
handle.dispose?.()
}
private halt(worktreeId: string, entry: Entry, handle: RunHandle): Promise<void> {
if (entry.stopping) return entry.stopping
const task = (() => {
try {
return Promise.resolve(handle.stop())
.then(() => undefined)
.catch((error) => this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`))
} catch (error) {
this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`)
return Promise.resolve()
}
})()
entry.stopping = task
return task
}
}
@@ -7,7 +7,7 @@ export function handleRunMessage(run: RunController, msg: AgentManagerInMessage)
return true
}
if (msg.type === "agentManager.runScript") {
void run.run(msg.worktreeId)
void run.run(msg.worktreeId, msg.destination)
return true
}
if (msg.type === "agentManager.stopRunScript") {
@@ -1,3 +1,11 @@
/**
* Legacy integrated terminal Run adapter.
*
* Kept while the Agent Manager terminal dropdown offers the "VS Code
* terminal" option so both execution paths can be compared. Remove this
* file together with that dropdown option and the integrated `pickRunStart`
* branch.
*/
import * as vscode from "vscode"
import type { RunHandle } from "./manager"
@@ -0,0 +1,81 @@
import type { KiloConnectionService } from "../services/cli-backend"
import type { OutputHandle } from "./host"
import { ScriptTerminalManager } from "./ScriptTerminalManager"
import { buildScriptTerminalWsUrl } from "./script-terminal-url"
import { readTerminalFont } from "./terminal-font"
import type { AgentManagerOutMessage } from "./types"
import type { WorktreeStateManager } from "./WorktreeStateManager"
import { RunController } from "./run/controller"
import { pickRunStart } from "./run/destination"
import { startVscodeRunTask } from "./run/task"
interface Input {
connection: KiloConnectionService
output: OutputHandle
post(message: AgentManagerOutMessage): void
}
export function createScriptTerminalRuntime(input: Input) {
const manager = new ScriptTerminalManager({
getClient: () => input.connection.getClient(),
getClientAsync: (directory) => input.connection.getClientAsync(directory),
buildWsUrl: (ptyID, cwd) => {
const config = input.connection.getServerConfig()
if (!config) throw new Error("Not connected to CLI backend")
return buildScriptTerminalWsUrl(config, ptyID, cwd)
},
getTerminalFont: () => readTerminalFont(),
emit: (terminals) => input.post({ type: "agentManager.scriptTerminals", terminals }),
closed: (terminalId) => input.post({ type: "agentManager.terminal.closed", terminalId }),
log: (msg) => input.output.appendLine(`[RunScript] ${msg}`),
})
const event = input.connection.onEventFiltered(
(value) => (value.type === "pty.exited" || value.type === "pty.deleted") && manager.owns(value.properties.id),
(value) => {
if (value.type === "pty.exited") manager.exited(value.properties.id, value.properties.exitCode)
if (value.type === "pty.deleted") manager.deleted(value.properties.id)
},
)
const connection = input.connection.onStateChange((state) => {
if (state === "connected") void manager.sync()
})
return {
manager,
dispose: async () => {
event()
connection()
await manager.dispose()
},
}
}
interface RunInput {
manager: ScriptTerminalManager
root(): string | undefined
state(): WorktreeStateManager | undefined
open(path: string): Promise<void>
trusted(): boolean
post(message: AgentManagerOutMessage): void
log(message: string): void
refresh(): void
}
export function createRunController(input: RunInput) {
return new RunController({
root: input.root,
state: input.state,
open: input.open,
start: async (config, done) => {
if (!input.trusted()) throw new Error("Trust the workspace before running scripts")
return pickRunStart(
config.destination,
(cfg, cb) => input.manager.start("run", cfg, cb),
startVscodeRunTask,
)(config, done)
},
post: (status) => input.post({ type: "agentManager.runStatus", ...status }),
error: (message) => input.post({ type: "error", message }),
log: input.log,
refresh: input.refresh,
})
}
@@ -0,0 +1,17 @@
export interface PtyServerConfig {
baseUrl: string
password: string
}
/** Build the canonical authenticated PTY WebSocket URL for script terminals. */
export function buildScriptTerminalWsUrl(config: PtyServerConfig, ptyID: string, cwd: string): string {
const base = config.baseUrl.replace(/^http/i, "ws").replace(/\/$/, "")
const token = Buffer.from(`kilo:${config.password}`).toString("base64")
const query = new URLSearchParams({
"location[directory]": cwd,
cursor: "0",
replayExited: "1",
auth_token: token,
})
return `${base}/api/pty/${encodeURIComponent(ptyID)}/connect?${query.toString()}`
}
@@ -16,6 +16,7 @@ import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import"
import type { RunStatus } from "./run/manager"
import type { TerminalFont } from "./terminal-font"
import type { TerminalDestination } from "./terminal-destination"
import type { ScriptTerminalView } from "./ScriptTerminalManager"
export type { TerminalFont }
@@ -177,6 +178,11 @@ interface TerminalFontChangedMessage {
font: TerminalFont
}
interface ScriptTerminalsMessage {
type: "agentManager.scriptTerminals"
terminals: ScriptTerminalView[]
}
interface ErrorOutMessage {
type: "error"
message: string
@@ -286,6 +292,18 @@ interface RevertWorktreeFileResultMessage {
message: string
}
/** Branch picker data for a context's diff directory. */
interface DiffBranchesMessage {
type: "agentManager.diffBranches"
sessionId: string
branches: BranchListItem[]
defaultBranch: string
autoBase?: string
currentBase?: string
isAuto: boolean
currentBranch?: string
}
interface PRStatusOutMessage {
type: "agentManager.prStatus"
worktreeId: string
@@ -324,6 +342,7 @@ export type AgentManagerOutMessage =
| WorktreeDiffMessage
| WorktreeDiffFileMessage
| RevertWorktreeFileResultMessage
| DiffBranchesMessage
| PRStatusOutMessage
| ActionOutMessage
| RunStatusMessage
@@ -332,6 +351,7 @@ export type AgentManagerOutMessage =
| TerminalErrorMessage
| TerminalDestinationChangedMessage
| TerminalFontChangedMessage
| ScriptTerminalsMessage
// ---------------------------------------------------------------------------
// Webview → Extension messages (onMessage)
@@ -398,6 +418,7 @@ interface ConfigureRunScriptIn {
interface RunScriptIn {
type: "agentManager.runScript"
worktreeId: string
destination: TerminalDestination
}
interface StopRunScriptIn {
@@ -517,6 +538,7 @@ interface ImportFromPRIn {
interface RequestWorktreeDiffIn {
type: "agentManager.requestWorktreeDiff"
sessionId: string
scope?: string
}
interface ApplyWorktreeDiffIn {
@@ -529,11 +551,13 @@ interface RequestWorktreeDiffFileIn {
type: "agentManager.requestWorktreeDiffFile"
sessionId: string
file: string
scope?: string
}
interface StartDiffWatchIn {
type: "agentManager.startDiffWatch"
sessionId: string
scope?: string
}
interface StopDiffWatchIn {
@@ -544,6 +568,20 @@ interface RevertWorktreeFileIn {
type: "agentManager.revertWorktreeFile"
sessionId: string
file: string
scope?: string
}
interface RequestDiffBranchesIn {
type: "agentManager.requestDiffBranches"
sessionId: string
scope?: string
}
interface SetDiffBaseBranchIn {
type: "agentManager.setDiffBaseBranch"
sessionId: string
scope?: string
branch?: string
}
interface RefreshPRIn {
@@ -809,6 +847,8 @@ export type AgentManagerInMessage =
| StartDiffWatchIn
| StopDiffWatchIn
| RevertWorktreeFileIn
| RequestDiffBranchesIn
| SetDiffBaseBranchIn
| RefreshPRIn
| OpenPRIn
| OpenSessionsIn
@@ -173,6 +173,10 @@ export class VscodeHost implements Host {
return getWorkspaceRoot()
}
isTrusted(): boolean {
return vscode.workspace.isTrusted
}
autoBranchNaming(): { enabled: boolean; prefix: string } {
const cfg = vscode.workspace.getConfiguration("kilo-code.new.agentManager")
return {
@@ -1,12 +1,13 @@
import { SourceController } from "../diff/SourceController"
import { resolveLocalDiffTarget } from "../diff/shared/target"
import { WorktreeDiffReverter, type StatusResolver } from "../diff/shared/reverter"
import type { DiffFile } from "../diff/types"
import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types"
import type { DiffFile, PanelContext } from "../diff/types"
import type { DiffSource } from "../diff/sources/types"
import type { DiffSourceCatalog } from "../diff/sources/catalog"
import type { ApplyConflict, GitOps } from "./GitOps"
import { shouldStopDiffPolling } from "./delete-worktree"
import { Semaphore } from "./semaphore"
import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager"
import { parseDiffId, scopeToSourceId } from "./diff-scope"
import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types"
const LOCAL_DIFF_ID = "local" as const
@@ -19,14 +20,11 @@ export interface WorktreeDiffControllerContext {
getState: () => WorktreeStateManager | undefined
getRoot: () => string | undefined
getStateReady: () => Promise<void> | undefined
/**
* In-process diff paths deliberately bypass the SDK client to keep git spawns
* out of the Bun `kilo serve` process (see oven-sh/bun#18265).
*/
/** Builds the underlying per-scope diff sources (workspace/staged/unstaged/session). */
catalog: DiffSourceCatalog
/** Shared git ops, injected into sources so they don't spawn their own channels. */
git: GitOps
/** In-process diff summary (replaces client.worktree.diffSummary). */
localDiff: (dir: string, base: string) => Promise<WorktreeDiffEntry[]>
/** In-process single-file diff (replaces client.worktree.diffFile). */
/** In-process single-file diff (replaces client.worktree.diffFile). Used by revert. */
localDiffFile: (dir: string, base: string, file: string) => Promise<WorktreeDiffEntry | null>
post: (msg: AgentManagerOutMessage) => void
log: (...args: unknown[]) => void
@@ -34,13 +32,16 @@ export interface WorktreeDiffControllerContext {
export class WorktreeDiffController {
private readonly controller: SourceController
private readonly details = new Semaphore(3)
private target: Target | undefined
private applying: string | undefined
/** Intended watch mode for the active context; isPolling lags the initial fetch. */
private poll = false
/** Ephemeral per-context base override, keyed by context id. */
private baseOverrides = new Map<string, string>()
constructor(private readonly ctx: WorktreeDiffControllerContext) {
this.controller = new SourceController(
(id) => this.source(id),
(id, ctx) => this.source(id, ctx),
() => [],
(msg) => this.ctx.post(msg as AgentManagerOutMessage),
{
@@ -80,7 +81,11 @@ export class WorktreeDiffController {
}
public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean {
return shouldStopDiffPolling(path, sessions, this.target, this.controller.currentId)
// Pass the parsed context id, not the composite id, so the orphaned-session
// check matches real session ids.
const current = this.controller.currentId
const ctxId = current ? parseDiffId(current).ctx : undefined
return shouldStopDiffPolling(path, sessions, this.target, ctxId)
}
public async apply(worktreeId: string, value?: unknown): Promise<void> {
@@ -144,131 +149,161 @@ export class WorktreeDiffController {
}
}
public async revert(sessionId: string, file: string): Promise<void> {
public async revert(id: string, file: string): Promise<void> {
if (!file) return
if (this.controller.currentId !== sessionId) {
const result = await this.revertFile(sessionId, file)
this.postRevertResult(sessionId, file, result)
if (this.controller.currentId !== id) {
const result = await this.revertFile(id, file)
this.postRevertResult(id, file, result)
return
}
await this.controller.revertFile(file)
}
public async request(sessionId: string): Promise<void> {
if (this.controller.currentId !== sessionId) {
await this.activate(sessionId, false, true)
public async request(id: string): Promise<void> {
if (this.controller.currentId !== id) {
await this.activate(id, false, true)
return
}
this.target = undefined
await this.controller.refresh()
}
public async requestFile(sessionId: string, file: string): Promise<void> {
public async requestFile(id: string, file: string): Promise<void> {
if (!file) return
if (this.controller.currentId !== sessionId) {
this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null })
if (this.controller.currentId !== id) {
this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId: id, file, diff: null })
return
}
await this.controller.requestFile(file)
}
public start(sessionId: string): void {
if (this.controller.isPolling && this.controller.currentId === sessionId) return
this.ctx.log(`Starting diff polling for session ${sessionId}`)
void this.activate(sessionId, true, true)
public start(id: string): void {
if (this.controller.isPolling && this.controller.currentId === id) return
this.ctx.log(`Starting diff polling for ${id}`)
void this.activate(id, true, true)
}
public stop(): void {
this.controller.stop()
this.target = undefined
this.poll = false
}
private async activate(sessionId: string, poll: boolean, fetch: boolean): Promise<void> {
/**
* Set or clear an ephemeral base override for a context (worktree or local),
* then re-activate the current source so it refetches against the new base.
* Passing undefined clears the override and falls back to the recorded parent.
*/
public async setBase(id: string, branch: string | undefined): Promise<void> {
const { ctx } = parseDiffId(id)
if (branch) this.baseOverrides.set(ctx, branch)
else this.baseOverrides.delete(ctx)
// Nothing to rebuild when the context isn't active; the override is
// picked up the next time start()/request() resolves it.
if (this.controller.currentId !== id) return
// Route through activate() so the base is re-resolved and pushed via
// setContext() — SourceController.reactivate() alone would rebuild the
// source against the stale context captured by the last activate(). The
// recorded poll intent preserves watch mode even when the initial fetch
// is still in flight (isPolling only turns true once it resolves).
await this.activate(id, this.poll, true)
}
/** Branch picker data for a context's directory, using any active override. */
public async branches(id: string) {
await this.ready("stateReady rejected, continuing diff branches resolve:")
const { ctx } = parseDiffId(id)
const target = await this.resolve(ctx)
if (!target) return undefined
return await this.ctx.catalog.listWorkspaceBranches(this.baseOverrides.get(ctx), target.directory)
}
private async activate(id: string, poll: boolean, fetch: boolean): Promise<void> {
this.target = undefined
this.controller.setContext({ workspaceRoot: this.ctx.getRoot() })
await this.controller.activate(sessionId, { poll, fetch })
this.poll = poll
await this.ready("stateReady rejected, continuing diff activate:")
const { ctx } = parseDiffId(id)
const resolved = await this.resolve(ctx)
this.target = resolved ? { sessionId: id, ...resolved } : undefined
this.controller.setContext({
workspaceRoot: this.ctx.getRoot(),
dir: resolved?.directory,
// The resolved base already bakes in any ephemeral override (see
// resolve()), so pass it as the explicit base and leave
// baseBranchOverride unset to avoid double resolution.
baseBranch: resolved?.baseBranch,
// Agent Manager always knows its intended directory (LOCAL resolves to
// the root). Never fall back to the workspace root for an unresolvable
// worktree context — return an empty diff instead.
strictDir: true,
git: this.ctx.git,
log: (...args) => this.ctx.log(...args),
})
await this.controller.activate(id, { poll, fetch })
}
private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> {
if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocal()
private async resolve(ctxId: string): Promise<{ directory: string; baseBranch: string } | undefined> {
if (ctxId === LOCAL_DIFF_ID) return await this.resolveLocal()
const state = this.ctx.getState()
if (!state) {
this.ctx.log(`resolveDiffTarget: no state manager for session ${sessionId}`)
this.ctx.log(`resolveDiffTarget: no state manager for context ${ctxId}`)
return undefined
}
const session = state.getSession(sessionId)
const session = state.getSession(ctxId)
if (!session) {
this.ctx.log(
`resolveDiffTarget: session ${sessionId} not found in state (${state.getSessions().length} total sessions)`,
`resolveDiffTarget: session ${ctxId} not found in state (${state.getSessions().length} total sessions)`,
)
return undefined
}
if (!session.worktreeId) {
this.ctx.log(`resolveDiffTarget: session ${sessionId} has no worktreeId (local session)`)
this.ctx.log(`resolveDiffTarget: session ${ctxId} has no worktreeId (local session)`)
return undefined
}
const worktree = state.getWorktree(session.worktreeId)
if (!worktree) {
this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${sessionId}`)
this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${ctxId}`)
return undefined
}
return { directory: worktree.path, baseBranch: remoteRef(worktree) }
const base = this.baseOverrides.get(ctxId) ?? remoteRef(worktree)
return { directory: worktree.path, baseBranch: base }
}
private async resolveLocal(): Promise<{ directory: string; baseBranch: string } | undefined> {
return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot())
const root = this.ctx.getRoot()
if (!root) return undefined
const override = this.baseOverrides.get(LOCAL_DIFF_ID)
if (override) {
return { directory: root, baseBranch: override }
}
return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), root)
}
private async ready(msg: string): Promise<void> {
await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err))
}
private source(sessionId: string): DiffSource {
const descriptor: DiffSourceDescriptor = {
id: sessionId,
type: "workspace",
group: "Git",
capabilities: { revert: true, comments: true },
}
/**
* Build the active source for a composite id by delegating to the catalog.
* The composite id (ctx#scope) is preserved as the descriptor id so the
* webview keys diff data by context+scope. Context resolution (dir/base)
* already happened in activate() and is carried by the PanelContext.
*/
private source(id: string, panelCtx: PanelContext): DiffSource {
const { ctx, scope } = parseDiffId(id)
const built = this.ctx.catalog.build(scopeToSourceId(scope, ctx), panelCtx)
return {
descriptor,
fetch: () => this.fetch(sessionId),
fetchFile: (file) => this.fetchFile(sessionId, file),
revert: (file) => this.revertFile(sessionId, file),
...built,
descriptor: { ...built.descriptor, id },
}
}
private async fetch(sessionId: string): Promise<DiffSourceFetch> {
await this.ready("stateReady rejected, continuing diff resolve:")
const target = await this.ensureTarget(sessionId)
if (!target) return { diffs: [], stopPolling: true }
const files = await this.ctx.localDiff(target.directory, target.baseBranch)
this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`)
return { diffs: files as AgentManagerDiffFile[] }
}
private async fetchFile(sessionId: string, file: string): Promise<DiffFile | null> {
await this.ready("stateReady rejected, continuing diff detail resolve:")
return this.details.run(async () => {
const target = await this.ensureTarget(sessionId)
if (!target) return null
try {
return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null
} catch (error) {
this.ctx.log("Failed to fetch worktree diff file:", error)
return null
}
})
}
private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> {
private async revertFile(id: string, file: string): Promise<{ ok: boolean; message: string }> {
await this.ready("stateReady rejected, continuing revert resolve:")
const target = await this.resolveTarget(sessionId)
const { ctx } = parseDiffId(id)
const target = await this.resolve(ctx)
if (!target) return { ok: false, message: "Could not resolve diff target" }
try {
@@ -285,19 +320,6 @@ export class WorktreeDiffController {
}
}
private async ensureTarget(sessionId: string): Promise<Target | undefined> {
if (this.controller.currentId !== sessionId) return undefined
if (this.target?.sessionId === sessionId) return this.target
return await this.resolveTarget(sessionId)
}
private async resolveTarget(sessionId: string): Promise<Target | undefined> {
const target = await this.resolve(sessionId)
if (!target) return undefined
this.target = { sessionId, ...target }
return this.target
}
private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void {
this.ctx.post({
type: "agentManager.revertWorktreeFileResult",
@@ -90,12 +90,17 @@ export class DiffSourceCatalog implements vscode.Disposable {
}
build(id: string, ctx: PanelContext): DiffSource {
const opts = { dir: () => ctx.dir, strictDir: ctx.strictDir, git: ctx.git, log: ctx.log }
if (id === WORKSPACE_SOURCE_ID) {
return createWorktreeDiffSource({ baseBranchOverride: ctx.baseBranchOverride })
return createWorktreeDiffSource({
...opts,
baseBranchOverride: ctx.baseBranchOverride,
baseBranch: ctx.baseBranch,
})
}
if (id === STAGED_SOURCE_ID) return createStagedDiffSource()
if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource()
if (id === STAGED_SOURCE_ID) return createStagedDiffSource(opts)
if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource(opts)
if (id.startsWith(TURN_PREFIX)) {
const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":")
@@ -108,14 +113,22 @@ export class DiffSourceCatalog implements vscode.Disposable {
if (id.startsWith(SESSION_PREFIX)) {
const sessionId = id.slice(SESSION_PREFIX.length)
if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`)
return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled)
return createSessionDiffSource(
sessionId,
this.sessionFetch,
ctx.dir ?? ctx.workspaceRoot,
this.checkSnapshotsEnabled,
)
}
throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`)
}
async listWorkspaceBranches(override: string | undefined): Promise<WorkspaceBranchesResult | undefined> {
const root = getWorkspaceRoot()
async listWorkspaceBranches(
override: string | undefined,
dir?: string,
): Promise<WorkspaceBranchesResult | undefined> {
const root = dir ?? getWorkspaceRoot()
if (!root) return undefined
const git = this.ensureBranchGit()
@@ -34,17 +34,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry {
return { ...entry, stamp: `${entry.status}:${before}:${after}` }
}
export interface StagedDiffSourceOptions {
/**
* Resolve the directory to diff. Defaults to the VS Code workspace root.
* Agent Manager passes a worktree path so the source diffs inside the
* worktree rather than the main checkout.
*/
dir?: () => string | undefined
/**
* When true, a `dir` that resolves to undefined yields an empty diff rather
* than falling back to the workspace root.
*/
strictDir?: boolean
/** Shared GitOps / log so sources don't each spawn their own channel. */
git?: GitOps
log?: (...args: unknown[]) => void
}
/**
* Diff between the git index and HEAD what `git diff --cached` would show.
* Polls on the standard interval; revert isn't supported (use `git reset` from
* a real git client). Read-only view.
*/
export function createStagedDiffSource(): DiffSource {
const output = vscode.window.createOutputChannel("Kilo Diff: Staged")
const log = (...args: unknown[]) => appendOutput(output, "StagedDiffSource", ...args)
const git = new GitOps({ log })
export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): DiffSource {
const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Staged")
const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "StagedDiffSource", ...args))
const git = opts.git ?? new GitOps({ log })
const root = (): string | undefined => getWorkspaceRoot()
const root = (): string | undefined => {
const dir = opts.dir?.()
if (dir) return dir
if (opts.strictDir) return undefined
return getWorkspaceRoot()
}
const listEntries = async (dir: string): Promise<FileEntry[]> => {
const [nameStatus, numstat, raw] = await Promise.all([
@@ -150,8 +172,10 @@ export function createStagedDiffSource(): DiffSource {
},
dispose(): void {
git.dispose()
output.dispose()
// Only dispose resources we own (created here). Injected git/log are
// owned by the caller.
if (!opts.git) git.dispose()
output?.dispose()
},
}
}
@@ -40,17 +40,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry {
return { ...entry, stamp: `${entry.status}:${before}:${after}` }
}
export interface UnstagedDiffSourceOptions {
/**
* Resolve the directory to diff. Defaults to the VS Code workspace root.
* Agent Manager passes a worktree path so the source diffs inside the
* worktree rather than the main checkout.
*/
dir?: () => string | undefined
/**
* When true, a `dir` that resolves to undefined yields an empty diff rather
* than falling back to the workspace root.
*/
strictDir?: boolean
/** Shared GitOps / log so sources don't each spawn their own channel. */
git?: GitOps
log?: (...args: unknown[]) => void
}
/**
* Diff between the working tree and the index what `git diff` shows for
* tracked files, plus untracked files (treated as fully-added). Read-only;
* polls on the standard interval.
*/
export function createUnstagedDiffSource(): DiffSource {
const output = vscode.window.createOutputChannel("Kilo Diff: Unstaged")
const log = (...args: unknown[]) => appendOutput(output, "UnstagedDiffSource", ...args)
const git = new GitOps({ log })
export function createUnstagedDiffSource(opts: UnstagedDiffSourceOptions = {}): DiffSource {
const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Unstaged")
const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "UnstagedDiffSource", ...args))
const git = opts.git ?? new GitOps({ log })
const root = (): string | undefined => getWorkspaceRoot()
const root = (): string | undefined => {
const dir = opts.dir?.()
if (dir) return dir
if (opts.strictDir) return undefined
return getWorkspaceRoot()
}
const listTracked = async (dir: string): Promise<FileEntry[]> => {
const [nameStatus, numstat, raw] = await Promise.all([
@@ -192,8 +214,10 @@ export function createUnstagedDiffSource(): DiffSource {
},
dispose(): void {
git.dispose()
output.dispose()
// Only dispose resources we own (created here). Injected git/log are
// owned by the caller.
if (!opts.git) git.dispose()
output?.dispose()
},
}
}
@@ -23,6 +23,28 @@ export interface WorktreeDiffSourceOptions {
* the current branch only the comparison target changes. Reset on dispose.
*/
baseBranchOverride?: string
/**
* Resolve the directory to diff. Defaults to the VS Code workspace root.
* Agent Manager passes a worktree path so the source diffs inside the
* worktree rather than the main checkout.
*/
dir?: () => string | undefined
/**
* When true, a `dir` that resolves to undefined yields an empty diff rather
* than falling back to the workspace root. Prevents an unresolvable
* worktree context from silently diffing the main checkout.
*/
strictDir?: boolean
/**
* Explicit base branch to diff against. When set, the source skips
* auto-resolution (tracking default) and diffs against this ref directly.
* Agent Manager passes the worktree's recorded parent so a worktree always
* compares against its own base even when the workspace default differs.
*/
baseBranch?: string
/** Shared GitOps / log so sources don't each spawn their own channel. */
git?: GitOps
log?: (...args: unknown[]) => void
}
/**
@@ -32,9 +54,16 @@ export interface WorktreeDiffSourceOptions {
* extension host no `kilo serve` round-trip.
*/
export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): DiffSource {
const output = vscode.window.createOutputChannel("Kilo Diff: Workspace")
const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args)
const git = new GitOps({ log })
const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace")
const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args))
const git = opts.git ?? new GitOps({ log })
const root = (): string | undefined => {
const dir = opts.dir?.()
if (dir) return dir
if (opts.strictDir) return undefined
return getWorkspaceRoot()
}
// Cached between fetches so repeated polling doesn't re-resolve the base
// branch every tick. Reset only on dispose (when the source is swapped out).
@@ -42,22 +71,32 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
const resolveTarget = async (): Promise<DiffTarget | undefined> => {
if (target) return target
if (opts.baseBranch) {
const dir = root()
if (!dir) {
log("Local diff: no directory (explicit base mode)")
return
}
target = { directory: dir, baseBranch: opts.baseBranch }
log(`Local diff: using explicit base=${opts.baseBranch} dir=${dir}`)
return target
}
if (opts.baseBranchOverride) {
const root = getWorkspaceRoot()
if (!root) {
const dir = root()
if (!dir) {
log("Local diff: no workspace root (override mode)")
return
}
const resolved = await resolveOverrideRef(git, root, opts.baseBranchOverride, log)
const resolved = await resolveOverrideRef(git, dir, opts.baseBranchOverride, log)
if (!resolved) {
log(`Local diff: override base="${opts.baseBranchOverride}" could not be resolved, falling back to auto`)
} else {
target = { directory: root, baseBranch: resolved }
target = { directory: dir, baseBranch: resolved }
log(`Local diff: using override base=${resolved}`)
return target
}
}
target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot())
target = await resolveLocalDiffTarget(git, log, root())
return target
}
@@ -109,8 +148,10 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
},
dispose(): void {
git.dispose()
output.dispose()
// Only dispose resources we own (created here). Injected git/log are
// owned by the caller.
if (!opts.git) git.dispose()
output?.dispose()
target = undefined
},
}
+21
View File
@@ -10,6 +10,27 @@ export interface PanelContext {
hidePicker?: boolean
/** User-picked base branch for the workspace source. Undefined = auto. */
baseBranchOverride?: string
/**
* Explicit directory to diff inside, overriding the workspace root lookup.
* Agent Manager passes a worktree path so its sources operate in the
* worktree rather than the main checkout.
*/
dir?: string
/**
* When true, a source whose `dir` resolves to undefined returns an empty
* diff instead of falling back to the workspace root. Agent Manager sets
* this so an unresolvable worktree context never silently diffs the main
* checkout.
*/
strictDir?: boolean
/**
* Explicit base ref for the workspace source, skipping auto-resolution.
* Agent Manager passes the worktree's recorded parent ref.
*/
baseBranch?: string
/** Shared GitOps / log injected by Agent Manager to avoid per-source channels. */
git?: import("../agent-manager/GitOps").GitOps
log?: (...args: unknown[]) => void
}
export type DiffImageError = "too-large" | "unreadable"
@@ -106,7 +106,7 @@ export async function handlePermissionResponse(
}
const replyResult = await ctx.client.permission
.reply({ requestID: permissionId, reply: response, directory: dir }, { throwOnError: true })
.reply({ requestID: permissionId, reply: response, directory: dir, interactive: true }, { throwOnError: true })
.then(() => "ok" as const)
.catch((error: unknown) => {
if (isNotFoundError(error)) return "stale" as const
@@ -4,6 +4,7 @@ type Item = {
id: string
title: string
updated: number
worktreeName?: string
}
type Message = {
@@ -22,11 +23,14 @@ type Input = {
}
/**
* Past-chat mention search. Lists root sessions for the directory the current
* chat runs in (workspace root for the sidebar, the worktree for Agent Manager
* sessions) the same directory-scoped `session.list` the session history and
* Agent Manager search are built on. Fuzzy title filtering happens in the
* webview (same mechanism as the Agent Manager sidebar search).
* Past-chat mention search. Lists root sessions across the current directory's
* worktree family (the repo root and its sibling worktrees for git projects,
* just the directory itself otherwise) the same family-wide listing the
* Agent Manager session search and the CLI's past-chat picker are built on.
* Every session in the family shares the project, so any of them can be
* attached regardless of which worktree the current chat runs in. Fuzzy title
* filtering happens in the webview (same mechanism as the Agent Manager
* sidebar search).
*/
export async function handleSessionSearch(input: Input): Promise<void> {
const client = input.client
@@ -39,10 +43,18 @@ export async function handleSessionSearch(input: Input): Promise<void> {
const dir = input.dir(id)
try {
const res = await client.session.list({ directory: dir, roots: true, limit: 50 }, { throwOnError: true })
const res = await client.experimental.session.list(
{ worktrees: true, roots: true, directory: dir, limit: 50 },
{ throwOnError: true },
)
const sessions: Item[] = res.data
.filter((session) => session.id !== input.exclude && session.title)
.map((session) => ({ id: session.id, title: session.title, updated: session.time.updated }))
.map((session) => ({
id: session.id,
title: session.title,
updated: session.time.updated,
worktreeName: session.worktreeName,
}))
input.post({ type: "sessionSearchResult", sessions, requestId: input.message.requestId })
} catch (err) {
console.error("[Kilo New] Session search failed:", err)
@@ -6,7 +6,7 @@ const NAMES = [
"Providers",
"Agent Behaviour",
"Auto-Approve",
"Browser",
"Web Tools",
"Checkpoints",
"Display",
"Autocomplete",
@@ -61,6 +61,10 @@ const IMPORTER_FILE = path.join(ROOT, "src/agent-manager/worktree-importer.ts")
const SETUP_SCRIPT_RUNNER_FILE = path.join(ROOT, "src/agent-manager/SetupScriptRunner.ts")
const RUN_MESSAGE_FILE = path.join(ROOT, "src/agent-manager/run/message.ts")
const TERMINAL_ROUTING_FILE = path.join(ROOT, "src/agent-manager/terminal-routing.ts")
const SCRIPT_TERMINAL_FILE = path.join(ROOT, "src/agent-manager/ScriptTerminalManager.ts")
const SCRIPT_TERMINAL_RUNTIME_FILE = path.join(ROOT, "src/agent-manager/script-terminal-runtime.ts")
const RUN_TASK_FILE = path.join(ROOT, "src/agent-manager/run/task.ts")
const RUN_DESTINATION_FILE = path.join(ROOT, "src/agent-manager/run/destination.ts")
function readAllCss(): string {
return CSS_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n")
@@ -455,6 +459,52 @@ describe("Agent Manager Provider — onMessage routing", () => {
expect(text).not.toContain("agentManager.requestState")
})
it("routes script terminal close and resize messages before user terminals", () => {
const text = body("onMessage")
expect(text.indexOf("this.scripts.manager.intercept(m)")).toBeLessThan(
text.indexOf("this.terminalRouter.handle(m)"),
)
})
it("runs scripts through the vscode-free canonical PTY manager", () => {
const text = fs.readFileSync(SCRIPT_TERMINAL_FILE, "utf-8")
expect(text).toMatch(/client\.v2\.pty\s*\.create/)
expect(text).toContain("client.v2.pty.get")
expect(text).toContain("client.v2.pty.update")
expect(text).toContain("client.v2.pty.remove")
expect(text).not.toContain("vscode")
})
it("selects the Run adapter from the panel dropdown message", () => {
const text = fs.readFileSync(SCRIPT_TERMINAL_RUNTIME_FILE, "utf-8")
expect(text).toContain("pickRunStart")
expect(text).toContain("config.destination")
expect(text).not.toContain("readRunTerminalDestination")
expect(text.indexOf("pickRunStart")).toBeLessThan(text.indexOf("config.destination"))
})
it("keeps the legacy integrated Run adapter isolated and removable", () => {
const task = fs.readFileSync(RUN_TASK_FILE, "utf-8")
expect(task).toContain("vscode.tasks.executeTask")
expect(task).toContain("Remove this")
const dest = fs.readFileSync(RUN_DESTINATION_FILE, "utf-8")
expect(dest).not.toContain('from "vscode"')
expect(dest).toContain("pickRunStart")
expect(dest).not.toContain("getConfiguration")
})
it("clears retained Run terminals before removing worktree state", () => {
for (const name of ["onDeleteWorktree", "onRemoveStaleWorktree"]) {
const text = body(name)
expect(text).toContain('this.scripts.manager.clear("run", worktreeId)')
expect(text.indexOf('this.scripts.manager.clear("run", worktreeId)')).toBeLessThan(
text.indexOf("state.removeWorktree"),
)
}
const deleted = body("onDeleteWorktree")
expect(deleted.indexOf("statsPoller.skipWorktree")).toBeLessThan(deleted.indexOf("this.run.remove"))
})
// -- onDeleteWorktree invariants -------------------------------------------
/**
@@ -572,7 +622,9 @@ describe("Agent Manager Provider — onMessage routing", () => {
expect(text).toContain("class WorktreeDiffController")
expect(text).toContain("buildWorktreePatch")
expect(text).toContain("revertFile")
expect(text).toContain("diffSummary")
// Summary/detail diff data comes from the shared DiffSourceCatalog sources
// (workspace/staged/unstaged/session), not a bespoke in-controller pipeline.
expect(text).toContain("catalog.build")
expect(text).toContain("shouldStopDiffPolling")
expect(providerText).toContain("this.diffs")
})
@@ -842,9 +894,6 @@ const VSCODE_ALLOWED: Record<string, { note: string }> = {
"task-runner.ts": {
note: "vscode adapter for SetupScriptRunner",
},
"run/task.ts": {
note: "vscode adapter for Agent Manager run scripts",
},
// Reads terminal.integrated.* and editor.font* config for xterm font settings
"terminal-font.ts": {
note: "vscode config reader for integrated terminal font settings",
@@ -0,0 +1,22 @@
import { describe, expect, it } from "bun:test"
import { terminalChrome } from "../../webview-ui/agent-manager/terminal/chrome"
describe("Agent Manager Run terminal chrome", () => {
it("keeps the console icon for user terminals", () => {
expect(terminalChrome("Terminal 1", undefined)).toEqual({ icon: "console", tooltip: "Terminal 1" })
})
it("renders compact status icons with accessible Run status details", () => {
expect(terminalChrome("Run", { state: "running" })).toEqual({ icon: "spinner", tooltip: "Run (Running)" })
expect(terminalChrome("Run", { state: "stopping" })).toEqual({ icon: "spinner", tooltip: "Run (Stopping)" })
expect(terminalChrome("Run", { state: "exited", exitCode: 0 })).toEqual({
icon: "success",
tooltip: "Run (Exited, code 0)",
})
expect(terminalChrome("Run", { state: "exited", exitCode: 1 })).toEqual({
icon: "failure",
tooltip: "Run (Exited, code 1)",
})
expect(terminalChrome("Run", { state: "failed" })).toEqual({ icon: "failure", tooltip: "Run (Failed)" })
})
})
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"
import {
createSideTerminal,
readSavedDestination,
resolveRunScriptRequest,
resolveVscodeTerminalRequest,
} from "../../webview-ui/agent-manager/terminal/side"
@@ -11,10 +12,12 @@ function scene(
saved?: "vscode" | "agentManager"
visible?: boolean
focusedId?: string
mac?: boolean
} = {},
) {
const calls = {
requestSide: 0,
ensureSide: 0,
closed: [] as string[],
hide: 0,
refocus: 0,
@@ -31,6 +34,7 @@ function scene(
calls.requestSide++
visible = true
},
ensureSide: () => calls.ensureSide++,
closeSide: (terminalId) => {
calls.closed.push(terminalId)
focusedId = undefined
@@ -49,6 +53,7 @@ function scene(
openVscode: () => calls.openVscode++,
saved: opts.saved,
save: (destination) => calls.persisted.push(destination),
mac: opts.mac,
})
if (opts.destination) ctl.syncDefault(opts.destination)
return { ctl, calls }
@@ -72,6 +77,29 @@ describe("Agent Manager side terminal controller", () => {
expect(hidden.calls.hide).toBe(0)
})
it("ensures an open terminal panel has a terminal after switching contexts", async () => {
const visible = scene({ visible: true })
visible.ctl.syncContext("wt-2", "wt-1")
await Promise.resolve()
expect(visible.calls.ensureSide).toBe(1)
visible.ctl.syncContext("wt-2", "wt-2")
visible.ctl.syncContext("wt-2", undefined)
await Promise.resolve()
expect(visible.calls.ensureSide).toBe(2)
expect(visible.calls.requestSide).toBe(0)
const hidden = scene()
hidden.ctl.syncContext("wt-2", "wt-1")
expect(hidden.calls.ensureSide).toBe(0)
const closed = scene({ visible: true })
closed.ctl.syncContext("wt-2", "wt-1")
closed.ctl.toggle()
await Promise.resolve()
expect(closed.calls.ensureSide).toBe(0)
})
it("kills the focused terminal and refocuses the chat", () => {
const focused = scene({ focusedId: "terminal:two" })
expect(focused.ctl.close()).toBe(true)
@@ -98,6 +126,70 @@ describe("Agent Manager side terminal controller", () => {
expect(panelFirst.calls.openVscode).toBe(0)
})
it("handles the platform terminal shortcut locally and dedupes the extension echo", () => {
const press = (opts: Partial<KeyboardEvent> = {}) =>
({ key: "/", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...opts }) as KeyboardEvent
// macOS: the workbench binding is Cmd+/, so only Cmd is accepted.
const mac = scene({ destination: "agentManager", mac: true })
expect(mac.ctl.press(press({ metaKey: true }))).toBe(true)
expect(mac.calls.requestSide).toBe(1)
expect(mac.ctl.press(press({ ctrlKey: true }))).toBe(false)
expect(mac.ctl.press(press({ metaKey: true, ctrlKey: true }))).toBe(false)
expect(mac.calls.requestSide).toBe(1)
// Windows/Linux: the workbench binding is Ctrl+/, so only Ctrl is accepted.
const win = scene({ destination: "agentManager", mac: false })
expect(win.ctl.press(press({ ctrlKey: true }))).toBe(true)
expect(win.calls.requestSide).toBe(1)
expect(win.ctl.press(press({ metaKey: true }))).toBe(false)
expect(win.calls.requestSide).toBe(1)
// Unrelated keys and modifier combinations are not the shortcut.
expect(win.ctl.press(press({ key: "?" }))).toBe(false)
expect(win.ctl.press(press({ ctrlKey: true, shiftKey: true }))).toBe(false)
expect(win.ctl.press(press({ ctrlKey: true, altKey: true }))).toBe(false)
expect(win.calls.requestSide).toBe(1)
// The extension echoes each locally handled keypress back as an action
// message; one echo is consumed per press, then invocations run again.
expect(mac.ctl.echo()).toBe(true)
expect(mac.ctl.echo()).toBe(false)
})
it("consumes one echo per press, even for rapid repeated presses", () => {
const item = scene({ destination: "agentManager", mac: true })
const press = () => item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
press()
press()
// Two presses toggled the panel open and closed again; both echoes
// must still be consumed so neither press toggles a third time.
expect(item.calls.requestSide).toBe(1)
expect(item.calls.hide).toBe(1)
expect(item.ctl.echo()).toBe(true)
expect(item.ctl.echo()).toBe(true)
expect(item.ctl.echo()).toBe(false)
})
it("drops a never-arriving echo after the timeout safety valve", async () => {
const item = scene({ destination: "agentManager", mac: true })
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
await new Promise((resolve) => setTimeout(resolve, 550))
expect(item.ctl.echo()).toBe(false)
expect(item.ctl.echo()).toBe(false)
})
it("expires a dropped echo's backlog at the next spaced press", async () => {
const item = scene({ destination: "agentManager", mac: true })
// First press's echo never arrives (dropped forwarding); its backlog
// must not outlive the echo window into the next press.
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
await new Promise((resolve) => setTimeout(resolve, 550))
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
expect(item.ctl.echo()).toBe(true)
expect(item.ctl.echo()).toBe(false)
})
it("persists the picked destination with a section-relative settings key", () => {
const item = scene()
item.ctl.choose("agentManager")
@@ -147,6 +239,21 @@ describe("readSavedDestination", () => {
})
})
describe("resolveRunScriptRequest", () => {
it("carries the current panel dropdown destination with every Run request", () => {
expect(resolveRunScriptRequest("wt-1", "agentManager")).toEqual({
type: "agentManager.runScript",
worktreeId: "wt-1",
destination: "agentManager",
})
expect(resolveRunScriptRequest("local", "vscode")).toEqual({
type: "agentManager.runScript",
worktreeId: "local",
destination: "vscode",
})
})
})
describe("resolveVscodeTerminalRequest", () => {
const sessions = new Map([
["wt-1", "session-a"],
@@ -5,6 +5,7 @@ import {
createTerminalHandlers,
createTerminalMessageHandler,
createTerminalState,
isTerminalTabId,
} from "../../webview-ui/agent-manager/terminal/state"
import type { ExtensionMessage } from "../../webview-ui/src/types/messages/extension-messages"
@@ -14,7 +15,14 @@ function scene(initial: string | null = LOCAL) {
const [selection, setSelection] = createSignal<string | null>(initial)
const state = createTerminalState(selection)
const posted: Array<Record<string, unknown>> = []
const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], errors: 0 }
const events = {
activated: [] as string[],
selected: [] as string[],
saved: 0,
shown: [] as string[],
errors: 0,
running: [] as Array<{ contextKey: string; terminalId: string }>,
}
const tabs = () => state.current().map((term) => term.id)
const handlers = createTerminalHandlers({
state,
@@ -41,6 +49,7 @@ function scene(initial: string | null = LOCAL) {
},
showError: () => events.errors++,
postMessage: (message) => posted.push(message as Record<string, unknown>),
onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }),
})
return { state, selection, setSelection, posted, events, handlers, dispatch }
}
@@ -58,6 +67,28 @@ function createdSide(createId: string, terminalId: string, title = "Terminal 1")
} satisfies ExtensionMessage
}
function script(
terminalId: string,
state: "running" | "stopping" | "exited" | "failed" = "running",
exitCode?: number,
) {
return {
type: "agentManager.scriptTerminals",
terminals: [
{
terminalId,
worktreeId: null,
kind: "run",
title: "Run",
wsUrl: `ws://${terminalId}`,
state,
...(exitCode === undefined ? {} : { exitCode }),
font,
},
],
} satisfies ExtensionMessage
}
describe("Agent Manager terminal state", () => {
it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => {
createRoot((dispose) => {
@@ -91,6 +122,47 @@ describe("Agent Manager terminal state", () => {
})
})
it("hydrates complete Run snapshots without create ids and preserves mounted terminal records", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" })
const user = item.state.sidesForContext(LOCAL)[0]!
expect(item.dispatch(script("script:run"))).toBe(true)
const run = item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run")
expect(run).toMatchObject({ title: "Run", placement: "side", kind: "run", contextKey: LOCAL })
expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }])
expect(item.state.scriptStatus("script:run")).toEqual({ state: "running" })
expect(isTerminalTabId("script:run")).toBe(true)
item.state.setTitle("script:run", "npm test")
expect(item.state.title("script:run")).toBe("Run")
item.dispatch(script("script:run", "exited", 0))
expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run")).toBe(run)
expect(item.state.scriptStatus("script:run")).toEqual({ state: "exited", exitCode: 0 })
expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "terminal:user")).toBe(user)
// Existing snapshots update status only; they do not re-open the inspector.
expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }])
item.dispatch({ type: "agentManager.scriptTerminals", terminals: [] } satisfies ExtensionMessage)
expect(item.state.sidesForContext(LOCAL)).toEqual([user])
expect(item.state.scriptStatus("script:run")).toBeUndefined()
dispose()
})
})
it("maps Local Run snapshots to LOCAL and does not reveal exited terminals", () => {
createRoot((dispose) => {
const item = scene()
item.dispatch(script("script:exit", "exited", 2))
expect(item.state.sidesForContext(LOCAL)[0]).toMatchObject({ id: "script:exit", contextKey: LOCAL })
expect(item.events.running).toEqual([])
dispose()
})
})
it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => {
createRoot((dispose) => {
const item = scene()
@@ -114,6 +186,23 @@ describe("Agent Manager terminal state", () => {
})
})
it("ensures a side terminal without revealing the panel", () => {
createRoot((dispose) => {
const item = scene("wt-1")
item.handlers.ensureSide()
item.handlers.ensureSide()
expect(item.events.shown).toEqual([])
expect(item.posted).toHaveLength(1)
expect(item.posted[0]).toMatchObject({
type: "agentManager.terminal.create",
placement: "side",
worktreeId: "wt-1",
})
dispose()
})
})
it("supports several side terminals per context with newest active", () => {
createRoot((dispose) => {
const item = scene()
@@ -170,6 +259,50 @@ describe("Agent Manager terminal state", () => {
})
})
it("keeps only the target side terminal on close others", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" })
item.state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" })
// Another context must survive untouched: "others" is per context.
item.state.add("wt-1", { id: "terminal:other", title: "Other", wsUrl: "ws://other", font, placement: "side" })
item.state.setSideActive(LOCAL, "terminal:one")
item.handlers.closeSideOthers("terminal:two")
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:two"])
expect(item.state.sidesForContext("wt-1").map((term) => term.id)).toEqual(["terminal:other"])
// The survivor becomes visible and focused, like selecting its tab.
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
expect(item.state.focusRequest()?.id).toBe("terminal:two")
expect(item.posted).toEqual([
{ type: "agentManager.terminal.close", terminalId: "terminal:one" },
{ type: "agentManager.terminal.close", terminalId: "terminal:three" },
])
dispose()
})
})
it("waits for Run closure confirmation while user terminal closes stay optimistic", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" })
item.dispatch(script("script:run"))
expect(item.handlers.closeSide("script:run")).toBe(true)
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:user", "script:run"])
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "script:run" }])
expect(item.handlers.closeSide("terminal:user")).toBe(true)
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["script:run"])
expect(item.posted).toEqual([
{ type: "agentManager.terminal.close", terminalId: "script:run" },
{ type: "agentManager.terminal.close", terminalId: "terminal:user" },
])
dispose()
})
})
it("closes a stale side answer whose create request is unknown", () => {
createRoot((dispose) => {
const item = scene()
@@ -0,0 +1,52 @@
import { describe, it, expect } from "bun:test"
import {
composeDiffId,
parseDiffId,
isDiffScope,
normalizeScope,
scopeToSourceId,
DEFAULT_DIFF_SCOPE,
} from "../../src/agent-manager/diff-scope"
describe("diff-scope composite ids", () => {
it("round-trips context and scope", () => {
expect(parseDiffId(composeDiffId("local", "branch"))).toEqual({ ctx: "local", scope: "branch" })
expect(parseDiffId(composeDiffId("ses_abc", "staged"))).toEqual({ ctx: "ses_abc", scope: "staged" })
expect(parseDiffId(composeDiffId("ses_abc", "unstaged"))).toEqual({ ctx: "ses_abc", scope: "unstaged" })
expect(parseDiffId(composeDiffId("ses_abc", "session"))).toEqual({ ctx: "ses_abc", scope: "session" })
})
it("parses session ids containing no separator as default branch scope", () => {
expect(parseDiffId("ses_abc")).toEqual({ ctx: "ses_abc", scope: DEFAULT_DIFF_SCOPE })
})
it("treats an unknown trailing segment as part of the context, not a scope", () => {
// A session id that happens to contain '#' but not a valid scope keeps the
// full id as context and falls back to branch.
expect(parseDiffId("ses_a#bogus")).toEqual({ ctx: "ses_a#bogus", scope: DEFAULT_DIFF_SCOPE })
})
it("isDiffScope guards the closed enum", () => {
expect(isDiffScope("branch")).toBe(true)
expect(isDiffScope("staged")).toBe(true)
expect(isDiffScope("unstaged")).toBe(true)
expect(isDiffScope("session")).toBe(true)
expect(isDiffScope("turn")).toBe(false)
expect(isDiffScope("")).toBe(false)
})
it("normalizeScope falls back to branch for unknown input", () => {
expect(normalizeScope("staged")).toBe("staged")
expect(normalizeScope("nope")).toBe("branch")
expect(normalizeScope(undefined)).toBe("branch")
expect(normalizeScope(42)).toBe("branch")
})
it("maps scopes to catalog source ids", () => {
expect(scopeToSourceId("branch", "ses_abc")).toBe("workspace")
expect(scopeToSourceId("staged", "ses_abc")).toBe("staged")
expect(scopeToSourceId("unstaged", "ses_abc")).toBe("unstaged")
expect(scopeToSourceId("session", "ses_abc")).toBe("session:ses_abc")
expect(scopeToSourceId("branch", "local")).toBe("workspace")
})
})
@@ -127,7 +127,7 @@ describe("Extension — package.json command sync", () => {
expect(terminal).toMatchObject({
key: "ctrl+/",
mac: "cmd+/",
when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'",
when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !kilo-code.new.sidebarFocused",
})
expect(create).toMatchObject({
key: "ctrl+shift+t",
@@ -141,7 +141,9 @@ describe("handlePermissionResponse", () => {
await handlePermissionResponse(fake, "p1", "s1", "once", [], [])
expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }])
expect(replies).toEqual([
{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true },
])
})
it("saves selected rules and replies in the recorded SSE directory", async () => {
@@ -158,7 +160,9 @@ describe("handlePermissionResponse", () => {
deniedAlways: ["rm *"],
},
])
expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }])
expect(replies).toEqual([
{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature", interactive: true },
])
})
it("treats an SDK-wrapped 404 while saving rules as stale", async () => {
@@ -192,7 +196,9 @@ describe("handlePermissionResponse", () => {
await handlePermissionResponse(fake, "p1", "s1", "once", [], [])
expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }])
expect(replies).toEqual([
{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true },
])
expect(permDirs.has("p1")).toBe(false)
expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }])
})
@@ -2,7 +2,13 @@ import { describe, expect, it } from "bun:test"
import { messageTurns } from "../../webview-ui/src/context/session-queue"
import { transcriptRows } from "../../webview-ui/src/context/transcript-rows"
import type { Message, Part, TextPart } from "../../webview-ui/src/types/messages"
import { capacity, previewText, promptItems, railItems } from "../../webview-ui/src/components/chat/prompt-rail"
import {
capacity,
historyAction,
previewText,
promptItems,
railEntries,
} from "../../webview-ui/src/components/chat/prompt-rail"
const base = {
sessionID: "session",
@@ -157,18 +163,38 @@ describe("promptItems", () => {
})
describe("capacity", () => {
it("counts how many worst-case rows fit the transcript height", () => {
expect(capacity(24 + 76 * 5)).toBe(5)
expect(capacity(100)).toBe(1)
it("counts how many ticks fit the transcript height", () => {
expect(capacity(24 + 7 * 5)).toBe(5)
expect(capacity(31)).toBe(1)
})
it("fits far more ticks than the navigator lists rows", () => {
// A tick is a hairline, so a sidebar-height transcript holds a whole
// session's prompts rather than the handful of card rows that fit.
expect(capacity(724)).toBe(100)
})
it("returns nothing usable for unmeasured or tiny transcripts", () => {
expect(capacity(0)).toBeLessThan(1)
expect(capacity(99)).toBeLessThan(1)
expect(capacity(30)).toBeLessThan(1)
})
})
describe("railItems", () => {
describe("historyAction", () => {
it("loads the next page only after the previous page made progress", () => {
expect(historyAction(80, 160, true)).toBe("load")
})
it("jumps after the final page", () => {
expect(historyAction(160, 200, false)).toBe("jump")
})
it("stops instead of retrying a page that made no progress", () => {
expect(historyAction(160, 160, true)).toBe("stop")
})
})
describe("railEntries", () => {
const items = Array.from({ length: 5 }, (_, i) => ({
key: `k${i}`,
turn: `t${i}`,
@@ -178,15 +204,36 @@ describe("railItems", () => {
}))
it("passes through when everything fits", () => {
expect(railItems(items, 5)).toEqual(items)
expect(railItems(items, 10)).toEqual(items)
expect(railEntries(items, 5)).toEqual(items.map((item, index) => ({ type: "prompt", item, index })))
expect(railEntries(items, 10)).toEqual(items.map((item, index) => ({ type: "prompt", item, index })))
})
it("keeps the newest items when capacity is smaller", () => {
expect(railItems(items, 2)).toEqual(items.slice(-2))
it("keeps the first and latest prompts at minimal capacity", () => {
expect(railEntries(items, 2)).toEqual([
{ type: "prompt", item: items[0], index: 0 },
{ type: "prompt", item: items[4], index: 4 },
])
})
it("summarizes hidden loaded prompts between the first and recent prompts", () => {
expect(railEntries(items, 4)).toEqual([
{ type: "prompt", item: items[0], index: 0 },
{ type: "overflow", count: 2, index: 1 },
{ type: "prompt", item: items[3], index: 3 },
{ type: "prompt", item: items[4], index: 4 },
])
})
it("reserves the first entry for unloaded history", () => {
expect(railEntries(items, 4, true)).toEqual([
{ type: "history" },
{ type: "overflow", count: 3, index: 0 },
{ type: "prompt", item: items[3], index: 3 },
{ type: "prompt", item: items[4], index: 4 },
])
})
it("returns nothing at zero capacity", () => {
expect(railItems(items, 0)).toEqual([])
expect(railEntries(items, 0)).toEqual([])
})
})
@@ -0,0 +1,30 @@
import { describe, expect, it, mock } from "bun:test"
import type { RunController } from "../../src/agent-manager/run/controller"
import { handleRunMessage } from "../../src/agent-manager/run/message"
import type { AgentManagerInMessage } from "../../src/agent-manager/types"
function controller() {
const run = mock(() => Promise.resolve())
const stop = mock(() => undefined)
const configure = mock(() => Promise.resolve())
return {
value: { run, stop, configure } as unknown as RunController,
run,
stop,
configure,
}
}
describe("Agent Manager Run messages", () => {
it.each(["agentManager", "vscode"] as const)("forwards the %s dropdown destination", (destination) => {
const item = controller()
const msg = {
type: "agentManager.runScript",
worktreeId: "wt-1",
destination,
} satisfies AgentManagerInMessage
expect(handleRunMessage(item.value, msg)).toBe(true)
expect(item.run).toHaveBeenCalledWith("wt-1", destination)
})
})
@@ -86,7 +86,7 @@ describe("RunScriptManager", () => {
let stopped = 0
await ctx.manager.start("wt-1", async () => ({ stop: () => stopped++ }))
ctx.manager.remove("wt-1")
await ctx.manager.remove("wt-1")
expect(stopped).toBe(1)
expect(ctx.manager.all()).toEqual([])
@@ -123,12 +123,28 @@ describe("RunScriptManager", () => {
it("finish after remove does not resurrect stale state", async () => {
const ctx = createManager()
await ctx.manager.start("wt-1", async () => ({ stop: () => {} }))
ctx.manager.remove("wt-1")
await ctx.manager.remove("wt-1")
ctx.manager.finish("wt-1", { exitCode: 0 })
expect(ctx.manager.all()).toEqual([])
})
it("stops and disposes once when removal races startup", async () => {
const ctx = createManager()
const gate = deferred<RunHandle>()
let stopped = 0
let disposed = 0
const started = ctx.manager.start("wt-1", () => gate.promise)
const removed = ctx.manager.remove("wt-1")
gate.resolve({ stop: () => stopped++, dispose: () => disposed++ })
await Promise.all([started, removed])
expect(stopped).toBe(1)
expect(disposed).toBe(1)
expect(ctx.manager.all()).toEqual([])
})
it("dispose tolerates handles that throw on stop", async () => {
const ctx = createManager()
await ctx.manager.start("wt-1", async () => ({
@@ -0,0 +1,14 @@
import { describe, expect, it } from "bun:test"
import type { StartTask } from "../../src/agent-manager/run/controller"
import { pickRunStart } from "../../src/agent-manager/run/destination"
describe("Run terminal destination", () => {
it("picks the adapter matching the panel dropdown destination", () => {
const handle = { stop: () => undefined, dispose: () => undefined }
const embedded: StartTask = async () => handle
const integrated: StartTask = async () => handle
expect(pickRunStart("agentManager", embedded, integrated)).toBe(embedded)
expect(pickRunStart("vscode", embedded, integrated)).toBe(integrated)
})
})
@@ -0,0 +1,357 @@
import { describe, expect, it } from "bun:test"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { ScriptTerminalManager, type ScriptTerminalView } from "../../src/agent-manager/ScriptTerminalManager"
import { buildScriptTerminalWsUrl } from "../../src/agent-manager/script-terminal-url"
import { RunScriptManager, type RunStatus } from "../../src/agent-manager/run/manager"
interface PtyInput {
location?: { directory?: string }
command?: string
args?: string[]
cwd?: string
env?: Record<string, string>
title?: string
}
interface PtyUpdate {
ptyID: string
location?: { directory?: string }
size?: { cols: number; rows: number }
}
interface PtyInfo {
id: string
title: string
command: string
args: string[]
cwd: string
status: "running" | "exited"
pid: number
exitCode?: number
}
interface PtyResponse {
data?: { location: { directory: string }; data: PtyInfo }
error?: unknown
}
function wait(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
function deferred<T>() {
let resolve: (value: T) => void = () => undefined
const promise = new Promise<T>((next) => {
resolve = next
})
return { promise, resolve }
}
function info(status: PtyInfo["status"] = "running", exitCode?: number): PtyInfo {
return {
id: "pty-1",
title: "Run",
command: "bun",
args: ["run", "check"],
cwd: "/repo/worktree",
status,
pid: 42,
...(exitCode === undefined ? {} : { exitCode }),
}
}
function harness(opts?: {
create?: (input: PtyInput) => Promise<PtyResponse>
get?: () => Promise<PtyResponse>
remove?: () => Promise<{ data?: unknown; error?: unknown }>
}) {
const calls: { create: PtyInput[]; get: unknown[]; update: PtyUpdate[]; remove: unknown[] } = {
create: [],
get: [],
update: [],
remove: [],
}
const snapshots: ScriptTerminalView[][] = []
const closed: string[] = []
const logs: string[] = []
const client = {
v2: {
pty: {
create: async (input: PtyInput) => {
calls.create.push(input)
return opts?.create ? opts.create(input) : { data: { location: { directory: config.cwd }, data: info() } }
},
get: async (input: unknown) => {
calls.get.push(input)
return opts?.get ? opts.get() : { data: { location: { directory: config.cwd }, data: info() } }
},
update: async (input: PtyUpdate) => {
calls.update.push(input)
return { data: info() }
},
remove: async (input: unknown) => {
calls.remove.push(input)
return opts?.remove ? opts.remove() : { data: undefined }
},
},
},
} as unknown as KiloClient
const manager = new ScriptTerminalManager({
getClient: () => client,
getClientAsync: async () => client,
buildWsUrl: (ptyID, cwd) => `ws://127.0.0.1:4096/api/pty/${ptyID}/connect?location=${cwd}`,
getTerminalFont: () => ({ fontFamily: "Menlo", fontSize: 12 }),
emit: (terminals) => snapshots.push(terminals),
closed: (terminalId) => closed.push(terminalId),
log: (msg) => logs.push(msg),
})
return { manager, calls, snapshots, closed, logs }
}
const config = {
worktreeId: "wt-1",
command: "bun",
args: ["run", "check"],
cwd: "/repo/worktree",
env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" },
}
describe("ScriptTerminalManager", () => {
it("creates a Run PTY with explicit command settings and a safe snapshot", async () => {
const ctx = harness()
const done: unknown[] = []
await ctx.manager.start("run", config, (exit) => done.push(exit))
expect(ctx.calls.create).toEqual([
{
location: { directory: "/repo/worktree" },
command: "bun",
args: ["run", "check"],
cwd: "/repo/worktree",
env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" },
title: "Run",
},
])
expect(ctx.calls.get).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
expect(ctx.snapshots.at(-1)).toEqual([
expect.objectContaining({
worktreeId: "wt-1",
kind: "run",
title: "Run",
state: "running",
font: { fontFamily: "Menlo", fontSize: 12 },
}),
])
expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"command"')
expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"env"')
expect(done).toEqual([])
})
it("normalizes the internal local Run key to a null external worktree id", async () => {
const ctx = harness()
await ctx.manager.start("run", { ...config, worktreeId: "local", cwd: "/repo" }, () => undefined)
expect(ctx.snapshots.at(-1)?.[0]?.worktreeId).toBeNull()
})
it("builds canonical authenticated replay URLs", () => {
const value = buildScriptTerminalWsUrl(
{ baseUrl: "http://127.0.0.1:4096", password: "secret" },
"pty / 1",
"/repo/worktree",
)
const url = new URL(value)
expect(url.protocol).toBe("ws:")
expect(url.pathname).toBe("/api/pty/pty%20%2F%201/connect")
expect(url.searchParams.get("location[directory]")).toBe("/repo/worktree")
expect(url.searchParams.get("cursor")).toBe("0")
expect(url.searchParams.get("replayExited")).toBe("1")
expect(url.searchParams.get("auth_token")).toBe(Buffer.from("kilo:secret").toString("base64"))
})
it("finishes once on a natural exit and retains the replayable terminal", async () => {
const ctx = harness()
const done: unknown[] = []
await ctx.manager.start("run", config, (exit) => done.push(exit))
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
if (!terminalId) throw new Error("missing Run terminal")
ctx.manager.exited("pty-1", 17)
ctx.manager.exited("pty-1", 17)
expect(done).toEqual([{ exitCode: 17 }])
expect(ctx.calls.remove).toEqual([])
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "exited", exitCode: 17 })])
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
await wait()
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
expect(ctx.closed).toEqual([terminalId])
expect(ctx.snapshots.at(-1)).toEqual([])
})
it("reconciles a PTY that exited before registration", async () => {
const ctx = harness({
get: async () => ({ data: { location: { directory: config.cwd }, data: info("exited", 7) } }),
})
const done: unknown[] = []
await ctx.manager.start("run", config, (exit) => done.push(exit))
expect(done).toEqual([{ exitCode: 7 }])
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 7 })])
})
it("reconciles an exit event that arrives before create registration", async () => {
const gate = deferred<PtyResponse>()
let state = info()
const ctx = harness({
create: async () => gate.promise,
get: async () => ({ data: { location: { directory: config.cwd }, data: state } }),
})
const done: unknown[] = []
const started = ctx.manager.start("run", config, (exit) => done.push(exit))
await wait()
state = info("exited", 9)
ctx.manager.exited("pty-1", 9)
gate.resolve({ data: { location: { directory: config.cwd }, data: info() } })
await started
expect(done).toEqual([{ exitCode: 9 }])
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 9 })])
})
it("treats an already removed backend PTY as a successful close", async () => {
const ctx = harness({ remove: async () => ({ error: { _tag: "PtyNotFoundError", status: 404 } }) })
const done: unknown[] = []
await ctx.manager.start("run", config, (exit) => done.push(exit))
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
if (!terminalId) throw new Error("missing Run terminal")
expect(await ctx.manager.close(terminalId)).toBe(true)
expect(done).toEqual([{ stopped: true }])
expect(ctx.snapshots.at(-1)).toEqual([])
})
it("stops a PTY when stop races startup", async () => {
const gate = deferred<PtyResponse>()
const ctx = harness({ create: async () => gate.promise })
const statuses: RunStatus[] = []
const run = new RunScriptManager(
() => undefined,
(status) => statuses.push({ ...status }),
() => new Date("2026-01-02T03:04:05.000Z"),
)
const started = run.start("wt-1", () => ctx.manager.start("run", config, (exit) => run.finish("wt-1", exit)))
await wait()
await run.stop("wt-1")
gate.resolve({ data: { location: { directory: config.cwd }, data: info() } })
await started
await wait()
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
expect(statuses.map((status) => status.state)).toEqual(["running", "stopping", "idle"])
expect(run.status("wt-1")).toMatchObject({ state: "idle", stopped: true })
})
it("intercepts resize and stops a running terminal when it closes", async () => {
const ctx = harness()
const done: unknown[] = []
await ctx.manager.start("run", config, (exit) => done.push(exit))
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
if (!terminalId) throw new Error("missing Run terminal")
expect(ctx.manager.intercept({ type: "agentManager.terminal.resize", terminalId, cols: 120, rows: 40 })).toBe(true)
await wait()
expect(ctx.calls.update).toEqual([
{ ptyID: "pty-1", location: { directory: "/repo/worktree" }, size: { cols: 120, rows: 40 } },
])
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
await wait()
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
expect(done).toEqual([{ stopped: true }])
expect(ctx.closed).toEqual([terminalId])
expect(ctx.snapshots.at(-1)).toEqual([])
})
it("retries closure after a Run terminal removal fails", async () => {
let attempt = 0
const ctx = harness({
remove: async () => {
attempt++
if (attempt === 1) return { error: new Error("still running") }
return { data: undefined }
},
})
await ctx.manager.start("run", config, () => undefined)
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
if (!terminalId) throw new Error("missing Run terminal")
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
await wait()
expect(ctx.closed).toEqual([])
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })])
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
await wait()
expect(ctx.calls.remove).toHaveLength(2)
expect(ctx.closed).toEqual([terminalId])
expect(ctx.snapshots.at(-1)).toEqual([])
})
it("drops a retained Run terminal when the backend evicts it", async () => {
const ctx = harness()
const done: unknown[] = []
await ctx.manager.start("run", config, (exit) => done.push(exit))
ctx.manager.exited("pty-1", 0)
ctx.manager.deleted("pty-1")
expect(done).toEqual([{ exitCode: 0 }])
expect(ctx.snapshots.at(-1)).toEqual([])
expect(ctx.calls.remove).toEqual([])
})
it("reconciles a natural exit missed during an event-stream reconnect", async () => {
let state: PtyInfo = info()
const ctx = harness({ get: async () => ({ data: { location: { directory: config.cwd }, data: state } }) })
const done: unknown[] = []
await ctx.manager.start("run", config, (exit) => done.push(exit))
state = info("exited", 23)
await ctx.manager.sync()
expect(done).toEqual([{ exitCode: 23 }])
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 23 })])
})
it("clears retained exited terminals by worktree context", async () => {
const ctx = harness()
await ctx.manager.start("run", config, () => undefined)
ctx.manager.exited("pty-1", 0)
expect(await ctx.manager.clear("run", "wt-1")).toBe(true)
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
expect(ctx.snapshots.at(-1)).toEqual([])
})
it("replays the full retained snapshot after a webview reload", async () => {
const ctx = harness()
await ctx.manager.start("run", config, () => undefined)
const first = ctx.snapshots.at(-1)
ctx.manager.snapshot()
expect(ctx.snapshots.at(-1)).toEqual(first)
})
})
@@ -0,0 +1,115 @@
import { describe, expect, it } from "bun:test"
import { handleSessionSearch } from "../../src/kilo-provider/session-search"
type Query = Record<string, unknown>
function stub(data: Array<Record<string, unknown>> | Error) {
const calls: Query[] = []
const client = {
experimental: {
session: {
list: async (query: Query) => {
calls.push(query)
if (data instanceof Error) throw data
return { data }
},
},
},
}
return { calls, client }
}
function session(id: string, title: string, updated: number, worktreeName?: string) {
return { id, title, time: { updated }, worktreeName }
}
describe("handleSessionSearch", () => {
it("lists root sessions across the worktree family for the resolved directory", async () => {
const { calls, client } = stub([session("ses_a", "Alpha", 2, "neon-author")])
const posted: unknown[] = []
await handleSessionSearch({
client: client as never,
message: { requestId: "r1", sessionID: "ses_current" },
dir: (id) => (id === "ses_current" ? "/repo/.kilo/worktrees/wt-1" : "/repo"),
post: (msg) => posted.push(msg),
})
expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 50 }])
expect(posted).toEqual([
{
type: "sessionSearchResult",
sessions: [{ id: "ses_a", title: "Alpha", updated: 2, worktreeName: "neon-author" }],
requestId: "r1",
},
])
})
it("falls back to the current and context sessions for directory resolution", async () => {
const { calls, client } = stub([])
await handleSessionSearch({
client: client as never,
message: { requestId: "r2" },
current: "ses_current",
context: "ses_context",
dir: (id) => `/dir/${id}`,
post: () => {},
})
expect(calls[0]?.directory).toBe("/dir/ses_current")
await handleSessionSearch({
client: client as never,
message: { requestId: "r3" },
context: "ses_context",
dir: (id) => `/dir/${id}`,
post: () => {},
})
expect(calls[1]?.directory).toBe("/dir/ses_context")
})
it("excludes the given session and sessions without titles", async () => {
const { client } = stub([
session("ses_keep", "Keep", 3),
session("ses_exclude", "Excluded", 2),
session("ses_untitled", "", 1),
])
const posted: Array<{ sessions: Array<{ id: string }> }> = []
await handleSessionSearch({
client: client as never,
message: { requestId: "r4" },
dir: () => "/repo",
exclude: "ses_exclude",
post: (msg) => posted.push(msg as never),
})
expect(posted[0]?.sessions.map((s) => s.id)).toEqual(["ses_keep"])
})
it("posts an empty result when the client is missing or the list fails", async () => {
const posted: unknown[] = []
await handleSessionSearch({
client: null,
message: { requestId: "r5" },
dir: () => "/repo",
post: (msg) => posted.push(msg),
})
const failing = stub(new Error("boom"))
await handleSessionSearch({
client: failing.client as never,
message: { requestId: "r6" },
dir: () => "/repo",
post: (msg) => posted.push(msg),
})
expect(posted).toEqual([
{ type: "sessionSearchResult", sessions: [], requestId: "r5" },
{ type: "sessionSearchResult", sessions: [], requestId: "r6" },
])
})
})
@@ -0,0 +1,119 @@
import { describe, it, expect } from "bun:test"
import { WorktreeDiffController } from "../../src/agent-manager/worktree-diff-controller"
import type { DiffSourceCatalog } from "../../src/diff/sources/catalog"
import type { DiffSource } from "../../src/diff/sources/types"
import type { PanelContext } from "../../src/diff/types"
import type { GitOps } from "../../src/agent-manager/GitOps"
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
// Records every PanelContext handed to catalog.build so tests can assert which
// base branch the active source was (re)built with. The controller, scope
// resolution, and SourceController lifecycle under test are all real.
function make(onFetch?: (n: number) => Promise<void>) {
const builds: { id: string; ctx: PanelContext }[] = []
let fetches = 0
const catalog = {
build: (id: string, ctx: PanelContext): DiffSource => {
builds.push({ id, ctx })
return {
descriptor: { id, type: "workspace", group: "Git", capabilities: { revert: true, comments: true } },
async fetch() {
await onFetch?.(++fetches)
return { diffs: [] }
},
}
},
} as unknown as DiffSourceCatalog
const state = {
getSession: (id: string) => (id === "s1" ? { id: "s1", worktreeId: "w1", createdAt: "" } : undefined),
getWorktree: (id: string) =>
id === "w1" ? { id: "w1", path: "/wt", parentBranch: "main", remote: "origin" } : undefined,
} as unknown as WorktreeStateManager
const controller = new WorktreeDiffController({
getState: () => state,
getRoot: () => "/repo",
getStateReady: () => undefined,
catalog,
git: {} as GitOps,
localDiffFile: async () => null,
post: () => {},
log: () => {},
})
return { controller, builds }
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
async function waitFor(cond: () => boolean): Promise<void> {
for (let i = 0; i < 50; i++) {
if (cond()) return
await tick()
}
throw new Error("waitFor timed out")
}
describe("WorktreeDiffController.setBase", () => {
it("rebuilds the active source against the overridden base branch", async () => {
const { controller, builds } = make()
controller.start("s1#branch")
await waitFor(() => builds.length === 1)
expect(builds[0]!.ctx.dir).toBe("/wt")
expect(builds[0]!.ctx.baseBranch).toBe("origin/main")
await controller.setBase("s1#branch", "feature-x")
expect(builds.length).toBe(2)
expect(builds[1]!.ctx.dir).toBe("/wt")
expect(builds[1]!.ctx.baseBranch).toBe("feature-x")
// Clearing the override falls back to the recorded parent ref.
await controller.setBase("s1#branch", undefined)
expect(builds.length).toBe(3)
expect(builds[2]!.ctx.baseBranch).toBe("origin/main")
controller.stop()
})
it("stores the override without rebuilding when the context isn't active", async () => {
const { controller, builds } = make()
await controller.setBase("s1#branch", "feature-x")
expect(builds.length).toBe(0)
// The next activation of that context resolves the stored override.
controller.start("s1#branch")
await waitFor(() => builds.length === 1)
expect(builds[0]!.ctx.baseBranch).toBe("feature-x")
controller.stop()
})
it("keeps watching when the base changes during the initial fetch", async () => {
// Hold the first activation's fetch in flight, simulating a slow worktree
// diff. isPolling is still false in this window, but the watch intent must
// survive the base change rather than downgrading the panel to one-shot.
let release: () => void = () => {}
const gate = new Promise<void>((resolve) => (release = resolve))
const { controller, builds } = make(async (n) => {
if (n === 1) await gate
})
controller.start("s1#branch")
await waitFor(() => builds.length === 1)
const change = controller.setBase("s1#branch", "feature-x")
release()
await change
expect(builds.length).toBe(2)
expect(builds[1]!.ctx.baseBranch).toBe("feature-x")
// Polling survives: start() early-returns for an id that is already
// watched. A downgraded one-shot panel would re-activate and rebuild here.
controller.start("s1#branch")
await tick()
expect(builds.length).toBe(2)
controller.stop()
})
})
@@ -23,6 +23,7 @@ import type {
AgentManagerWorktreeDiffMessage,
AgentManagerWorktreeDiffFileMessage,
AgentManagerWorktreeDiffLoadingMessage,
AgentManagerDiffBranchesMessage,
AgentManagerApplyWorktreeDiffResultMessage,
AgentManagerWorktreeStatsMessage,
AgentManagerLocalStatsMessage,
@@ -38,6 +39,7 @@ import type {
SessionInfo,
SessionCreatedMessage,
BranchInfo,
TerminalDestination,
} from "../src/types/messages"
import {
DragDropProvider,
@@ -105,6 +107,7 @@ import {
createTerminalMessageHandler,
createSideTerminal,
readSavedDestination,
resolveRunScriptRequest,
resolveVscodeTerminalRequest,
} from "./terminal"
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
@@ -136,6 +139,9 @@ import {
} from "./section-helpers"
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { DiffScopeControls } from "../diff-viewer/DiffScopeControls"
import { scopeCapabilities } from "./diff-scope-state"
import { createDiffReviewScope } from "./diff-review-scope"
import { initialMessage, seedInitialVariant } from "./initial-message"
import { createMarkdownRender } from "./review-preferences"
import { createSidebarCollapse } from "./sidebar-collapse"
@@ -398,20 +404,20 @@ const AgentManagerContent: Component = () => {
vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel })
}
const runWorktree = (id: string) => {
const runWorktree = (id: string, destination: TerminalDestination) => {
const state = runStatuses()[id]?.state ?? "idle"
if (state === "running" || state === "stopping") {
vscode.postMessage({ type: "agentManager.stopRunScript", worktreeId: id })
return
}
vscode.postMessage({ type: "agentManager.runScript", worktreeId: id })
vscode.postMessage(resolveRunScriptRequest(id, destination))
}
const configureRunScript = () => vscode.postMessage({ type: "agentManager.configureRunScript" })
const runSelected = () => {
const sel = selection()
if (sel) runWorktree(sel)
if (sel) runWorktree(sel, sideCtl.destination())
}
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
@@ -929,7 +935,7 @@ const AgentManagerContent: Component = () => {
requestAnimationFrame(() => sidebarSearchMenu?.open())
}
} else if (msg.action === "showTerminal") {
sideCtl.openPreferred("keyboard_shortcut")
if (!sideCtl.echo()) sideCtl.openPreferred("keyboard_shortcut")
} else if (msg.action === "toggleDiff") {
if (reviewActive()) {
closeReviewTab()
@@ -987,6 +993,13 @@ const AgentManagerContent: Component = () => {
}
window.addEventListener("keydown", preventDefaults, true)
// Cmd/Ctrl+/ toggles the terminal even when VS Code's webview keybinding
// forwarding drops the key before it reaches the workbench (reported with
// the prompt input focused). When forwarding does work, the extension
// echoes the shortcut back as an action message and sideCtl dedupes it.
const shortcut = (e: KeyboardEvent) => sideCtl.press(e)
window.addEventListener("keydown", shortcut, true)
// Delete/Backspace on a selected worktree triggers inline delete confirmation.
// Pressing the key twice in a row (within the 2500ms window) confirms the delete.
const deleteKeyHandler = (e: KeyboardEvent) => {
@@ -1086,7 +1099,15 @@ const AgentManagerContent: Component = () => {
onSideCreated: (contextKey, terminalId) => {
// Focus only when the user is still looking at this panel —
// a slow create landing after a mode switch must not steal it.
if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId)
if (sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) {
terms.requestFocus(terminalId)
}
},
onScriptRunning: (contextKey, terminalId) => {
if (terms.sideKey() !== contextKey) return
showSideTerminal()
terms.setSideActive(contextKey, terminalId)
terms.requestFocus(terminalId)
},
onDestinationChanged: (destination) => sideCtl.syncDefault(destination),
})
@@ -1308,6 +1329,10 @@ const AgentManagerContent: Component = () => {
diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage)
}
if (msg.type === "agentManager.diffBranches") {
review.onBranches(msg as AgentManagerDiffBranchesMessage)
}
if (msg.type === "agentManager.applyWorktreeDiffResult") {
apply.onApplyResult(msg as AgentManagerApplyWorktreeDiffResultMessage)
}
@@ -1336,6 +1361,7 @@ const AgentManagerContent: Component = () => {
onCleanup(() => {
window.removeEventListener("message", handler)
window.removeEventListener("keydown", preventDefaults, true)
window.removeEventListener("keydown", shortcut, true)
window.removeEventListener("keydown", deleteKeyHandler)
window.removeEventListener("keydown", modTrack, true)
window.removeEventListener("keyup", modTrack, true)
@@ -1379,15 +1405,47 @@ const AgentManagerContent: Component = () => {
const currentDiffSessionId = createMemo(selectedDiffSessionId)
// Start/stop diff watch when panel opens/closes, review tab opens, or session changes
// Diff scope + base branch state, shared by the side panel and review tab.
const review = createDiffReviewScope({
ctx: currentDiffSessionId,
panelOpen: diffOpen,
reviewActive,
local: LOCAL,
vscode,
})
// The composite id (ctx#scope) the extension keys diff data by.
const diffScopeId = review.id
// Shared scope + base-picker controls for the side panel and review tab.
const diffScopeControls = (compact: boolean) => (
<DiffScopeControls
descriptors={review.descriptors()}
currentId={review.id()}
onSelectScope={review.select}
showBase={review.isBranch()}
branches={review.branches()}
branchesLoading={review.loading()}
defaultBranch={review.defaultBranch()}
autoBase={review.autoBase()}
currentBase={review.currentBase()}
isAuto={review.isAuto()}
currentBranch={review.currentBranch()}
onSelectBase={review.selectBase}
compact={compact}
/>
)
// Start/stop diff watch when panel opens/closes, review tab opens, scope
// changes, or session changes.
createEffect(() => {
const panel = diffOpen()
const review = reviewActive()
const active = reviewActive()
const scope = review.scope()
if (panel || review) {
if (panel || active) {
const id = currentDiffSessionId()
if (id) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id })
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id, scope })
return
}
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
@@ -1432,33 +1490,17 @@ const AgentManagerContent: Component = () => {
tabFocus.restore()
}
// Data for the review tab: use local diff data for local context,
// current session for selected worktree context, or first available in that worktree.
// Data for the review tab / side panel: keyed by the composite diff id
// (ctx#scope) the extension pushes, so each scope keeps its own file set and
// switching back to a fetched scope is instant.
const reviewDiffs = createMemo(() => {
const data = diffDatas()
const sel = selection()
const id = session.currentSessionID()
if (sel === LOCAL) return data[LOCAL] ?? []
if (id && data[id]) {
const current = managedSessions().find((s) => s.id === id)
if (sel && current?.worktreeId === sel) return data[id]!
}
if (!sel) return []
const ids = managedSessions()
.filter((s) => s.worktreeId === sel)
.map((s) => s.id)
for (const sid of ids) {
if (data[sid]) return data[sid]!
}
return []
const key = diffScopeId()
if (!key) return []
return data[key] ?? []
})
const diffSessionKey = createMemo(() => {
const sel = selection()
if (sel === LOCAL) return `local:${LOCAL}`
if (sel === null) return `session:${session.currentSessionID() ?? ""}`
return `worktree:${sel}`
})
const diffSessionKey = createMemo(() => diffScopeId() ?? "")
const setSharedDiffStyle = (style: "unified" | "split") => {
if (reviewDiffStyle() === style) return
@@ -1467,14 +1509,14 @@ const AgentManagerContent: Component = () => {
}
const requestDiffFile = (file: string) => {
const sessionId = currentDiffSessionId()
if (!sessionId) return
diffs.requestDiffFile(sessionId, file)
const id = diffScopeId()
if (!id) return
diffs.requestDiffFile(id, file)
}
const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(currentDiffSessionId))
const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId))
const revertCtl = createRevertFile(currentDiffSessionId, vscode, showToast, t)
const revertCtl = createRevertFile(diffScopeId, currentDiffSessionId, () => review.scope(), vscode, showToast, t)
const handleConfigureSetupScript = () => {
vscode.postMessage({ type: "agentManager.configureSetupScript" })
@@ -1820,7 +1862,7 @@ const AgentManagerContent: Component = () => {
const sideCtl = createSideTerminal({
handlers: termHandlers,
visible: () => sidePanel() === "terminal",
visible: () => sidePanel() === "terminal" && !history() && !reviewActive(),
focusedId: () => terms.sideFocusedId(),
hide: () => setSidePanel(null),
refocus: () => window.dispatchEvent(new Event("focusPrompt")),
@@ -1838,6 +1880,7 @@ const AgentManagerContent: Component = () => {
) as never,
),
})
createEffect(on(terms.sideKey, (key, previous) => sideCtl.syncContext(key, previous), { defer: true }))
const handleReviewTabMouseDown = (e: MouseEvent) => {
if (e.button !== 1) return
@@ -2475,12 +2518,19 @@ const AgentManagerContent: Component = () => {
{t("agentManager.open.button")}
</Button>
</Tooltip>
<Tooltip value={t("agentManager.apply.tooltip")} placement="bottom">
<Tooltip
value={
review.scope() === "branch"
? t("agentManager.apply.tooltip")
: t("agentManager.diff.applyBranchOnly")
}
placement="bottom"
>
<Button
size="small"
variant="ghost"
onClick={openApplyDialog}
disabled={!hasChanges() || applyBusy()}
disabled={!hasChanges() || applyBusy() || review.scope() !== "branch"}
>
<Show when={applyBusy()}>
<Spinner class="am-apply-spinner" />
@@ -2510,7 +2560,7 @@ const AgentManagerContent: Component = () => {
onClick={metrics.click(
"run_script",
"tab_toolbar",
() => runWorktree(rid()),
() => runWorktree(rid(), sideCtl.destination()),
() => ({
action: active() ? "stop" : configured() ? "run" : "configure",
}),
@@ -2782,6 +2832,8 @@ const AgentManagerContent: Component = () => {
loadingFiles={diffFileLoadingForCurrent()}
sessionId={currentDiffSessionId()}
sessionKey={diffSessionKey()}
lead={diffScopeControls(true)}
canRevert={scopeCapabilities(review.scope()).revert}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
@@ -2814,6 +2866,7 @@ const AgentManagerContent: Component = () => {
visible={() => sidePanel() === "terminal"}
onSelect={(id) => termHandlers.selectSide(id)}
onClose={(id) => termHandlers.closeSide(id)}
onCloseOthers={(id) => termHandlers.closeSideOthers(id)}
onStart={() => termHandlers.addSide()}
/>
</div>
@@ -2829,6 +2882,9 @@ const AgentManagerContent: Component = () => {
loadingFiles={diffFileLoadingForCurrent()}
sessionId={currentDiffSessionId()}
sessionKey={diffSessionKey()}
lead={diffScopeControls(false)}
canRevert={scopeCapabilities(review.scope()).revert}
canComment={scopeCapabilities(review.scope()).comments}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
@@ -1,4 +1,4 @@
import { type Component, createSignal, createMemo, Show, createEffect, on } from "solid-js"
import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js"
import type { VirtualizerHandle } from "virtua/solid"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Accordion } from "@kilocode/kilo-ui/accordion"
@@ -7,7 +7,6 @@ import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
@@ -15,6 +14,7 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie
import type { WorktreeFileDiff } from "../src/types/messages"
import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions"
import { useLanguage } from "../src/context/language"
import { DiffStyleSelect } from "../diff-viewer/InlineSelect"
import { useVSCode } from "../src/context/vscode"
import { useServer } from "../src/context/server"
import { useProvider } from "../src/context/provider"
@@ -86,6 +86,10 @@ interface DiffPanelProps {
onRevertFile?: (file: string) => void
revertingFiles?: Set<string>
activeTerminalId?: string
/** Optional leading row rendered under the header (e.g. the scope selector). */
lead?: JSXElement
/** Defaults to true. Hides the per-file Revert action when false. */
canRevert?: boolean
}
export const DiffPanel: Component<DiffPanelProps> = (props) => {
@@ -475,21 +479,18 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<div class="am-diff-panel" onKeyDown={handleKeyDown} onMouseDown={handleRootMouseDown} tabIndex={-1} ref={rootRef}>
<div class="am-diff-header">
<div class="am-diff-header-main">
<span class="am-diff-header-title">{t("session.review.change.other")}</span>
{/* Scope + base picker replace the static "Changes" title: it names
what you're looking at and is the primary control. Always shown,
so an empty scope can still be switched away from. */}
<Show when={props.lead}>{props.lead}</Show>
<Show when={props.diffs.length > 0}>
<>
<RadioGroup
options={["unified", "split"] as const}
current={props.diffStyle ?? "unified"}
size="small"
value={(style) => style}
label={(style) =>
style === "unified" ? t("ui.sessionReview.diffStyle.unified") : t("ui.sessionReview.diffStyle.split")
}
onSelect={(style) => {
if (!style) return
props.onDiffStyleChange?.(style)
}}
<DiffStyleSelect
value={props.diffStyle ?? "unified"}
onSelect={(style) => props.onDiffStyleChange?.(style)}
unifiedLabel={t("ui.sessionReview.diffStyle.unified")}
splitLabel={t("ui.sessionReview.diffStyle.split")}
title={t("ui.sessionReview.diffStyle.unified")}
/>
<span class="am-diff-header-stats">
<span>{t("session.review.filesChanged", { count: totals().files })}</span>
@@ -636,7 +637,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
/>
</Tooltip>
</Show>
<Show when={props.onRevertFile}>
<Show when={props.onRevertFile && props.canRevert !== false}>
<Tooltip value={t("agentManager.diff.revertFile")} placement="top">
<IconButton
icon="discard"
@@ -43,12 +43,20 @@
.am-review-toolbar-left {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* Keep the radio group from being the tallest thing in the row so it matches
the 22px selector chips and the small ghost buttons. The inline scope/base
controls are styled in banners.css (non-am- prefixed, shared with the
standalone diff viewer). */
.am-review-toolbar [data-component="radio-group"] {
font-size: var(--font-size-small);
}
.am-review-toolbar-right {
display: flex;
align-items: center;
@@ -77,7 +85,7 @@
.am-review-toolbar-stats {
display: flex;
align-items: center;
flex: 1 1 auto;
flex: 0 100 auto;
gap: 8px;
font-size: var(--font-size-small);
color: var(--text-weak);
@@ -1296,6 +1296,19 @@ button.am-section-toggle:hover .am-section-label {
color: currentColor;
}
.am-tab-icon[data-run-status="success"] {
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-tab-icon[data-run-status="failure"] {
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-terminal-tab-spinner {
width: 12px;
height: 12px;
}
.am-tab-label {
flex: 1;
min-width: 0;
@@ -1717,35 +1730,58 @@ body.am-wt-dragging-active * {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 4px 12px;
gap: 6px;
padding: 4px 4px 4px 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
position: relative;
z-index: 20;
background: var(--surface-base);
}
.am-diff-header-title {
font-size: var(--font-size-small);
font-weight: 500;
color: var(--text-weak);
/* Query container for the narrow-panel rules below and in banners.css. The
panel width is user-draggable, so the header adapts to its own width
rather than the viewport. */
container-type: inline-size;
container-name: am-diff-header;
}
.am-diff-header-main {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.am-diff-header-stats {
display: flex;
align-items: center;
gap: 8px;
flex: 0 1 auto;
min-width: 0;
font-size: var(--font-size-small);
color: var(--text-weak);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Progressive disclosure, least important first. Thresholds are derived from the
measured control widths: scope 76px, base <=191px, diff style 76px, 6px gaps,
plus ~90px of action buttons and 12px padding. Each breakpoint fires while the
remaining set still fits, so nothing is ever clipped under the buttons.
The collapsed-files hint goes before the totals; both are also shown on the
worktree row and the diff toggle button, so no information is lost. */
@container am-diff-header (max-width: 560px) {
.am-diff-header-collapsed {
display: none;
}
}
@container am-diff-header (max-width: 520px) {
.am-diff-header-stats {
display: none;
}
}
.am-diff-header-adds {
@@ -4620,15 +4656,17 @@ body.vscode-high-contrast-light {
/* Side terminal tab strip one row of tabs reusing the top bar's
.am-tab chrome, plus the "+" action. Height matches .am-diff-header
(32px: 4px padding + 24px content) so switching inspector modes does
not shift the panel chrome. 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). */
(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 {
display: flex;
align-items: stretch;
height: 32px;
padding: 4px 4px 0;
padding: 0 4px;
gap: 2px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
@@ -4637,11 +4675,20 @@ body.vscode-high-contrast-light {
z-index: 20;
}
/* 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
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-tab-max-width: 180px;
--am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width));
display: flex;
align-items: stretch;
gap: 2px;
flex: 1;
/* No gap, like .am-tab-list: equal-share tab widths already consume
the full width, so any gap would leave the list a few pixels
scrollable and keep the overflow fade lit for nothing. */
flex: 0 1 calc(var(--tab-count, 1) * var(--am-tab-max-width));
min-width: 0;
height: 100%;
overflow-x: auto;
@@ -4653,20 +4700,20 @@ body.vscode-high-contrast-light {
display: none;
}
/* Each tab shares the available width and shrinks with ellipsis.
touch-action unlocks pointer-based drag reordering (same as
.am-tab-sortable). */
.am-side-terminal-tab {
display: flex;
flex: 0 1 140px;
min-width: 64px;
height: 100%;
touch-action: none;
.am-side-terminal-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 {
border-left-color: transparent;
}
.am-side-terminal-add {
display: flex;
align-items: center;
align-self: center;
flex-shrink: 0;
padding: 0 2px;
}
@@ -0,0 +1,118 @@
/**
* Diff scope + base branch state for the Agent Manager review surfaces.
*
* Owns the per-context scope selection, the branch picker data for the active
* context, and the message senders that drive both. Extracted from
* AgentManagerApp to keep that file under its line cap; both the side panel
* and the full-screen review tab consume the single instance returned here.
*/
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import type { BranchInfo } from "../src/types/messages"
import { createDiffScope, isDiffScope, scopeDescriptors, type DiffScope } from "./diff-scope-state"
interface VsCode {
postMessage(msg: unknown): void
}
export interface DiffReviewScopeOptions {
/** Current diff context (worktree session id or the LOCAL pseudo-id). */
ctx: Accessor<string | undefined>
/** Whether the diff side panel is open. */
panelOpen: Accessor<boolean>
/** Whether the full-screen review tab is active. */
reviewActive: Accessor<boolean>
/** The id that marks the local pseudo-context (omits the Session scope). */
local: string
vscode: VsCode
}
export function createDiffReviewScope(opts: DiffReviewScopeOptions) {
const scope = createDiffScope(opts.ctx)
// The composite id (ctx#scope) the extension keys diff data by.
const id = createMemo(() => scope.id())
// Branch picker state for the active context (Branch scope only).
const [branches, setBranches] = createSignal<BranchInfo[]>([])
const [loading, setLoading] = createSignal(false)
const [defaultBranch, setDefaultBranch] = createSignal("")
const [autoBase, setAutoBase] = createSignal<string | undefined>(undefined)
const [currentBase, setCurrentBase] = createSignal<string | undefined>(undefined)
const [isAuto, setIsAuto] = createSignal(true)
const [currentBranch, setCurrentBranch] = createSignal<string | undefined>(undefined)
// Scope descriptors for the current context. The `local` pseudo-context and
// contexts without a real session omit the Session scope.
const descriptors = createMemo(() => {
const ctx = opts.ctx()
if (!ctx) return []
return scopeDescriptors(ctx, ctx !== opts.local)
})
const isBranch = () => scope.scope() === "branch"
const select = (next: string) => {
const ctx = opts.ctx()
if (!ctx) return
const value = next.slice(ctx.length + 1)
scope.setScope(isDiffScope(value) ? value : "branch")
}
const selectBase = (branch: string | undefined) => {
const ctx = opts.ctx()
if (!ctx) return
// Optimistic update; the extension echoes authoritative state back.
setCurrentBase(branch ?? autoBase())
setIsAuto(branch === undefined)
opts.vscode.postMessage({ type: "agentManager.setDiffBaseBranch", sessionId: ctx, scope: scope.scope(), branch })
}
// Fetch branch picker data whenever the Branch scope becomes active for the
// current context. The extension owns override state, so ask each time.
createEffect(() => {
if (scope.scope() !== "branch") return
const ctx = opts.ctx()
if (!ctx) return
if (!opts.panelOpen() && !opts.reviewActive()) return
setLoading(true)
opts.vscode.postMessage({ type: "agentManager.requestDiffBranches", sessionId: ctx, scope: scope.scope() })
})
/** Handle the extension's diffBranches push, ignoring stale contexts. */
const onBranches = (ev: {
sessionId: string
branches: BranchInfo[]
defaultBranch: string
autoBase?: string
currentBase?: string
isAuto: boolean
currentBranch?: string
}) => {
if (ev.sessionId === id()) {
setBranches(ev.branches)
setDefaultBranch(ev.defaultBranch)
setAutoBase(ev.autoBase)
setCurrentBase(ev.currentBase)
setIsAuto(ev.isAuto)
setCurrentBranch(ev.currentBranch)
}
setLoading(false)
}
return {
scope: scope.scope,
id,
descriptors,
isBranch,
select,
selectBase,
onBranches,
branches,
loading,
defaultBranch,
autoBase,
currentBase,
isAuto,
currentBranch,
}
}
@@ -0,0 +1,103 @@
/**
* Webview-side diff scope state for Agent Manager.
*
* Mirrors the extension's composite diff id (`ctx#scope`, see
* `src/agent-manager/diff-scope.ts`) and builds the fixed scope descriptor
* list shown in the scope selector. Agent Manager always offers the same four
* scopes per context, so the descriptors are computed client-side rather than
* pushed from the extension.
*/
import { createMemo, createSignal, type Accessor } from "solid-js"
import type { DiffSourceDescriptor } from "../../src/diff/sources/types"
export type DiffScope = "branch" | "staged" | "unstaged" | "session"
export const DEFAULT_DIFF_SCOPE: DiffScope = "branch"
const SEP = "#"
export function composeDiffId(ctx: string, scope: DiffScope): string {
return `${ctx}${SEP}${scope}`
}
export function parseDiffId(id: string): { ctx: string; scope: DiffScope } {
const idx = id.lastIndexOf(SEP)
const scope = id.slice(idx + SEP.length)
if (idx !== -1 && isDiffScope(scope)) return { ctx: id.slice(0, idx), scope }
return { ctx: id, scope: DEFAULT_DIFF_SCOPE }
}
export function isDiffScope(value: string): value is DiffScope {
return value === "branch" || value === "staged" || value === "unstaged" || value === "session"
}
/**
* The fixed scope descriptors for a context. `workspace` maps to the Branch
* scope to reuse the existing i18n keys (`diffViewer.source.workspace.*`).
* Session scope is only meaningful for a real session context, so it is
* omitted for the `local` pseudo-context and for contexts without a session.
*/
export function scopeDescriptors(ctx: string, hasSession: boolean): DiffSourceDescriptor[] {
const out: DiffSourceDescriptor[] = [
{
id: composeDiffId(ctx, "branch"),
type: "workspace",
group: "Git",
capabilities: { revert: true, comments: true },
},
{ id: composeDiffId(ctx, "staged"), type: "staged", group: "Git", capabilities: { revert: false, comments: true } },
{
id: composeDiffId(ctx, "unstaged"),
type: "unstaged",
group: "Git",
capabilities: { revert: false, comments: true },
},
]
if (hasSession) {
out.push({
id: composeDiffId(ctx, "session"),
type: "session",
group: "Session",
capabilities: { revert: false, comments: true },
})
}
return out
}
/**
* Whether the Branch scope supports revert. Staged/unstaged/session are
* read-only; only the Branch scope can revert files back to the merge base.
*/
export function scopeCapabilities(scope: DiffScope): { revert: boolean; comments: boolean } {
return { revert: scope === "branch", comments: true }
}
/**
* Per-context scope selection. Keeps the last-picked scope per context id so
* switching between worktrees restores each worktree's scope, while a brand
* new context defaults to Branch.
*/
export function createDiffScope(currentCtx: Accessor<string | undefined>) {
const [scopes, setScopes] = createSignal<Record<string, DiffScope>>({})
const scope = createMemo((): DiffScope => {
const ctx = currentCtx()
if (!ctx) return DEFAULT_DIFF_SCOPE
return scopes()[ctx] ?? DEFAULT_DIFF_SCOPE
})
const id = createMemo(() => {
const ctx = currentCtx()
if (!ctx) return undefined
return composeDiffId(ctx, scope())
})
const setScope = (next: DiffScope) => {
const ctx = currentCtx()
if (!ctx) return
setScopes((prev) => ({ ...prev, [ctx]: next }))
}
return { scope, id, setScope }
}
@@ -136,6 +136,8 @@ export const dict = {
"agentManager.diff.revertFile": "استعادة الملف",
"agentManager.diff.revertSuccess": "تم استعادة الملف",
"agentManager.diff.revertError": "فشل الاستعادة",
"agentManager.diff.applyBranchOnly":
"لا يعمل تطبيق التغييرات إلا على فرق الفرع الكامل. انتقل إلى نطاق Branch لتطبيقها.",
"agentManager.open.button": "فتح",
"agentManager.open.tooltip": "فتح Worktree هذا في VS Code",
"agentManager.apply.globalButton": "تطبيق",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Reverter arquivo",
"agentManager.diff.revertSuccess": "Arquivo revertido",
"agentManager.diff.revertError": "Falha ao reverter",
"agentManager.diff.applyBranchOnly":
"Aplicar funciona apenas no diff completo da branch. Mude para o escopo Branch para aplicar.",
"agentManager.open.button": "Abrir",
"agentManager.open.tooltip": "Abrir este Worktree no VS Code",
"agentManager.apply.globalButton": "Aplicar",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Vrati datoteku",
"agentManager.diff.revertSuccess": "Datoteka vraćena",
"agentManager.diff.revertError": "Vraćanje neuspješno",
"agentManager.diff.applyBranchOnly":
"Primijeni radi samo s kompletnim diffom grane. Prebacite se na opseg Branch da biste primijenili.",
"agentManager.open.button": "Otvori",
"agentManager.open.tooltip": "Otvori ovaj worktree u VS Code-u",
"agentManager.apply.globalButton": "Primijeni",
@@ -140,6 +140,8 @@ export const dict = {
"agentManager.diff.revertFile": "Gendan fil",
"agentManager.diff.revertSuccess": "Fil gendannet",
"agentManager.diff.revertError": "Gendannelse fejlede",
"agentManager.diff.applyBranchOnly":
"Anvend virker kun på hele Branch-diffen. Skift til Branch-området for at anvende.",
"agentManager.open.button": "Åbn",
"agentManager.open.tooltip": "Åbn dette Worktree i VS Code",
"agentManager.apply.globalButton": "Anvend",
@@ -140,6 +140,8 @@ export const dict = {
"agentManager.diff.revertFile": "Datei zurücksetzen",
"agentManager.diff.revertSuccess": "Datei zurückgesetzt",
"agentManager.diff.revertError": "Zurücksetzen fehlgeschlagen",
"agentManager.diff.applyBranchOnly":
"Anwenden funktioniert nur für den vollständigen Branch-Diff. Wechsle zum Bereich Branch, um anzuwenden.",
"agentManager.open.button": "Öffnen",
"agentManager.open.tooltip": "Dieses Worktree in VS Code öffnen",
"agentManager.apply.globalButton": "Anwenden",
@@ -143,6 +143,7 @@ export const dict = {
"agentManager.diff.revertFile": "Revert file",
"agentManager.diff.revertSuccess": "File reverted",
"agentManager.diff.revertError": "Revert failed",
"agentManager.diff.applyBranchOnly": "Apply works on the full branch diff. Switch to the Branch scope to apply.",
"agentManager.open.button": "Open",
"agentManager.open.tooltip": "Open this worktree in VS Code",
"agentManager.apply.globalButton": "Apply",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Revertir archivo",
"agentManager.diff.revertSuccess": "Archivo revertido",
"agentManager.diff.revertError": "Error al revertir",
"agentManager.diff.applyBranchOnly":
"Aplicar solo funciona con el diff completo de la rama. Cambia al ámbito Branch para aplicar.",
"agentManager.open.button": "Abrir",
"agentManager.open.tooltip": "Abrir este Worktree en VS Code",
"agentManager.apply.globalButton": "Aplicar",
@@ -143,6 +143,8 @@ export const dict = {
"agentManager.diff.revertFile": "بازگردانی فایل",
"agentManager.diff.revertSuccess": "فایل بازگردانی شد",
"agentManager.diff.revertError": "بازگردانی ناموفق بود",
"agentManager.diff.applyBranchOnly":
"اعمال تغییرات روی اختلاف کامل شاخه انجام می‌شود. برای اعمال، به محدوده Branch بروید.",
"agentManager.open.button": "باز کردن",
"agentManager.open.tooltip": "باز کردن این Worktree در VS Code",
"agentManager.apply.globalButton": "اعمال",
@@ -139,6 +139,8 @@ export const dict = {
"agentManager.diff.revertFile": "Rétablir le fichier",
"agentManager.diff.revertSuccess": "Fichier rétabli",
"agentManager.diff.revertError": "Échec du rétablissement",
"agentManager.diff.applyBranchOnly":
"Appliquer ne fonctionne que sur le diff complet de la branche. Passez à la portée Branch pour appliquer.",
"agentManager.open.button": "Ouvrir",
"agentManager.open.tooltip": "Ouvrir ce worktree dans VS Code",
"agentManager.apply.globalButton": "Appliquer",
@@ -145,6 +145,8 @@ export const dict = {
"agentManager.diff.revertFile": "Ripristina file",
"agentManager.diff.revertSuccess": "File ripristinato",
"agentManager.diff.revertError": "Ripristino non riuscito",
"agentManager.diff.applyBranchOnly":
"Applica funziona solo sul diff completo del branch. Passa all'ambito Branch per applicare.",
"agentManager.open.button": "Apri",
"agentManager.open.tooltip": "Apri questo worktree in VS Code",
"agentManager.apply.globalButton": "Applica",
@@ -138,6 +138,8 @@ export const dict = {
"agentManager.diff.revertFile": "ファイルを元に戻す",
"agentManager.diff.revertSuccess": "ファイルを元に戻しました",
"agentManager.diff.revertError": "元に戻せませんでした",
"agentManager.diff.applyBranchOnly":
"適用はブランチ全体の差分に対してのみ利用できます。適用するにはスコープを Branch に切り替えてください。",
"agentManager.open.button": "開く",
"agentManager.open.tooltip": "このWorktreeをVS Codeで開く",
"agentManager.apply.globalButton": "適用",
@@ -137,6 +137,8 @@ export const dict = {
"agentManager.diff.revertFile": "파일 되돌리기",
"agentManager.diff.revertSuccess": "파일이 되돌려졌습니다",
"agentManager.diff.revertError": "되돌리기 실패",
"agentManager.diff.applyBranchOnly":
"적용은 전체 브랜치 diff에서만 작동합니다. 적용하려면 범위를 Branch로 전환하세요.",
"agentManager.open.button": "열기",
"agentManager.open.tooltip": "이 Worktree를 VS Code에서 열기",
"agentManager.apply.globalButton": "적용",

Some files were not shown because too many files have changed in this diff Show More