Merge branch 'main' into puddle-barometer

This commit is contained in:
Kirill Kalishev
2026-08-18 09:38:38 -04:00
committed by GitHub
142 changed files with 3223 additions and 796 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Stop broad permission rules from letting Ask and Plan modes change your workspace. Catch-all approvals, the "Allow everything" toggle, and the `<command> *` rules that "Always allow" persists no longer grant these modes shell commands, subagents, notebook edits or other mutating tools, and MCP tools go back to prompting. To opt a single mode in, set `agent.ask.permission` or `agent.plan.permission` instead of a top-level `permission` rule.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep the Agent Manager inline diff position stable while scrolling upward through large reviews
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Prevent concurrent Kilo processes from crashing while recovering the shared SQLite WAL.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep provider names out of the compact prompt model selector label while retaining them in the expanded picker.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Start Kilo with a persistent fallback when the default runtime state directory is not writable.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Accept JWT share tokens when importing a session from a Kilo share URL.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Agent Manager PR and base-branch worktrees when a repository uses a restrictive Git fetch refspec.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep the Allow and Deny buttons reachable when a permission prompt contains a large diff or a long command: the prompt now scrolls its own content and shrinks with the available chat height instead of pushing its buttons out of view
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Persist disabling snapshots from the slow-repo prompt across restarts.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent subagent descriptions from appearing as Agent Manager worktree titles.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep `kilo upgrade` on the Kilo CLI release channel when GitHub's latest release is a JetBrains release.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prompt for explicit, one-shot approval before mutating Git commands run outside the sandbox.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Open delegated subagent sessions in Agent Manager inspector tabs alongside terminals.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep recently used Kilo Gateway models visible in the TUI picker, and find them when filtering by kilo.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Open `/sessions` on a git worktree with the Worktree history filter selected by default.
+5 -3
View File
@@ -27,9 +27,11 @@ const layer = Layer.effect(
Effect.gen(function* () {
const db = yield* makeDatabase
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
// kilocode_change start - install SQLite's busy handler before concurrent processes can race to recover the WAL
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA journal_mode = WAL")
// kilocode_change end
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
@@ -42,7 +44,7 @@ const layer = Layer.effect(
export function layerFromPath(filename: string) {
DbPreflight.assertWritable(filename) // kilocode_change - actionable error (and self-heal for kilo-owned files) instead of an opaque wal_checkpoint crash on read-only db files
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
return layer.pipe(Layer.provide(sqliteLayer({ filename, disableWAL: true }))) // kilocode_change - Database configures WAL after busy_timeout
}
export function path() {
+3 -3
View File
@@ -5,7 +5,7 @@ import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { markNoIndex } from "./kilocode/spotlight" // kilocode_change
import { ensureRealDir } from "./kilocode/global" // kilocode_change
import { ensureRealDir, resolveState } from "./kilocode/global" // kilocode_change
import { Flag } from "./flag/flag"
import { makeGlobalNode } from "./effect/app-node"
@@ -22,7 +22,8 @@ const clean = (p: string | undefined) => p?.replace(/[\r\n]+/g, "")
const data = path.join(clean(xdgData)!, app)
const cache = path.join(clean(xdgCache)!, app)
const config = path.join(clean(xdgConfig)!, app)
const state = path.join(clean(xdgState)!, app)
const preferred = path.join(clean(xdgState)!, app)
const state = await resolveState(preferred, process.env.XDG_STATE_HOME ? undefined : path.join(data, "state"))
// kilocode_change end
const tmp = path.join(os.tmpdir(), app)
@@ -47,7 +48,6 @@ Flock.setGlobal({ state })
await Promise.all([
ensureRealDir(Path.data), // kilocode_change
ensureRealDir(Path.config), // kilocode_change
ensureRealDir(Path.state), // kilocode_change
ensureRealDir(Path.tmp), // kilocode_change
ensureRealDir(Path.log), // kilocode_change
ensureRealDir(Path.bin), // kilocode_change
+49
View File
@@ -1,4 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { randomUUID } from "crypto"
/**
* Like `fs.mkdir({ recursive: true })` but also repairs broken symlinks and
@@ -21,3 +23,50 @@ export async function ensureRealDir(p: string) {
await fs.mkdir(p, { recursive: true })
}
}
async function writable(p: string) {
const probe = path.join(p, `.kilo-write-${process.pid}-${randomUUID()}`)
await fs.writeFile(probe, "", { flag: "wx", mode: 0o600 })
await fs.unlink(probe)
}
async function ready(p: string) {
await ensureRealDir(p)
await writable(p)
}
export async function resolveState(p: string, fallback?: string) {
const sticky =
fallback === undefined
? false
: await fs.stat(fallback).then(
(stat) =>
stat.isDirectory() &&
writable(fallback).then(
() => true,
() => false,
),
() => false,
)
if (sticky && fallback !== undefined) return fallback
const err = await ready(p).then(
() => undefined,
(err: unknown) => err,
)
if (err === undefined) return p
if (fallback === undefined) throw err
const failed = await ready(fallback).then(
() => undefined,
(err: unknown) => err,
)
if (failed !== undefined) {
throw new AggregateError([err, failed], `Cannot use state directory "${p}" or fallback "${fallback}"`)
}
const msg = err instanceof Error ? err.message : "Unknown error"
// Logging is not initialized until Global.Path.log exists.
console.warn(`[kilo] Cannot use state directory "${p}"; using "${fallback}" instead: ${msg}`)
return fallback
}
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Database } from "@opencode-ai/core/database/database"
import { Effect, Layer } from "effect"
import { tmpdir } from "../fixture/tmpdir"
const wait = async (dir: string, glob: string, count = 1, end = Date.now() + 5_000): Promise<void> => {
const files = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: dir }))
if (files.length >= count) return
if (Date.now() >= end) throw new Error(`Timed out waiting for ${glob}`)
await Bun.sleep(1)
return wait(dir, glob, count, end)
}
const remove = async (file: string, retry = 30): Promise<void> => {
try {
await fs.rm(file, { force: true })
} catch (err) {
if (retry === 0 || !err || typeof err !== "object" || !("code" in err) || err.code !== "EBUSY") throw err
await Bun.sleep(100)
return remove(file, retry - 1)
}
}
describe("database WAL recovery", () => {
test("starts concurrent processes while recovering an abandoned WAL", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
await Effect.runPromise(Layer.build(Database.layerFromPath(file).pipe(Layer.fresh)).pipe(Effect.scoped))
const worker = path.join(import.meta.dir, "fixture/database-recovery-worker.ts")
const seed = Bun.spawn([process.execPath, worker, "seed", tmp.path], { stdout: "ignore", stderr: "pipe" })
try {
await wait(tmp.path, "seed-ready")
} finally {
if (seed.exitCode === null) seed.kill(9)
await seed.exited
}
await remove(`${file}-shm`)
const children: (typeof seed)[] = []
try {
for (const _ of Array.from({ length: 12 }))
children.push(Bun.spawn([process.execPath, worker, "open", tmp.path], { stdout: "ignore", stderr: "pipe" }))
await wait(tmp.path, "open-ready-*", children.length)
await Bun.write(path.join(tmp.path, "start"), "")
const statuses = await Promise.all(children.map((child) => child.exited))
const errors = await Promise.all(children.map((child) => new Response(child.stderr).text()))
if (statuses.some((status) => status !== 0)) throw new Error(errors.filter(Boolean).join("\n"))
expect(statuses).toEqual(Array.from({ length: children.length }, () => 0))
} finally {
for (const child of children) if (child.exitCode === null) child.kill(9)
await Promise.all(children.map((child) => child.exited))
}
}, 20_000)
})
@@ -0,0 +1,30 @@
import { Database as SQLite } from "bun:sqlite"
import path from "path"
import { Database } from "@opencode-ai/core/database/database"
import { Effect, Layer } from "effect"
const mode = process.argv[2]
const dir = process.argv[3]
if (!mode || !dir) throw new Error("Expected mode and data directory")
const file = path.join(dir, "kilo.db")
if (mode === "seed") {
const sqlite = new SQLite(file)
sqlite.run("PRAGMA journal_mode = WAL")
sqlite.run("PRAGMA wal_autocheckpoint = 0")
sqlite.run("CREATE TABLE recovery_load (value BLOB)")
const insert = sqlite.prepare("INSERT INTO recovery_load VALUES (?)")
const value = new Uint8Array(4096)
sqlite.transaction(() => {
for (const _ of Array.from({ length: 1_000 })) insert.run(value)
})()
await Bun.write(path.join(dir, "seed-ready"), "")
await new Promise(() => {})
}
if (mode === "open") {
await Bun.write(path.join(dir, `open-ready-${process.pid}`), "")
while (!(await Bun.file(path.join(dir, "start")).exists())) await Bun.sleep(1)
await Effect.runPromise(Layer.build(Database.layerFromPath(file).pipe(Layer.fresh)).pipe(Effect.scoped))
}
+109
View File
@@ -0,0 +1,109 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { resolveState } from "@opencode-ai/core/kilocode/global"
import { tmpdir } from "../fixture/tmpdir"
const skip = process.platform === "win32" || process.getuid?.() === 0
describe("global state directory", () => {
test("uses the preferred state directory when available", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
expect(await resolveState(preferred, path.join(tmp.path, "fallback"))).toBe(preferred)
expect((await fs.stat(preferred)).isDirectory()).toBe(true)
expect(await fs.readdir(preferred)).toEqual([])
})
test("falls back when the default state directory is unusable", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "data", "state")
await fs.writeFile(preferred, "not a directory")
expect(await resolveState(preferred, fallback)).toBe(fallback)
expect((await fs.stat(fallback)).isDirectory()).toBe(true)
})
test("keeps using an existing fallback", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "fallback")
await fs.mkdir(fallback)
expect(await resolveState(preferred, fallback)).toBe(fallback)
expect(
await fs.stat(preferred).then(
() => true,
() => false,
),
).toBe(false)
})
test.skipIf(skip)("uses the preferred directory when an existing fallback is not writable", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "fallback")
await fs.mkdir(fallback)
await fs.chmod(fallback, 0o500)
try {
expect(await resolveState(preferred, fallback)).toBe(preferred)
} finally {
await fs.chmod(fallback, 0o700)
}
})
test.skipIf(skip)("falls back when the preferred directory cannot be created", async () => {
await using tmp = await tmpdir()
const parent = path.join(tmp.path, "preferred")
const preferred = path.join(parent, "kilo")
const fallback = path.join(tmp.path, "data", "state")
await fs.mkdir(parent)
await fs.chmod(parent, 0o500)
try {
expect(await resolveState(preferred, fallback)).toBe(fallback)
} finally {
await fs.chmod(parent, 0o700)
}
})
test.skipIf(skip)("falls back when the preferred directory exists but is not writable", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "data", "state")
await fs.mkdir(preferred)
await fs.chmod(preferred, 0o500)
try {
expect(await resolveState(preferred, fallback)).toBe(fallback)
} finally {
await fs.chmod(preferred, 0o700)
}
})
test("preserves errors for explicitly configured state directories", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
await fs.writeFile(preferred, "not a directory")
const err = await resolveState(preferred).catch((err: unknown) => err)
expect(err).toBeInstanceOf(Error)
})
test("reports both paths when the fallback also fails", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "fallback")
await Promise.all([fs.writeFile(preferred, "not a directory"), fs.writeFile(fallback, "not a directory")])
const err = await resolveState(preferred, fallback).catch((err: unknown) => err)
expect(err).toBeInstanceOf(AggregateError)
if (!(err instanceof AggregateError)) throw err
expect(err.message).toContain(preferred)
expect(err.message).toContain(fallback)
expect(err.errors).toHaveLength(2)
})
})
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1115e3b2056f1b00c6b9a657f61c3540e00b14ec0ff4fb04f072157bb5059b3
size 17935
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12f523463f86c86dffc350a32c0f76e39391f602c32f0b778d410c87cc83fb1a
size 15708
oid sha256:627a7ab79c52de6663138ab748994cfc4fb3022601d388e0ef5dd538c54d63fa
size 16379
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:58b145cc30b2d7580ea859dcd27b88535d33983d4125004586ba2568e3024252
size 15889
oid sha256:18098fafe8eb05a020d8070e1d8783611750cdad4d71025f81eaa42d62ba75f5
size 16005
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee6ea794cf665570dcc208ad5975a14843d1680e59eb785fb5298b4919b7dd1d
size 25757
oid sha256:501a1d8eee78345bcd538df3b9df6f9f5f6b49903aff66a7f2fd15827c66371f
size 22159
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee9fb41fb522684de6aa7fd9acda8357f33f2ba90bcb3eb7679ddfbcdecc6cdb
size 20764
oid sha256:6ac541494bb7faf06bd89d51aaf81dbd405945911e9570b370b7741e398734e5
size 17822
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d544d3964f4a442b12d1eb47aa992c756ac537c2c810adb9abb072f99a815135
size 17177
oid sha256:679a9d8cf02036ea103f93f1c7c2df9b5c329044bd302ba10141f9d34495663e
size 17000
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7905a190dff49bc7e23af1773ea93ac79cc71659336469745561355cba5f413
size 22602
oid sha256:d60096bf740a7503ba0724b01dc94830ddaeb70d02d86777e481269b445ff629
size 21137
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:83a8fca09c11e19b37dac155ea5856677141a7bac46450b3557ebf82b4f2a3f7
size 12963
oid sha256:800358fb3669a879eaa0228e72f3b623c275b88fa810bab4a3d6f0e015b5d84e
size 15307
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:10956634c051d1d1eff54229083434ddb1882adc49161d524a14ab3fc3859554
size 13822
oid sha256:83673f48e121d5e181a8700f6eb7e091d2ef14ce17552647e0552881977fd0f1
size 16209
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0249acab24874167ad0f6e88d12897be50dc12861e763a89a4d90545309de3b3
size 15709
oid sha256:5c3ba074f2080ac4ad03adb7a1bc81ca56efefd29ca8fd1186d5c9d928bcd0af
size 15828
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc61e88dc65c15583e0c972f2d370e1c75c7a87949c557492ffbc146b2f75ac5
size 15535
oid sha256:21d9d9ef350b57515cb956fba4bdbc5d0801c6fe4cef6f18983ae809741480a5
size 15652
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d76fe59b3a6b07d89243d1f9bc434eb3e813d361e0e59db97bd4916097279e2a
size 15842
oid sha256:4b5438ebc5b9f7ec214315b0a91f931815da087c11dcd3db99f17e196d367c84
size 16736
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04037b1aac2456ba09ffafc1bed2cd1c9c00a09cf926453518231b1bd14fc974
size 16470
oid sha256:b9b06384010b48a7a2624ab895f4ef8baebcdba4e0d7e123f2cc0dd3b6748b8a
size 16304
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b197d749f21df8d3061b93d13281a346b52514bec1b6855b474a03cfaeeb117b
size 2500
oid sha256:4b2de227517c16c9aa90af0245da92f8e532ad29e21de2142e50121dbebf55fc
size 1574
@@ -24,6 +24,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
"selectedLines",
"commentedLines",
"virtualized",
"sizeKey",
])
const workerPool = useWorkerPool(props.diffStyle)
+33 -1
View File
@@ -23,6 +23,23 @@ const MIN_PLACEHOLDER_HEIGHT = 160
const MAX_PLACEHOLDER_HEIGHT = 1200
type Job = { run: () => void; cancelled: boolean }
const sizes = new WeakMap<object, Map<number, number>>()
const WIDTH_LIMIT = 8
function remember(key: object | undefined, width: number, height: number) {
if (!key || width <= 0 || height <= 0) return
const widths = sizes.get(key) ?? new Map<number, number>()
widths.delete(width)
widths.set(width, height)
if (widths.size > WIDTH_LIMIT) widths.delete(widths.keys().next().value!)
sizes.set(key, widths)
}
function reserved(key: object | undefined, width: number) {
if (!key || width <= 0) return
return sizes.get(key)?.get(width)
}
// A review can contain many expanded diff components. Creating one
// IntersectionObserver per diff showed up in profiles, so all deferred diffs
// share a single observer and only register their element + render callback.
@@ -173,6 +190,7 @@ export function Diff<T>(props: DiffProps<T>) {
"commentedLines",
"onRendered",
"virtualized",
"sizeKey",
])
const mobile = createMediaQuery("(max-width: 640px)")
@@ -247,7 +265,7 @@ export function Diff<T>(props: DiffProps<T>) {
createEffect(() => {
if (visible()) return
container.style.minHeight = `${estimate()}px`
container.style.minHeight = `${reserved(local.sizeKey, container.clientWidth) ?? estimate()}px`
})
createEffect(() => {
@@ -266,6 +284,17 @@ export function Diff<T>(props: DiffProps<T>) {
return root
}
createEffect(() => {
if (typeof ResizeObserver === "undefined") return
const resize = new ResizeObserver(() => {
const root = getRoot()
if (!visible() || !current() || !root?.querySelector("[data-line]")) return
remember(local.sizeKey, container.clientWidth, container.offsetHeight)
})
resize.observe(container)
onCleanup(() => resize.disconnect())
})
const applyScheme = () => {
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
@@ -370,6 +399,7 @@ export function Diff<T>(props: DiffProps<T>) {
if (token !== renderToken) return
// Clear the height pin now that Pierre has rendered new content.
container.style.minHeight = ""
remember(local.sizeKey, container.clientWidth, container.offsetHeight)
setSelectedLines(lastSelection)
local.onRendered?.()
})
@@ -411,6 +441,7 @@ export function Diff<T>(props: DiffProps<T>) {
if (typeof MutationObserver === "undefined") {
container.style.minHeight = ""
if (!root || !isReady(root)) return
remember(local.sizeKey, container.clientWidth, container.offsetHeight)
setSelectedLines(lastSelection)
local.onRendered?.()
return
@@ -777,6 +808,7 @@ export function Diff<T>(props: DiffProps<T>) {
if (!instance) return
instance.setLineAnnotations(annotations ?? [])
instance.rerender()
notifyRendered()
},
{ defer: true },
),
+3
View File
@@ -58,6 +58,9 @@ type DiffShared<T> = FileDiffOptions<T> & {
// files so eager rendering does not expand full before/after content.
// Defaults to virtualized.
virtualized?: boolean
// Stable rendered-content identity used to preserve deferred height when a
// surrounding row virtualizer unmounts and later re-creates this diff.
sizeKey?: object
class?: string
classList?: ComponentProps<"div">["classList"]
}
+64 -9
View File
@@ -379,6 +379,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private readonly openSessionIds = new Set<string>()
private modelUsageSessionIds: Set<string> = new Set()
private syncedChildSessions: Set<string> = new Set()
private readonly inspectorSessionIds = new Set<string>()
private readonly checkpoints = new Map<string, Promise<void>>()
private readonly sessionCreations = new Map<string, Promise<{ sid: string; dir: string } | undefined>>()
private readonly draftSessions = new Map<string, { sid: string; dir: string; expires: number }>()
@@ -1083,6 +1084,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (await this.handleModelSelectorExpandedMessage(message)) return
this.handleWebviewFocusMessage(message)
this.visibleTaskStreams.handle(message)
this.handleStreamVisibilityMessage(message)
if (this.handleChildSyncMessage(message)) return
if (await this.handleMemoryMessage(message)) return
if (this.handleLegacyMigrationMessage(message)) return
switch (message.type) {
@@ -1171,15 +1174,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// isn't blocked by slow responses for earlier sessions.
void this.handleLoadMessages(message.sessionID, {
mode: message.mode,
focus: message.focus,
before: message.before,
limit: message.limit,
})
break
case "syncSession":
this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) =>
console.error("[Kilo New] handleSyncSession failed:", e),
)
break
case "loadSessions":
this.handleLoadSessions().catch((e) => console.error("[Kilo New] handleLoadSessions failed:", e))
break
@@ -1572,6 +1571,33 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private handleChildSyncMessage(
message: TypedWebviewMessage & { sessionID?: unknown; parentSessionID?: unknown; scope?: unknown },
): boolean {
if (message.type !== "syncSession" && message.type !== "unsyncSession") return false
if (typeof message.sessionID !== "string") return true
if (message.type === "syncSession") {
if (message.scope === "inspector") this.inspectorSessionIds.add(message.sessionID)
const parent = typeof message.parentSessionID === "string" ? message.parentSessionID : undefined
this.handleSyncSession(message.sessionID, parent).catch((e) =>
console.error("[Kilo New] handleSyncSession failed:", e),
)
return true
}
if (message.scope === "inspector") this.inspectorSessionIds.delete(message.sessionID)
this.releaseChildSession(message.sessionID)
return true
}
private handleStreamVisibilityMessage(
message: TypedWebviewMessage & { sessionID?: unknown; visible?: unknown },
): void {
if (message.type !== "streamSessionVisible" || message.visible !== false || typeof message.sessionID !== "string") {
return
}
this.releaseChildSession(message.sessionID)
}
private handleEditorOpenMessage(message: Parameters<typeof handleEditorAction>[0]): boolean {
return handleEditorAction(message, {
// An explicit sessionID (e.g. from validateFiles) takes precedence over
@@ -1976,14 +2002,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async handleLoadMessages(
sessionID: string,
options: { mode?: MessageLoadMode; before?: string; limit?: number; preserveStream?: boolean } = {},
options: {
mode?: MessageLoadMode
focus?: boolean
before?: string
limit?: number
preserveStream?: boolean
} = {},
): Promise<void> {
const mode = options.mode ?? "replace"
if (mode === "replace" || mode === "focus") {
this.stopCurrentSessionProcesses(sessionID)
this.trackedSessionIds.add(sessionID)
this.focusSession(sessionID)
this.contextSessionID = sessionID
if (options.focus !== false) {
this.stopCurrentSessionProcesses(sessionID)
this.focusSession(sessionID)
this.contextSessionID = sessionID
}
}
if (!this.client) {
this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID })
@@ -2119,6 +2153,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private releaseChildSession(sessionID: string): void {
if (
this.inspectorSessionIds.has(sessionID) ||
this.visibleTaskStreams.has(sessionID) ||
this.currentSession?.id === sessionID ||
this.openSessionIds.has(sessionID)
) {
return
}
if (!this.syncedChildSessions.delete(sessionID)) return
this.trackedSessionIds.delete(sessionID)
this.streams.drop(sessionID)
this.visibleTaskStreams.delete(sessionID)
this.sessionDirectories.delete(sessionID)
this.sessionGitDirectories.delete(sessionID)
this.sessionGitRecoveries.delete(sessionID)
this.connectionService.pruneSession(sessionID)
}
/**
* Build the context object used by the extracted session-refresh helpers.
*/
@@ -2252,6 +2305,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.streams.drop(sessionID)
this.visibleTaskStreams.delete(sessionID)
this.syncedChildSessions.delete(sessionID)
this.inspectorSessionIds.delete(sessionID)
this.sessionDirectories.delete(sessionID)
this.sessionGitDirectories.delete(sessionID)
this.sessionGitRecoveries.delete(sessionID)
@@ -5091,6 +5145,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.trackedSessionIds.clear()
this.openSessionIds.clear()
this.syncedChildSessions.clear()
this.inspectorSessionIds.clear()
this.draftSessions.clear()
this.sessionDirectories.clear()
this.anacondaDesktop.dispose()
@@ -320,7 +320,7 @@ export class AgentManagerProvider implements Disposable {
// Session events from sync or older backends can lack time/directory; a
// throw here would escape into the SSE dispatch loop and starve the other
// listeners (there is no per-listener error isolation).
if (!info?.time || !dir) return
if (!info?.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return
const ctx = this.contexts.byDirectory(dir)
if (!ctx || ctx.lifecycle !== "ready") return
const state = ctx.peekState()
@@ -839,6 +839,8 @@ export class WorktreeManager {
private async refreshBase(branch: string, requested?: string): Promise<void> {
const remote = requested ?? (await this.resolveRemote())
if (!remote) return
validateGitRef(remote, "remote")
validateGitRef(branch, "branch")
const key = `${this.root}:${remote}:${branch}`
const cached = WorktreeManager.fetchCache.get(key)
if (cached && Date.now() - cached < WorktreeManager.FETCH_CACHE_TTL) return
@@ -849,7 +851,7 @@ export class WorktreeManager {
const env = nonInteractiveEnv()
await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } })
.env(env)
.fetch(remote, branch, { "--quiet": null, "--no-tags": null })
.raw(["fetch", "--quiet", "--no-tags", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`])
WorktreeManager.fetchCache.set(key, Date.now())
}
@@ -1107,10 +1109,17 @@ export class WorktreeManager {
if (!remotes.some((r) => r.name === forkOwner)) {
await this.git.addRemote(forkOwner, `https://github.com/${forkOwner}/${parsed.repo}.git`)
}
await this.gitExec(["fetch", forkOwner, info.headRefName])
await this.gitExec([
"fetch",
"--quiet",
"--no-tags",
forkOwner,
`+refs/heads/${info.headRefName}:refs/remotes/${forkOwner}/${info.headRefName}`,
])
} else {
validateGitRef(info.headRefName, "branch name")
const ok = await this.gitTry(["fetch", "origin", info.headRefName])
const ref = `+refs/heads/${info.headRefName}:refs/remotes/origin/${info.headRefName}`
const ok = await this.gitTry(["fetch", "--quiet", "--no-tags", "origin", ref])
if (!ok) {
await this.gitExec([
"fetch",
@@ -1118,6 +1127,14 @@ export class WorktreeManager {
`+refs/pull/${parsed.number}/head:refs/remotes/origin/${info.headRefName}`,
])
}
if (!(await this.gitTry(["show-ref", "--verify", "--quiet", `refs/heads/${info.headRefName}`]))) {
const start = `refs/remotes/origin/${info.headRefName}`
await this.gitExec(["branch", info.headRefName, start])
if (ok) {
await this.gitExec(["config", `branch.${info.headRefName}.remote`, "origin"])
await this.gitExec(["config", `branch.${info.headRefName}.merge`, `refs/heads/${info.headRefName}`])
}
}
}
}
@@ -182,6 +182,7 @@ export async function collectProjectSessions(
const out: ProjectSessionView[] = []
for (const { dir, worktreeId, items } of byDir) {
for (const s of items) {
if (s.parentID !== undefined && s.parentID !== null) continue
if (seen.has(s.id)) continue
seen.add(s.id)
sessions.setSessionDirectory(s.id, dir)
@@ -817,6 +817,7 @@ interface LoadMessagesIn {
type: "loadMessages"
sessionID: string
mode?: "replace" | "prepend" | "focus"
focus?: boolean
before?: string
limit?: number
}
@@ -73,6 +73,7 @@ export function registerToggleAutoApprove(
const { data: pending } = await client.permission.list({ directory: dir }, { throwOnError: true })
for (const req of pending) {
if (generation !== snapshot) break
if (req.metadata?.["sandboxEscalation"] === true) continue
await client.permission
.reply({ requestID: req.id, directory: dir, reply: "once" }, { throwOnError: true })
.catch((err) => {
@@ -91,6 +92,7 @@ export function registerToggleAutoApprove(
if (!active) return false
const client = tryGetClient(connectionService)
if (!client) return false
if (event.properties.metadata?.["sandboxEscalation"] === true) return false
const dir =
directory ?? connectionService.getPermissionDirectory(event.properties.id) ?? resolve(event.properties.sessionID)
return client.permission
@@ -25,6 +25,10 @@ export class VisibleTaskStreams {
this.refs.delete(id)
}
has(id: string): boolean {
return this.refs.has(id)
}
setActive(active: boolean): void {
if (this.active === active) return
this.active = active
@@ -2,11 +2,16 @@ import { expect, test, type Page } from "@playwright/test"
const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
const STORY_ID = "agentmanager--full-screen-diff-agent-edit-scroll"
const INLINE_STORY_ID = "agentmanager--diff-panel-scroll-up"
function storyUrl() {
return `/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`
}
function inlineStoryUrl() {
return `/iframe.html?id=${INLINE_STORY_ID}&viewMode=story&globals=${GLOBALS}`
}
async function disableAnimations(page: Page) {
await page.addStyleTag({
content: `
@@ -159,3 +164,60 @@ test("resets virtual measurements and scroll when the review context changes", a
await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBe(1_200)
await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBe(0)
})
test("keeps the inline diff position stable while scrolling upward", async ({ page }) => {
await page.setViewportSize({ width: 900, height: 760 })
await page.goto(inlineStoryUrl(), { waitUntil: "load" })
await disableAnimations(page)
await page.waitForSelector(".am-diff-content diffs-container", { state: "attached" })
const result = await page.locator(".am-diff-content").evaluate(async (el) => {
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
const settle = async (count: number) => {
for (let i = 0; i < count; i++) await frame()
}
const seen = new Set(
Array.from(el.querySelectorAll("[data-file-path]"), (row) => row.getAttribute("data-file-path")),
)
let remounts = 0
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (!(node instanceof HTMLElement)) continue
const rows = node.matches("[data-file-path]") ? [node] : Array.from(node.querySelectorAll("[data-file-path]"))
for (const row of rows) {
const file = row.getAttribute("data-file-path")
if (seen.has(file)) remounts++
seen.add(file)
}
}
}
})
observer.observe(el, { childList: true, subtree: true })
// Materialize every row once, then start from the settled bottom. The bug
// appears when upward scrolling re-creates rows above the viewport.
while (el.scrollTop < el.scrollHeight - el.clientHeight - 1) {
el.scrollTop = Math.min(el.scrollHeight - el.clientHeight, el.scrollTop + 120)
await frame()
}
await settle(30)
let correction = 0
let range = 0
while (el.scrollTop > 0) {
const height = el.scrollHeight
const intended = Math.max(0, el.scrollTop - 80)
el.scrollTop = intended
await settle(2)
correction = Math.max(correction, Math.abs(el.scrollTop - intended))
range = Math.max(range, Math.abs(el.scrollHeight - height))
}
observer.disconnect()
return { correction, range, remounts }
})
expect(result.remounts).toBeGreaterThan(0)
expect(result.correction).toBeLessThanOrEqual(1)
expect(result.range).toBeLessThanOrEqual(1)
})
@@ -105,6 +105,9 @@ test.describe("history session accessibility", () => {
const local = page.getByRole("tab", { name: "Local" })
const worktree = page.getByRole("tab", { name: "Worktree" })
await expect(worktree).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("tabpanel", { name: "Worktree" })).toBeVisible()
await local.focus()
await page.keyboard.press("End")
await expect(worktree).toBeFocused()
@@ -204,8 +204,8 @@ test("selected favorite remains selected when its duplicate group is collapsed",
test("large catalogs keep the rendered tree bounded and navigate to distant models", async ({ page }) => {
await load(page, "shared--model-selector-large-catalog")
await page.getByRole("button", { name: "Select model: Provider 0 / Model 300" }).click()
const combobox = page.getByRole("combobox", { name: "Select model: Provider 0 / Model 300. Search models" })
await page.getByRole("button", { name: "Select model: Model 300" }).click()
const combobox = page.getByRole("combobox", { name: "Select model: Model 300. Search models" })
const tree = page.getByRole("tree", { name: "Select model" })
// The window mounts before we measure it, yet stays far smaller than the catalog.
@@ -7,6 +7,7 @@ test("edit approval diff shows line numbers in compact viewer", async ({ page })
await page.setViewportSize({ width: 420, height: 720 })
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
await page.locator('[data-slot="permission-diff"]').scrollIntoViewIfNeeded()
const number = page.locator('[data-slot="permission-diff-content"] [data-column-number]').first()
await expect(number).toBeVisible()
})
@@ -21,6 +21,7 @@ const CSS_FILES = [
]
const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SubagentPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"),
path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
@@ -51,6 +52,8 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/SidebarBody.tsx"),
path.join(ROOT, "webview-ui/agent-manager/Skeleton.tsx"),
path.join(ROOT, "webview-ui/agent-manager/TabBar.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ClosableTab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/InspectorTabStrip.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ProjectBranchDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DefaultBaseBranchDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"),
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { mergeWorktreeDiffs } from "../../webview-ui/diff-viewer/diff-state"
import { diffSizeKey, mergeWorktreeDiffs } from "../../webview-ui/diff-viewer/diff-state"
import {
EXTREME_DIFF_CHANGED_LINES,
allOpenFiles,
@@ -28,6 +28,18 @@ function diff(overrides: Partial<WorktreeFileDiff>): WorktreeFileDiff {
}
}
describe("diffSizeKey", () => {
it("changes with rendered content, style, and review context", () => {
const base = diff({ summarized: false, patch: "@@ -1 +1 @@\n-old\n+new\n" })
const key = diffSizeKey("review-a", base, "unified")
expect(diffSizeKey("review-a", base, "unified")).toBe(key)
expect(diffSizeKey("review-b", base, "unified")).not.toBe(key)
expect(diffSizeKey("review-a", base, "split")).not.toBe(key)
expect(diffSizeKey("review-a", { ...base, patch: "@@ -1 +1 @@\n-old\n+newer\n" }, "unified")).not.toBe(key)
})
})
describe("agent manager diff state", () => {
it("preserves loaded detail and patch when summary metadata is unchanged", () => {
const prev = [diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -1 +1 @@\n-old\n+new\n" })]
@@ -10,6 +10,7 @@ import {
const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8")
const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8")
const subagent = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/SubagentPanel.tsx"), "utf8")
const terminal = readFileSync(
resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"),
"utf8",
@@ -25,13 +26,28 @@ test("xterm owns the padding used by FitAddon", () => {
expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/)
})
test("uses one persisted width for the diff and terminal inspector", () => {
test("uses one persisted width for every inspector panel", () => {
expect(app).toContain("persisted?.sidePanelWidth")
expect(app).toContain("createPanelResize(setPanelWidth")
expect(app).toContain("style={{ width: `${panelWidth()}px` }}")
expect(subagent).toContain("InspectorTabStrip")
expect(app).not.toContain("diffWidth")
expect(app).not.toContain("terminalWidth")
})
test("hides keyboard hints only in inspector tabs", () => {
const side = readFileSync(
resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"),
"utf8",
)
expect(subagent).toContain("showKeybind={false}")
expect(side).toContain("showKeybind={false}")
expect(
readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/render.tsx"), "utf8"),
).not.toContain("showKeybind={false}")
})
test("limits inspector layout updates during resize", () => {
const frames: ((time: number) => void)[] = []
const widths: number[] = []
@@ -148,6 +148,18 @@ describe("Agent Manager per-project session discovery", () => {
expect(out.find((s) => s.id === "ses-root")?.title).toBe("Session ses-root")
})
it("does not expose child sessions as project sidebar sessions", async () => {
const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
const ctx = makeContext(ROOT, fakeState([wt]))
const rootSession = mkSession("ses-root", WT_PATH)
const child = { ...mkSession("ses-child", WT_PATH), parentID: rootSession.id }
const { listing } = recordingListing({ [ROOT]: [], [WT_PATH]: [child, rootSession] })
const out = await collectProjectSessions(ctx, listing)
expect(out.map((s) => s.id)).toEqual(["ses-root"])
})
it("does not list or include sessions from unrelated-project directories", async () => {
const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
const ctx = makeContext(ROOT, fakeState([wt]))
@@ -255,69 +255,67 @@ describe("hasByok", () => {
describe("buildTriggerLabel", () => {
it("returns resolved model name for non-kilo provider unchanged", () => {
expect(buildTriggerLabel("GPT-4o", "openai", undefined, null, false, "", true, labels)).toBe("GPT-4o")
expect(buildTriggerLabel("GPT-4o", "openai", null, false, "", true, labels)).toBe("GPT-4o")
})
it("strips sub-provider prefix from resolved name for kilo gateway models", () => {
expect(
buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, undefined, null, false, "", true, labels),
).toBe("Claude Sonnet")
expect(buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, null, false, "", true, labels)).toBe(
"Claude Sonnet",
)
})
it("does not strip prefix for non-kilo provider even if name contains ': '", () => {
expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", undefined, null, false, "", true, labels)).toBe(
expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", null, false, "", true, labels)).toBe(
"Anthropic: Claude Sonnet",
)
})
it("returns resolved name as-is when providerID is undefined", () => {
expect(buildTriggerLabel("GPT-4o", undefined, undefined, null, false, "", true, labels)).toBe("GPT-4o")
expect(buildTriggerLabel("GPT-4o", undefined, null, false, "", true, labels)).toBe("GPT-4o")
})
it("returns providerName / resolvedName for non-kilo provider with providerName", () => {
expect(buildTriggerLabel("GPT-4o", "openai", "OpenAI", null, false, "", true, labels)).toBe("OpenAI / GPT-4o")
it("does not add provider name to the compact label", () => {
expect(buildTriggerLabel("GPT-5.6 Luna", "openai", null, false, "", true, labels)).toBe("GPT-5.6 Luna")
})
it("returns modelID for kilo gateway raw selection", () => {
const raw = { providerID: "kilo", modelID: "kilo-auto/frontier" }
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier")
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier")
})
it("returns providerID / modelID for non-kilo raw selection", () => {
const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" }
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe(
"anthropic / claude-3-5-sonnet",
)
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("anthropic / claude-3-5-sonnet")
})
it("returns clearLabel when allowClear and no selection", () => {
expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "None", true, labels)).toBe("None")
expect(buildTriggerLabel(undefined, undefined, null, true, "None", true, labels)).toBe("None")
})
it("falls back to labels.notSet when allowClear and clearLabel is empty", () => {
expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "", true, labels)).toBe("Not set")
expect(buildTriggerLabel(undefined, undefined, null, true, "", true, labels)).toBe("Not set")
})
it("returns labels.select when providers exist and no selection", () => {
expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", true, labels)).toBe("Select model")
expect(buildTriggerLabel(undefined, undefined, null, false, "", true, labels)).toBe("Select model")
})
it("returns labels.noProviders when no providers available", () => {
expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", false, labels)).toBe("No providers")
expect(buildTriggerLabel(undefined, undefined, null, false, "", false, labels)).toBe("No providers")
})
it("prefers resolvedName over raw selection", () => {
const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" }
expect(buildTriggerLabel("Claude Sonnet", undefined, undefined, raw, false, "", true, labels)).toBe("Claude Sonnet")
expect(buildTriggerLabel("Claude Sonnet", undefined, raw, false, "", true, labels)).toBe("Claude Sonnet")
})
it("ignores partial raw selection (only providerID)", () => {
const raw = { providerID: "anthropic", modelID: "" }
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
})
it("ignores partial raw selection (only modelID)", () => {
const raw = { providerID: "", modelID: "claude-3-5-sonnet" }
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
})
})
@@ -0,0 +1,26 @@
import { describe, expect, it } from "bun:test"
import { rootSessions } from "../../webview-ui/agent-manager/project/session-filter"
import type { ProjectSessionInfo } from "../../webview-ui/src/types/messages"
const session = (id: string, worktreeId: string | null, parentID: string | null): ProjectSessionInfo => ({
id,
worktreeId,
parentID,
title: id,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
})
describe("rootSessions", () => {
it("ignores child sessions when selecting a worktree label", () => {
const sessions = [session("child", "wt-1", "root"), session("root", "wt-1", null), session("other", "wt-2", null)]
expect(rootSessions(sessions, "wt-1").map((item) => item.id)).toEqual(["root"])
})
it("filters subagents from the local session list too", () => {
const sessions = [session("child", null, "root"), session("root", null, null)]
expect(rootSessions(sessions, null).map((item) => item.id)).toEqual(["root"])
})
})
@@ -50,7 +50,7 @@ describe("selectSession keeps the chat in sync with the selection while offline"
// Queue a replay unconditionally. The earlier `deferredFetch = ready ? undefined : id`
// form skipped cached sessions, so a reconnect never re-sent the focus load that
// re-focuses the backend (focusSession/contextSessionID/SSE tracking/reconcile).
expect(body).toContain("deferredFetch = id")
expect(body).toContain("deferredFetch = { id, focus }")
expect(body).not.toMatch(/deferredFetch\s*=\s*ready\s*\?/)
})
})
@@ -61,12 +61,15 @@ describe("a deferred fetch is replayed on reconnect", () => {
const effect = source.slice(source.indexOf("on(server.isConnected"))
expect(effect).toContain("deferredFetch")
// Replays with the focus/replace choice so cached sessions still re-focus the backend.
expect(effect).toMatch(/loadFocusedMessages\(\s*id,\s*loaded\(\)\.has\(id\)\s*\)/)
expect(effect).toMatch(
/loadFocusedMessages\(\s*pending\.id,\s*loaded\(\)\.has\(pending\.id\),\s*pending\.focus\s*\)/,
)
})
it("the focused load helper sends focus for cached sessions and replace otherwise", () => {
const helper = source.slice(source.indexOf("function loadFocusedMessages("))
expect(helper).toMatch(/mode: "focus"/)
expect(helper).toMatch(/mode: "replace"/)
expect(helper).toContain("focus: false")
})
})
@@ -0,0 +1,92 @@
import { describe, expect, it } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSubagentTabs } from "../../webview-ui/agent-manager/subagent-tabs"
function scene() {
const [current] = createSignal<string | undefined>("parent")
const calls = {
synced: [] as Array<[string, string | undefined]>,
unsynced: [] as string[],
shown: 0,
hidden: 0,
}
const tabs = createSubagentTabs({
current,
sync: (id, parent) => calls.synced.push([id, parent]),
unsync: (id) => calls.unsynced.push(id),
show: () => calls.shown++,
hide: () => calls.hidden++,
})
return { tabs, calls }
}
describe("Agent Manager subagent tabs", () => {
it("opens multiple child sessions and syncs each to its parent", () => {
createRoot((dispose) => {
const item = scene()
item.tabs.open("child-1", "First", "parent-1")
item.tabs.open("child-2", "Second", "parent-2")
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["child-1", "child-2"])
expect(item.tabs.active()).toBe("child-2")
expect(item.calls.synced).toEqual([
["child-1", "parent-1"],
["child-2", "parent-2"],
])
expect(item.calls.shown).toBe(2)
dispose()
})
})
it("closes the active tab onto its nearest survivor and hides when empty", () => {
createRoot((dispose) => {
const item = scene()
item.tabs.open("one", "One")
item.tabs.open("two", "Two")
item.tabs.open("three", "Three")
item.tabs.close("two")
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one", "three"])
expect(item.tabs.active()).toBe("three")
item.tabs.close("three")
item.tabs.close("one")
expect(item.tabs.tabs()).toEqual([])
expect(item.tabs.active()).toBeUndefined()
expect(item.calls.unsynced).toEqual(["two", "three", "one"])
expect(item.calls.hidden).toBe(1)
dispose()
})
})
it("supports Close Others and preserves the selected child", () => {
createRoot((dispose) => {
const item = scene()
item.tabs.open("one")
item.tabs.open("two")
item.tabs.open("three")
item.tabs.closeOthers("one")
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one"])
expect(item.tabs.active()).toBe("one")
expect(item.calls.unsynced).toEqual(["two", "three"])
expect(item.calls.shown).toBe(4)
dispose()
})
})
it("reorders tabs without changing the active child", () => {
createRoot((dispose) => {
const item = scene()
item.tabs.open("one")
item.tabs.open("two")
item.tabs.open("three")
item.tabs.select("two")
item.tabs.reorder("three", "one")
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["three", "one", "two"])
expect(item.tabs.active()).toBe("two")
dispose()
})
})
})
@@ -11,6 +11,7 @@ import {
versionedName,
} from "../../src/agent-manager/branch-name"
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
import type { PRInfo } from "../../src/agent-manager/git-import"
import simpleGit from "simple-git"
// Each test gets its own temp directory -- no shared state, safe to run in parallel.
@@ -1138,6 +1139,92 @@ describe("WorktreeManager.createWorktree advanced", () => {
const devParams = await git.log(["-1"])
expect(headParams.latest?.hash).toBe(devParams.latest?.hash)
})
it("creates from a base branch excluded by the remote fetch refspec", async () => {
const { clone } = await createTempRepoWithOrigin()
const git = simpleGit(clone)
await git.checkoutLocalBranch("topic")
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
await git.add(".")
await git.commit("topic commit")
await git.push("origin", "topic")
await git.checkout("main")
await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"])
await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"])
const result = await createManager(clone).createWorktree({ baseBranch: "topic", prompt: "from topic" })
const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim()
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()
expect(worktreeHead).toBe(remoteHead)
expect(result.parentBranch).toBe("topic")
})
it("creates from a same-repository PR branch excluded by the remote fetch refspec", async () => {
const { clone } = await createTempRepoWithOrigin()
const git = simpleGit(clone)
await git.checkoutLocalBranch("topic")
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
await git.add(".")
await git.commit("topic commit")
await git.push("origin", "topic")
await git.checkout("main")
await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"])
await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"])
await git.branch(["-D", "topic"])
const manager = createManager(clone)
const internal = manager as unknown as {
fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise<PRInfo>
}
internal.fetchPRInfo = async () => ({
headRefName: "topic",
isCrossRepository: false,
title: "Topic PR",
})
const result = await manager.createFromPR("https://github.com/org/repo/pull/1")
const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim()
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()
expect(worktreeHead).toBe(remoteHead)
expect(result.parentBranch).toBe("topic")
})
it("does not track a deleted PR source branch when using the pull ref fallback", async () => {
const { bare, clone } = await createTempRepoWithOrigin()
const git = simpleGit(clone)
await git.checkoutLocalBranch("topic")
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
await git.add(".")
await git.commit("topic commit")
await git.push("origin", "topic")
const head = (await git.revparse(["topic"])).trim()
await git.checkout("main")
await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"])
await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"])
gitExec(["git", "--git-dir", bare, "update-ref", "refs/pull/1/head", head])
gitExec(["git", "--git-dir", bare, "update-ref", "-d", "refs/heads/topic"])
await git.branch(["-D", "topic"])
const manager = createManager(clone)
const internal = manager as unknown as {
fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise<PRInfo>
}
internal.fetchPRInfo = async () => ({
headRefName: "topic",
isCrossRepository: false,
title: "Topic PR",
})
const result = await manager.createFromPR("https://github.com/org/repo/pull/1")
const upstream = await git.raw(["config", "--get", "branch.topic.remote"]).catch(() => "")
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()
expect(worktreeHead).toBe(head)
expect(upstream.trim()).toBe("")
})
})
// ---------------------------------------------------------------------------
@@ -178,6 +178,8 @@ import { initialMessage, seedInitialVariant } from "./initial-message"
import { SidebarToggleButton } from "./SidebarToggleButton"
import { setTabWidths } from "./tab-widths"
import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout"
import { SubagentPanel } from "./SubagentPanel"
import { createSubagentTabs } from "./subagent-tabs"
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import { createChatFocus, createPromptFocus, hasQuestionOption } from "./focus"
@@ -306,8 +308,8 @@ const AgentManagerContent: Component = () => {
const diffLoading = diffs.diffLoading
const setDiffLoading = diffs.setDiffLoading
const diffNotices = diffs.diffNotices
// Diff and terminal views share one inspector width, restored from webview
// state so the user's divider position survives panel reloads.
// Diff, PR, terminal, and subagent views share one inspector width, restored
// from webview state so the user's divider position survives panel reloads.
const [panelWidth, setPanelWidth] = createSignal(clampPanelWidth(persisted?.sidePanelWidth, window.innerWidth))
const resizeSide = createPanelResize(setPanelWidth, () => window.innerWidth)
const showSideTerminal = () => {
@@ -321,6 +323,17 @@ const AgentManagerContent: Component = () => {
const reviewComposer = createReviewComposer()
const [reviewActive, setReviewActive] = createSignal(false)
const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified")
const subagents = createSubagentTabs({
current: session.currentSessionID,
sync: (id, parentID) => session.syncSession(id, parentID, "inspector"),
unsync: (id) => session.unsyncSession(id, "inspector"),
show: () => {
setHistory(false)
setReviewActive(false)
setSidePanel(SidePanel.Subagents)
},
hide: () => setSidePanel(null),
})
const markdown = createMarkdownRender(vscode)
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
const worktreeStats = () => registry.active().worktreeStats()
@@ -1214,7 +1227,17 @@ const AgentManagerContent: Component = () => {
if (match) projectNav.jump(parseInt(match[1]!) - 1)
}
}
const subagent = (event: Event) => {
const detail = (event as CustomEvent<{ sessionID?: unknown; title?: unknown; parentSessionID?: unknown }>).detail
if (typeof detail?.sessionID !== "string") return
subagents.open(
detail.sessionID,
typeof detail.title === "string" ? detail.title : undefined,
typeof detail.parentSessionID === "string" ? detail.parentSessionID : undefined,
)
}
window.addEventListener("message", handler)
window.addEventListener("agentManager.openSubagent", subagent)
// Prevent Cmd/Ctrl shortcuts from triggering native browser actions
const preventDefaults = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return
@@ -1263,6 +1286,7 @@ const AgentManagerContent: Component = () => {
confirmDeleteWorktree(sel)
}
window.addEventListener("keydown", deleteKeyHandler)
onCleanup(() => window.removeEventListener("agentManager.openSubagent", subagent))
// Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held.
// Capture phase so the terminal's key handlers can't swallow them; blur resets state
@@ -2157,6 +2181,10 @@ const AgentManagerContent: Component = () => {
// Close the currently active tab via keyboard shortcut.
// If no tabs remain, fall through to close the selected worktree.
const closeActiveTab = () => {
if (sidePanel() === SidePanel.Subagents && subagents.active()) {
subagents.close(subagents.active()!)
return
}
// A focused side terminal owns Cmd+W while its panel is visible.
// Closing a chat tab out from under the user's cursor would be surprising.
if (sidePanel() === SidePanel.Terminal && terms.sideFocusedId()) {
@@ -2587,7 +2615,7 @@ const AgentManagerContent: Component = () => {
mounted while a side terminal is alive hidden via
.am-side-host-hidden (absolute + opacity), never
unmounted, so xterm render loops keep streaming. */}
<Show when={sidePanel() !== null || terms.sides().length > 0}>
<Show when={sidePanel() !== null || terms.sides().length > 0 || subagents.tabs().length > 0}>
<div
class={`am-diff-resize ${sidePanel() === null ? "am-side-host-hidden" : ""}`}
style={{ width: `${panelWidth()}px` }}
@@ -2654,6 +2682,20 @@ const AgentManagerContent: Component = () => {
}
/>
</Show>
<Show when={subagents.tabs().length > 0}>
<SubagentPanel
tabs={subagents.tabs}
active={subagents.active}
visible={() => sidePanel() === SidePanel.Subagents}
nextKeybind={kb().nextTab ?? ""}
closeKeybind={kb().closeTab ?? ""}
onSelect={subagents.select}
onClose={subagents.close}
onCloseOthers={subagents.closeOthers}
onReorder={subagents.reorder}
onClosePanel={() => setSidePanel(null)}
/>
</Show>
<SideTerminalPanel
state={terms}
contextKey={terms.sideKey}
@@ -0,0 +1,155 @@
import type { IconProps } from "@kilocode/kilo-ui/icon"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { Show, type Component, type JSX } from "solid-js"
import { SessionTabMenu } from "../src/components/chat/SessionTabMenu"
import { SortableTabContainer } from "../src/components/chat/TabDnd"
import { useLanguage } from "../src/context/language"
import { parseBindingTokens } from "./keybind-tokens"
type TabIcon = IconProps["name"] | "spinner"
type Value<T> = T | (() => T)
function value<T>(input: Value<T>): T {
return typeof input === "function" ? (input as () => T)() : input
}
export interface ClosableTabProps {
id?: string
label: Value<string>
tooltip: Value<string>
icon: Value<TabIcon>
iconStatus?: Value<"success" | "failure" | undefined>
class?: string
focused?: boolean
active: boolean
closeable?: boolean
showKeybind?: boolean
keybind?: string
closeKeybind?: string
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
onSelect: () => void
onMiddleClick?: (event: MouseEvent) => void
onClose: () => void
trailing?: JSX.Element
}
export const ClosableTabChrome: Component<ClosableTabProps> = (props) => {
const { t } = useLanguage()
const label = () => value(props.label)
const tooltip = () => value(props.tooltip)
const icon = () => value(props.icon)
const status = () => (props.iconStatus ? value(props.iconStatus) : undefined)
const keybind = () => (props.showKeybind === false ? "" : (props.keybind ?? ""))
const closeKeybind = () => (props.showKeybind === false ? "" : (props.closeKeybind ?? ""))
return (
<div
class={`am-tab am-tab-closable ${props.active ? "am-tab-active" : ""} ${props.focused ? "am-tab-focused" : ""} ${props.class ?? ""}`}
>
<div
class="am-tab-target"
role={props.role}
aria-selected={props.selected}
aria-label={tooltip()}
tabIndex={props.tabIndex}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
onKeyDown={props.onKeyDown}
>
<TooltipKeybind
title={tooltip()}
keybind={keybind()}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
>
<span class="am-tab-title">
<span class="am-tab-icon" data-run-status={status()}>
<Show when={icon() === "spinner"} fallback={<Icon name={icon() as IconProps["name"]} size="small" />}>
<Spinner class="am-tab-spinner" />
</Show>
</span>
<span class="am-tab-label">{label()}</span>
</span>
</TooltipKeybind>
</div>
{props.trailing}
<Show when={props.closeable !== false}>
<TooltipKeybind
title={t("agentManager.tab.close")}
keybind={closeKeybind()}
placement="top"
gutter={8}
class="am-tab-close-wrap"
openDelay={0}
>
<IconButton
icon="close-small"
size="small"
variant="ghost"
aria-label={t("agentManager.tab.closeTab")}
tabIndex={props.active ? 0 : -1}
class="am-tab-close"
data-tab-close="true"
onClick={(event) => {
event.stopPropagation()
props.onClose()
}}
/>
</TooltipKeybind>
</Show>
</div>
)
}
export const SortableClosableTab: Component<
ClosableTabProps & {
id: string
onCloseOthers: () => void
}
> = (props) => (
<SortableTabContainer id={props.id}>
<SessionTabMenu
onClose={props.onClose}
onCloseOthers={props.onCloseOthers}
closeable={props.closeable}
closeShortcut={
props.closeKeybind ? (
<span class="am-menu-shortcut">
{parseBindingTokens(props.closeKeybind).map((token) => (
<kbd class="am-menu-key">{token}</kbd>
))}
</span>
) : undefined
}
>
<ClosableTabChrome
label={props.label}
tooltip={props.tooltip}
icon={props.icon}
iconStatus={props.iconStatus}
class={props.class}
focused={props.focused}
active={props.active}
closeable={props.closeable}
showKeybind={props.showKeybind}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
role={props.role}
selected={props.selected}
tabIndex={props.tabIndex}
onKeyDown={props.onKeyDown}
onSelect={props.onSelect}
onMiddleClick={props.onMiddleClick}
onClose={props.onClose}
trailing={props.trailing}
/>
</SessionTabMenu>
</SortableTabContainer>
)
@@ -60,7 +60,7 @@ import { VirtualDiffList } from "../diff-viewer/VirtualDiffList"
import { treeOrder } from "../diff-viewer/file-tree-utils"
import { isMarkdownFile, MarkdownDiffView } from "../diff-viewer/MarkdownDiffView"
import { ImageDiffView } from "../diff-viewer/ImageDiffView"
import { createDiffRows } from "../diff-viewer/diff-state"
import { createDiffRows, diffSizeKey } from "../diff-viewer/diff-state"
import { createDiffRequests } from "../diff-viewer/diff-requests"
// --- Data model ---
@@ -728,6 +728,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
after={{ name: diff.file, contents: diff.after }}
patch={diff.patch}
diffStyle={props.diffStyle ?? "unified"}
sizeKey={diffSizeKey(props.sessionKey, diff, props.diffStyle ?? "unified")}
virtualized={shouldVirtualizeDiff(diff)}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
@@ -0,0 +1,108 @@
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import { For, Show, createSignal, type Accessor, type Component, type JSX } from "solid-js"
import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
import { createTabFocus } from "../src/utils/tab-navigation"
import { useTabScroll } from "../src/utils/tab-scroll"
import { setTabWidths } from "../src/utils/tab-widths"
const TABLIST = ".am-inspector-tablist"
type InspectorTabFocus = ReturnType<typeof createTabFocus>
interface InspectorTabStripApi {
focus: InspectorTabFocus
freeze: () => void
release: () => void
}
interface Props {
ids: Accessor<readonly string[]>
active: Accessor<string | undefined>
label: string
renderTab: (id: string, api: InspectorTabStripApi) => JSX.Element
overlay: (id: string) => string
onSelect: (id: string) => void
onReorder: (from: string, to: string) => void
action?: (api: InspectorTabStripApi) => JSX.Element
}
export const InspectorTabStrip: Component<Props> = (props) => {
let host!: HTMLDivElement
const scroll = useTabScroll(props.ids, props.active)
const focus = createTabFocus({ ids: props.ids, select: props.onSelect, root: () => host })
const [dragging, setDragging] = createSignal<{ id: string; width: number }>()
const freeze = () => setTabWidths(true, host, TABLIST)
const release = () => setTabWidths(false, host, TABLIST)
const api = { focus, freeze, release }
const start = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id !== "string") return
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
freeze()
setDragging({ id, width })
}
const end = () => {
setDragging(undefined)
release()
}
const over = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
props.onReorder(from, to)
}
return (
<div
ref={host}
class="am-inspector-tabs"
onPointerDown={(event) => {
if (event.target instanceof Element && event.target.closest(".am-tab-close[data-tab-close]")) freeze()
}}
onPointerLeave={() => {
if (!dragging()) release()
}}
>
<DragDropProvider onDragStart={start} onDragEnd={end} onDragOver={over} collisionDetector={closestCenter}>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list-wrap">
<div
class="am-inspector-tablist"
ref={(el) => {
scroll.setRef(el)
}}
role={props.ids().length > 0 ? "tablist" : undefined}
aria-label={props.ids().length > 0 ? props.label : undefined}
style={{ "--tab-count": `${props.ids().length}` } as JSX.CSSProperties}
>
<SortableProvider ids={[...props.ids()]}>
<For each={props.ids()}>{(id) => props.renderTab(id, api)}</For>
</SortableProvider>
</div>
</div>
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
<DragOverlay>
<Show when={dragging()}>
{(tab) => (
<div class="am-tab am-tab-overlay" style={{ width: `${tab().width}px` }}>
<span class="am-tab-label">{props.overlay(tab().id)}</span>
</div>
)}
</Show>
</DragOverlay>
</DragDropProvider>
{props.action?.(api)}
</div>
)
}
@@ -33,6 +33,7 @@ import { ConstrainDragXAxis } from "./constrain-drag-x"
import { createProjectStore, type ProjectStore } from "./project/store"
import { randomColor } from "./section-colors"
import { projectSidebarOrder, projectWorktreeRow } from "./project-local-navigation"
import { rootSessions } from "./project/session-filter"
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
@@ -94,8 +95,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
pendingTimer = setTimeout(() => setPending(undefined), 2500)
}
const state = () => props.state
const sessions = (worktreeId: string | null) =>
(props.sessions ?? []).filter((item) => item.worktreeId === worktreeId)
const sessions = (worktreeId: string | null) => rootSessions(props.sessions ?? [], worktreeId)
const active = () => props.selectedProject === props.project.id
const runs = () => store.runStatuses()
const sections = () => store.sections()
@@ -0,0 +1,131 @@
/**
* Read-only subagent chats for the Agent Manager inspector.
*
* The nested session provider keeps the parent chat selection independent from
* the child transcript while still consuming the same webview event stream.
*/
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { createEffect, type Accessor, type Component } from "solid-js"
import { DataBridge } from "../src/App"
import { ChatView } from "../src/components/chat"
import { SessionProvider, useSession } from "../src/context/session"
import { SortableClosableTab } from "./ClosableTab"
import { InspectorTabStrip } from "./InspectorTabStrip"
import type { SubagentTab } from "./subagent-tabs"
interface Props {
tabs: Accessor<SubagentTab[]>
active: Accessor<string | undefined>
visible: Accessor<boolean>
nextKeybind: string
closeKeybind: string
onSelect: (id: string) => void
onClose: (id: string) => void
onCloseOthers: (id: string) => void
onReorder: (from: string, to: string) => void
onClosePanel: () => void
}
const SubagentChat: Component<{ active: Accessor<string | undefined> }> = (props) => {
const session = useSession()
createEffect(() => {
const id = props.active()
if (!id) return
session.selectSession(id, { focus: false })
})
return (
<DataBridge>
<ChatView readonly promptBoxId="agent-manager:subagent" />
</DataBridge>
)
}
const SubagentContent: Component<Props> = (props) => {
const session = useSession()
const ids = () => props.tabs().map((tab) => tab.id)
const title = (id: string) => props.tabs().find((tab) => tab.id === id)?.title ?? "Sub-agent"
const close = (id: string, focus: { restore: () => void }) => {
props.onClose(id)
session.releaseSession(id)
if (ids().length > 0) focus.restore()
}
const closeOthers = (id: string) => {
const gone = ids().filter((item) => item !== id)
props.onCloseOthers(id)
for (const item of gone) session.releaseSession(item)
}
return (
<section
class="am-subagent-panel"
classList={{ "am-subagent-panel-visible": props.visible() }}
aria-label="Subagents"
aria-hidden={!props.visible()}
inert={!props.visible()}
>
<header class="am-subagent-header">
<div class="am-subagent-heading">
<Icon name="task" size="small" />
<span>Subagents</span>
<span class="am-subagent-count">{props.tabs().length}</span>
</div>
<IconButton
icon="x"
size="small"
variant="ghost"
aria-label="Close subagents panel"
onClick={props.onClosePanel}
/>
</header>
<InspectorTabStrip
ids={ids}
active={props.active}
label="Subagent sessions"
overlay={title}
onSelect={props.onSelect}
onReorder={props.onReorder}
renderTab={(id, api) => {
const label = title(id)
return (
<SortableClosableTab
id={id}
label={label}
tooltip={label}
icon="task"
showKeybind={false}
keybind={props.active() === id ? "" : props.nextKeybind}
closeKeybind={props.closeKeybind}
active={props.active() === id}
role="tab"
selected={props.active() === id}
tabIndex={props.active() === id ? 0 : -1}
onKeyDown={(event) => api.focus.key(id, event)}
onSelect={() => props.onSelect(id)}
onMiddleClick={(event) => {
if (event.button !== 1) return
event.preventDefault()
event.stopPropagation()
close(id, api.focus)
}}
onClose={() => close(id, api.focus)}
onCloseOthers={() => closeOthers(id)}
/>
)
}}
/>
<div class="am-subagent-chat">
<SubagentChat active={props.active} />
</div>
</section>
)
}
export const SubagentPanel: Component<Props> = (props) => (
<SessionProvider>
<SubagentContent {...props} />
</SessionProvider>
)
@@ -1514,7 +1514,7 @@ html[data-theme="kilo-vscode"]
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-terminal-tab-spinner {
.am-tab-spinner {
width: 12px;
height: 12px;
}
@@ -4841,8 +4841,9 @@ body.vscode-high-contrast-light {
}
}
/* Experimental terminal tabs (feature-flagged) */
/* Shared sortable inspector tabs. */
.am-tab-closable,
.am-tab-terminal {
display: flex;
align-items: center;
@@ -4850,12 +4851,13 @@ body.vscode-high-contrast-light {
border-left: 1px solid var(--border-weak-base);
}
.am-tab-closable [data-component="icon"],
.am-tab-terminal [data-component="icon"] {
flex-shrink: 0;
opacity: 0.7;
}
.am-tab-terminal-focused {
.am-tab-focused {
background: var(--surface-base-hover);
}
@@ -4978,15 +4980,14 @@ body.vscode-high-contrast-light {
color: var(--text-weak);
}
/* Side terminal tab strip one row of tabs reusing the top bar's
.am-tab chrome, plus the "+" action. Height matches .am-diff-header
/* Inspector tab strip one row of tabs reusing the top bar's
.am-tab chrome, plus an optional action. Height matches .am-diff-header
(32px) so switching inspector modes does not shift the panel chrome.
No vertical padding: tabs fill the strip like they fill .am-tab-bar,
which also keeps the "+" optically centered. The strip itself never
scrolls; the tab list does, so a narrow panel never pushes the "+"
action out of view (same split as .am-tab-list-wrap /
.am-tab-add-wrap). */
.am-side-terminal-tabs {
which also keeps actions optically centered. The strip itself never
scrolls; the tab list does, so a narrow panel never pushes an action out
of view (same split as .am-tab-list-wrap / .am-tab-add-wrap). */
.am-inspector-tabs {
display: flex;
align-items: stretch;
height: 32px;
@@ -5001,10 +5002,10 @@ body.vscode-high-contrast-light {
/* Same width model as .am-tab-list: tabs claim an equal share of the
strip up to a maximum, and the list itself only grows as wide as its
tabs, so the "+" action stays glued to the last tab instead of
tabs, so an action stays glued to the last tab instead of
drifting to the far edge of a wide panel. The cap is smaller than the
top bar's 240px because the panel is narrow. */
.am-side-terminal-tablist {
.am-inspector-tablist {
--am-tab-max-width: 180px;
--am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width));
display: flex;
@@ -5020,17 +5021,16 @@ body.vscode-high-contrast-light {
scrollbar-width: none;
}
.am-side-terminal-tablist::-webkit-scrollbar {
.am-inspector-tablist::-webkit-scrollbar {
display: none;
}
.am-side-terminal-tablist[data-tab-widths-frozen] .am-tab-sortable {
.am-inspector-tablist[data-tab-widths-frozen] .am-tab-sortable {
transition: none;
}
/* The left divider marks a terminal among session tabs in the top bar.
Every tab here is a terminal, so it would just be noise. */
.am-side-terminal-tablist .am-tab-terminal {
/* The left divider belongs to the top-level mixed tab bar. */
.am-inspector-tablist .am-tab-closable {
border-left-color: transparent;
}
@@ -5054,6 +5054,69 @@ body.vscode-high-contrast-light {
pointer-events: none;
}
/* Subagent inspector panel. It remains mounted while another inspector mode
is active so switching back keeps the child transcript and scroll position. */
.am-subagent-panel {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
opacity: 0;
pointer-events: none;
z-index: 1;
background: var(--surface-base);
will-change: opacity;
}
.am-subagent-panel-visible {
opacity: 1;
pointer-events: auto;
}
.am-subagent-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
height: 32px;
padding: 0 4px 0 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
background: var(--surface-base);
}
.am-subagent-heading {
display: flex;
align-items: center;
min-width: 0;
gap: 6px;
color: var(--text-base);
font-size: var(--font-size-small);
font-weight: 600;
}
.am-subagent-count {
color: var(--text-weak);
font-size: var(--kilo-font-size-10);
font-variant-numeric: tabular-nums;
}
.am-subagent-chat {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
}
.am-subagent-chat > [data-component="data-provider"],
.am-subagent-chat [class~="chat-view"] {
min-width: 0;
min-height: 0;
flex: 1;
}
.am-terminal-host {
flex: 1;
min-height: 0;
@@ -5,8 +5,13 @@
import { render } from "solid-js/web"
import "@kilocode/kilo-ui/styles"
import "../src/styles/chat.css"
import { registerExpandedTaskTool } from "../src/components/chat/TaskToolExpanded"
import { registerVscodeToolOverrides } from "../src/components/chat/VscodeToolOverrides"
import { AgentManagerApp } from "./AgentManagerApp"
registerExpandedTaskTool()
registerVscodeToolOverrides()
const root = document.getElementById("root")
if (root) {
render(() => <AgentManagerApp />, root)
@@ -0,0 +1,6 @@
import type { ProjectSessionInfo } from "../../src/types/messages"
import { isKnownRootSession } from "../navigate"
export function rootSessions(sessions: ProjectSessionInfo[], worktreeId: string | null): ProjectSessionInfo[] {
return sessions.filter((session) => session.worktreeId === worktreeId && isKnownRootSession(session))
}
@@ -9,6 +9,7 @@ export enum SidePanel {
Diff = "diff",
PR = "pr",
Terminal = "terminal",
Subagents = "subagents",
}
function viewportWidth(viewport: number): number {
@@ -0,0 +1,90 @@
import { batch, createSignal, type Accessor } from "solid-js"
import { reorderTabs } from "../src/utils/tab-order"
export interface SubagentTab {
id: string
title: string
}
interface Options {
current: Accessor<string | undefined>
sync: (id: string, parentID?: string) => void
unsync: (id: string) => void
show: () => void
hide: () => void
}
export function createSubagentTabs(opts: Options) {
const [tabs, setTabs] = createSignal<SubagentTab[]>([])
const [active, setActive] = createSignal<string>()
const open = (id: string, title?: string, parentID?: string) => {
if (!id) return
const label = title?.trim() || "Sub-agent"
const existing = tabs().some((tab) => tab.id === id)
batch(() => {
setTabs((prev) => {
const existing = prev.find((tab) => tab.id === id)
if (!existing) return [...prev, { id, title: label }]
if (title?.trim() && existing.title !== label) {
return prev.map((tab) => (tab.id === id ? { ...tab, title: label } : tab))
}
return prev
})
setActive(id)
opts.show()
})
if (!existing) opts.sync(id, parentID ?? opts.current())
}
const select = (id: string) => {
if (!tabs().some((tab) => tab.id === id)) return
setActive(id)
opts.show()
}
const close = (id: string) => {
const current = tabs()
const index = current.findIndex((tab) => tab.id === id)
if (index < 0) return
const next = current.filter((tab) => tab.id !== id)
opts.unsync(id)
setTabs(next)
if (active() !== id) return
const replacement = next[Math.min(index, next.length - 1)]
if (replacement) {
setActive(replacement.id)
return
}
setActive(undefined)
opts.hide()
}
const closeOthers = (id: string) => {
if (!tabs().some((tab) => tab.id === id)) return
for (const tab of tabs()) {
if (tab.id !== id) opts.unsync(tab.id)
}
setTabs((prev) => prev.filter((tab) => tab.id === id))
setActive(id)
opts.show()
}
const reorder = (from: string, to: string) => {
const order = reorderTabs(
tabs().map((tab) => tab.id),
from,
to,
)
if (!order) return
setTabs((prev) => {
const lookup = new Map(prev.map((tab) => [tab.id, tab]))
return order.flatMap((id) => {
const tab = lookup.get(id)
return tab ? [tab] : []
})
})
}
return { tabs, active, open, select, close, closeOthers, reorder }
}
@@ -1,48 +1,26 @@
/**
* Right-side terminal panel for the Agent Manager inspector.
*
* Lives inside the shared `.am-diff-panel-wrapper` host next to the diff
* and PR panels, so all three inspector modes share one resize handle
* and one width.
* Lives inside the shared inspector host next to diff, PR, and subagent
* panels, so every mode uses the same persisted resize width. The tab row is
* the shared inspector strip used by subagents as well.
*
* A context can own several side terminals. The header is a tab strip
* that reuses the top tab bar's whole chrome: `SortableTerminalTab`
* (icon, title, X close, right-click Close / Close Others), the same
* `@thisbeyond/solid-dnd` reorder stack, the same overflow scrolling
* with edge fades, the same width freeze while tabs close, and the same
* arrow-key tab navigation, so a terminal behaves identically in
* either surface. Reorder state lives in the terminal state, so it is
* preserved across sidebar context switches for the webview's lifetime.
*
* The `+` action sits directly after the last tab (outside the
* scrolling region, like the tab bar's `am-tab-add-wrap`), so it never
* scrolls away and never drifts to the far edge of a wide panel. The
* strip stays visible even when empty so `+` is always reachable.
*
* Visibility is opacity-based, never unmount: the xterm render loop
* dies when its subtree leaves the paint tree (see `render.tsx`).
* Visibility is opacity-based, never unmount: the xterm render loop dies when
* its subtree leaves the paint tree (see `render.tsx`).
*/
import type { Accessor, Component, JSX } from "solid-js"
import { For, Show, createEffect, createSignal } from "solid-js"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import type { Accessor, Component } from "solid-js"
import { Show, createEffect } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../../src/context/language"
import { ConstrainDragYAxis } from "../../src/components/chat/TabDnd"
import { useTabScroll } from "../../src/utils/tab-scroll"
import { setTabWidths } from "../../src/utils/tab-widths"
import { createTabFocus } from "../../src/utils/tab-navigation"
import { InspectorTabStrip } from "../InspectorTabStrip"
import { renderSideTerminalLayer } from "./render"
import { SortableTerminalTab } from "./SortableTerminalTab"
import type { TerminalStateControls } from "./state"
/** Only this strip's tabs freeze; the top tab bar keeps its own widths. */
const TABLIST = ".am-side-terminal-tablist"
interface Props {
state: TerminalStateControls
/** Context the panel currently shows (`state.sideKey`). */
@@ -67,65 +45,18 @@ interface Props {
export const SideTerminalPanel: Component<Props> = (props) => {
const { t } = useLanguage()
let panel!: HTMLElement
let strip!: HTMLDivElement
createEffect(() => {
panel.inert = !props.visible()
})
const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>()
const sides = () => props.state.sidesForContext(props.contextKey())
const ids = () => sides().map((term) => term.id)
const active = () => props.state.sideActiveFor(props.contextKey())
const pending = () => props.state.pendingSide(props.contextKey())
const scroll = useTabScroll(ids, active)
// Scoped to `strip` so arrow keys and focus restore never jump to a
// tab in the top bar, which uses the same role="tab" markup.
const focus = createTabFocus({ ids, select: props.onSelect, root: () => strip })
// Only freeze while the pointer is over the strip: the widths must
// survive until the pointer leaves, so the remaining X buttons stay
// put across repeated closes. Releasing on the next frame would undo
// the freeze before it is ever painted (rAF runs before paint).
// "Close others" needs none of this: its context menu is portaled, so
// the pointer is off the strip, and the survivor spans the strip anyway.
const freeze = () => {
if (strip.closest(".am-side-terminal-tabs")?.matches(":hover")) setTabWidths(true, document, TABLIST)
}
const release = () => setTabWidths(false, document, TABLIST)
const close = (id: string) => {
freeze()
const close = (id: string, focus: { restore: () => void }) => {
props.onClose(id)
// Restore focus inside the strip only while it still owns a tab.
// Falling through to `focusPrompt` would pull focus into the chat
// composer while the panel is still open on its empty state.
if (ids().length > 0) focus.restore()
}
// Adding a tab shrinks every tab's equal share, so any freeze left
// over from a close in the same hover has to go first. `+` lives
// inside the strip, so no pointerleave happens between the two
// clicks and the surviving tabs would keep their wider pixel widths.
const start = () => {
release()
props.onStart()
}
const onDragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id !== "string") return
// Pin the overlay to the tab's width: the overlay container uses
// min-width, so a long OSC title would otherwise overflow it and
// shift the visual center off the cursor (the "drag offset" bug).
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
setTabWidths(true, document, TABLIST)
setDragging({ id, width })
}
const onDragEnd = () => {
setDragging(undefined)
release()
}
const onDragOver = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
props.state.reorderSideDrag(props.contextKey(), from, to)
}
return (
<section
ref={panel}
@@ -133,105 +64,64 @@ export const SideTerminalPanel: Component<Props> = (props) => {
aria-label={t("agentManager.tab.terminal")}
aria-hidden={!props.visible()}
>
<div
class="am-side-terminal-tabs"
onPointerLeave={() => {
if (!dragging()) release()
}}
>
<DragDropProvider
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
{/* Overflow chrome copied from the top tab bar: the list is the
only scrolling element, wrapped by a fade host, so the "+"
action stays pinned next to the last tab. */}
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list-wrap">
{/* role="tablist" only when tabs exist: axe
aria-required-children rejects an empty tablist. */}
<div
class="am-side-terminal-tablist"
ref={(el) => {
strip = el
scroll.setRef(el)
}}
role={sides().length > 0 ? "tablist" : undefined}
aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined}
style={{ "--tab-count": `${sides().length}` } as JSX.CSSProperties}
>
<SortableProvider ids={ids()}>
<For each={sides()}>
{(term) => (
<SortableTerminalTab
id={term.id}
label={props.state.title(term.id) ?? term.title}
tooltip={props.state.title(term.id) ?? term.title}
status={props.state.scriptStatus(term.id)}
keybind={active() === term.id ? "" : props.nextKeybind}
closeKeybind={props.closeKeybind}
active={active() === term.id}
focused={props.state.sideFocusedId() === term.id}
role="tab"
selected={active() === term.id}
tabIndex={active() === term.id ? 0 : -1}
onKeyDown={(event) => focus.key(term.id, event)}
onSelect={() => props.onSelect(term.id)}
onMiddleClick={(e: MouseEvent) => {
if (e.button !== 1) return
e.preventDefault()
e.stopPropagation()
close(term.id)
}}
onClose={(e: MouseEvent) => {
e.stopPropagation()
close(term.id)
}}
onCloseOthers={() => props.onCloseOthers(term.id)}
onStop={(e: MouseEvent) => {
e.stopPropagation()
props.onStop(term.id)
}}
/>
)}
</For>
</SortableProvider>
</div>
</div>
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
{/* Cursor-following clone of the dragged tab (same pattern as
the top tab bar). The overlay is what makes the in-list
original use solid-dnd's slot-compensated transform, so the
dragged tab tracks the cursor without a jump/offset. The
original stays dimmed in its slot via .am-tab-dragging. */}
<DragOverlay>
<Show when={dragging()}>
{(tab) => (
<div class="am-tab am-tab-overlay" style={{ width: `${tab().width}px` }}>
<span class="am-tab-label">{props.state.title(tab().id) ?? t("agentManager.tab.terminal")}</span>
</div>
)}
</Show>
</DragOverlay>
</DragDropProvider>
<div class="am-side-terminal-add">
<Tooltip value={t("agentManager.terminal.add")} placement="bottom">
<IconButton
icon="plus"
size="small"
variant="ghost"
aria-label={t("agentManager.terminal.add")}
onClick={start}
<InspectorTabStrip
ids={ids}
active={active}
label={t("agentManager.tab.terminal")}
overlay={(id) => props.state.title(id) ?? t("agentManager.tab.terminal")}
onSelect={props.onSelect}
onReorder={(from, to) => props.state.reorderSideDrag(props.contextKey(), from, to)}
renderTab={(id, api) => {
const term = sides().find((item) => item.id === id)
if (!term) return null
return (
<SortableTerminalTab
id={term.id}
label={props.state.title(term.id) ?? term.title}
tooltip={props.state.title(term.id) ?? term.title}
status={props.state.scriptStatus(term.id)}
showKeybind={false}
keybind={active() === term.id ? "" : props.nextKeybind}
closeKeybind={props.closeKeybind}
active={active() === term.id}
focused={props.state.sideFocusedId() === term.id}
role="tab"
selected={active() === term.id}
tabIndex={active() === term.id ? 0 : -1}
onKeyDown={(event) => api.focus.key(term.id, event)}
onSelect={() => props.onSelect(term.id)}
onMiddleClick={(event) => {
if (event.button !== 1) return
event.preventDefault()
event.stopPropagation()
close(term.id, api.focus)
}}
onClose={() => close(term.id, api.focus)}
onCloseOthers={() => props.onCloseOthers(term.id)}
onStop={(event) => {
event.stopPropagation()
props.onStop(term.id)
}}
/>
</Tooltip>
</div>
</div>
)
}}
action={(api) => (
<div class="am-side-terminal-add">
<Tooltip value={t("agentManager.terminal.add")} placement="bottom">
<IconButton
icon="plus"
size="small"
variant="ghost"
aria-label={t("agentManager.terminal.add")}
onClick={() => {
api.release()
props.onStart()
}}
/>
</Tooltip>
</div>
)}
/>
{renderSideTerminalLayer({
state: props.state,
contextKey: props.contextKey,
@@ -1,191 +1,99 @@
/**
* Tab chrome for xterm terminals.
* Terminal-specific adapter for the shared sortable inspector tab.
*
* `TerminalTabChrome` is the shared visual tab: console icon, title,
* tooltip/keybinding hints, and the X close button the same
* `am-tab*` structure the session tabs use. `SortableTerminalTab`
* wraps it with drag-and-drop and a right-click context menu; both the
* top tab bar and the side terminal panel render that wrapper, so a
* terminal tab behaves identically in either surface.
* PTY status determines the icon and whether a Setup tab can be closed. The
* tab chrome, drag wrapper, context menu, and close behavior are shared with
* subagent tabs.
*/
import { Component, Show, type JSX } from "solid-js"
import { Show, type Component } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { useLanguage } from "../../src/context/language"
import { SortableTabContainer } from "../../src/components/chat/TabDnd"
import { parseBindingTokens } from "../keybind-tokens"
import { SortableClosableTab, type ClosableTabProps } from "../ClosableTab"
import { terminalChrome, terminalClosable, terminalStoppable } from "./chrome"
import type { ScriptTerminalStatus } from "./state"
export const TerminalTabChrome: Component<{
interface Props extends Omit<ClosableTabProps, "icon" | "onClose" | "trailing"> {
label: string
tooltip: string
status?: ScriptTerminalStatus
keybind?: string
closeKeybind?: string
focused?: boolean
active: boolean
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
onSelect: () => void
onMiddleClick?: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
onStop?: (e: MouseEvent) => void
}> = (props) => {
onClose: () => void
onStop?: (event: MouseEvent) => void
}
const StopButton: Component<{ active: boolean; tabIndex: number; onStop?: (event: MouseEvent) => void }> = (props) => {
const { t } = useLanguage()
const chrome = () => terminalChrome(props.tooltip, props.status)
const icon = () => {
const kind = chrome().icon
if (kind === "success") return "check-small"
if (kind === "failure") return "warning"
return "console"
}
return (
<div
class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""} ${props.focused ? "am-tab-terminal-focused" : ""}`}
>
<div
class="am-tab-target"
role={props.role}
aria-selected={props.selected}
aria-label={chrome().tooltip}
tabIndex={props.tabIndex}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
onKeyDown={props.onKeyDown}
<Show when={props.active && props.onStop}>
<TooltipKeybind
title={t("agentManager.terminal.stopSetup")}
keybind=""
placement="top"
gutter={8}
class="am-tab-close-wrap"
openDelay={0}
>
<TooltipKeybind
title={chrome().tooltip}
keybind={props.keybind ?? ""}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
>
<span class="am-tab-title">
<span class="am-tab-icon" data-run-status={chrome().icon}>
<Show when={chrome().icon === "spinner"} fallback={<Icon name={icon()} size="small" />}>
<Spinner class="am-terminal-tab-spinner" />
</Show>
</span>
<span class="am-tab-label">{props.label}</span>
</span>
</TooltipKeybind>
</div>
<Show when={terminalStoppable(props.status) && props.onStop}>
<TooltipKeybind
title={t("agentManager.terminal.stopSetup")}
keybind=""
placement="top"
gutter={8}
class="am-tab-close-wrap"
openDelay={0}
>
<IconButton
icon="stop"
size="small"
variant="ghost"
aria-label={t("agentManager.terminal.stopSetup")}
tabIndex={props.active ? 0 : -1}
class="am-tab-close"
onClick={props.onStop}
/>
</TooltipKeybind>
</Show>
<Show when={terminalClosable(props.status)}>
<TooltipKeybind
title={t("agentManager.tab.close")}
keybind={props.closeKeybind ?? ""}
placement="top"
gutter={8}
class="am-tab-close-wrap"
openDelay={0}
>
<IconButton
icon="close-small"
size="small"
variant="ghost"
aria-label={t("agentManager.tab.closeTab")}
tabIndex={props.active ? 0 : -1}
class="am-tab-close"
onClick={props.onClose}
/>
</TooltipKeybind>
</Show>
</div>
<IconButton
icon="stop"
size="small"
variant="ghost"
aria-label={t("agentManager.terminal.stopSetup")}
tabIndex={props.tabIndex}
class="am-tab-close"
onClick={(event) => {
event.stopPropagation()
props.onStop?.(event)
}}
/>
</TooltipKeybind>
</Show>
)
}
export const SortableTerminalTab: Component<{
id: string
label: string
tooltip: string
status?: ScriptTerminalStatus
keybind?: string
closeKeybind?: string
focused?: boolean
active: boolean
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
onSelect: () => void
onMiddleClick: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
onCloseOthers: () => void
onStop?: (e: MouseEvent) => void
}> = (props) => {
const { t } = useLanguage()
return (
<SortableTabContainer id={props.id}>
<ContextMenu>
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
<TerminalTabChrome
label={props.label}
tooltip={props.tooltip}
status={props.status}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
focused={props.focused}
active={props.active}
role={props.role}
selected={props.selected}
tabIndex={props.tabIndex}
onKeyDown={props.onKeyDown}
onSelect={props.onSelect}
onMiddleClick={props.onMiddleClick}
onClose={props.onClose}
onStop={props.onStop}
/>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content class="am-ctx-menu">
<ContextMenu.Item
onSelect={() => props.onClose(new MouseEvent("click", { bubbles: true, cancelable: true }) as MouseEvent)}
>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
<Show when={props.closeKeybind}>
<span class="am-menu-shortcut">
{parseBindingTokens(props.closeKeybind ?? "").map((token) => (
<kbd class="am-menu-key">{token}</kbd>
))}
</span>
</Show>
</ContextMenu.Item>
<ContextMenu.Item onSelect={props.onCloseOthers}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.closeOthers")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</SortableTabContainer>
)
function icon(status: ScriptTerminalStatus | undefined) {
const value = terminalChrome("", status).icon
if (value === "success") return "check-small" as const
if (value === "failure") return "warning" as const
if (value === "spinner") return "spinner" as const
return "console" as const
}
function iconStatus(status: ScriptTerminalStatus | undefined) {
const value = terminalChrome("", status).icon
if (value === "success") return "success" as const
if (value === "failure") return "failure" as const
return undefined
}
export const SortableTerminalTab: Component<
Props & {
id: string
onCloseOthers: () => void
}
> = (props) => (
<SortableClosableTab
id={props.id}
label={props.label}
tooltip={terminalChrome(props.tooltip, props.status).tooltip}
icon={() => icon(props.status)}
iconStatus={() => iconStatus(props.status)}
class="am-tab-terminal"
focused={props.focused}
active={props.active}
closeable={terminalClosable(props.status)}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
role={props.role}
selected={props.selected}
tabIndex={props.tabIndex}
onKeyDown={props.onKeyDown}
onSelect={props.onSelect}
onMiddleClick={props.onMiddleClick}
onClose={props.onClose}
onCloseOthers={props.onCloseOthers}
trailing={
<StopButton active={terminalStoppable(props.status)} tabIndex={props.active ? 0 : -1} onStop={props.onStop} />
}
/>
)
@@ -59,10 +59,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
onKeyDown={deps.onKeyDown}
onSelect={() => deps.onSelect(deps.id)}
onMiddleClick={(e: MouseEvent) => deps.onMiddleClick(deps.id, e)}
onClose={(e: MouseEvent) => {
e.stopPropagation()
deps.onClose(deps.id)
}}
onClose={() => deps.onClose(deps.id)}
onCloseOthers={() => deps.onCloseOthers(deps.id)}
/>
)
@@ -61,7 +61,7 @@ import { DiffEndMarker } from "./DiffEndMarker"
import { VirtualDiffList } from "./VirtualDiffList"
import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView"
import { ImageDiffView } from "./ImageDiffView"
import { createDiffRows } from "./diff-state"
import { createDiffRows, diffSizeKey } from "./diff-state"
import { createDiffRequests } from "./diff-requests"
type DiffStyle = "unified" | "split"
@@ -802,6 +802,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
after={{ name: diff.file, contents: diff.after }}
patch={diff.patch}
diffStyle={props.diffStyle}
sizeKey={diffSizeKey(props.sessionKey, diff, props.diffStyle)}
virtualized={shouldVirtualizeDiff(diff)}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
@@ -1,6 +1,18 @@
import { createMemo, createSignal } from "solid-js"
import type { WorktreeFileDiff } from "../src/types/messages"
const sizeKeys = new WeakMap<
WorktreeFileDiff,
{
context: string | undefined
style: string
patch: string | undefined
before: string
after: string
key: object
}
>()
export function sameDiffMeta(left: WorktreeFileDiff, right: WorktreeFileDiff) {
return (
left.file === right.file &&
@@ -20,6 +32,23 @@ export function diffToken(diff: WorktreeFileDiff) {
return diff.stamp ?? parts.join(":")
}
export function diffSizeKey(context: string | undefined, diff: WorktreeFileDiff, style: string) {
const cached = sizeKeys.get(diff)
if (
cached &&
cached.context === context &&
cached.style === style &&
cached.patch === diff.patch &&
cached.before === diff.before &&
cached.after === diff.after
)
return cached.key
const key = {}
sizeKeys.set(diff, { context, style, patch: diff.patch, before: diff.before, after: diff.after, key })
return key
}
// Keep each rendered row mounted while live detail refreshes replace its data.
// Otherwise Solid's keyed <For> remounts the row and deferred rendering swaps a
// previously rendered diff above the viewport for a short placeholder.
@@ -62,6 +62,7 @@ export const PermissionDock: Component<{
}
const text = (rule: string) => (command() ? label(rule) : describeRule(props.request.toolName, rule, language.t))
const external = () => props.request.toolName === "external_directory"
const sandboxEscalation = () => props.request.toolName === "sandbox_escalation"
const cmdDescription = () => {
const val = props.request.args?.description
return typeof val === "string" && val.length > 0 ? val : undefined
@@ -129,6 +130,7 @@ export const PermissionDock: Component<{
}
const title = () => {
if (sandboxEscalation()) return language.t("notification.permission.titleSandboxEscalation")
const skill = props.request.args?.skill
if (skillShell() && typeof skill === "string" && skill.length > 0)
// Escape the untrusted skill name so bidi/control chars can't reorder the header text.
@@ -287,50 +289,58 @@ export const PermissionDock: Component<{
</Show>
}
>
<Show
when={skillShellCommands().length > 0}
fallback={
<>
<Show when={cmdDescription()}>{(desc) => <div data-slot="permission-hint">{desc()}</div>}</Show>
<Show when={command()}>
{(cmd) => <PermissionCommand command={cmd()} plain={props.request.args.heredoc === true} />}
</Show>
{/* Everything above the buttons scrolls: a long command or a large diff must never
push Allow/Deny out of the clipped chat view. */}
<div data-slot="permission-scroll">
{/* Pierre's virtualizer uses the scroll root's first child as its content
container, so keep all variable-height permission content in one wrapper. */}
<div data-slot="permission-scroll-content">
<Show
when={skillShellCommands().length > 0}
fallback={
<>
<Show when={cmdDescription()}>{(desc) => <div data-slot="permission-hint">{desc()}</div>}</Show>
<Show when={command()}>
{(cmd) => <PermissionCommand command={cmd()} plain={props.request.args.heredoc === true} />}
</Show>
{(() => {
const desc = description()
if (!desc)
return !command() && toolDescription() ? (
<div data-slot="permission-hint">{toolDescription()}</div>
) : null
if (desc.kind === "single")
return (
<div
data-slot="permission-hint"
data-wrap={external() ? "" : undefined}
title={external() ? desc.text : undefined}
>
{desc.text}
</div>
)
return (
<div data-slot="permission-patterns">
<span data-slot="permission-patterns-title">{desc.title}</span>
<For each={desc.paths}>{(path) => <code data-slot="permission-pattern">{path}</code>}</For>
</div>
)
})()}
</>
}
>
{/* Verbatim commands (args.commands), control-char/bidi-escaped so the displayed command matches execution. */}
<For each={skillShellCommands()}>{(cmd) => <PermissionCommand command={displaySkillCommand(cmd)} />}</For>
</Show>
{(() => {
const desc = description()
if (!desc)
return !command() && toolDescription() ? (
<div data-slot="permission-hint">{toolDescription()}</div>
) : null
if (desc.kind === "single")
return (
<div
data-slot="permission-hint"
data-wrap={external() ? "" : undefined}
title={external() ? desc.text : undefined}
>
{desc.text}
</div>
)
return (
<div data-slot="permission-patterns">
<span data-slot="permission-patterns-title">{desc.title}</span>
<For each={desc.paths}>{(path) => <code data-slot="permission-pattern">{path}</code>}</For>
</div>
)
})()}
</>
}
>
{/* Verbatim commands (args.commands), control-char/bidi-escaped so the displayed command matches execution. */}
<For each={skillShellCommands()}>{(cmd) => <PermissionCommand command={displaySkillCommand(cmd)} />}</For>
</Show>
<Show when={diffs().length > 0}>
<div data-slot="permission-diffs" data-count={diffs().length}>
<For each={diffs()}>{(diff) => <PermissionDiff filediff={diff} />}</For>
<Show when={diffs().length > 0}>
<div data-slot="permission-diffs" data-count={diffs().length}>
<For each={diffs()}>{(diff) => <PermissionDiff filediff={diff} />}</For>
</div>
</Show>
</div>
</Show>
</div>
<div data-slot="permission-actions">
<Button
@@ -8,6 +8,7 @@ export const SessionTabMenu: ParentComponent<{
onFork?: () => void
onClose: () => void
onCloseOthers?: () => void
closeable?: boolean
closeShortcut?: JSX.Element
}> = (props) => {
const { t } = useLanguage()
@@ -23,13 +24,17 @@ export const SessionTabMenu: ParentComponent<{
<Icon name="fork" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.forkSession")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<Show when={props.closeable !== false || props.onCloseOthers}>
<ContextMenu.Separator />
</Show>
</Show>
<Show when={props.closeable !== false}>
<ContextMenu.Item onSelect={props.onClose}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
{props.closeShortcut}
</ContextMenu.Item>
</Show>
<ContextMenu.Item onSelect={props.onClose}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
{props.closeShortcut}
</ContextMenu.Item>
<Show when={props.onCloseOthers}>
<ContextMenu.Item onSelect={() => props.onCloseOthers?.()}>
<Icon name="close" size="small" />
@@ -18,6 +18,7 @@ import { useI18n } from "@kilocode/kilo-ui/context/i18n"
import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
import { useSession } from "../../context/session"
import { useVSCode } from "../../context/vscode"
import { useWorktreeMode } from "../../context/worktree-mode"
import { childID } from "../../context/session-utils"
import { taskResult, taskRunning, taskVisible } from "./task-tool-state"
@@ -26,6 +27,7 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
const language = useLanguage()
const session = useSession()
const vscode = useVSCode()
const worktree = useWorktreeMode()
const childSessionId = () =>
childID({
@@ -50,11 +52,18 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
}),
)
let synced: string | undefined
createEffect(() => {
const id = taskVisible(open(), childSessionId())
if (synced === id) return
if (synced) session.unsyncSession(synced)
synced = id
if (!id) return
session.syncSession(id)
})
onCleanup(() => {
if (synced) session.unsyncSession(synced)
})
const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }))
@@ -115,7 +124,16 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
e.stopPropagation()
const id = childSessionId()
if (!id) return
vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title: description() })
const title = description()
if (worktree) {
window.dispatchEvent(
new CustomEvent("agentManager.openSubagent", {
detail: { sessionID: id, title, parentSessionID: session.currentSessionID() },
}),
)
return
}
vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title })
}
const trigger = () => (
@@ -138,7 +156,7 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
icon="square-arrow-top-right"
size="small"
variant="ghost"
aria-label="Open sub-agent in tab"
aria-label={worktree ? "Open sub-agent in panel" : "Open sub-agent in tab"}
onClick={openInTab}
/>
</Show>
@@ -29,7 +29,8 @@ const HistoryView: Component<HistoryViewProps> = (props) => {
const dialog = useDialog()
const session = useSession()
const tabs = useLocalTabs()
const [tab, setTab] = createSignal<Source>("local")
const worktreeIds = () => props.worktreeSessionIds?.()
const [tab, setTab] = createSignal<Source>(worktreeIds() ? "worktree" : "local")
let local: HTMLButtonElement | undefined
let cloud: HTMLButtonElement | undefined
let worktree: HTMLButtonElement | undefined
@@ -37,8 +38,6 @@ const HistoryView: Component<HistoryViewProps> = (props) => {
let cloudPanel: HTMLDivElement | undefined
let worktreePanel: HTMLDivElement | undefined
const worktreeIds = () => props.worktreeSessionIds?.()
createEffect(() => {
if (tab() === "worktree" && !worktreeIds()) setTab("local")
})
@@ -776,7 +776,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
buildTriggerLabel(
activeModel()?.name,
activeModel()?.providerID,
activeModel()?.providerName,
props.value,
props.allowClear ?? false,
props.clearLabel ?? "",
@@ -216,7 +216,6 @@ export function stripSubProviderPrefix(name: string): string {
export function buildTriggerLabel(
resolvedName: string | undefined,
providerID: string | undefined,
providerName: string | undefined,
raw: ModelSelection | null,
allowClear: boolean,
clearLabel: string,
@@ -225,7 +224,6 @@ export function buildTriggerLabel(
): string {
if (resolvedName) {
if (providerID === KILO_GATEWAY_ID) return stripSubProviderPrefix(resolvedName)
if (providerName) return `${providerName} / ${resolvedName}`
return resolvedName
}
if (raw?.providerID && raw?.modelID) {
@@ -286,11 +286,13 @@ interface SessionContextValue {
clearCurrentSession: () => void
loadSessions: () => void
loadOlderMessages: () => boolean
selectSession: (id: string) => void
selectSession: (id: string, options?: { focus?: boolean }) => void
releaseSession: (id: string) => void
deleteSession: (id: string) => void
renameSession: (id: string, title: string) => void
exportSessionTranscript: (id: string) => void
syncSession: (sessionID: string) => void
syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void
unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void
// Cloud session preview
cloudPreviewId: Accessor<string | null>
@@ -2598,9 +2600,9 @@ export const SessionProvider: ParentComponent = (props) => {
// Session whose message fetch was deferred because the backend was offline at
// selection time. Replayed by the reconnect effect below.
let deferredFetch: string | undefined
let deferredFetch: { id: string; focus: boolean } | undefined
function selectSession(id: string) {
function selectSession(id: string, options: { focus?: boolean } = {}) {
// Cloud preview sessions use a separate keyed path (selectCloudSession).
if (id.startsWith("cloud:")) {
console.warn("[Kilo New] Cannot select cloud preview session via selectSession")
@@ -2621,15 +2623,26 @@ export const SessionProvider: ParentComponent = (props) => {
// load message is what re-focuses the backend (focusSession, contextSessionID,
// SSE tracking, active worktree) and runs the reconcile self-heal, so skipping
// it would leave the extension focused on the previously selected session.
const focus = options.focus !== false
if (!server.isConnected()) {
deferredFetch = id
deferredFetch = { id, focus }
return
}
deferredFetch = undefined
loadFocusedMessages(id, ready)
loadFocusedMessages(id, ready, focus)
}
function loadFocusedMessages(id: string, ready: boolean) {
function loadFocusedMessages(id: string, ready: boolean, focus = true) {
if (!focus) {
vscode.postMessage({
type: "loadMessages",
sessionID: id,
mode: "replace",
focus: false,
limit: MESSAGE_PAGE_LIMIT,
})
return
}
vscode.postMessage(
ready
? { type: "loadMessages", sessionID: id, mode: "focus" }
@@ -2644,10 +2657,10 @@ export const SessionProvider: ParentComponent = (props) => {
createEffect(
on(server.isConnected, (connected) => {
if (!connected) return
const id = deferredFetch
const pending = deferredFetch
deferredFetch = undefined
if (!id || id !== currentSessionID()) return
loadFocusedMessages(id, loaded().has(id))
if (!pending || pending.id !== currentSessionID()) return
loadFocusedMessages(pending.id, loaded().has(pending.id), pending.focus)
}),
)
@@ -2835,8 +2848,12 @@ export const SessionProvider: ParentComponent = (props) => {
vscode.postMessage({ type: "deleteMessage", sessionID, messageID })
}
function syncSession(sessionID: string) {
vscode.postMessage({ type: "syncSession", sessionID, parentSessionID: currentSessionID() })
function syncSession(sessionID: string, parentSessionID = currentSessionID(), scope: "task" | "inspector" = "task") {
vscode.postMessage({ type: "syncSession", sessionID, parentSessionID, scope })
}
function unsyncSession(sessionID: string, scope: "task" | "inspector" = "task") {
vscode.postMessage({ type: "unsyncSession", sessionID, scope })
}
const todos = () => {
@@ -3031,10 +3048,12 @@ export const SessionProvider: ParentComponent = (props) => {
loadSessions,
loadOlderMessages,
selectSession,
releaseSession: handleSessionDeleted,
deleteSession,
renameSession,
exportSessionTranscript,
syncSession,
unsyncSession,
cloudPreviewId,
selectCloudSession,
draftSessionID,
+1
View File
@@ -265,6 +265,7 @@ export const dict = {
"notification.permission.title": "مطلوب إذن",
"notification.permission.titleSubagent": "مطلوب إذن (وكيل فرعي)",
"notification.permission.titleSkillShell": "هل تريد تشغيل أوامر الصدفة من المهارة «{{skill}}»؟",
"notification.permission.titleSandboxEscalation": "السماح بعملية Git خارج البيئة المعزولة؟",
"ui.permission.manageAutoApprove": "إدارة قواعد الموافقة التلقائية",
"ui.permission.doomLoop.prompt": "تم اكتشاف حلقة محتملة في أداة {{tool}}. هل تريد متابعة التشغيل؟",
"ui.permission.doomLoop.rule": "متابعة استدعاءات {{tool}}",
+1
View File
@@ -275,6 +275,7 @@ export const dict = {
"notification.permission.title": "Permissão necessária",
"notification.permission.titleSubagent": "Permissão necessária (subagente)",
"notification.permission.titleSkillShell": "Executar comandos de shell da skill “{{skill}}”?",
"notification.permission.titleSandboxEscalation": "Permitir operação do Git fora da sandbox?",
"ui.permission.manageAutoApprove": "Gerenciar regras de aprovação automática",
"ui.permission.doomLoop.prompt": "Possível loop detectado na ferramenta {{tool}}. Continuar executando?",
"ui.permission.doomLoop.rule": "Continuar chamadas de {{tool}}",
+1
View File
@@ -273,6 +273,7 @@ export const dict = {
"notification.permission.title": "Potrebna dozvola",
"notification.permission.titleSubagent": "Potrebna dozvola (podagent)",
"notification.permission.titleSkillShell": "Pokrenuti shell komande iz vještine „{{skill}}”?",
"notification.permission.titleSandboxEscalation": "Dozvoliti Git operaciju izvan sandboxa?",
"ui.permission.manageAutoApprove": "Upravljanje pravilima automatskog odobravanja",
"ui.permission.doomLoop.prompt": "Otkrivena je moguća petlja za alat {{tool}}. Nastaviti izvršavanje?",
"ui.permission.doomLoop.rule": "Nastavi pozive alata {{tool}}",
+1
View File
@@ -272,6 +272,7 @@ export const dict = {
"notification.permission.title": "Tilladelse påkrævet",
"notification.permission.titleSubagent": "Tilladelse påkrævet (underagent)",
"notification.permission.titleSkillShell": "Kør shell-kommandoer fra færdigheden „{{skill}}“?",
"notification.permission.titleSandboxEscalation": "Tillad Git-handling uden for sandkassen?",
"ui.permission.manageAutoApprove": "Administrer regler for automatisk godkendelse",
"ui.permission.doomLoop.prompt": "Der blev registreret en mulig løkke for værktøjet {{tool}}. Fortsæt kørslen?",
"ui.permission.doomLoop.rule": "Fortsæt {{tool}}-kald",
@@ -281,6 +281,7 @@ export const dict = {
"notification.permission.title": "Berechtigung erforderlich",
"notification.permission.titleSubagent": "Berechtigung erforderlich (Subagent)",
"notification.permission.titleSkillShell": "Shell-Befehle aus dem Skill „{{skill}}“ ausführen?",
"notification.permission.titleSandboxEscalation": "Git-Vorgang außerhalb der Sandbox zulassen?",
"ui.permission.manageAutoApprove": "Regeln für automatische Genehmigung verwalten",
"ui.permission.doomLoop.prompt": "Potenzielle Schleife beim Tool {{tool}} erkannt. Weiter ausführen?",
"ui.permission.doomLoop.rule": "{{tool}}-Aufrufe fortsetzen",
@@ -270,6 +270,7 @@ export const dict = {
"notification.permission.title": "Permission required",
"notification.permission.titleSubagent": "Permission required (subagent)",
"notification.permission.titleSkillShell": 'Run shell commands from skill "{{skill}}"?',
"notification.permission.titleSandboxEscalation": "Allow Git operation outside the sandbox?",
"ui.permission.manageAutoApprove": "Manage Auto-Approve Rules",
"ui.permission.doomLoop.prompt": "Potential loop detected for the {{tool}} tool. Continue running?",
"ui.permission.doomLoop.rule": "Continue {{tool}} calls",
+1
View File
@@ -276,6 +276,7 @@ export const dict = {
"notification.permission.title": "Permiso requerido",
"notification.permission.titleSubagent": "Permiso requerido (subagente)",
"notification.permission.titleSkillShell": "¿Ejecutar comandos de shell de la habilidad «{{skill}}»?",
"notification.permission.titleSandboxEscalation": "¿Permitir la operación de Git fuera del entorno aislado?",
"ui.permission.manageAutoApprove": "Gestionar reglas de aprobación automática",
"ui.permission.doomLoop.prompt": "Se detectó un posible bucle en la herramienta {{tool}}. ¿Continuar ejecutando?",
"ui.permission.doomLoop.rule": "Continuar llamadas a {{tool}}",
+1
View File
@@ -270,6 +270,7 @@ export const dict = {
"notification.permission.title": "مجوز لازم است",
"notification.permission.titleSubagent": "مجوز مورد نیاز است (زیرعامل)",
"notification.permission.titleSkillShell": "دستورهای شل از مهارت «{{skill}}» اجرا شود؟",
"notification.permission.titleSandboxEscalation": "اجازه انجام عملیات Git خارج از sandbox داده شود؟",
"ui.permission.manageAutoApprove": "مدیریت قوانین تأیید خودکار",
"ui.permission.doomLoop.prompt": "حلقه احتمالی برای ابزار {{tool}} شناسایی شد. ادامه می‌دهید؟",
"ui.permission.doomLoop.rule": "ادامه فراخوانی‌های {{tool}}",
+1
View File
@@ -275,6 +275,7 @@ export const dict = {
"notification.permission.title": "Permission requise",
"notification.permission.titleSubagent": "Permission requise (sous-agent)",
"notification.permission.titleSkillShell": "Exécuter les commandes shell de la compétence «\u00a0{{skill}}\u00a0» ?",
"notification.permission.titleSandboxEscalation": "Autoriser lopération Git en dehors du bac à sable ?",
"ui.permission.manageAutoApprove": "Gérer les règles d'approbation automatique",
"ui.permission.doomLoop.prompt": "Boucle potentielle détectée pour loutil {{tool}}. Continuer lexécution ?",
"ui.permission.doomLoop.rule": "Continuer les appels à {{tool}}",
+1
View File
@@ -187,6 +187,7 @@ export const dict = {
"notification.permission.title": "Autorizzazione richiesta",
"notification.permission.titleSubagent": "Autorizzazione richiesta (sub-agent)",
"notification.permission.titleSkillShell": "Eseguire i comandi shell della skill “{{skill}}”?",
"notification.permission.titleSandboxEscalation": "Consentire l'operazione Git al di fuori della sandbox?",
"ui.permission.manageAutoApprove": "Gestisci regole approvazione automatica",
"ui.permission.doomLoop.prompt": "Rilevato un potenziale ciclo nello strumento {{tool}}. Continuare l'esecuzione?",
"ui.permission.doomLoop.rule": "Continua le chiamate a {{tool}}",
+1
View File
@@ -272,6 +272,7 @@ export const dict = {
"notification.permission.title": "権限が必要です",
"notification.permission.titleSubagent": "権限が必要です(サブエージェント)",
"notification.permission.titleSkillShell": "スキル「{{skill}}」のシェルコマンドを実行しますか?",
"notification.permission.titleSandboxEscalation": "サンドボックス外での Git 操作を許可しますか?",
"ui.permission.manageAutoApprove": "自動承認ルールを管理",
"ui.permission.doomLoop.prompt": "{{tool}} ツールでループの可能性が検出されました。実行を続行しますか?",
"ui.permission.doomLoop.rule": "{{tool}} の呼び出しを続行",
+1
View File
@@ -273,6 +273,7 @@ export const dict = {
"notification.permission.title": "권한 필요",
"notification.permission.titleSubagent": "권한 필요 (서브에이전트)",
"notification.permission.titleSkillShell": '스킬 "{{skill}}"의 셸 명령을 실행할까요?',
"notification.permission.titleSandboxEscalation": "샌드박스 외부에서 Git 작업을 허용할까요?",
"ui.permission.manageAutoApprove": "자동 승인 규칙 관리",
"ui.permission.doomLoop.prompt": "{{tool}} 도구에서 잠재적인 반복 실행이 감지되었습니다. 계속 실행하시겠습니까?",
"ui.permission.doomLoop.rule": "{{tool}} 호출 계속",
+1
View File
@@ -276,6 +276,7 @@ export const dict = {
"notification.permission.title": "Toestemming vereist",
"notification.permission.titleSubagent": "Toestemming vereist (subagent)",
"notification.permission.titleSkillShell": "Shell-opdrachten uit vaardigheid “{{skill}}” uitvoeren?",
"notification.permission.titleSandboxEscalation": "Git-bewerking buiten de sandbox toestaan?",
"ui.permission.manageAutoApprove": "Beheer automatisch goedkeuren regels",
"ui.permission.doomLoop.prompt": "Mogelijke lus gedetecteerd voor het hulpmiddel {{tool}}. Doorgaan met uitvoeren?",
"ui.permission.doomLoop.rule": "Doorgaan met {{tool}}-aanroepen",
+1
View File
@@ -279,6 +279,7 @@ export const dict = {
"notification.permission.title": "Tillatelse påkrevd",
"notification.permission.titleSubagent": "Tillatelse påkrevd (underagent)",
"notification.permission.titleSkillShell": "Kjøre skallkommandoer fra ferdigheten «{{skill}}»?",
"notification.permission.titleSandboxEscalation": "Tillate Git-operasjon utenfor sandkassen?",
"ui.permission.manageAutoApprove": "Administrer regler for automatisk godkjenning",
"ui.permission.doomLoop.prompt": "Mulig løkke oppdaget for verktøyet {{tool}}. Fortsette kjøringen?",
"ui.permission.doomLoop.rule": "Fortsett {{tool}}-kall",
+1
View File
@@ -273,6 +273,7 @@ export const dict = {
"notification.permission.title": "Wymagane uprawnienie",
"notification.permission.titleSubagent": "Wymagane uprawnienie (podagent)",
"notification.permission.titleSkillShell": "Uruchomić polecenia powłoki z umiejętności „{{skill}}”?",
"notification.permission.titleSandboxEscalation": "Zezwolić na operację Git poza piaskownicą?",
"ui.permission.manageAutoApprove": "Zarządzaj regułami automatycznego zatwierdzania",
"ui.permission.doomLoop.prompt": "Wykryto potencjalną pętlę dla narzędzia {{tool}}. Kontynuować działanie?",
"ui.permission.doomLoop.rule": "Kontynuuj wywołania {{tool}}",
+1
View File
@@ -270,6 +270,7 @@ export const dict = {
"notification.permission.title": "Требуется разрешение",
"notification.permission.titleSubagent": "Требуется разрешение (субагент)",
"notification.permission.titleSkillShell": "Выполнить команды оболочки из навыка «{{skill}}»?",
"notification.permission.titleSandboxEscalation": "Разрешить операцию Git за пределами песочницы?",
"ui.permission.manageAutoApprove": "Управление правилами автоодобрения",
"ui.permission.doomLoop.prompt":
"Обнаружен потенциальный цикл при работе инструмента {{tool}}. Продолжить выполнение?",
+1
View File
@@ -270,6 +270,7 @@ export const dict = {
"notification.permission.title": "ต้องการสิทธิ์",
"notification.permission.titleSubagent": "ต้องการสิทธิ์ (ตัวแทนย่อย)",
"notification.permission.titleSkillShell": 'เรียกใช้คำสั่งเชลล์จากสกิล "{{skill}}" หรือไม่?',
"notification.permission.titleSandboxEscalation": "อนุญาตการดำเนินการ Git นอกแซนด์บ็อกซ์หรือไม่?",
"ui.permission.manageAutoApprove": "จัดการกฎการอนุมัติอัตโนมัติ",
"ui.permission.doomLoop.prompt": "ตรวจพบการวนซ้ำที่อาจเกิดขึ้นในเครื่องมือ {{tool}} ต้องการดำเนินการต่อหรือไม่",
"ui.permission.doomLoop.rule": "เรียกใช้ {{tool}} ต่อไป",
+2
View File
@@ -271,6 +271,8 @@ export const dict = {
"notification.permission.title": "İzin gerekli",
"notification.permission.titleSubagent": "İzin gerekli (alt ajan)",
"notification.permission.titleSkillShell": "“{{skill}}” becerisindeki kabuk komutları çalıştırılsın mı?",
"notification.permission.titleSandboxEscalation":
"Git işleminin korumalı alan dışında gerçekleştirilmesine izin verilsin mi?",
"ui.permission.manageAutoApprove": "Otomatik Onay Kurallarını Yönet",
"ui.permission.doomLoop.prompt": "{{tool}} aracında olası bir döngü algılandı. Çalıştırmaya devam edilsin mi?",
"ui.permission.doomLoop.rule": "{{tool}} çağrılarına devam et",
+1
View File
@@ -274,6 +274,7 @@ export const dict = {
"notification.permission.title": "Потрібен дозвіл",
"notification.permission.titleSubagent": "Потрібен дозвіл (підагент)",
"notification.permission.titleSkillShell": "Виконати команди оболонки з навички «{{skill}}»?",
"notification.permission.titleSandboxEscalation": "Дозволити операцію Git за межами пісочниці?",
"ui.permission.manageAutoApprove": "Керувати правилами автоматичного схвалення",
"ui.permission.doomLoop.prompt":
"Виявлено потенційний цикл під час роботи інструмента {{tool}}. Продовжити виконання?",

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