mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge remote-tracking branch 'origin/main' into abalone-bactrosaurus
# Conflicts: # packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts # packages/kilo-vscode/src/agent-manager/host.ts # packages/kilo-vscode/src/agent-manager/vscode-host.ts # packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Make Cmd/Ctrl+/ toggle the Agent Manager terminal even when the webview keybinding forwarding drops the key while the prompt input is focused, and stop it from triggering the Agent Manager terminal while the Kilo sidebar is focused.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Run Agent Manager project scripts in the terminal selected by the existing toolbar dropdown. Agent Manager panel uses the named side terminal, while VS Code terminal retains the integrated task flow.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-telemetry": patch
|
||||
---
|
||||
|
||||
Reduce CLI startup time by deferring Kilo-specific module loading until commands actually run, caching the telemetry profile lookup across invocations, and uploading telemetry in the background so process exit is not delayed by a network round trip
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix settings snapping back to their previous value after being cleared to "Not set" when multiple config files exist (e.g. both `kilo.json` and `kilo.jsonc`)
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Allow users to enable web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix the `@` "Past chats" picker in Agent Manager showing only the current session's directory. It now lists previous sessions across the whole worktree family — the local workspace and every Agent Manager worktree — each labeled with its worktree name, matching the Agent Manager session search. Any listed session can be attached as context, including chats from other worktrees of the same repository.
|
||||
@@ -0,0 +1,179 @@
|
||||
import { spawn } from "child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import type { Proc } from "../../pty/pty"
|
||||
import { Log } from "../../util/log"
|
||||
|
||||
const log = Log.create({ service: "pty.termination" })
|
||||
const GRACE_MS = 200
|
||||
const SPAWN_TIMEOUT_MS = 5_000
|
||||
|
||||
export type Process = Pick<Proc, "pid" | "onExit" | "kill">
|
||||
|
||||
export type Runtime = {
|
||||
readonly platform: NodeJS.Platform
|
||||
readonly taskkill: (
|
||||
file: string,
|
||||
args: string[],
|
||||
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
|
||||
) => Promise<boolean>
|
||||
readonly tree: () => Promise<Array<{ pid: number; parent: number }>>
|
||||
readonly alive: (pid: number) => boolean
|
||||
readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void
|
||||
readonly sleep: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
const runtime: Runtime = {
|
||||
platform: process.platform,
|
||||
taskkill,
|
||||
tree,
|
||||
alive: (pid) => {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
signal: (pid, signal) => process.kill(pid, signal),
|
||||
sleep,
|
||||
}
|
||||
|
||||
function direct(proc: Process, signal?: "SIGTERM" | "SIGKILL") {
|
||||
try {
|
||||
proc.kill(signal)
|
||||
} catch (err) {
|
||||
log.warn("failed to kill PTY directly", { err, pid: proc.pid, signal })
|
||||
}
|
||||
}
|
||||
|
||||
function descendants(root: number, rows: Array<{ pid: number; parent: number }>) {
|
||||
const children = new Map<number, number[]>()
|
||||
for (const row of rows) {
|
||||
const list = children.get(row.parent) ?? []
|
||||
list.push(row.pid)
|
||||
children.set(row.parent, list)
|
||||
}
|
||||
const seen = new Set<number>()
|
||||
const collect = (pid: number): number[] => {
|
||||
const result: number[] = []
|
||||
for (const child of children.get(pid) ?? []) {
|
||||
if (seen.has(child)) continue
|
||||
seen.add(child)
|
||||
result.push(...collect(child), child)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return collect(root)
|
||||
}
|
||||
|
||||
async function family(root: number, input: Runtime) {
|
||||
const rows = await input.tree().catch((err) => {
|
||||
log.debug("failed to inspect PTY process tree", { err, pid: root })
|
||||
return []
|
||||
})
|
||||
return [...descendants(root, rows), root]
|
||||
}
|
||||
|
||||
function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", input: Runtime) {
|
||||
for (const pid of pids) {
|
||||
let sent = false
|
||||
for (const target of [-pid, pid]) {
|
||||
try {
|
||||
input.signal(target, value)
|
||||
sent = true
|
||||
} catch (err) {
|
||||
log.debug("failed to signal PTY process", { err, pid: target, signal: value })
|
||||
}
|
||||
}
|
||||
if (pid === proc.pid && !sent) direct(proc, value)
|
||||
}
|
||||
}
|
||||
|
||||
async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) {
|
||||
return await new Promise<Array<{ pid: number; parent: number }>>((resolve) => {
|
||||
try {
|
||||
const child = spawn(file, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: "SIGKILL",
|
||||
})
|
||||
const chunks: Buffer[] = []
|
||||
child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk))
|
||||
child.once("error", () => resolve([]))
|
||||
child.once("close", (code) => {
|
||||
if (code !== 0) return resolve([])
|
||||
const rows = Buffer.concat(chunks)
|
||||
.toString("utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => line.trim().split(/\s+/).map(Number))
|
||||
.filter(([pid, parent]) => Number.isSafeInteger(pid) && Number.isSafeInteger(parent))
|
||||
.map(([pid, parent]) => ({ pid: pid!, parent: parent! }))
|
||||
resolve(rows)
|
||||
})
|
||||
} catch {
|
||||
resolve([])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function taskkill(
|
||||
file: string,
|
||||
args: string[],
|
||||
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
|
||||
) {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
try {
|
||||
const child = spawn(file, args, opts)
|
||||
child.once("exit", (code) => resolve(code === 0))
|
||||
child.once("error", (err) => {
|
||||
log.warn("taskkill failed", { err })
|
||||
resolve(false)
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn("failed to start taskkill", { err })
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function terminate(proc: Process, input: Runtime = runtime): Promise<void> {
|
||||
const state = { exited: false }
|
||||
const listener = proc.onExit(() => {
|
||||
state.exited = true
|
||||
})
|
||||
try {
|
||||
if (!proc.pid) {
|
||||
direct(proc)
|
||||
if (!state.exited) await input.sleep(GRACE_MS)
|
||||
return
|
||||
}
|
||||
|
||||
if (input.platform === "win32") {
|
||||
const killed = await input.taskkill("taskkill", ["/pid", String(proc.pid), "/f", "/t"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
})
|
||||
if (!killed && !state.exited) direct(proc)
|
||||
if (!state.exited) await input.sleep(GRACE_MS)
|
||||
return
|
||||
}
|
||||
|
||||
const initial = await family(proc.pid, input)
|
||||
signal(proc, initial, "SIGTERM", input)
|
||||
await input.sleep(GRACE_MS)
|
||||
const remaining = new Set(initial.filter(input.alive))
|
||||
if (input.alive(proc.pid)) for (const pid of await family(proc.pid, input)) remaining.add(pid)
|
||||
if (remaining.size > 0) {
|
||||
signal(proc, [...remaining], "SIGKILL", input)
|
||||
await input.sleep(GRACE_MS)
|
||||
}
|
||||
} finally {
|
||||
listener.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export * as KiloPtyTermination from "./termination"
|
||||
+37
-24
@@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema" // kilocode_change
|
||||
import { Shell } from "./shell"
|
||||
import { lazy } from "./util/lazy"
|
||||
import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change
|
||||
import { KiloPtyTermination } from "./kilocode/pty/termination" // kilocode_change
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
|
||||
@@ -35,6 +36,7 @@ type Active = {
|
||||
cursor: number
|
||||
subscribers: Map<object, Subscriber>
|
||||
listeners: Disp[]
|
||||
stopping: boolean // kilocode_change
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
@@ -83,6 +85,8 @@ export type AttachInput = {
|
||||
readonly onData: (chunk: string) => void
|
||||
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
|
||||
readonly onEnd: (event: { exitCode?: number }) => void
|
||||
// Canonical routes can replay retained output after exit; legacy callers retain the former error.
|
||||
readonly allowExited?: boolean // kilocode_change
|
||||
}
|
||||
|
||||
export type Attachment = {
|
||||
@@ -147,23 +151,25 @@ export const layer = Layer.effect(
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
function teardown(session: Active) {
|
||||
// kilocode_change start - terminate the complete PTY tree before reporting removal.
|
||||
async function teardown(session: Active) {
|
||||
session.stopping = true
|
||||
if (session.info.status === "running") await KiloPtyTermination.terminate(session.process)
|
||||
for (const listener of session.listeners) listener.dispose()
|
||||
session.listeners.length = 0
|
||||
if (session.info.status === "running") {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
}
|
||||
notifyEnd(session, {})
|
||||
notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {})
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const session of sessions.values()) teardown(session)
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
yield* Effect.addFinalizer(
|
||||
() =>
|
||||
// kilocode_change start - wait for process-tree termination during async service teardown.
|
||||
Effect.promise(async () => {
|
||||
await Promise.all(Array.from(sessions.values()).map(teardown))
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
// kilocode_change end
|
||||
)
|
||||
|
||||
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
|
||||
@@ -173,14 +179,18 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
yield* Effect.logInfo("removing session", { id })
|
||||
teardown(session)
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
// kilocode_change start - removal and its deleted event are one uninterruptible lifecycle transition.
|
||||
yield* Effect.gen(function* () {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
yield* Effect.logInfo("removing session", { id })
|
||||
yield* Effect.promise(() => teardown(session))
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
}).pipe(Effect.uninterruptible)
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
@@ -204,9 +214,10 @@ export const layer = Layer.effect(
|
||||
args: input.args ? [...input.args] : undefined,
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const implicit = !resolved.command
|
||||
const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
|
||||
const base = resolved.args ?? []
|
||||
const args = Shell.login(command) ? [...base, "-l"] : [...base]
|
||||
const args = implicit && Shell.login(command) ? [...base, "-l"] : [...base]
|
||||
const cwd = resolved.cwd || location.directory
|
||||
// kilocode_change end
|
||||
const env = {
|
||||
@@ -246,6 +257,7 @@ export const layer = Layer.effect(
|
||||
cursor: 0,
|
||||
subscribers: new Map(),
|
||||
listeners: [],
|
||||
stopping: false, // kilocode_change
|
||||
}
|
||||
sessions.set(id, session)
|
||||
session.listeners.push(
|
||||
@@ -269,7 +281,7 @@ export const layer = Layer.effect(
|
||||
session.bufferCursor += excess
|
||||
}),
|
||||
proc.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
if (session.info.status === "exited" || session.stopping) return // kilocode_change
|
||||
session.info.status = "exited"
|
||||
session.info.exitCode = exitCode
|
||||
notifyEnd(session, { exitCode })
|
||||
@@ -309,7 +321,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
|
||||
if (session.info.status !== "running" && !input.allowExited) return yield* new ExitedError({ ptyID: id }) // kilocode_change
|
||||
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
|
||||
const token = {}
|
||||
const subscriber: Subscriber = {
|
||||
@@ -318,6 +330,7 @@ export const layer = Layer.effect(
|
||||
active: false,
|
||||
detached: false,
|
||||
pending: [],
|
||||
end: session.info.status === "exited" ? { exitCode: session.info.exitCode } : undefined, // kilocode_change
|
||||
}
|
||||
session.subscribers.set(token, subscriber)
|
||||
const start = session.bufferCursor
|
||||
|
||||
@@ -229,6 +229,9 @@ export const Info = Schema.Struct({
|
||||
layout: Schema.optional(ConfigLayoutV1.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||
permission: Schema.optional(ConfigPermissionV1.Info),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
web_search: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Make web search available to models from all providers (default: false)",
|
||||
}), // kilocode_change
|
||||
attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({
|
||||
description: "Attachment processing configuration, including image size limits and resizing behavior",
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { KiloPtyTermination } from "../../src/kilocode/pty/termination"
|
||||
|
||||
function fake(pid = 123) {
|
||||
const calls: Array<string | undefined> = []
|
||||
const proc: KiloPtyTermination.Process = {
|
||||
pid,
|
||||
onExit: () => ({ dispose() {} }),
|
||||
kill: (signal) => calls.push(signal),
|
||||
}
|
||||
return { proc, calls }
|
||||
}
|
||||
|
||||
function runtime(
|
||||
platform: NodeJS.Platform,
|
||||
input: {
|
||||
taskkill?: boolean
|
||||
signal?: "throw"
|
||||
tree?: Array<{ pid: number; parent: number }>
|
||||
} = {},
|
||||
) {
|
||||
const tasks: Array<{
|
||||
file: string
|
||||
args: string[]
|
||||
opts: { stdio: "ignore"; windowsHide: true; timeout: number }
|
||||
}> = []
|
||||
const signals: Array<{ pid: number; signal: "SIGTERM" | "SIGKILL" }> = []
|
||||
const sleeps: number[] = []
|
||||
const value: KiloPtyTermination.Runtime = {
|
||||
platform,
|
||||
taskkill: async (file, args, opts) => {
|
||||
tasks.push({ file, args, opts })
|
||||
return input.taskkill ?? true
|
||||
},
|
||||
tree: async () => input.tree ?? [],
|
||||
alive: () => true,
|
||||
signal: (pid, signal) => {
|
||||
signals.push({ pid, signal })
|
||||
if (input.signal === "throw") throw new Error("process group unavailable")
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms)
|
||||
},
|
||||
}
|
||||
return { value, tasks, signals, sleeps }
|
||||
}
|
||||
|
||||
describe("pty process-tree termination", () => {
|
||||
test("uses hidden taskkill for Windows process trees", async () => {
|
||||
const item = fake(42)
|
||||
const input = runtime("win32")
|
||||
|
||||
await KiloPtyTermination.terminate(item.proc, input.value)
|
||||
|
||||
expect(input.tasks).toEqual([
|
||||
{
|
||||
file: "taskkill",
|
||||
args: ["/pid", "42", "/f", "/t"],
|
||||
opts: { stdio: "ignore", windowsHide: true, timeout: 5_000 },
|
||||
},
|
||||
])
|
||||
expect(input.signals).toEqual([])
|
||||
expect(item.calls).toEqual([])
|
||||
expect(input.sleeps).toEqual([200])
|
||||
})
|
||||
|
||||
test("signals POSIX process groups before escalating", async () => {
|
||||
const item = fake(42)
|
||||
const input = runtime("linux")
|
||||
|
||||
await KiloPtyTermination.terminate(item.proc, input.value)
|
||||
|
||||
expect(input.signals).toEqual([
|
||||
{ pid: -42, signal: "SIGTERM" },
|
||||
{ pid: 42, signal: "SIGTERM" },
|
||||
{ pid: -42, signal: "SIGKILL" },
|
||||
{ pid: 42, signal: "SIGKILL" },
|
||||
])
|
||||
expect(item.calls).toEqual([])
|
||||
expect(input.sleeps).toEqual([200, 200])
|
||||
})
|
||||
|
||||
test("falls back to direct PTY signals when a process group is unavailable", async () => {
|
||||
const item = fake(42)
|
||||
const input = runtime("darwin", { signal: "throw" })
|
||||
|
||||
await KiloPtyTermination.terminate(item.proc, input.value)
|
||||
|
||||
expect(item.calls).toEqual(["SIGTERM", "SIGKILL"])
|
||||
})
|
||||
|
||||
test("signals descendants that run in separate process groups", async () => {
|
||||
const item = fake(42)
|
||||
const input = runtime("linux", {
|
||||
tree: [
|
||||
{ pid: 43, parent: 42 },
|
||||
{ pid: 44, parent: 43 },
|
||||
],
|
||||
})
|
||||
|
||||
await KiloPtyTermination.terminate(item.proc, input.value)
|
||||
|
||||
expect(input.signals).toContainEqual({ pid: -44, signal: "SIGTERM" })
|
||||
expect(input.signals).toContainEqual({ pid: 44, signal: "SIGKILL" })
|
||||
expect(input.signals).toContainEqual({ pid: -43, signal: "SIGTERM" })
|
||||
expect(input.signals).toContainEqual({ pid: 43, signal: "SIGKILL" })
|
||||
})
|
||||
})
|
||||
@@ -127,6 +127,43 @@ describe("pty", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// kilocode_change start - explicit commands must not acquire implicit login-shell arguments.
|
||||
ptyTest("preserves explicit command arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const args = ["-c", 'printf "<%s>" "$0"; sleep 5']
|
||||
const info = yield* createPty("sh", args)
|
||||
expect(info.args).toEqual(args)
|
||||
|
||||
const attached = yield* attachCollecting(info.id)
|
||||
expect(yield* waitForOutput(attached.output, "<sh>")).toContain("<sh>")
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("terminates background descendants outside the shell process group", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("sh", ["-c", 'sleep 30 & printf "<CHILD:%s>" "$!"; wait'])
|
||||
const attached = yield* attachCollecting(info.id)
|
||||
const output = yield* waitForOutput(attached.output, ">")
|
||||
const match = output.match(/<CHILD:(\d+)>/)
|
||||
expect(match?.[1]).toBeDefined()
|
||||
const pid = Number(match?.[1])
|
||||
|
||||
yield* pty.remove(info.id)
|
||||
yield* Effect.sleep("100 millis")
|
||||
const alive = yield* Effect.sync(() => {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
expect(alive).toBe(false)
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
ptyTest("replays buffered output and streams live output to attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
@@ -201,6 +238,31 @@ describe("pty", () => {
|
||||
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id })
|
||||
}),
|
||||
)
|
||||
|
||||
// kilocode_change start - canonical attachments replay retained exited output, then end without accepting input.
|
||||
ptyTest("replays exited output and ends when enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const events = yield* subscribePtyEvents()
|
||||
const info = yield* createPty("sh", ["-c", 'printf "replayed"; exit 7'])
|
||||
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"])
|
||||
|
||||
const ended = yield* Deferred.make<{ exitCode?: number }>()
|
||||
const attachment = yield* pty.attach(info.id, {
|
||||
allowExited: true,
|
||||
onData: () => {},
|
||||
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
|
||||
})
|
||||
expect(attachment.replay).toContain("replayed")
|
||||
|
||||
attachment.write("ignored")
|
||||
yield* pty.remove(info.id)
|
||||
attachment.activate()
|
||||
expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 })
|
||||
attachment.detach()
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console"
|
||||
import { Button } from "@kilocode/kilo-web-ui/button"
|
||||
import { Card } from "@kilocode/kilo-web-ui/card"
|
||||
import { SearchField } from "../../components/SearchField"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { toolCapabilities, toolName } from "../../shared/utils"
|
||||
import { ConfigCountTag as CountTag, ConfigPage } from "./ConfigPage"
|
||||
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
|
||||
|
||||
export function ToolsRoute() {
|
||||
const ctx = useConfig()
|
||||
const [search, setSearch] = createSignal("")
|
||||
const snap = () => ctx.data()
|
||||
const websearch = createMemo(() => snap()?.overlay.fields.web_search)
|
||||
const searchEnabled = createMemo(() => websearch()?.value === true)
|
||||
const rows = createMemo(() => {
|
||||
const data = snap()
|
||||
if (!data) return []
|
||||
@@ -52,6 +56,48 @@ export function ToolsRoute() {
|
||||
}
|
||||
description="Built-in tools available to agents, including file access, terminal execution, search, fetch, and orchestration tools."
|
||||
>
|
||||
<Card class="ui-card" padding={0}>
|
||||
<header class="ui-card-header">
|
||||
<div>
|
||||
<h2>Web search</h2>
|
||||
<p>Control web search availability for models from providers that do not enable it by default.</p>
|
||||
</div>
|
||||
<Show when={ctx.query()?.scope === "project" && websearch()?.overridden}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={Boolean(ctx.saving())}
|
||||
onClick={() => ctx.unset([["web_search"]])}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
</Show>
|
||||
</header>
|
||||
<div class="ui-form">
|
||||
<button
|
||||
class="ui-toggle"
|
||||
classList={{ selected: searchEnabled() }}
|
||||
type="button"
|
||||
aria-pressed={searchEnabled()}
|
||||
disabled={Boolean(ctx.saving()) || websearch()?.editable === false}
|
||||
onClick={() => ctx.save({ web_search: !searchEnabled() })}
|
||||
>
|
||||
<span>
|
||||
<strong>Enable for all providers</strong>
|
||||
<small>Search requests use Exa or Parallel.</small>
|
||||
<Show when={websearch()?.reason}>{(reason) => <small>{reason()}</small>}</Show>
|
||||
</span>
|
||||
<span class="tags">
|
||||
<SourceBadge
|
||||
source={websearch()?.source}
|
||||
inherited={websearch()?.inherited}
|
||||
overridden={websearch()?.overridden}
|
||||
/>
|
||||
<Tag tone={searchEnabled() ? "success" : "neutral"}>{searchEnabled() ? "On" : "Off"}</Tag>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<SearchField
|
||||
label="Filter tools"
|
||||
value={search()}
|
||||
|
||||
@@ -46,6 +46,7 @@ export const AiProvidersNav: NavSection[] = [
|
||||
{ href: "/ai-providers/groq", children: "Groq" },
|
||||
{ href: "/ai-providers/cerebras", children: "Cerebras" },
|
||||
{ href: "/ai-providers/fireworks", children: "Fireworks AI" },
|
||||
{ href: "/ai-providers/mixlayer", children: "Mixlayer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "Using Mixlayer with Kilo Code | Fast Open-Model Inference"
|
||||
description: "Run open models like GLM and Qwen on Mixlayer's OpenAI-compatible API in Kilo Code. Setup guide for VS Code and the CLI."
|
||||
---
|
||||
|
||||
# Using Mixlayer With Kilo Code
|
||||
|
||||
Mixlayer is an inference platform for open models such as GLM and Qwen, with a serving stack built from scratch by core contributors to Candle. It exposes an OpenAI-compatible API and is available as a built-in provider in Kilo Code.
|
||||
|
||||
**Website:** [https://mixlayer.com/](https://mixlayer.com/)
|
||||
|
||||
## Getting an API Key
|
||||
|
||||
1. **Sign Up/Sign In:** Go to [Mixlayer](https://mixlayer.com/) and create an account or sign in.
|
||||
2. **Navigate to API Keys:** Open the [Mixlayer console](https://console.mixlayer.com/) and go to the API Keys page.
|
||||
3. **Create a Key:** Click **New Key**, give it a descriptive name (e.g., "Kilo Code"), and copy it. You will not be able to view it again.
|
||||
|
||||
## Configuration in Kilo Code
|
||||
|
||||
Mixlayer is available as a **built-in provider** in Kilo Code, so you can connect it directly — no custom provider setup needed.
|
||||
|
||||
{% tabs %}
|
||||
{% tab label="VSCode" %}
|
||||
|
||||
1. Open **Settings** (gear icon) and go to the **Providers** tab.
|
||||
2. Click **Connect provider**, search for **Mixlayer**, and select it.
|
||||
3. Enter your Mixlayer API key.
|
||||
4. Pick a model — Kilo Code fetches the available models automatically.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
**Method 1 — `/connect` (recommended)**
|
||||
|
||||
Run `kilo`, then use the `/connect` command, select **Mixlayer**, and paste your API key when prompted:
|
||||
|
||||
```bash
|
||||
kilo
|
||||
# then, inside Kilo, run:
|
||||
/connect
|
||||
```
|
||||
|
||||
**Method 2 — config file**
|
||||
|
||||
Set your API key and add Mixlayer in your `kilo.json` config file (`~/.config/kilo/kilo.json` or `./kilo.json`):
|
||||
|
||||
```bash
|
||||
export MIXLAYER_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"provider": {
|
||||
"mixlayer": {
|
||||
"env": ["MIXLAYER_API_KEY"],
|
||||
},
|
||||
},
|
||||
"model": "mixlayer/z-ai/glm-5.2",
|
||||
}
|
||||
```
|
||||
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
## Models
|
||||
|
||||
Mixlayer serves open models including:
|
||||
|
||||
- `z-ai/glm-5.2` — 256K context
|
||||
- `qwen/qwen3.5-397b-a17b` and the Qwen 3.5 / 3.6 line (vision-capable)
|
||||
- `moonshotai/kimi-k2.7-code`
|
||||
|
||||
Tool calling and reasoning are supported across the model line. See the [Mixlayer docs](https://docs.mixlayer.com) for the full, current model list and supported parameters.
|
||||
|
||||
## Tips and Notes
|
||||
|
||||
- **Model list:** Kilo Code auto-detects available models from Mixlayer's `/v1/models` endpoint, so the picker stays current with your account.
|
||||
- **Pricing:** See the [Mixlayer console](https://console.mixlayer.com/) for current per-model pricing.
|
||||
- **Reasoning:** Qwen models support a thinking mode; reasoning tokens count against the output budget, so give responses enough room when reasoning is enabled.
|
||||
@@ -341,10 +341,12 @@ Two extra variables are injected into the script's environment:
|
||||
|
||||
### Using the run button
|
||||
|
||||
- **Run:** Click the play button in the toolbar or press `Cmd+E` (macOS) / `Ctrl+E` (Windows/Linux). Output appears in a dedicated VS Code task panel.
|
||||
- **Run:** Click the play button in the toolbar or press `Cmd+E` (macOS) / `Ctrl+E` (Windows/Linux). Output appears in a named `Run` tab in the Agent Manager terminal panel and remains available after the script exits.
|
||||
- **Stop:** Click the stop button (same position) or press `Cmd+E` again while running.
|
||||
- **Configure:** Click the dropdown arrow next to the run button and select "Configure run script" to open the script in your editor.
|
||||
|
||||
The terminal destination dropdown in the Agent Manager toolbar also controls where the script runs. **Agent Manager panel** uses the named side terminal, while **VS Code terminal** runs it as a task in the integrated terminal. The integrated terminal option is kept for comparison and will be removed in a future release.
|
||||
|
||||
## Session State and Persistence
|
||||
|
||||
Agent Manager state is persisted in `.kilo/agent-manager.json`. It stores worktrees, sections, session tabs, ordering, collapsed state, diff preferences, and cached PR metadata. Git branches and worktree directories remain on disk separately.
|
||||
|
||||
@@ -36,6 +36,16 @@ Before using Cloud Agents:
|
||||
|
||||
Your work is always pushed to GitHub, ensuring nothing is lost.
|
||||
|
||||
## Starting Tasks from the CLI
|
||||
|
||||
Use the `kilo cloud` command to run Cloud Agent tasks without opening the browser:
|
||||
|
||||
```bash
|
||||
kilo cloud start --prompt "Fix the flaky login test" --repo Kilo-Org/kilocode
|
||||
```
|
||||
|
||||
`kilo cloud` can start tasks, send follow-up prompts, and check task status and results. Repository, branch, model, mode, and organization are inferred from your local checkout and CLI defaults unless you pass the matching flags. Add `--stream` to `kilo cloud start` to print task events as JSONL until the task completes. See the [CLI reference](/docs/code-with-ai/platforms/cli-reference#kilo-cloud) for all commands and options.
|
||||
|
||||
## How Cloud Agents Work
|
||||
|
||||
- Each user receives an **isolated Linux container** with common dev tools preinstalled (Node.js, git, gh CLI, glab CLI, etc.).
|
||||
@@ -96,7 +106,7 @@ You can customize each Cloud Agent session by also defining env vars and startup
|
||||
|
||||
## Skills
|
||||
|
||||
Cloud Agents support project-level [skills](/docs/code-with-ai/platforms/cli#skills) stored in your repository. When your repo is cloned, any skills in `.kilocode/skills/` are automatically available.
|
||||
Cloud Agents support project-level [skills](/docs/code-with-ai/platforms/cli#skills) stored in your repository. When your repo is cloned, any skills in `.kilocode/skills/` are automatically available. Skill folders are uploaded as `.zip` archives, with up to 40 companion files per skill.
|
||||
|
||||
{% callout type="note" %}
|
||||
Global skills (`~/.kilocode/skills/`) are not available in Cloud Agents since there is no persistent user home directory.
|
||||
|
||||
@@ -29,6 +29,8 @@ This is especially useful for complex configuration like custom model definition
|
||||
|
||||
Kilo reads JSONC config from a **global** location (`~/.config/kilo/kilo.jsonc`) and from your **project** (`kilo.jsonc`, or `.kilo/kilo.jsonc`). All clients — CLI, VS Code, and JetBrains — read the same files.
|
||||
|
||||
If `kilo.json` or the legacy `opencode.json`, `opencode.jsonc`, or `config.json` files exist in the same locations, Kilo reads and deep-merges them as well. Clearing a setting in the Settings UI (for example, setting a model back to "Not set") removes it from every config file that contains it.
|
||||
|
||||
{% callout type="warning" %}
|
||||
**Migrating from opencode?** Kilo no longer falls back to opencode configuration stored in `.opencode` directories (such as `~/.config/opencode` or a project `./.opencode/`). To keep using it, move your global config into `~/.config/kilo/` and any project config into `./.kilo/`.
|
||||
{% /callout %}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdtempSync, readFileSync, existsSync, statSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test"
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
let profileCalls = 0
|
||||
mock.module("@kilocode/kilo-gateway", () => ({
|
||||
fetchProfile: async (token: string) => {
|
||||
profileCalls++
|
||||
if (token === "bad-token") return null
|
||||
return { email: `user-${token}@example.com` }
|
||||
},
|
||||
}))
|
||||
|
||||
const { Identity } = await import("../identity.js")
|
||||
|
||||
function digest(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex")
|
||||
}
|
||||
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
profileCalls = 0
|
||||
dir = mkdtempSync(path.join(tmpdir(), "kilo-telemetry-identity-"))
|
||||
Identity.reset()
|
||||
Identity.setDataPath(dir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Identity.setDataPath("")
|
||||
})
|
||||
|
||||
describe("Identity.updateFromKiloAuth profile cache", () => {
|
||||
test("fetches profile and writes cache keyed by token hash", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
expect(Identity.getUserId()).toBe("user-token-a@example.com")
|
||||
expect(profileCalls).toBe(1)
|
||||
|
||||
const file = path.join(dir, "telemetry-profile.json")
|
||||
expect(existsSync(file)).toBe(true)
|
||||
const cache = JSON.parse(readFileSync(file, "utf8"))
|
||||
expect(cache.token).toBe(digest("token-a"))
|
||||
expect(cache.email).toBe("user-token-a@example.com")
|
||||
expect(cache.token).not.toBe("token-a")
|
||||
// The cache stores an email and a token verifier, so it must be owner-only.
|
||||
// POSIX only: Windows reports default mode bits and enforces access via ACLs.
|
||||
if (process.platform !== "win32") expect(statSync(file).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
test("uses cached email without a network request on later invocations", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
expect(profileCalls).toBe(1)
|
||||
|
||||
// Simulate a fresh process: identity state resets, cache file persists.
|
||||
Identity.reset()
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
expect(Identity.getUserId()).toBe("user-token-a@example.com")
|
||||
expect(profileCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("refetches when the token changes", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
Identity.reset()
|
||||
await Identity.updateFromKiloAuth("token-b")
|
||||
expect(Identity.getUserId()).toBe("user-token-b@example.com")
|
||||
expect(profileCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("clears identity when token is null", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
Identity.reset()
|
||||
await Identity.updateFromKiloAuth(null)
|
||||
expect(Identity.getUserId()).toBeNull()
|
||||
expect(profileCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("ignores a cache file for a different token", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
const file = path.join(dir, "telemetry-profile.json")
|
||||
const cache = JSON.parse(readFileSync(file, "utf8"))
|
||||
expect(cache.token).toBe(digest("token-a"))
|
||||
|
||||
Identity.reset()
|
||||
await Identity.updateFromKiloAuth("token-b")
|
||||
expect(Identity.getUserId()).toBe("user-token-b@example.com")
|
||||
})
|
||||
})
|
||||
@@ -82,4 +82,25 @@ export namespace Client {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush queued events in the background without blocking the caller. The
|
||||
// flush is delayed slightly so commands that exit immediately pay only the
|
||||
// single shutdown() flush instead of an in-flight flush plus a follow-up
|
||||
// flush for CLI_EXIT. For commands that outlive the delay, the upload
|
||||
// overlaps with execution, so by the time shutdown() runs the queue is
|
||||
// usually empty (or the connection is still warm) and process exit is not
|
||||
// delayed by a network round trip. The unref'd timer never keeps a process
|
||||
// alive on its own. The authoritative, error-handled flush still happens in
|
||||
// shutdown(); failures here are retried there, so they are only surfaced
|
||||
// when debug logging is on.
|
||||
export function flushInBackground(delayMs = 300): void {
|
||||
if (!enabled || !client) return
|
||||
const timer = setTimeout(() => {
|
||||
if (!client) return
|
||||
client.flush().catch((err) => {
|
||||
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry background flush failed", err)
|
||||
})
|
||||
}, delayMs)
|
||||
timer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as path from "path"
|
||||
import { createHash } from "crypto"
|
||||
import { writeFile, chmod, rename, rm } from "fs/promises"
|
||||
import { fetchProfile } from "@kilocode/kilo-gateway"
|
||||
|
||||
export namespace Identity {
|
||||
@@ -7,6 +9,21 @@ export namespace Identity {
|
||||
let organizationId: string | null = null
|
||||
let dataPath = ""
|
||||
|
||||
// Cache the email resolved from the auth token so CLI startup does not block on
|
||||
// a profile request for every invocation. Keyed by token hash; refreshed when
|
||||
// the token changes. Stale entries (older than a week) are still used for the
|
||||
// current run and refreshed on a best-effort basis for a later run: the
|
||||
// background refresh is not awaited, so short-lived invocations may exit before
|
||||
// it completes and simply retry next time.
|
||||
const CACHE_FILE = "telemetry-profile.json"
|
||||
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
interface Cache {
|
||||
token: string
|
||||
email: string
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
export function setDataPath(p: string) {
|
||||
dataPath = p
|
||||
}
|
||||
@@ -51,6 +68,45 @@ export namespace Identity {
|
||||
organizationId = orgId
|
||||
}
|
||||
|
||||
function digest(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex")
|
||||
}
|
||||
|
||||
async function read(): Promise<Cache | null> {
|
||||
if (!dataPath) return null
|
||||
const file = Bun.file(path.join(dataPath, CACHE_FILE))
|
||||
if (!(await file.exists())) return null
|
||||
const parsed = await file.json().catch(() => null)
|
||||
if (!parsed || typeof parsed.token !== "string" || typeof parsed.email !== "string") return null
|
||||
if (typeof parsed.fetchedAt !== "number") return null
|
||||
return parsed as Cache
|
||||
}
|
||||
|
||||
async function write(cache: Cache): Promise<void> {
|
||||
if (!dataPath) return
|
||||
const filepath = path.join(dataPath, CACHE_FILE)
|
||||
// The cache stores the user's email and a token verifier, so keep it
|
||||
// readable only by the owner, including when replacing an existing file.
|
||||
// Write to a temp file and rename so concurrent invocations or a mid-write
|
||||
// kill cannot leave a truncated cache behind (POSIX rename is atomic).
|
||||
const tmp = `${filepath}.${process.pid}.tmp`
|
||||
try {
|
||||
await writeFile(tmp, JSON.stringify(cache), { mode: 0o600 })
|
||||
await chmod(tmp, 0o600)
|
||||
await rename(tmp, filepath)
|
||||
} catch (err) {
|
||||
await rm(tmp, { force: true }).catch((rmErr) => {
|
||||
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache temp cleanup failed", rmErr)
|
||||
})
|
||||
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache write failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(token: string, tokenHash: string): Promise<void> {
|
||||
const profile = await fetchProfile(token).catch(() => null)
|
||||
if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() })
|
||||
}
|
||||
|
||||
export async function updateFromKiloAuth(token: string | null, accountId?: string): Promise<void> {
|
||||
organizationId = accountId || null
|
||||
|
||||
@@ -59,8 +115,21 @@ export namespace Identity {
|
||||
return
|
||||
}
|
||||
|
||||
const tokenHash = digest(token)
|
||||
const cached = await read()
|
||||
if (cached && cached.token === tokenHash) {
|
||||
userId = cached.email
|
||||
if (Date.now() - cached.fetchedAt > CACHE_TTL) {
|
||||
refresh(token, tokenHash).catch((err) => {
|
||||
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile refresh failed", err)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const profile = await fetchProfile(token).catch(() => null)
|
||||
userId = profile?.email || null
|
||||
if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() })
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
|
||||
@@ -135,6 +135,12 @@ export namespace Telemetry {
|
||||
track(TelemetryEvent.CLI_START)
|
||||
}
|
||||
|
||||
// Upload queued events without blocking. Call after bootstrap so the flush
|
||||
// overlaps with command execution and shutdown() stays fast (#10242).
|
||||
export function flushInBackground() {
|
||||
Client.flushInBackground()
|
||||
}
|
||||
|
||||
export function trackCliExit(exitCode?: number) {
|
||||
track(TelemetryEvent.CLI_EXIT, {
|
||||
duration: Date.now() - startTime,
|
||||
|
||||
@@ -678,7 +678,7 @@
|
||||
"command": "kilo-code.new.agentManager.showTerminal",
|
||||
"key": "ctrl+/",
|
||||
"mac": "cmd+/",
|
||||
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
|
||||
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !kilo-code.new.sidebarFocused"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManager.runScript",
|
||||
|
||||
@@ -36,9 +36,9 @@ import { SessionTerminalManager } from "./SessionTerminalManager"
|
||||
import { createTerminalHost } from "./terminal-host"
|
||||
import { TerminalRouter } from "./terminal-routing"
|
||||
import { executeVscodeTask } from "./task-runner"
|
||||
import { startVscodeRunTask } from "./run/task"
|
||||
import { RunController } from "./run/controller"
|
||||
import { handleRunMessage } from "./run/message"
|
||||
import { createRunController, createScriptTerminalRuntime } from "./script-terminal-runtime"
|
||||
import { forkSession } from "./fork-session"
|
||||
import { AgentManagerVisiblePresence } from "./am-visible-presence"
|
||||
import { continueInWorktree } from "./continue-in-worktree"
|
||||
@@ -85,6 +85,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
private importer: WorktreeImporter
|
||||
private terminalManager: SessionTerminalManager
|
||||
private terminalRouter: TerminalRouter
|
||||
private scripts: ReturnType<typeof createScriptTerminalRuntime>
|
||||
private run: RunController
|
||||
private stateReady: Promise<void> | undefined
|
||||
private statsPoller: GitStatsPoller
|
||||
@@ -137,19 +138,25 @@ export class AgentManagerProvider implements Disposable {
|
||||
post: (msg) => this.postToWebview(msg),
|
||||
getTerminalFont: () => readTerminalFont(),
|
||||
})
|
||||
this.scripts = createScriptTerminalRuntime({
|
||||
connection: this.connectionService,
|
||||
output: this.outputChannel,
|
||||
post: (message) => this.postToWebview(message),
|
||||
})
|
||||
this.unsubFont = watchTerminalFont((font) => {
|
||||
this.postToWebview({ type: "agentManager.terminal.fontChanged", font })
|
||||
this.scripts.manager.snapshot()
|
||||
})
|
||||
this.unsubDestination = watchTerminalDestination((destination) => {
|
||||
this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination })
|
||||
})
|
||||
this.run = new RunController({
|
||||
this.run = createRunController({
|
||||
manager: this.scripts.manager,
|
||||
root: () => this.getRoot(),
|
||||
state: () => this.getStateManager(),
|
||||
open: (file) => this.host.openDocument(file),
|
||||
start: startVscodeRunTask,
|
||||
post: (status) => this.postToWebview({ type: "agentManager.runStatus", ...status }),
|
||||
error: (message) => this.postToWebview({ type: "error", message }),
|
||||
trusted: () => this.host.isTrusted(),
|
||||
post: (message) => this.postToWebview(message),
|
||||
log: (msg) => this.outputChannel.appendLine(`[RunScript] ${msg}`),
|
||||
refresh: () => this.pushState(),
|
||||
})
|
||||
@@ -493,6 +500,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
if (diff !== undefined) return diff
|
||||
const bridge = this.onBridgeMessage(m)
|
||||
if (bridge !== undefined) return bridge
|
||||
if (this.scripts.manager.intercept(m)) return null
|
||||
if (this.terminalRouter.handle(m)) return null
|
||||
|
||||
return msg
|
||||
@@ -846,6 +854,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
// the panel itself is disposed. In-flight creates from the dying
|
||||
// instance are reaped by the router's generation guard.
|
||||
void this.terminalRouter.dispose()
|
||||
this.scripts.manager.snapshot()
|
||||
this.log(
|
||||
`onRequestState: stateReady=${this.stateReady ? "pending" : "missing"}, state=${this.state ? "ok" : "missing"}`,
|
||||
)
|
||||
@@ -1408,8 +1417,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
push: () => this.pushState(),
|
||||
register: (sid, dir) => this.registerWorktreeSession(sid, dir),
|
||||
skipStats: (id) => this.statsPoller.skipWorktree(id),
|
||||
unskipStats: (id) => this.statsPoller.unskipWorktree(id),
|
||||
removePR: (id) => this.prBridge.remove(id),
|
||||
removeRun: (id) => this.run.remove(id),
|
||||
clearRun: (id) => this.scripts.manager.clear("run", id),
|
||||
forgetName: (id) => this.naming.forget(id),
|
||||
stopDiffs: (path, orphaned) => {
|
||||
if (this.diffs.shouldStopForWorktree(path, orphaned)) this.diffs.stop()
|
||||
@@ -1743,6 +1754,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.unsubFont?.()
|
||||
this.unsubProjects?.()
|
||||
this.unsubDestination?.()
|
||||
await this.scripts.dispose()
|
||||
this.orchestration.dispose()
|
||||
this.visiblePresence.clear()
|
||||
this.diffs.stop()
|
||||
|
||||
@@ -103,6 +103,10 @@ export class GitStatsPoller {
|
||||
this.skipWorktreeIds.add(id)
|
||||
}
|
||||
|
||||
unskipWorktree(id: string): void {
|
||||
this.skipWorktreeIds.delete(id)
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
if (this.active) return
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import type { TerminalFont } from "./terminal-font"
|
||||
import type { RunHandle } from "./run/manager"
|
||||
|
||||
type ScriptTerminalKind = "run"
|
||||
type ScriptTerminalState = "running" | "stopping" | "exited" | "failed"
|
||||
|
||||
interface ScriptTerminalConfig {
|
||||
worktreeId: string
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
interface ScriptTerminalExit {
|
||||
exitCode?: number
|
||||
stopped?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ScriptTerminalView {
|
||||
terminalId: string
|
||||
/** null for the LOCAL workspace; RunController retains its internal "local" key. */
|
||||
worktreeId: string | null
|
||||
kind: ScriptTerminalKind
|
||||
title: "Run"
|
||||
wsUrl: string
|
||||
state: ScriptTerminalState
|
||||
exitCode?: number
|
||||
font: TerminalFont
|
||||
}
|
||||
|
||||
interface ScriptTerminalDeps {
|
||||
getClient(): KiloClient
|
||||
getClientAsync(directory: string): Promise<KiloClient>
|
||||
buildWsUrl(ptyID: string, cwd: string): string
|
||||
getTerminalFont(): TerminalFont
|
||||
emit(terminals: ScriptTerminalView[]): void
|
||||
closed(terminalId: string): void
|
||||
log(msg: string): void
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
key: string
|
||||
kind: ScriptTerminalKind
|
||||
terminalId: string
|
||||
ptyID: string
|
||||
worktreeId: string
|
||||
cwd: string
|
||||
wsUrl: string
|
||||
state: ScriptTerminalState
|
||||
exitCode?: number
|
||||
done: (exit: ScriptTerminalExit) => void
|
||||
finished: boolean
|
||||
closing?: Promise<void>
|
||||
}
|
||||
|
||||
interface TerminalMessage {
|
||||
type: string
|
||||
terminalId?: unknown
|
||||
cols?: unknown
|
||||
rows?: unknown
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false
|
||||
const value = error as Record<string, unknown>
|
||||
if (value.status === 404 || value._tag === "PtyNotFoundError") return true
|
||||
if (!value.data || typeof value.data !== "object") return false
|
||||
const data = value.data as Record<string, unknown>
|
||||
return data.status === 404 || data._tag === "PtyNotFoundError"
|
||||
}
|
||||
|
||||
function key(kind: ScriptTerminalKind, worktreeId: string): string {
|
||||
return `${kind}:${worktreeId}`
|
||||
}
|
||||
|
||||
function terminalId(): string {
|
||||
return `script:${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns extension-host script PTYs independently from webview terminal routing.
|
||||
* Exited records stay available for output replay until the user closes them.
|
||||
*/
|
||||
export class ScriptTerminalManager {
|
||||
private readonly entries = new Map<string, Entry>()
|
||||
private readonly terminals = new Map<string, Entry>()
|
||||
private readonly ptys = new Map<string, Entry>()
|
||||
|
||||
constructor(private readonly deps: ScriptTerminalDeps) {}
|
||||
|
||||
async start(
|
||||
kind: ScriptTerminalKind,
|
||||
config: ScriptTerminalConfig,
|
||||
done: (exit: ScriptTerminalExit) => void,
|
||||
): Promise<RunHandle> {
|
||||
const id = key(kind, config.worktreeId)
|
||||
const prior = this.entries.get(id)
|
||||
if (prior) {
|
||||
if (prior.state === "running" || prior.state === "stopping") throw new Error("Run terminal is already active")
|
||||
await this.remove(prior, false)
|
||||
if (this.entries.has(id)) throw new Error("Failed to remove previous Run terminal")
|
||||
}
|
||||
|
||||
const client = await this.deps.getClientAsync(config.cwd).catch((error) => {
|
||||
const detail = message(error)
|
||||
this.deps.log(`Run terminal create failed: ${detail}`)
|
||||
throw new Error(detail)
|
||||
})
|
||||
const created = await client.v2.pty
|
||||
.create({
|
||||
location: { directory: config.cwd },
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
cwd: config.cwd,
|
||||
env: config.env,
|
||||
title: "Run",
|
||||
})
|
||||
.catch((error) => {
|
||||
const detail = message(error)
|
||||
this.deps.log(`Run terminal create failed: ${detail}`)
|
||||
throw new Error(detail)
|
||||
})
|
||||
const pty = created.data?.data
|
||||
if (created.error || !pty) {
|
||||
const detail = message(created.error ?? "unknown error")
|
||||
this.deps.log(`Run terminal create failed: ${detail}`)
|
||||
throw new Error(`Failed to create Run terminal: ${detail}`)
|
||||
}
|
||||
|
||||
const wsUrl = await this.url(client, pty.id, config.cwd)
|
||||
const entry: Entry = {
|
||||
key: id,
|
||||
kind,
|
||||
terminalId: terminalId(),
|
||||
ptyID: pty.id,
|
||||
worktreeId: config.worktreeId,
|
||||
cwd: config.cwd,
|
||||
wsUrl,
|
||||
state: "running",
|
||||
done,
|
||||
finished: false,
|
||||
}
|
||||
this.entries.set(entry.key, entry)
|
||||
this.terminals.set(entry.terminalId, entry)
|
||||
this.ptys.set(entry.ptyID, entry)
|
||||
this.emit()
|
||||
|
||||
await this.reconcile(entry, client)
|
||||
|
||||
return {
|
||||
stop: () => this.stop(entry),
|
||||
}
|
||||
}
|
||||
|
||||
/** Return true only for close/resize messages owned by a script terminal. */
|
||||
intercept(msg: TerminalMessage): boolean {
|
||||
const id = msg.terminalId
|
||||
if (typeof id !== "string" || !this.terminals.has(id)) return false
|
||||
if (msg.type === "agentManager.terminal.close") {
|
||||
void this.close(id).then((closed) => {
|
||||
if (closed) this.deps.closed(id)
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (msg.type !== "agentManager.terminal.resize") return false
|
||||
if (typeof msg.cols !== "number" || typeof msg.rows !== "number") return true
|
||||
void this.resize(id, msg.cols, msg.rows)
|
||||
return true
|
||||
}
|
||||
|
||||
exited(ptyID: string, exitCode: number): void {
|
||||
const entry = this.ptys.get(ptyID)
|
||||
if (!entry) return
|
||||
this.finishExited(entry, exitCode)
|
||||
}
|
||||
|
||||
deleted(ptyID: string): void {
|
||||
const entry = this.ptys.get(ptyID)
|
||||
if (!entry) return
|
||||
const state = entry.state
|
||||
this.drop(entry)
|
||||
this.emit()
|
||||
if (state === "stopping") {
|
||||
this.done(entry, { stopped: true })
|
||||
return
|
||||
}
|
||||
if (state === "running") this.done(entry, { error: "Run terminal was removed before it exited" })
|
||||
}
|
||||
|
||||
snapshot(): void {
|
||||
this.emit()
|
||||
}
|
||||
|
||||
owns(ptyID: string): boolean {
|
||||
return this.ptys.has(ptyID)
|
||||
}
|
||||
|
||||
async sync(): Promise<void> {
|
||||
await Promise.all(
|
||||
[...this.entries.values()].map(async (entry) => {
|
||||
const client = await this.deps.getClientAsync(entry.cwd).catch((error) => {
|
||||
this.deps.log(`Failed to reconnect Run terminal: ${message(error)}`)
|
||||
return undefined
|
||||
})
|
||||
if (client) await this.reconcile(entry, client)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async clear(kind: ScriptTerminalKind, worktreeId: string): Promise<boolean> {
|
||||
const entry = this.entries.get(key(kind, worktreeId))
|
||||
if (!entry) return true
|
||||
return this.close(entry.terminalId)
|
||||
}
|
||||
|
||||
async close(terminalId: string): Promise<boolean> {
|
||||
const entry = this.terminals.get(terminalId)
|
||||
if (!entry) return true
|
||||
if (entry.state === "running") {
|
||||
await this.stop(entry)
|
||||
return !this.terminals.has(terminalId)
|
||||
}
|
||||
if (entry.state === "stopping") {
|
||||
await entry.closing
|
||||
return !this.terminals.has(terminalId)
|
||||
}
|
||||
await this.remove(entry, false)
|
||||
return !this.terminals.has(terminalId)
|
||||
}
|
||||
|
||||
async resize(terminalId: string, cols: number, rows: number): Promise<void> {
|
||||
const entry = this.terminals.get(terminalId)
|
||||
if (!entry) return
|
||||
try {
|
||||
const client = this.deps.getClient()
|
||||
const result = await client.v2.pty.update({
|
||||
ptyID: entry.ptyID,
|
||||
location: { directory: entry.cwd },
|
||||
size: { cols, rows },
|
||||
})
|
||||
if (!result.error) return
|
||||
this.deps.log(`Run terminal resize failed (${terminalId}): ${message(result.error)}`)
|
||||
} catch (error) {
|
||||
this.deps.log(`Run terminal resize failed (${terminalId}): ${message(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await Promise.all([...this.terminals.keys()].map((terminalId) => this.close(terminalId)))
|
||||
}
|
||||
|
||||
private async reconcile(entry: Entry, client: KiloClient): Promise<void> {
|
||||
if (!this.current(entry)) return
|
||||
try {
|
||||
const result = await client.v2.pty.get({ ptyID: entry.ptyID, location: { directory: entry.cwd } })
|
||||
const pty = result.data?.data
|
||||
if (result.error || !pty) {
|
||||
this.missing(entry, `Run terminal is no longer available: ${message(result.error ?? "unknown error")}`)
|
||||
return
|
||||
}
|
||||
if (pty.status === "exited") this.finishExited(entry, pty.exitCode ?? 0)
|
||||
} catch (error) {
|
||||
this.deps.log(`Failed to read Run terminal: ${message(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async stop(entry: Entry): Promise<void> {
|
||||
if (!this.current(entry)) return
|
||||
if (entry.state === "stopping") {
|
||||
await entry.closing
|
||||
return
|
||||
}
|
||||
if (entry.state === "exited" || entry.state === "failed") {
|
||||
await this.remove(entry, false)
|
||||
return
|
||||
}
|
||||
entry.state = "stopping"
|
||||
this.emit()
|
||||
await this.remove(entry, true)
|
||||
}
|
||||
|
||||
private remove(entry: Entry, stopped: boolean): Promise<void> {
|
||||
if (entry.closing) return entry.closing
|
||||
const task = this.removeEntry(entry, stopped)
|
||||
entry.closing = task
|
||||
void task.finally(() => {
|
||||
if (this.current(entry) && entry.closing === task) entry.closing = undefined
|
||||
})
|
||||
return task
|
||||
}
|
||||
|
||||
private async removeEntry(entry: Entry, stopped: boolean): Promise<void> {
|
||||
try {
|
||||
const client = await this.deps.getClientAsync(entry.cwd)
|
||||
const result = await client.v2.pty.remove({ ptyID: entry.ptyID, location: { directory: entry.cwd } })
|
||||
if (result.error) {
|
||||
if (missing(result.error)) {
|
||||
this.drop(entry)
|
||||
this.emit()
|
||||
if (stopped) this.done(entry, { stopped: true })
|
||||
return
|
||||
}
|
||||
this.failed(entry, `Failed to remove Run terminal: ${message(result.error)}`)
|
||||
return
|
||||
}
|
||||
this.drop(entry)
|
||||
this.emit()
|
||||
if (stopped) this.done(entry, { stopped: true })
|
||||
} catch (error) {
|
||||
this.failed(entry, `Failed to remove Run terminal: ${message(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async url(client: KiloClient, ptyID: string, cwd: string): Promise<string> {
|
||||
try {
|
||||
return this.deps.buildWsUrl(ptyID, cwd)
|
||||
} catch (error) {
|
||||
this.deps.log(`Failed to build Run terminal URL: ${message(error)}`)
|
||||
try {
|
||||
const result = await client.v2.pty.remove({ ptyID, location: { directory: cwd } })
|
||||
if (result.error) this.deps.log(`Failed to remove Run terminal after URL failure: ${message(result.error)}`)
|
||||
} catch (cleanup) {
|
||||
this.deps.log(`Failed to remove Run terminal after URL failure: ${message(cleanup)}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private finishExited(entry: Entry, exitCode: number): void {
|
||||
if (!this.current(entry) || entry.state === "exited") return
|
||||
entry.state = "exited"
|
||||
entry.exitCode = exitCode
|
||||
this.emit()
|
||||
this.done(entry, { exitCode })
|
||||
}
|
||||
|
||||
private failed(entry: Entry, error: string): void {
|
||||
if (!this.current(entry)) return
|
||||
this.deps.log(error)
|
||||
entry.state = "failed"
|
||||
this.emit()
|
||||
this.done(entry, { error })
|
||||
}
|
||||
|
||||
private missing(entry: Entry, error: string): void {
|
||||
if (!this.current(entry)) return
|
||||
this.deps.log(error)
|
||||
this.drop(entry)
|
||||
this.emit()
|
||||
this.done(entry, { error })
|
||||
}
|
||||
|
||||
private done(entry: Entry, exit: ScriptTerminalExit): void {
|
||||
if (entry.finished) return
|
||||
entry.finished = true
|
||||
entry.done(exit)
|
||||
}
|
||||
|
||||
private drop(entry: Entry): void {
|
||||
if (!this.current(entry)) return
|
||||
this.entries.delete(entry.key)
|
||||
this.terminals.delete(entry.terminalId)
|
||||
this.ptys.delete(entry.ptyID)
|
||||
}
|
||||
|
||||
private current(entry: Entry): boolean {
|
||||
return this.entries.get(entry.key) === entry
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const terminals: ScriptTerminalView[] = []
|
||||
for (const entry of this.entries.values()) {
|
||||
const terminal: ScriptTerminalView = {
|
||||
terminalId: entry.terminalId,
|
||||
worktreeId: entry.worktreeId === "local" ? null : entry.worktreeId,
|
||||
kind: entry.kind,
|
||||
title: "Run",
|
||||
wsUrl: entry.wsUrl,
|
||||
state: entry.state,
|
||||
font: this.deps.getTerminalFont(),
|
||||
}
|
||||
if (entry.exitCode !== undefined) terminal.exitCode = entry.exitCode
|
||||
terminals.push(terminal)
|
||||
}
|
||||
this.deps.emit(terminals)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ function createMockHost(): Host {
|
||||
return {
|
||||
openPanel: vi.fn(),
|
||||
workspacePath: () => "/repo",
|
||||
isTrusted: () => true,
|
||||
autoBranchNaming: () => ({ enabled: true, prefix: "" }),
|
||||
showError: vi.fn(),
|
||||
openDocument: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -78,7 +79,10 @@ function createMockHost(): Host {
|
||||
openFolder: vi.fn(),
|
||||
createOutput: () => ({ appendLine: vi.fn(), dispose: vi.fn() }) as OutputHandle,
|
||||
extensionKeybindings: () => [],
|
||||
copyToClipboard: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
refreshGit: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}
|
||||
}
|
||||
@@ -102,6 +106,7 @@ function createHarness() {
|
||||
prBridge: { handleMessage: ReturnType<typeof vi.fn> }
|
||||
activeSessionId: string | undefined
|
||||
naming: { prompt: ReturnType<typeof vi.fn> }
|
||||
scripts: { intercept: ReturnType<typeof vi.fn>; snapshot: ReturnType<typeof vi.fn> }
|
||||
terminalRouter: { handle: ReturnType<typeof vi.fn> }
|
||||
stateReady: Promise<void> | undefined
|
||||
contextTarget: ReturnType<typeof vi.fn>
|
||||
@@ -125,6 +130,7 @@ function createHarness() {
|
||||
manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) }
|
||||
manager.activeSessionId = undefined
|
||||
manager.naming = { prompt: vi.fn() }
|
||||
manager.scripts = { intercept: vi.fn().mockReturnValue(false), snapshot: vi.fn() }
|
||||
manager.terminalRouter = { handle: vi.fn().mockReturnValue(false) }
|
||||
manager.stateReady = Promise.resolve()
|
||||
manager.contextTarget = vi.fn()
|
||||
|
||||
@@ -143,6 +143,8 @@ export interface Host {
|
||||
|
||||
/** Subscribe to multi-project flag changes. */
|
||||
onDidChangeMultiProject(cb: (enabled: boolean) => void): Disposable
|
||||
/** Whether the workspace permits executing configured scripts. */
|
||||
isTrusted(): boolean
|
||||
|
||||
/** Read the user's automatic branch naming preferences. */
|
||||
autoBranchNaming(): { enabled: boolean; prefix: string }
|
||||
|
||||
@@ -32,8 +32,11 @@ export interface LifecycleHost {
|
||||
push: () => void
|
||||
register: (sessionId: string, dir: string) => void
|
||||
skipStats: (worktreeId: string) => void
|
||||
unskipStats: (worktreeId: string) => void
|
||||
removePR: (worktreeId: string) => void
|
||||
removeRun: (worktreeId: string) => void
|
||||
removeRun: (worktreeId: string) => Promise<void>
|
||||
/** Stop the run script's terminal; false aborts the worktree removal. */
|
||||
clearRun: (worktreeId: string) => Promise<boolean>
|
||||
forgetName: (worktreeId: string) => void
|
||||
stopDiffs: (path: string, orphaned: ManagedSession[]) => void
|
||||
capture: (event: string, props: Record<string, unknown>) => void
|
||||
@@ -100,8 +103,13 @@ export async function deleteLifecycleWorktree(
|
||||
// Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree.
|
||||
// Pre-emptive skip covers any in-flight poll that already captured getWorktrees().
|
||||
host.skipStats(worktreeId)
|
||||
await host.removeRun(worktreeId)
|
||||
if (!(await host.clearRun(worktreeId))) {
|
||||
host.unskipStats(worktreeId)
|
||||
host.post({ type: "error", message: "Failed to stop the Run script before deleting the worktree" })
|
||||
return null
|
||||
}
|
||||
host.removePR(worktreeId)
|
||||
host.removeRun(worktreeId)
|
||||
host.forgetName(worktreeId)
|
||||
const orphaned = state.removeWorktree(worktreeId)
|
||||
host.stopDiffs(worktree.path, orphaned)
|
||||
@@ -138,6 +146,11 @@ export async function removeStaleLifecycleWorktree(
|
||||
return null
|
||||
}
|
||||
|
||||
await host.removeRun(worktreeId)
|
||||
if (!(await host.clearRun(worktreeId))) {
|
||||
host.post({ type: "error", message: "Failed to stop the Run script before removing the worktree" })
|
||||
return null
|
||||
}
|
||||
host.forgetName(worktreeId)
|
||||
const orphaned = state.removeWorktree(worktreeId)
|
||||
host.stopDiffs(worktree.path, orphaned)
|
||||
|
||||
@@ -4,8 +4,10 @@ import { getShellEnvironment } from "../shell-env"
|
||||
import { RunScriptManager, type RunHandle, type RunStatus } from "./manager"
|
||||
import { RunScriptService } from "./service"
|
||||
import type { WorktreeStateManager } from "../WorktreeStateManager"
|
||||
import type { RunTerminalDestination } from "./destination"
|
||||
|
||||
export interface RunTaskConfig {
|
||||
destination: RunTerminalDestination
|
||||
worktreeId: string
|
||||
branch: string
|
||||
command: string
|
||||
@@ -14,11 +16,13 @@ export interface RunTaskConfig {
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
interface TaskExit {
|
||||
export interface RunTaskExit {
|
||||
exitCode?: number
|
||||
stopped?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
type StartTask = (config: RunTaskConfig, done: (exit: TaskExit) => void) => Promise<RunHandle>
|
||||
export type StartTask = (config: RunTaskConfig, done: (exit: RunTaskExit) => void) => Promise<RunHandle>
|
||||
|
||||
interface Options {
|
||||
root: () => string | undefined
|
||||
@@ -60,7 +64,7 @@ export class RunController {
|
||||
this.opts.refresh?.()
|
||||
}
|
||||
|
||||
async run(worktreeId: string): Promise<void> {
|
||||
async run(worktreeId: string, destination: RunTerminalDestination): Promise<void> {
|
||||
const status = this.manager.status(worktreeId)
|
||||
if (status.state !== "idle") {
|
||||
this.stop(worktreeId)
|
||||
@@ -109,18 +113,19 @@ export class RunController {
|
||||
}
|
||||
|
||||
const start = () =>
|
||||
this.opts.start({ worktreeId, branch, command: script.command, args: script.args, cwd, env }, (exit) =>
|
||||
this.manager.finish(worktreeId, { exitCode: exit.exitCode }),
|
||||
this.opts.start(
|
||||
{ destination, worktreeId, branch, command: script.command, args: script.args, cwd, env },
|
||||
(exit) => this.manager.finish(worktreeId, exit),
|
||||
)
|
||||
await this.manager.start(worktreeId, start)
|
||||
}
|
||||
|
||||
stop(worktreeId: string): void {
|
||||
this.manager.stop(worktreeId)
|
||||
void this.manager.stop(worktreeId)
|
||||
}
|
||||
|
||||
remove(worktreeId: string): void {
|
||||
this.manager.remove(worktreeId)
|
||||
remove(worktreeId: string): Promise<void> {
|
||||
return this.manager.remove(worktreeId)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Where the Agent Manager Run button executes the project run script.
|
||||
*
|
||||
* The Agent Manager terminal dropdown owns this choice per panel.
|
||||
* "agentManager" runs through the canonical PTY service in the embedded
|
||||
* side terminal. "vscode" is the legacy integrated terminal task path,
|
||||
* kept for comparison while the embedded path proves itself. Remove the
|
||||
* "vscode" dropdown option, `run/task.ts`, and the integrated branch below
|
||||
* together once the embedded path is the only one.
|
||||
*/
|
||||
|
||||
export type RunTerminalDestination = "agentManager" | "vscode"
|
||||
|
||||
export function pickRunStart<T>(destination: RunTerminalDestination, embedded: T, integrated: T): T {
|
||||
return destination === "vscode" ? integrated : embedded
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface RunStatus {
|
||||
worktreeId: string
|
||||
state: RunState
|
||||
exitCode?: number
|
||||
stopped?: boolean
|
||||
signal?: string
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
@@ -11,17 +12,21 @@ export interface RunStatus {
|
||||
}
|
||||
|
||||
export interface RunHandle {
|
||||
stop(): void
|
||||
stop(): void | Promise<void>
|
||||
dispose?(): void
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
status: RunStatus
|
||||
handle?: RunHandle
|
||||
task?: Promise<RunHandle>
|
||||
released?: boolean
|
||||
stopping?: Promise<void>
|
||||
}
|
||||
|
||||
interface FinishOptions {
|
||||
exitCode?: number
|
||||
stopped?: boolean
|
||||
signal?: string
|
||||
error?: string
|
||||
}
|
||||
@@ -42,6 +47,7 @@ export class RunScriptManager {
|
||||
) {}
|
||||
|
||||
async start(worktreeId: string, start: () => Promise<RunHandle>): Promise<boolean> {
|
||||
this.removed.delete(worktreeId)
|
||||
const current = this.entries.get(worktreeId)
|
||||
if (current && current.status.state !== "idle") return false
|
||||
|
||||
@@ -56,21 +62,25 @@ export class RunScriptManager {
|
||||
this.emit(entry.status)
|
||||
|
||||
try {
|
||||
const handle = await start()
|
||||
const task = start()
|
||||
entry.task = task
|
||||
const handle = await task
|
||||
const latest = this.entries.get(worktreeId)
|
||||
if (latest !== entry) {
|
||||
handle.dispose?.()
|
||||
await this.release(worktreeId, entry, handle, this.removed.has(worktreeId))
|
||||
return true
|
||||
}
|
||||
entry.handle = handle
|
||||
if (entry.status.state === "stopping") handle.stop()
|
||||
if (entry.status.state === "stopping") {
|
||||
void this.halt(worktreeId, entry, handle)
|
||||
}
|
||||
} catch (error) {
|
||||
this.finish(worktreeId, { error: message(error) })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
stop(worktreeId: string): void {
|
||||
async stop(worktreeId: string): Promise<void> {
|
||||
const entry = this.entries.get(worktreeId)
|
||||
if (!entry || entry.status.state === "idle" || entry.status.state === "stopping") return
|
||||
|
||||
@@ -81,11 +91,7 @@ export class RunScriptManager {
|
||||
this.emit(entry.status)
|
||||
|
||||
if (!entry.handle) return
|
||||
try {
|
||||
entry.handle.stop()
|
||||
} catch (error) {
|
||||
this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`)
|
||||
}
|
||||
await this.halt(worktreeId, entry, entry.handle)
|
||||
}
|
||||
|
||||
finish(worktreeId: string, opts: FinishOptions = {}): void {
|
||||
@@ -100,6 +106,7 @@ export class RunScriptManager {
|
||||
}
|
||||
if (entry?.status.startedAt) status.startedAt = entry.status.startedAt
|
||||
if (opts.exitCode !== undefined) status.exitCode = opts.exitCode
|
||||
if (opts.stopped) status.stopped = true
|
||||
if (opts.signal) status.signal = opts.signal
|
||||
if (opts.error) status.error = opts.error
|
||||
|
||||
@@ -115,24 +122,57 @@ export class RunScriptManager {
|
||||
return [...this.entries.values()].map((entry) => entry.status)
|
||||
}
|
||||
|
||||
remove(worktreeId: string): void {
|
||||
async remove(worktreeId: string): Promise<void> {
|
||||
const entry = this.entries.get(worktreeId)
|
||||
if (entry?.status.state !== "idle") this.stop(worktreeId)
|
||||
this.entries.delete(worktreeId)
|
||||
this.removed.add(worktreeId)
|
||||
this.entries.delete(worktreeId)
|
||||
const handle =
|
||||
entry?.handle ??
|
||||
(entry?.task
|
||||
? await entry.task.catch((error) => {
|
||||
this.log(`Failed to start removed run script for ${worktreeId}: ${message(error)}`)
|
||||
return undefined
|
||||
})
|
||||
: undefined)
|
||||
if (!entry || !handle) return
|
||||
if (entry.status.state !== "idle") {
|
||||
await this.release(worktreeId, entry, handle, true)
|
||||
return
|
||||
}
|
||||
await this.release(worktreeId, entry, handle, false)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.status.state !== "idle") {
|
||||
try {
|
||||
entry.handle?.stop()
|
||||
} catch (error) {
|
||||
this.log(`Failed to stop run script during dispose: ${message(error)}`)
|
||||
}
|
||||
}
|
||||
entry.handle?.dispose?.()
|
||||
for (const [id, entry] of this.entries) {
|
||||
this.removed.add(id)
|
||||
if (!entry.handle || entry.released) continue
|
||||
entry.released = true
|
||||
if (entry.status.state !== "idle") void this.halt(id, entry, entry.handle)
|
||||
entry.handle.dispose?.()
|
||||
}
|
||||
this.entries.clear()
|
||||
}
|
||||
|
||||
private async release(worktreeId: string, entry: Entry, handle: RunHandle, stop: boolean): Promise<void> {
|
||||
if (entry.released) return
|
||||
entry.released = true
|
||||
if (stop) await this.halt(worktreeId, entry, handle)
|
||||
handle.dispose?.()
|
||||
}
|
||||
|
||||
private halt(worktreeId: string, entry: Entry, handle: RunHandle): Promise<void> {
|
||||
if (entry.stopping) return entry.stopping
|
||||
const task = (() => {
|
||||
try {
|
||||
return Promise.resolve(handle.stop())
|
||||
.then(() => undefined)
|
||||
.catch((error) => this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`))
|
||||
} catch (error) {
|
||||
this.log(`Failed to stop run script for ${worktreeId}: ${message(error)}`)
|
||||
return Promise.resolve()
|
||||
}
|
||||
})()
|
||||
entry.stopping = task
|
||||
return task
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function handleRunMessage(run: RunController, msg: AgentManagerInMessage)
|
||||
return true
|
||||
}
|
||||
if (msg.type === "agentManager.runScript") {
|
||||
void run.run(msg.worktreeId)
|
||||
void run.run(msg.worktreeId, msg.destination)
|
||||
return true
|
||||
}
|
||||
if (msg.type === "agentManager.stopRunScript") {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Legacy integrated terminal Run adapter.
|
||||
*
|
||||
* Kept while the Agent Manager terminal dropdown offers the "VS Code
|
||||
* terminal" option so both execution paths can be compared. Remove this
|
||||
* file together with that dropdown option and the integrated `pickRunStart`
|
||||
* branch.
|
||||
*/
|
||||
import * as vscode from "vscode"
|
||||
import type { RunHandle } from "./manager"
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { KiloConnectionService } from "../services/cli-backend"
|
||||
import type { OutputHandle } from "./host"
|
||||
import { ScriptTerminalManager } from "./ScriptTerminalManager"
|
||||
import { buildScriptTerminalWsUrl } from "./script-terminal-url"
|
||||
import { readTerminalFont } from "./terminal-font"
|
||||
import type { AgentManagerOutMessage } from "./types"
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import { RunController } from "./run/controller"
|
||||
import { pickRunStart } from "./run/destination"
|
||||
import { startVscodeRunTask } from "./run/task"
|
||||
|
||||
interface Input {
|
||||
connection: KiloConnectionService
|
||||
output: OutputHandle
|
||||
post(message: AgentManagerOutMessage): void
|
||||
}
|
||||
|
||||
export function createScriptTerminalRuntime(input: Input) {
|
||||
const manager = new ScriptTerminalManager({
|
||||
getClient: () => input.connection.getClient(),
|
||||
getClientAsync: (directory) => input.connection.getClientAsync(directory),
|
||||
buildWsUrl: (ptyID, cwd) => {
|
||||
const config = input.connection.getServerConfig()
|
||||
if (!config) throw new Error("Not connected to CLI backend")
|
||||
return buildScriptTerminalWsUrl(config, ptyID, cwd)
|
||||
},
|
||||
getTerminalFont: () => readTerminalFont(),
|
||||
emit: (terminals) => input.post({ type: "agentManager.scriptTerminals", terminals }),
|
||||
closed: (terminalId) => input.post({ type: "agentManager.terminal.closed", terminalId }),
|
||||
log: (msg) => input.output.appendLine(`[RunScript] ${msg}`),
|
||||
})
|
||||
const event = input.connection.onEventFiltered(
|
||||
(value) => (value.type === "pty.exited" || value.type === "pty.deleted") && manager.owns(value.properties.id),
|
||||
(value) => {
|
||||
if (value.type === "pty.exited") manager.exited(value.properties.id, value.properties.exitCode)
|
||||
if (value.type === "pty.deleted") manager.deleted(value.properties.id)
|
||||
},
|
||||
)
|
||||
const connection = input.connection.onStateChange((state) => {
|
||||
if (state === "connected") void manager.sync()
|
||||
})
|
||||
return {
|
||||
manager,
|
||||
dispose: async () => {
|
||||
event()
|
||||
connection()
|
||||
await manager.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface RunInput {
|
||||
manager: ScriptTerminalManager
|
||||
root(): string | undefined
|
||||
state(): WorktreeStateManager | undefined
|
||||
open(path: string): Promise<void>
|
||||
trusted(): boolean
|
||||
post(message: AgentManagerOutMessage): void
|
||||
log(message: string): void
|
||||
refresh(): void
|
||||
}
|
||||
|
||||
export function createRunController(input: RunInput) {
|
||||
return new RunController({
|
||||
root: input.root,
|
||||
state: input.state,
|
||||
open: input.open,
|
||||
start: async (config, done) => {
|
||||
if (!input.trusted()) throw new Error("Trust the workspace before running scripts")
|
||||
return pickRunStart(
|
||||
config.destination,
|
||||
(cfg, cb) => input.manager.start("run", cfg, cb),
|
||||
startVscodeRunTask,
|
||||
)(config, done)
|
||||
},
|
||||
post: (status) => input.post({ type: "agentManager.runStatus", ...status }),
|
||||
error: (message) => input.post({ type: "error", message }),
|
||||
log: input.log,
|
||||
refresh: input.refresh,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface PtyServerConfig {
|
||||
baseUrl: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/** Build the canonical authenticated PTY WebSocket URL for script terminals. */
|
||||
export function buildScriptTerminalWsUrl(config: PtyServerConfig, ptyID: string, cwd: string): string {
|
||||
const base = config.baseUrl.replace(/^http/i, "ws").replace(/\/$/, "")
|
||||
const token = Buffer.from(`kilo:${config.password}`).toString("base64")
|
||||
const query = new URLSearchParams({
|
||||
"location[directory]": cwd,
|
||||
cursor: "0",
|
||||
replayExited: "1",
|
||||
auth_token: token,
|
||||
})
|
||||
return `${base}/api/pty/${encodeURIComponent(ptyID)}/connect?${query.toString()}`
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { TerminalFont } from "./terminal-font"
|
||||
import type { ProjectSnapshot } from "./project/contexts"
|
||||
import type { SidebarTarget } from "./project/route"
|
||||
import type { TerminalDestination } from "./terminal-destination"
|
||||
import type { ScriptTerminalView } from "./ScriptTerminalManager"
|
||||
|
||||
export type { TerminalFont }
|
||||
export type { ProjectSnapshot }
|
||||
@@ -216,6 +217,11 @@ interface TerminalFontChangedMessage {
|
||||
font: TerminalFont
|
||||
}
|
||||
|
||||
interface ScriptTerminalsMessage {
|
||||
type: "agentManager.scriptTerminals"
|
||||
terminals: ScriptTerminalView[]
|
||||
}
|
||||
|
||||
interface ErrorOutMessage {
|
||||
type: "error"
|
||||
message: string
|
||||
@@ -390,6 +396,7 @@ export type AgentManagerOutMessage =
|
||||
| TerminalErrorMessage
|
||||
| TerminalDestinationChangedMessage
|
||||
| TerminalFontChangedMessage
|
||||
| ScriptTerminalsMessage
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webview → Extension messages (onMessage)
|
||||
@@ -514,6 +521,7 @@ interface RunScriptIn {
|
||||
type: "agentManager.runScript"
|
||||
projectId?: string
|
||||
worktreeId: string
|
||||
destination: TerminalDestination
|
||||
}
|
||||
|
||||
interface StopRunScriptIn {
|
||||
|
||||
@@ -255,6 +255,10 @@ export class VscodeHost implements Host {
|
||||
})
|
||||
}
|
||||
|
||||
isTrusted(): boolean {
|
||||
return vscode.workspace.isTrusted
|
||||
}
|
||||
|
||||
autoBranchNaming(): { enabled: boolean; prefix: string } {
|
||||
const cfg = vscode.workspace.getConfiguration("kilo-code.new.agentManager")
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ type Item = {
|
||||
id: string
|
||||
title: string
|
||||
updated: number
|
||||
worktreeName?: string
|
||||
}
|
||||
|
||||
type Message = {
|
||||
@@ -22,11 +23,14 @@ type Input = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Past-chat mention search. Lists root sessions for the directory the current
|
||||
* chat runs in (workspace root for the sidebar, the worktree for Agent Manager
|
||||
* sessions) — the same directory-scoped `session.list` the session history and
|
||||
* Agent Manager search are built on. Fuzzy title filtering happens in the
|
||||
* webview (same mechanism as the Agent Manager sidebar search).
|
||||
* Past-chat mention search. Lists root sessions across the current directory's
|
||||
* worktree family (the repo root and its sibling worktrees for git projects,
|
||||
* just the directory itself otherwise) — the same family-wide listing the
|
||||
* Agent Manager session search and the CLI's past-chat picker are built on.
|
||||
* Every session in the family shares the project, so any of them can be
|
||||
* attached regardless of which worktree the current chat runs in. Fuzzy title
|
||||
* filtering happens in the webview (same mechanism as the Agent Manager
|
||||
* sidebar search).
|
||||
*/
|
||||
export async function handleSessionSearch(input: Input): Promise<void> {
|
||||
const client = input.client
|
||||
@@ -39,10 +43,18 @@ export async function handleSessionSearch(input: Input): Promise<void> {
|
||||
const dir = input.dir(id)
|
||||
|
||||
try {
|
||||
const res = await client.session.list({ directory: dir, roots: true, limit: 50 }, { throwOnError: true })
|
||||
const res = await client.experimental.session.list(
|
||||
{ worktrees: true, roots: true, directory: dir, limit: 50 },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const sessions: Item[] = res.data
|
||||
.filter((session) => session.id !== input.exclude && session.title)
|
||||
.map((session) => ({ id: session.id, title: session.title, updated: session.time.updated }))
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
updated: session.time.updated,
|
||||
worktreeName: session.worktreeName,
|
||||
}))
|
||||
input.post({ type: "sessionSearchResult", sessions, requestId: input.message.requestId })
|
||||
} catch (err) {
|
||||
console.error("[Kilo New] Session search failed:", err)
|
||||
|
||||
@@ -6,7 +6,7 @@ const NAMES = [
|
||||
"Providers",
|
||||
"Agent Behaviour",
|
||||
"Auto-Approve",
|
||||
"Browser",
|
||||
"Web Tools",
|
||||
"Checkpoints",
|
||||
"Display",
|
||||
"Autocomplete",
|
||||
|
||||
@@ -68,6 +68,10 @@ const IMPORTER_FILE = path.join(ROOT, "src/agent-manager/worktree-importer.ts")
|
||||
const SETUP_SCRIPT_RUNNER_FILE = path.join(ROOT, "src/agent-manager/SetupScriptRunner.ts")
|
||||
const RUN_MESSAGE_FILE = path.join(ROOT, "src/agent-manager/run/message.ts")
|
||||
const TERMINAL_ROUTING_FILE = path.join(ROOT, "src/agent-manager/terminal-routing.ts")
|
||||
const SCRIPT_TERMINAL_FILE = path.join(ROOT, "src/agent-manager/ScriptTerminalManager.ts")
|
||||
const SCRIPT_TERMINAL_RUNTIME_FILE = path.join(ROOT, "src/agent-manager/script-terminal-runtime.ts")
|
||||
const RUN_TASK_FILE = path.join(ROOT, "src/agent-manager/run/task.ts")
|
||||
const RUN_DESTINATION_FILE = path.join(ROOT, "src/agent-manager/run/destination.ts")
|
||||
|
||||
function readAllCss(): string {
|
||||
return CSS_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n")
|
||||
@@ -512,6 +516,50 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
expect(text).not.toContain("agentManager.requestState")
|
||||
})
|
||||
|
||||
it("routes script terminal close and resize messages before user terminals", () => {
|
||||
const text = body("dispatchMessage")
|
||||
expect(text.indexOf("this.scripts.manager.intercept(m)")).toBeLessThan(
|
||||
text.indexOf("this.terminalRouter.handle(m)"),
|
||||
)
|
||||
})
|
||||
|
||||
it("runs scripts through the vscode-free canonical PTY manager", () => {
|
||||
const text = fs.readFileSync(SCRIPT_TERMINAL_FILE, "utf-8")
|
||||
expect(text).toMatch(/client\.v2\.pty\s*\.create/)
|
||||
expect(text).toContain("client.v2.pty.get")
|
||||
expect(text).toContain("client.v2.pty.update")
|
||||
expect(text).toContain("client.v2.pty.remove")
|
||||
expect(text).not.toContain("vscode")
|
||||
})
|
||||
|
||||
it("selects the Run adapter from the panel dropdown message", () => {
|
||||
const text = fs.readFileSync(SCRIPT_TERMINAL_RUNTIME_FILE, "utf-8")
|
||||
expect(text).toContain("pickRunStart")
|
||||
expect(text).toContain("config.destination")
|
||||
expect(text).not.toContain("readRunTerminalDestination")
|
||||
expect(text.indexOf("pickRunStart")).toBeLessThan(text.indexOf("config.destination"))
|
||||
})
|
||||
|
||||
it("keeps the legacy integrated Run adapter isolated and removable", () => {
|
||||
const task = fs.readFileSync(RUN_TASK_FILE, "utf-8")
|
||||
expect(task).toContain("vscode.tasks.executeTask")
|
||||
expect(task).toContain("Remove this")
|
||||
const dest = fs.readFileSync(RUN_DESTINATION_FILE, "utf-8")
|
||||
expect(dest).not.toContain('from "vscode"')
|
||||
expect(dest).toContain("pickRunStart")
|
||||
expect(dest).not.toContain("getConfiguration")
|
||||
})
|
||||
|
||||
it("clears retained Run terminals before removing worktree state", () => {
|
||||
for (const name of ["onDeleteWorktree", "onRemoveStaleWorktree"]) {
|
||||
const text = body(name)
|
||||
expect(text).toContain("host.clearRun(worktreeId)")
|
||||
expect(text.indexOf("host.clearRun(worktreeId)")).toBeLessThan(text.indexOf("state.removeWorktree"))
|
||||
}
|
||||
const deleted = body("onDeleteWorktree")
|
||||
expect(deleted.indexOf("host.skipStats")).toBeLessThan(deleted.indexOf("host.removeRun"))
|
||||
})
|
||||
|
||||
// -- onDeleteWorktree invariants -------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -901,9 +949,6 @@ const VSCODE_ALLOWED: Record<string, { note: string }> = {
|
||||
"task-runner.ts": {
|
||||
note: "vscode adapter for SetupScriptRunner",
|
||||
},
|
||||
"run/task.ts": {
|
||||
note: "vscode adapter for Agent Manager run scripts",
|
||||
},
|
||||
// Reads terminal.integrated.* and editor.font* config for xterm font settings
|
||||
"terminal-font.ts": {
|
||||
note: "vscode config reader for integrated terminal font settings",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { terminalChrome } from "../../webview-ui/agent-manager/terminal/chrome"
|
||||
|
||||
describe("Agent Manager Run terminal chrome", () => {
|
||||
it("keeps the console icon for user terminals", () => {
|
||||
expect(terminalChrome("Terminal 1", undefined)).toEqual({ icon: "console", tooltip: "Terminal 1" })
|
||||
})
|
||||
|
||||
it("renders compact status icons with accessible Run status details", () => {
|
||||
expect(terminalChrome("Run", { state: "running" })).toEqual({ icon: "spinner", tooltip: "Run (Running)" })
|
||||
expect(terminalChrome("Run", { state: "stopping" })).toEqual({ icon: "spinner", tooltip: "Run (Stopping)" })
|
||||
expect(terminalChrome("Run", { state: "exited", exitCode: 0 })).toEqual({
|
||||
icon: "success",
|
||||
tooltip: "Run (Exited, code 0)",
|
||||
})
|
||||
expect(terminalChrome("Run", { state: "exited", exitCode: 1 })).toEqual({
|
||||
icon: "failure",
|
||||
tooltip: "Run (Exited, code 1)",
|
||||
})
|
||||
expect(terminalChrome("Run", { state: "failed" })).toEqual({ icon: "failure", tooltip: "Run (Failed)" })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
createSideTerminal,
|
||||
readSavedDestination,
|
||||
resolveRunScriptRequest,
|
||||
resolveVscodeTerminalRequest,
|
||||
} from "../../webview-ui/agent-manager/terminal/side"
|
||||
|
||||
@@ -98,6 +99,35 @@ describe("Agent Manager side terminal controller", () => {
|
||||
expect(panelFirst.calls.openVscode).toBe(0)
|
||||
})
|
||||
|
||||
it("handles Cmd/Ctrl+/ presses locally and dedupes the extension echo", () => {
|
||||
const press = (key: string, opts: Partial<KeyboardEvent> = {}) =>
|
||||
({ key, metaKey: true, ctrlKey: false, shiftKey: false, altKey: false, ...opts }) as KeyboardEvent
|
||||
|
||||
const item = scene({ destination: "agentManager" })
|
||||
expect(item.ctl.press(press("/"))).toBe(true)
|
||||
expect(item.calls.requestSide).toBe(1)
|
||||
// The extension echoes the same keypress back as an action message;
|
||||
// it must be ignored so the panel does not toggle twice.
|
||||
expect(item.ctl.echo()).toBe(true)
|
||||
|
||||
// Unrelated keys and modifier combinations are not the shortcut.
|
||||
const other = scene({ destination: "agentManager" })
|
||||
expect(other.ctl.press(press("?"))).toBe(false)
|
||||
expect(other.ctl.press(press("/", { shiftKey: true }))).toBe(false)
|
||||
expect(other.ctl.press(press("/", { altKey: true }))).toBe(false)
|
||||
expect(other.ctl.press(press("/", { metaKey: false }))).toBe(false)
|
||||
expect(other.ctl.press(press("/", { metaKey: false, ctrlKey: true }))).toBe(true)
|
||||
expect(other.calls.requestSide).toBe(1)
|
||||
})
|
||||
|
||||
it("stops deduping after the echo window passes", async () => {
|
||||
const item = scene({ destination: "agentManager" })
|
||||
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
|
||||
expect(item.ctl.echo()).toBe(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 550))
|
||||
expect(item.ctl.echo()).toBe(false)
|
||||
})
|
||||
|
||||
it("persists the picked destination with a section-relative settings key", () => {
|
||||
const item = scene()
|
||||
item.ctl.choose("agentManager")
|
||||
@@ -147,6 +177,21 @@ describe("readSavedDestination", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveRunScriptRequest", () => {
|
||||
it("carries the current panel dropdown destination with every Run request", () => {
|
||||
expect(resolveRunScriptRequest("wt-1", "agentManager")).toEqual({
|
||||
type: "agentManager.runScript",
|
||||
worktreeId: "wt-1",
|
||||
destination: "agentManager",
|
||||
})
|
||||
expect(resolveRunScriptRequest("local", "vscode")).toEqual({
|
||||
type: "agentManager.runScript",
|
||||
worktreeId: "local",
|
||||
destination: "vscode",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveVscodeTerminalRequest", () => {
|
||||
const sessions = new Map([
|
||||
["wt-1", "session-a"],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createTerminalHandlers,
|
||||
createTerminalMessageHandler,
|
||||
createTerminalState,
|
||||
isTerminalTabId,
|
||||
} from "../../webview-ui/agent-manager/terminal/state"
|
||||
import type { ExtensionMessage } from "../../webview-ui/src/types/messages/extension-messages"
|
||||
|
||||
@@ -14,7 +15,14 @@ function scene(initial: string | null = LOCAL) {
|
||||
const [selection, setSelection] = createSignal<string | null>(initial)
|
||||
const state = createTerminalState(selection)
|
||||
const posted: Array<Record<string, unknown>> = []
|
||||
const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], errors: 0 }
|
||||
const events = {
|
||||
activated: [] as string[],
|
||||
selected: [] as string[],
|
||||
saved: 0,
|
||||
shown: [] as string[],
|
||||
errors: 0,
|
||||
running: [] as Array<{ contextKey: string; terminalId: string }>,
|
||||
}
|
||||
const tabs = () => state.current().map((term) => term.id)
|
||||
const handlers = createTerminalHandlers({
|
||||
state,
|
||||
@@ -41,6 +49,7 @@ function scene(initial: string | null = LOCAL) {
|
||||
},
|
||||
showError: () => events.errors++,
|
||||
postMessage: (message) => posted.push(message as Record<string, unknown>),
|
||||
onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }),
|
||||
})
|
||||
return { state, selection, setSelection, posted, events, handlers, dispatch }
|
||||
}
|
||||
@@ -58,6 +67,28 @@ function createdSide(createId: string, terminalId: string, title = "Terminal 1")
|
||||
} satisfies ExtensionMessage
|
||||
}
|
||||
|
||||
function script(
|
||||
terminalId: string,
|
||||
state: "running" | "stopping" | "exited" | "failed" = "running",
|
||||
exitCode?: number,
|
||||
) {
|
||||
return {
|
||||
type: "agentManager.scriptTerminals",
|
||||
terminals: [
|
||||
{
|
||||
terminalId,
|
||||
worktreeId: null,
|
||||
kind: "run",
|
||||
title: "Run",
|
||||
wsUrl: `ws://${terminalId}`,
|
||||
state,
|
||||
...(exitCode === undefined ? {} : { exitCode }),
|
||||
font,
|
||||
},
|
||||
],
|
||||
} satisfies ExtensionMessage
|
||||
}
|
||||
|
||||
describe("Agent Manager terminal state", () => {
|
||||
it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => {
|
||||
createRoot((dispose) => {
|
||||
@@ -91,6 +122,47 @@ describe("Agent Manager terminal state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("hydrates complete Run snapshots without create ids and preserves mounted terminal records", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" })
|
||||
const user = item.state.sidesForContext(LOCAL)[0]!
|
||||
|
||||
expect(item.dispatch(script("script:run"))).toBe(true)
|
||||
const run = item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run")
|
||||
expect(run).toMatchObject({ title: "Run", placement: "side", kind: "run", contextKey: LOCAL })
|
||||
expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }])
|
||||
expect(item.state.scriptStatus("script:run")).toEqual({ state: "running" })
|
||||
expect(isTerminalTabId("script:run")).toBe(true)
|
||||
|
||||
item.state.setTitle("script:run", "npm test")
|
||||
expect(item.state.title("script:run")).toBe("Run")
|
||||
|
||||
item.dispatch(script("script:run", "exited", 0))
|
||||
expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run")).toBe(run)
|
||||
expect(item.state.scriptStatus("script:run")).toEqual({ state: "exited", exitCode: 0 })
|
||||
expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "terminal:user")).toBe(user)
|
||||
// Existing snapshots update status only; they do not re-open the inspector.
|
||||
expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }])
|
||||
|
||||
item.dispatch({ type: "agentManager.scriptTerminals", terminals: [] } satisfies ExtensionMessage)
|
||||
expect(item.state.sidesForContext(LOCAL)).toEqual([user])
|
||||
expect(item.state.scriptStatus("script:run")).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("maps Local Run snapshots to LOCAL and does not reveal exited terminals", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.dispatch(script("script:exit", "exited", 2))
|
||||
|
||||
expect(item.state.sidesForContext(LOCAL)[0]).toMatchObject({ id: "script:exit", contextKey: LOCAL })
|
||||
expect(item.events.running).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
@@ -170,6 +242,26 @@ describe("Agent Manager terminal state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("waits for Run closure confirmation while user terminal closes stay optimistic", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" })
|
||||
item.dispatch(script("script:run"))
|
||||
|
||||
expect(item.handlers.closeSide("script:run")).toBe(true)
|
||||
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:user", "script:run"])
|
||||
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "script:run" }])
|
||||
|
||||
expect(item.handlers.closeSide("terminal:user")).toBe(true)
|
||||
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["script:run"])
|
||||
expect(item.posted).toEqual([
|
||||
{ type: "agentManager.terminal.close", terminalId: "script:run" },
|
||||
{ type: "agentManager.terminal.close", terminalId: "terminal:user" },
|
||||
])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("closes a stale side answer whose create request is unknown", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("Extension — package.json command sync", () => {
|
||||
expect(terminal).toMatchObject({
|
||||
key: "ctrl+/",
|
||||
mac: "cmd+/",
|
||||
when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'",
|
||||
when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !kilo-code.new.sidebarFocused",
|
||||
})
|
||||
expect(create).toMatchObject({
|
||||
key: "ctrl+shift+t",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
import type { RunController } from "../../src/agent-manager/run/controller"
|
||||
import { handleRunMessage } from "../../src/agent-manager/run/message"
|
||||
import type { AgentManagerInMessage } from "../../src/agent-manager/types"
|
||||
|
||||
function controller() {
|
||||
const run = mock(() => Promise.resolve())
|
||||
const stop = mock(() => undefined)
|
||||
const configure = mock(() => Promise.resolve())
|
||||
return {
|
||||
value: { run, stop, configure } as unknown as RunController,
|
||||
run,
|
||||
stop,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
describe("Agent Manager Run messages", () => {
|
||||
it.each(["agentManager", "vscode"] as const)("forwards the %s dropdown destination", (destination) => {
|
||||
const item = controller()
|
||||
const msg = {
|
||||
type: "agentManager.runScript",
|
||||
worktreeId: "wt-1",
|
||||
destination,
|
||||
} satisfies AgentManagerInMessage
|
||||
|
||||
expect(handleRunMessage(item.value, msg)).toBe(true)
|
||||
expect(item.run).toHaveBeenCalledWith("wt-1", destination)
|
||||
})
|
||||
})
|
||||
@@ -86,7 +86,7 @@ describe("RunScriptManager", () => {
|
||||
let stopped = 0
|
||||
await ctx.manager.start("wt-1", async () => ({ stop: () => stopped++ }))
|
||||
|
||||
ctx.manager.remove("wt-1")
|
||||
await ctx.manager.remove("wt-1")
|
||||
|
||||
expect(stopped).toBe(1)
|
||||
expect(ctx.manager.all()).toEqual([])
|
||||
@@ -123,12 +123,28 @@ describe("RunScriptManager", () => {
|
||||
it("finish after remove does not resurrect stale state", async () => {
|
||||
const ctx = createManager()
|
||||
await ctx.manager.start("wt-1", async () => ({ stop: () => {} }))
|
||||
ctx.manager.remove("wt-1")
|
||||
await ctx.manager.remove("wt-1")
|
||||
ctx.manager.finish("wt-1", { exitCode: 0 })
|
||||
|
||||
expect(ctx.manager.all()).toEqual([])
|
||||
})
|
||||
|
||||
it("stops and disposes once when removal races startup", async () => {
|
||||
const ctx = createManager()
|
||||
const gate = deferred<RunHandle>()
|
||||
let stopped = 0
|
||||
let disposed = 0
|
||||
const started = ctx.manager.start("wt-1", () => gate.promise)
|
||||
const removed = ctx.manager.remove("wt-1")
|
||||
|
||||
gate.resolve({ stop: () => stopped++, dispose: () => disposed++ })
|
||||
await Promise.all([started, removed])
|
||||
|
||||
expect(stopped).toBe(1)
|
||||
expect(disposed).toBe(1)
|
||||
expect(ctx.manager.all()).toEqual([])
|
||||
})
|
||||
|
||||
it("dispose tolerates handles that throw on stop", async () => {
|
||||
const ctx = createManager()
|
||||
await ctx.manager.start("wt-1", async () => ({
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { StartTask } from "../../src/agent-manager/run/controller"
|
||||
import { pickRunStart } from "../../src/agent-manager/run/destination"
|
||||
|
||||
describe("Run terminal destination", () => {
|
||||
it("picks the adapter matching the panel dropdown destination", () => {
|
||||
const handle = { stop: () => undefined, dispose: () => undefined }
|
||||
const embedded: StartTask = async () => handle
|
||||
const integrated: StartTask = async () => handle
|
||||
|
||||
expect(pickRunStart("agentManager", embedded, integrated)).toBe(embedded)
|
||||
expect(pickRunStart("vscode", embedded, integrated)).toBe(integrated)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { ScriptTerminalManager, type ScriptTerminalView } from "../../src/agent-manager/ScriptTerminalManager"
|
||||
import { buildScriptTerminalWsUrl } from "../../src/agent-manager/script-terminal-url"
|
||||
import { RunScriptManager, type RunStatus } from "../../src/agent-manager/run/manager"
|
||||
|
||||
interface PtyInput {
|
||||
location?: { directory?: string }
|
||||
command?: string
|
||||
args?: string[]
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface PtyUpdate {
|
||||
ptyID: string
|
||||
location?: { directory?: string }
|
||||
size?: { cols: number; rows: number }
|
||||
}
|
||||
|
||||
interface PtyInfo {
|
||||
id: string
|
||||
title: string
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
status: "running" | "exited"
|
||||
pid: number
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
interface PtyResponse {
|
||||
data?: { location: { directory: string }; data: PtyInfo }
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function wait(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => undefined
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function info(status: PtyInfo["status"] = "running", exitCode?: number): PtyInfo {
|
||||
return {
|
||||
id: "pty-1",
|
||||
title: "Run",
|
||||
command: "bun",
|
||||
args: ["run", "check"],
|
||||
cwd: "/repo/worktree",
|
||||
status,
|
||||
pid: 42,
|
||||
...(exitCode === undefined ? {} : { exitCode }),
|
||||
}
|
||||
}
|
||||
|
||||
function harness(opts?: {
|
||||
create?: (input: PtyInput) => Promise<PtyResponse>
|
||||
get?: () => Promise<PtyResponse>
|
||||
remove?: () => Promise<{ data?: unknown; error?: unknown }>
|
||||
}) {
|
||||
const calls: { create: PtyInput[]; get: unknown[]; update: PtyUpdate[]; remove: unknown[] } = {
|
||||
create: [],
|
||||
get: [],
|
||||
update: [],
|
||||
remove: [],
|
||||
}
|
||||
const snapshots: ScriptTerminalView[][] = []
|
||||
const closed: string[] = []
|
||||
const logs: string[] = []
|
||||
const client = {
|
||||
v2: {
|
||||
pty: {
|
||||
create: async (input: PtyInput) => {
|
||||
calls.create.push(input)
|
||||
return opts?.create ? opts.create(input) : { data: { location: { directory: config.cwd }, data: info() } }
|
||||
},
|
||||
get: async (input: unknown) => {
|
||||
calls.get.push(input)
|
||||
return opts?.get ? opts.get() : { data: { location: { directory: config.cwd }, data: info() } }
|
||||
},
|
||||
update: async (input: PtyUpdate) => {
|
||||
calls.update.push(input)
|
||||
return { data: info() }
|
||||
},
|
||||
remove: async (input: unknown) => {
|
||||
calls.remove.push(input)
|
||||
return opts?.remove ? opts.remove() : { data: undefined }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const manager = new ScriptTerminalManager({
|
||||
getClient: () => client,
|
||||
getClientAsync: async () => client,
|
||||
buildWsUrl: (ptyID, cwd) => `ws://127.0.0.1:4096/api/pty/${ptyID}/connect?location=${cwd}`,
|
||||
getTerminalFont: () => ({ fontFamily: "Menlo", fontSize: 12 }),
|
||||
emit: (terminals) => snapshots.push(terminals),
|
||||
closed: (terminalId) => closed.push(terminalId),
|
||||
log: (msg) => logs.push(msg),
|
||||
})
|
||||
return { manager, calls, snapshots, closed, logs }
|
||||
}
|
||||
|
||||
const config = {
|
||||
worktreeId: "wt-1",
|
||||
command: "bun",
|
||||
args: ["run", "check"],
|
||||
cwd: "/repo/worktree",
|
||||
env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" },
|
||||
}
|
||||
|
||||
describe("ScriptTerminalManager", () => {
|
||||
it("creates a Run PTY with explicit command settings and a safe snapshot", async () => {
|
||||
const ctx = harness()
|
||||
const done: unknown[] = []
|
||||
|
||||
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
||||
|
||||
expect(ctx.calls.create).toEqual([
|
||||
{
|
||||
location: { directory: "/repo/worktree" },
|
||||
command: "bun",
|
||||
args: ["run", "check"],
|
||||
cwd: "/repo/worktree",
|
||||
env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" },
|
||||
title: "Run",
|
||||
},
|
||||
])
|
||||
expect(ctx.calls.get).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([
|
||||
expect.objectContaining({
|
||||
worktreeId: "wt-1",
|
||||
kind: "run",
|
||||
title: "Run",
|
||||
state: "running",
|
||||
font: { fontFamily: "Menlo", fontSize: 12 },
|
||||
}),
|
||||
])
|
||||
expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"command"')
|
||||
expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"env"')
|
||||
expect(done).toEqual([])
|
||||
})
|
||||
|
||||
it("normalizes the internal local Run key to a null external worktree id", async () => {
|
||||
const ctx = harness()
|
||||
|
||||
await ctx.manager.start("run", { ...config, worktreeId: "local", cwd: "/repo" }, () => undefined)
|
||||
|
||||
expect(ctx.snapshots.at(-1)?.[0]?.worktreeId).toBeNull()
|
||||
})
|
||||
|
||||
it("builds canonical authenticated replay URLs", () => {
|
||||
const value = buildScriptTerminalWsUrl(
|
||||
{ baseUrl: "http://127.0.0.1:4096", password: "secret" },
|
||||
"pty / 1",
|
||||
"/repo/worktree",
|
||||
)
|
||||
const url = new URL(value)
|
||||
|
||||
expect(url.protocol).toBe("ws:")
|
||||
expect(url.pathname).toBe("/api/pty/pty%20%2F%201/connect")
|
||||
expect(url.searchParams.get("location[directory]")).toBe("/repo/worktree")
|
||||
expect(url.searchParams.get("cursor")).toBe("0")
|
||||
expect(url.searchParams.get("replayExited")).toBe("1")
|
||||
expect(url.searchParams.get("auth_token")).toBe(Buffer.from("kilo:secret").toString("base64"))
|
||||
})
|
||||
|
||||
it("finishes once on a natural exit and retains the replayable terminal", async () => {
|
||||
const ctx = harness()
|
||||
const done: unknown[] = []
|
||||
|
||||
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
||||
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
||||
if (!terminalId) throw new Error("missing Run terminal")
|
||||
ctx.manager.exited("pty-1", 17)
|
||||
ctx.manager.exited("pty-1", 17)
|
||||
|
||||
expect(done).toEqual([{ exitCode: 17 }])
|
||||
expect(ctx.calls.remove).toEqual([])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "exited", exitCode: 17 })])
|
||||
|
||||
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
|
||||
await wait()
|
||||
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
||||
expect(ctx.closed).toEqual([terminalId])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("reconciles a PTY that exited before registration", async () => {
|
||||
const ctx = harness({
|
||||
get: async () => ({ data: { location: { directory: config.cwd }, data: info("exited", 7) } }),
|
||||
})
|
||||
const done: unknown[] = []
|
||||
|
||||
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
||||
|
||||
expect(done).toEqual([{ exitCode: 7 }])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 7 })])
|
||||
})
|
||||
|
||||
it("reconciles an exit event that arrives before create registration", async () => {
|
||||
const gate = deferred<PtyResponse>()
|
||||
let state = info()
|
||||
const ctx = harness({
|
||||
create: async () => gate.promise,
|
||||
get: async () => ({ data: { location: { directory: config.cwd }, data: state } }),
|
||||
})
|
||||
const done: unknown[] = []
|
||||
const started = ctx.manager.start("run", config, (exit) => done.push(exit))
|
||||
|
||||
await wait()
|
||||
state = info("exited", 9)
|
||||
ctx.manager.exited("pty-1", 9)
|
||||
gate.resolve({ data: { location: { directory: config.cwd }, data: info() } })
|
||||
await started
|
||||
|
||||
expect(done).toEqual([{ exitCode: 9 }])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 9 })])
|
||||
})
|
||||
|
||||
it("treats an already removed backend PTY as a successful close", async () => {
|
||||
const ctx = harness({ remove: async () => ({ error: { _tag: "PtyNotFoundError", status: 404 } }) })
|
||||
const done: unknown[] = []
|
||||
|
||||
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
||||
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
||||
if (!terminalId) throw new Error("missing Run terminal")
|
||||
|
||||
expect(await ctx.manager.close(terminalId)).toBe(true)
|
||||
expect(done).toEqual([{ stopped: true }])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("stops a PTY when stop races startup", async () => {
|
||||
const gate = deferred<PtyResponse>()
|
||||
const ctx = harness({ create: async () => gate.promise })
|
||||
const statuses: RunStatus[] = []
|
||||
const run = new RunScriptManager(
|
||||
() => undefined,
|
||||
(status) => statuses.push({ ...status }),
|
||||
() => new Date("2026-01-02T03:04:05.000Z"),
|
||||
)
|
||||
const started = run.start("wt-1", () => ctx.manager.start("run", config, (exit) => run.finish("wt-1", exit)))
|
||||
|
||||
await wait()
|
||||
await run.stop("wt-1")
|
||||
gate.resolve({ data: { location: { directory: config.cwd }, data: info() } })
|
||||
await started
|
||||
await wait()
|
||||
|
||||
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
||||
expect(statuses.map((status) => status.state)).toEqual(["running", "stopping", "idle"])
|
||||
expect(run.status("wt-1")).toMatchObject({ state: "idle", stopped: true })
|
||||
})
|
||||
|
||||
it("intercepts resize and stops a running terminal when it closes", async () => {
|
||||
const ctx = harness()
|
||||
const done: unknown[] = []
|
||||
|
||||
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
||||
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
||||
if (!terminalId) throw new Error("missing Run terminal")
|
||||
expect(ctx.manager.intercept({ type: "agentManager.terminal.resize", terminalId, cols: 120, rows: 40 })).toBe(true)
|
||||
await wait()
|
||||
expect(ctx.calls.update).toEqual([
|
||||
{ ptyID: "pty-1", location: { directory: "/repo/worktree" }, size: { cols: 120, rows: 40 } },
|
||||
])
|
||||
|
||||
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
|
||||
await wait()
|
||||
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
||||
expect(done).toEqual([{ stopped: true }])
|
||||
expect(ctx.closed).toEqual([terminalId])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("retries closure after a Run terminal removal fails", async () => {
|
||||
let attempt = 0
|
||||
const ctx = harness({
|
||||
remove: async () => {
|
||||
attempt++
|
||||
if (attempt === 1) return { error: new Error("still running") }
|
||||
return { data: undefined }
|
||||
},
|
||||
})
|
||||
|
||||
await ctx.manager.start("run", config, () => undefined)
|
||||
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
||||
if (!terminalId) throw new Error("missing Run terminal")
|
||||
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
|
||||
await wait()
|
||||
|
||||
expect(ctx.closed).toEqual([])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })])
|
||||
|
||||
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
|
||||
await wait()
|
||||
|
||||
expect(ctx.calls.remove).toHaveLength(2)
|
||||
expect(ctx.closed).toEqual([terminalId])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("drops a retained Run terminal when the backend evicts it", async () => {
|
||||
const ctx = harness()
|
||||
const done: unknown[] = []
|
||||
|
||||
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
||||
ctx.manager.exited("pty-1", 0)
|
||||
ctx.manager.deleted("pty-1")
|
||||
|
||||
expect(done).toEqual([{ exitCode: 0 }])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([])
|
||||
expect(ctx.calls.remove).toEqual([])
|
||||
})
|
||||
|
||||
it("reconciles a natural exit missed during an event-stream reconnect", async () => {
|
||||
let state: PtyInfo = info()
|
||||
const ctx = harness({ get: async () => ({ data: { location: { directory: config.cwd }, data: state } }) })
|
||||
const done: unknown[] = []
|
||||
|
||||
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
||||
state = info("exited", 23)
|
||||
await ctx.manager.sync()
|
||||
|
||||
expect(done).toEqual([{ exitCode: 23 }])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 23 })])
|
||||
})
|
||||
|
||||
it("clears retained exited terminals by worktree context", async () => {
|
||||
const ctx = harness()
|
||||
|
||||
await ctx.manager.start("run", config, () => undefined)
|
||||
ctx.manager.exited("pty-1", 0)
|
||||
|
||||
expect(await ctx.manager.clear("run", "wt-1")).toBe(true)
|
||||
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
||||
expect(ctx.snapshots.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("replays the full retained snapshot after a webview reload", async () => {
|
||||
const ctx = harness()
|
||||
|
||||
await ctx.manager.start("run", config, () => undefined)
|
||||
const first = ctx.snapshots.at(-1)
|
||||
ctx.manager.snapshot()
|
||||
|
||||
expect(ctx.snapshots.at(-1)).toEqual(first)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { handleSessionSearch } from "../../src/kilo-provider/session-search"
|
||||
|
||||
type Query = Record<string, unknown>
|
||||
|
||||
function stub(data: Array<Record<string, unknown>> | Error) {
|
||||
const calls: Query[] = []
|
||||
const client = {
|
||||
experimental: {
|
||||
session: {
|
||||
list: async (query: Query) => {
|
||||
calls.push(query)
|
||||
if (data instanceof Error) throw data
|
||||
return { data }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return { calls, client }
|
||||
}
|
||||
|
||||
function session(id: string, title: string, updated: number, worktreeName?: string) {
|
||||
return { id, title, time: { updated }, worktreeName }
|
||||
}
|
||||
|
||||
describe("handleSessionSearch", () => {
|
||||
it("lists root sessions across the worktree family for the resolved directory", async () => {
|
||||
const { calls, client } = stub([session("ses_a", "Alpha", 2, "neon-author")])
|
||||
const posted: unknown[] = []
|
||||
|
||||
await handleSessionSearch({
|
||||
client: client as never,
|
||||
message: { requestId: "r1", sessionID: "ses_current" },
|
||||
dir: (id) => (id === "ses_current" ? "/repo/.kilo/worktrees/wt-1" : "/repo"),
|
||||
post: (msg) => posted.push(msg),
|
||||
})
|
||||
|
||||
expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 50 }])
|
||||
expect(posted).toEqual([
|
||||
{
|
||||
type: "sessionSearchResult",
|
||||
sessions: [{ id: "ses_a", title: "Alpha", updated: 2, worktreeName: "neon-author" }],
|
||||
requestId: "r1",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("falls back to the current and context sessions for directory resolution", async () => {
|
||||
const { calls, client } = stub([])
|
||||
|
||||
await handleSessionSearch({
|
||||
client: client as never,
|
||||
message: { requestId: "r2" },
|
||||
current: "ses_current",
|
||||
context: "ses_context",
|
||||
dir: (id) => `/dir/${id}`,
|
||||
post: () => {},
|
||||
})
|
||||
|
||||
expect(calls[0]?.directory).toBe("/dir/ses_current")
|
||||
|
||||
await handleSessionSearch({
|
||||
client: client as never,
|
||||
message: { requestId: "r3" },
|
||||
context: "ses_context",
|
||||
dir: (id) => `/dir/${id}`,
|
||||
post: () => {},
|
||||
})
|
||||
|
||||
expect(calls[1]?.directory).toBe("/dir/ses_context")
|
||||
})
|
||||
|
||||
it("excludes the given session and sessions without titles", async () => {
|
||||
const { client } = stub([
|
||||
session("ses_keep", "Keep", 3),
|
||||
session("ses_exclude", "Excluded", 2),
|
||||
session("ses_untitled", "", 1),
|
||||
])
|
||||
const posted: Array<{ sessions: Array<{ id: string }> }> = []
|
||||
|
||||
await handleSessionSearch({
|
||||
client: client as never,
|
||||
message: { requestId: "r4" },
|
||||
dir: () => "/repo",
|
||||
exclude: "ses_exclude",
|
||||
post: (msg) => posted.push(msg as never),
|
||||
})
|
||||
|
||||
expect(posted[0]?.sessions.map((s) => s.id)).toEqual(["ses_keep"])
|
||||
})
|
||||
|
||||
it("posts an empty result when the client is missing or the list fails", async () => {
|
||||
const posted: unknown[] = []
|
||||
|
||||
await handleSessionSearch({
|
||||
client: null,
|
||||
message: { requestId: "r5" },
|
||||
dir: () => "/repo",
|
||||
post: (msg) => posted.push(msg),
|
||||
})
|
||||
|
||||
const failing = stub(new Error("boom"))
|
||||
await handleSessionSearch({
|
||||
client: failing.client as never,
|
||||
message: { requestId: "r6" },
|
||||
dir: () => "/repo",
|
||||
post: (msg) => posted.push(msg),
|
||||
})
|
||||
|
||||
expect(posted).toEqual([
|
||||
{ type: "sessionSearchResult", sessions: [], requestId: "r5" },
|
||||
{ type: "sessionSearchResult", sessions: [], requestId: "r6" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
SessionInfo,
|
||||
SessionCreatedMessage,
|
||||
BranchInfo,
|
||||
TerminalDestination,
|
||||
} from "../src/types/messages"
|
||||
import { IndexingProvider } from "../src/context/indexing"
|
||||
import {} from "@thisbeyond/solid-dnd"
|
||||
@@ -128,6 +129,7 @@ import {
|
||||
createTerminalMessageHandler,
|
||||
createSideTerminal,
|
||||
readSavedDestination,
|
||||
resolveRunScriptRequest,
|
||||
resolveVscodeTerminalRequest,
|
||||
} from "./terminal"
|
||||
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
|
||||
@@ -453,20 +455,20 @@ const AgentManagerContent: Component = () => {
|
||||
vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel })
|
||||
}
|
||||
|
||||
const runWorktree = (id: string) => {
|
||||
const runWorktree = (id: string, destination: TerminalDestination) => {
|
||||
const state = runStatuses()[id]?.state ?? "idle"
|
||||
if (state === "running" || state === "stopping") {
|
||||
vscode.postMessage({ type: "agentManager.stopRunScript", worktreeId: id })
|
||||
return
|
||||
}
|
||||
vscode.postMessage({ type: "agentManager.runScript", worktreeId: id })
|
||||
vscode.postMessage(resolveRunScriptRequest(id, destination))
|
||||
}
|
||||
|
||||
const configureRunScript = () => vscode.postMessage({ type: "agentManager.configureRunScript" })
|
||||
|
||||
const runSelected = () => {
|
||||
const sel = selection()
|
||||
if (sel) runWorktree(sel)
|
||||
if (sel) runWorktree(sel, sideCtl.destination())
|
||||
}
|
||||
|
||||
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
|
||||
@@ -1066,7 +1068,7 @@ const AgentManagerContent: Component = () => {
|
||||
requestAnimationFrame(() => sidebarSearchMenu?.open())
|
||||
}
|
||||
} else if (msg.action === "showTerminal") {
|
||||
sideCtl.openPreferred("keyboard_shortcut")
|
||||
if (!sideCtl.echo()) sideCtl.openPreferred("keyboard_shortcut")
|
||||
} else if (msg.action === "toggleDiff") {
|
||||
if (reviewActive()) {
|
||||
closeReviewTab()
|
||||
@@ -1124,6 +1126,13 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
window.addEventListener("keydown", preventDefaults, true)
|
||||
|
||||
// Cmd/Ctrl+/ toggles the terminal even when VS Code's webview keybinding
|
||||
// forwarding drops the key before it reaches the workbench (reported with
|
||||
// the prompt input focused). When forwarding does work, the extension
|
||||
// echoes the shortcut back as an action message and sideCtl dedupes it.
|
||||
const shortcut = (e: KeyboardEvent) => sideCtl.press(e)
|
||||
window.addEventListener("keydown", shortcut, true)
|
||||
|
||||
// Delete/Backspace on a selected worktree triggers inline delete confirmation.
|
||||
// Pressing the key twice in a row (within the 2500ms window) confirms the delete.
|
||||
const deleteKeyHandler = (e: KeyboardEvent) => {
|
||||
@@ -1226,6 +1235,12 @@ const AgentManagerContent: Component = () => {
|
||||
// a slow create landing after a mode switch must not steal it.
|
||||
if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId)
|
||||
},
|
||||
onScriptRunning: (contextKey, terminalId) => {
|
||||
if (terms.sideKey() !== contextKey) return
|
||||
showSideTerminal()
|
||||
terms.setSideActive(contextKey, terminalId)
|
||||
terms.requestFocus(terminalId)
|
||||
},
|
||||
onDestinationChanged: (destination) => sideCtl.syncDefault(destination),
|
||||
})
|
||||
const unsubTerminals = vscode.onMessage((msg) => {
|
||||
@@ -1423,6 +1438,7 @@ const AgentManagerContent: Component = () => {
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("message", handler)
|
||||
window.removeEventListener("keydown", preventDefaults, true)
|
||||
window.removeEventListener("keydown", shortcut, true)
|
||||
window.removeEventListener("keydown", deleteKeyHandler)
|
||||
window.removeEventListener("keydown", modTrack, true)
|
||||
window.removeEventListener("keyup", modTrack, true)
|
||||
@@ -2283,7 +2299,7 @@ const AgentManagerContent: Component = () => {
|
||||
onApply={openApplyDialog}
|
||||
runStatuses={runStatuses}
|
||||
runConfigured={runScriptConfigured}
|
||||
onRun={runWorktree}
|
||||
onRun={(id) => runWorktree(id, sideCtl.destination())}
|
||||
onConfigureRun={configureRunScript}
|
||||
diffOpen={diffOpen}
|
||||
reviewActive={reviewActive}
|
||||
|
||||
@@ -1445,6 +1445,19 @@ button.am-section-toggle:hover .am-section-label {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.am-tab-icon[data-run-status="success"] {
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
|
||||
.am-tab-icon[data-run-status="failure"] {
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
|
||||
.am-terminal-tab-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.am-tab-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -104,6 +104,7 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
<TerminalTabChrome
|
||||
label={props.state.title(term.id) ?? term.title}
|
||||
tooltip={props.state.title(term.id) ?? term.title}
|
||||
status={props.state.scriptStatus(term.id)}
|
||||
active={props.state.sideActiveFor(props.contextKey()) === term.id}
|
||||
role="tab"
|
||||
selected={props.state.sideActiveFor(props.contextKey()) === term.id}
|
||||
|
||||
@@ -12,15 +12,19 @@
|
||||
import { Component, Show, type JSX } 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 { terminalChrome } from "./chrome"
|
||||
import type { ScriptTerminalStatus } from "./state"
|
||||
|
||||
export const TerminalTabChrome: Component<{
|
||||
label: string
|
||||
tooltip: string
|
||||
status?: ScriptTerminalStatus
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
active: boolean
|
||||
@@ -33,19 +37,27 @@ export const TerminalTabChrome: Component<{
|
||||
onClose: (e: 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" : ""}`}>
|
||||
<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}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={props.tooltip}
|
||||
title={chrome().tooltip}
|
||||
keybind={props.keybind ?? ""}
|
||||
placement="bottom"
|
||||
gutter={8}
|
||||
@@ -53,8 +65,10 @@ export const TerminalTabChrome: Component<{
|
||||
openDelay={0}
|
||||
>
|
||||
<span class="am-tab-title">
|
||||
<span class="am-tab-icon">
|
||||
<Icon name="console" size="small" />
|
||||
<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>
|
||||
@@ -86,6 +100,7 @@ export const SortableTerminalTab: Component<{
|
||||
id: string
|
||||
label: string
|
||||
tooltip: string
|
||||
status?: ScriptTerminalStatus
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
active: boolean
|
||||
@@ -106,6 +121,7 @@ export const SortableTerminalTab: Component<{
|
||||
<TerminalTabChrome
|
||||
label={props.label}
|
||||
tooltip={props.tooltip}
|
||||
status={props.status}
|
||||
keybind={props.keybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
active={props.active}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ScriptTerminalStatus } from "./state"
|
||||
|
||||
export type TerminalChromeIcon = "console" | "spinner" | "success" | "failure"
|
||||
|
||||
export interface TerminalChrome {
|
||||
icon: TerminalChromeIcon
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
/** Keep Run status in the existing tab chrome rather than adding another layout. */
|
||||
export function terminalChrome(title: string, status: ScriptTerminalStatus | undefined): TerminalChrome {
|
||||
if (!status) return { icon: "console", tooltip: title }
|
||||
if (status.state === "running") return { icon: "spinner", tooltip: `${title} (Running)` }
|
||||
if (status.state === "stopping") return { icon: "spinner", tooltip: `${title} (Stopping)` }
|
||||
if (status.state === "exited" && status.exitCode === 0)
|
||||
return { icon: "success", tooltip: `${title} (Exited, code 0)` }
|
||||
if (status.state === "exited")
|
||||
return { icon: "failure", tooltip: `${title} (Exited, code ${status.exitCode ?? "unknown"})` }
|
||||
return {
|
||||
icon: "failure",
|
||||
tooltip: `${title} (Failed${status.exitCode === undefined ? "" : `, code ${status.exitCode}`})`,
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,6 @@ export type { TerminalTabState, TerminalStateControls, TerminalHandlerDeps } fro
|
||||
export { renderTerminalTab, renderTerminalLayer, renderSideTerminalLayer } from "./render"
|
||||
export { SideTerminalPanel } from "./SideTerminalPanel"
|
||||
export { TerminalDestinationButton } from "./TerminalDestinationButton"
|
||||
export { createSideTerminal, readSavedDestination, resolveVscodeTerminalRequest } from "./side"
|
||||
export { createSideTerminal, readSavedDestination, resolveRunScriptRequest, resolveVscodeTerminalRequest } from "./side"
|
||||
export { TerminalTab } from "./TerminalTab"
|
||||
export { SortableTerminalTab } from "./SortableTerminalTab"
|
||||
|
||||
@@ -48,6 +48,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
|
||||
id={deps.id}
|
||||
label={deps.terms.title(deps.id) ?? term.title}
|
||||
tooltip={deps.terms.title(deps.id) ?? term.title}
|
||||
status={deps.terms.scriptStatus(deps.id)}
|
||||
keybind={isActive() ? "" : deps.keybind()}
|
||||
closeKeybind={deps.closeKeybind()}
|
||||
active={isActive()}
|
||||
|
||||
@@ -36,6 +36,11 @@ export type VscodeTerminalRequest =
|
||||
| { type: "agentManager.showWorktreeTerminal"; worktreeId: string }
|
||||
| { type: "agentManager.showLocalTerminal" }
|
||||
|
||||
/** Carry the panel-local dropdown choice with every Run click. */
|
||||
export function resolveRunScriptRequest(worktreeId: string, destination: TerminalDestination) {
|
||||
return { type: "agentManager.runScript" as const, worktreeId, destination }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the message the terminal button / Focus Terminal shortcut sends
|
||||
* when the destination is the VS Code integrated terminal. The fallback
|
||||
@@ -154,5 +159,26 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
setDestination(target)
|
||||
}
|
||||
|
||||
return { destination, syncDefault, toggle, close, openPreferred, choose }
|
||||
/**
|
||||
* Cmd/Ctrl+/ pressed while the webview holds DOM focus. VS Code normally
|
||||
* forwards the keybinding to the workbench too, and the extension echoes
|
||||
* it back as a showTerminal action message; `echo()` lets the action
|
||||
* handler skip that duplicate so one keypress never toggles twice.
|
||||
* Handling the key locally keeps the shortcut working when the
|
||||
* forwarding path drops it (e.g. the chat prompt input is focused).
|
||||
*/
|
||||
let lastPress = 0
|
||||
const ECHO_MS = 500
|
||||
|
||||
const press = (e: KeyboardEvent): boolean => {
|
||||
if (e.key !== "/" || !(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return false
|
||||
lastPress = Date.now()
|
||||
openPreferred("keyboard_shortcut")
|
||||
return true
|
||||
}
|
||||
|
||||
/** True while an incoming showTerminal action is the echo of `press`. */
|
||||
const echo = () => Date.now() - lastPress < ECHO_MS
|
||||
|
||||
return { destination, syncDefault, toggle, close, openPreferred, choose, press, echo }
|
||||
}
|
||||
|
||||
@@ -15,15 +15,20 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { LOCAL } from "../navigate"
|
||||
import type { ExtensionMessage } from "../../src/types/messages/extension-messages"
|
||||
import type { ExtensionMessage, ScriptTerminalView } from "../../src/types/messages/extension-messages"
|
||||
import type { TerminalDestination, TerminalFont, TerminalPlacement } from "../../src/types/messages/agent-manager"
|
||||
|
||||
export type { TerminalFont }
|
||||
|
||||
/** Prefix used for terminal tab IDs in the webview (mirrors terminal-manager.ts). */
|
||||
export const TERMINAL_PREFIX = "terminal:"
|
||||
export const SCRIPT_TERMINAL_PREFIX = "script:"
|
||||
|
||||
export const isTerminalTabId = (id: string): boolean => id.startsWith(TERMINAL_PREFIX)
|
||||
export const isTerminalTabId = (id: string): boolean =>
|
||||
id.startsWith(TERMINAL_PREFIX) || id.startsWith(SCRIPT_TERMINAL_PREFIX)
|
||||
|
||||
/** Status is separate from mounted xterm records so snapshot updates never remount them. */
|
||||
export type ScriptTerminalStatus = Pick<ScriptTerminalView, "state" | "exitCode">
|
||||
|
||||
/** One row in `terminalsByContext`. `wsUrl` is short-lived and never persisted. */
|
||||
export interface TerminalTabState {
|
||||
@@ -32,6 +37,8 @@ export interface TerminalTabState {
|
||||
wsUrl: string
|
||||
font: TerminalFont
|
||||
placement: TerminalPlacement
|
||||
/** Provider-owned Run terminal, never created through the webview create flow. */
|
||||
kind?: "run"
|
||||
}
|
||||
|
||||
/** Terminal row enriched with the sidebar context it belongs to. Used by
|
||||
@@ -63,6 +70,12 @@ export interface TerminalStateControls {
|
||||
remove(terminalId: string): TerminalTabStateWithContext | undefined
|
||||
/** Resolve the context key a terminal lives in, if any. */
|
||||
contextFor(terminalId: string): string | undefined
|
||||
/** Whether a terminal belongs to a provider-owned Run script. */
|
||||
isScript(terminalId: string): boolean
|
||||
/** Reactive Run state, kept apart from stable xterm terminal records. */
|
||||
scriptStatus(terminalId: string): ScriptTerminalStatus | undefined
|
||||
/** Reconcile a complete provider-owned Run terminal snapshot. Returns newly hydrated records. */
|
||||
syncScripts(views: ScriptTerminalView[]): TerminalTabStateWithContext[]
|
||||
/** All tab terminals for the given sidebar selection. */
|
||||
forSelection(selection: string | null): TerminalTabStateWithContext[]
|
||||
/** Map of { id -> tab state } for O(1) lookup. */
|
||||
@@ -166,6 +179,7 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
// records on purpose: replacing a record would remount its xterm via
|
||||
// <For> reference inequality (see the module comment above).
|
||||
const [titles, setTitles] = createSignal<Record<string, string>>({})
|
||||
const [scripts, setScripts] = createSignal<Record<string, ScriptTerminalStatus>>({})
|
||||
// Active side terminal per context.
|
||||
const [actives, setActives] = createSignal<Record<string, string>>({})
|
||||
let focusSerial = 0
|
||||
@@ -228,14 +242,18 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
}
|
||||
|
||||
const title = (terminalId: string): string | undefined => {
|
||||
const live = titles()[terminalId]
|
||||
if (live) return live
|
||||
const key = contextFor(terminalId)
|
||||
if (!key) return undefined
|
||||
return terminalsByContext()[key]?.find((t) => t.id === terminalId)?.title
|
||||
const term = terminalsByContext()[key]?.find((t) => t.id === terminalId)
|
||||
if (!term) return undefined
|
||||
// Run terminals always retain their semantic title, even when their
|
||||
// command emits OSC title sequences.
|
||||
if (term.kind === "run") return term.title
|
||||
return titles()[terminalId] ?? term.title
|
||||
}
|
||||
|
||||
const setTitle = (terminalId: string, next: string) => {
|
||||
if (isScript(terminalId)) return
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
setTitles((prev) => (prev[terminalId] === trimmed ? prev : { ...prev, [terminalId]: trimmed }))
|
||||
@@ -250,6 +268,16 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isScript = (terminalId: string): boolean => {
|
||||
const key = contextFor(terminalId)
|
||||
return terminalsByContext()[key ?? ""]?.some((term) => term.id === terminalId && term.kind === "run") ?? false
|
||||
}
|
||||
|
||||
const scriptStatus = (terminalId: string): ScriptTerminalStatus | undefined => {
|
||||
if (!isScript(terminalId)) return undefined
|
||||
return scripts()[terminalId]
|
||||
}
|
||||
|
||||
const forSelection = (sel: string | null): TerminalTabStateWithContext[] => {
|
||||
if (sel === null) return []
|
||||
const key = sel === LOCAL ? LOCAL : sel
|
||||
@@ -296,9 +324,95 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (removed?.kind === "run" && scripts()[terminalId] !== undefined) {
|
||||
setScripts((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[terminalId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
const syncScripts = (views: ScriptTerminalView[]): TerminalTabStateWithContext[] => {
|
||||
const ids = new Set(views.map((view) => view.terminalId))
|
||||
const added: TerminalTabStateWithContext[] = []
|
||||
const removed: TerminalTabStateWithContext[] = []
|
||||
|
||||
setTerminalsByContext((prev) => {
|
||||
let changed = false
|
||||
const next: Record<string, TerminalTabStateWithContext[]> = {}
|
||||
for (const [key, list] of Object.entries(prev)) {
|
||||
const kept = list.filter((term) => {
|
||||
if (term.kind !== "run" || ids.has(term.id)) return true
|
||||
removed.push(term)
|
||||
changed = true
|
||||
return false
|
||||
})
|
||||
if (kept.length > 0) next[key] = kept
|
||||
}
|
||||
for (const view of views) {
|
||||
const key = view.worktreeId ?? LOCAL
|
||||
const list = next[key] ?? []
|
||||
if (list.some((term) => term.id === view.terminalId)) continue
|
||||
const term: TerminalTabStateWithContext = {
|
||||
id: view.terminalId,
|
||||
title: "Run",
|
||||
wsUrl: view.wsUrl,
|
||||
font: view.font,
|
||||
placement: "side",
|
||||
kind: "run",
|
||||
contextKey: key,
|
||||
}
|
||||
next[key] = [...list, term]
|
||||
added.push(term)
|
||||
changed = true
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
|
||||
const states: Record<string, ScriptTerminalStatus> = {}
|
||||
for (const view of views) {
|
||||
const status: ScriptTerminalStatus = { state: view.state }
|
||||
if (view.exitCode !== undefined) status.exitCode = view.exitCode
|
||||
states[view.terminalId] = status
|
||||
}
|
||||
setScripts((prev) => {
|
||||
const keys = Object.keys(states)
|
||||
if (keys.length !== Object.keys(prev).length) return states
|
||||
for (const id of keys) {
|
||||
const before = prev[id]
|
||||
const after = states[id]
|
||||
if (before?.state !== after?.state || before?.exitCode !== after?.exitCode) return states
|
||||
}
|
||||
return prev
|
||||
})
|
||||
|
||||
if (removed.length > 0) {
|
||||
const removedIds = new Set(removed.map((term) => term.id))
|
||||
if (focusedId() && removedIds.has(focusedId()!)) setFocusedId(undefined)
|
||||
if (activeId() && removedIds.has(activeId()!)) setActiveId(undefined)
|
||||
setTitles((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const id of removedIds) delete next[id]
|
||||
return next
|
||||
})
|
||||
setActives((prev) => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
for (const key of new Set(removed.map((term) => term.contextKey))) {
|
||||
if (!prev[key] || !removedIds.has(prev[key]!)) continue
|
||||
const rest = sidesForContext(key)
|
||||
if (rest.length === 0) delete next[key]
|
||||
else next[key] = rest[rest.length - 1]!.id
|
||||
changed = true
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
const requestFocus = (id: string) => {
|
||||
focusSerial++
|
||||
setFocusRequest({ id, serial: focusSerial })
|
||||
@@ -418,6 +532,9 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
add,
|
||||
remove,
|
||||
contextFor,
|
||||
isScript,
|
||||
scriptStatus,
|
||||
syncScripts,
|
||||
forSelection,
|
||||
lookup,
|
||||
current,
|
||||
@@ -539,6 +656,13 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
}
|
||||
|
||||
const closeTerminal = (terminalId: string) => {
|
||||
// Run terminals transition through a provider-owned stopping snapshot.
|
||||
// Keep their xterm mounted until closure is confirmed by a snapshot or
|
||||
// terminal.closed message so live output is never discarded early.
|
||||
if (deps.state.isScript(terminalId)) {
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
|
||||
return
|
||||
}
|
||||
deps.onRemove?.()
|
||||
const ids = deps.tabIds()
|
||||
const idx = ids.indexOf(terminalId)
|
||||
@@ -582,6 +706,10 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
// unmount its xterm while the backend PTY leaks (no close sent).
|
||||
const term = deps.state.sides().find((t) => t.id === terminalId)
|
||||
if (!term) return false
|
||||
if (term.kind === "run") {
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
|
||||
return true
|
||||
}
|
||||
deps.state.remove(terminalId)
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
|
||||
return true
|
||||
@@ -644,11 +772,14 @@ export interface TerminalMessageHandlerDeps {
|
||||
onSideError?: (contextKey: string) => void
|
||||
/** Side terminal was closed (locally or by the extension). */
|
||||
onSideClosed?: (contextKey: string) => void
|
||||
/** A newly hydrated running Run terminal belongs to the selected context. */
|
||||
onScriptRunning?: (contextKey: string, terminalId: string) => void
|
||||
/** The destination setting changed (live settings sync). */
|
||||
onDestinationChanged?: (destination: TerminalDestination) => void
|
||||
}
|
||||
|
||||
type CreatedMessage = Extract<ExtensionMessage, { type: "agentManager.terminal.created" }>
|
||||
type ScriptTerminalsMessage = Extract<ExtensionMessage, { type: "agentManager.scriptTerminals" }>
|
||||
|
||||
function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) {
|
||||
const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId
|
||||
@@ -682,6 +813,13 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) {
|
||||
deps.activate(msg.terminalId)
|
||||
}
|
||||
|
||||
function handleScriptTerminals(deps: TerminalMessageHandlerDeps, msg: ScriptTerminalsMessage) {
|
||||
const added = deps.state.syncScripts(msg.terminals)
|
||||
for (const term of added) {
|
||||
if (deps.state.scriptStatus(term.id)?.state === "running") deps.onScriptRunning?.(term.contextKey, term.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire handlers for the inbound terminal messages. Returns a dispatcher
|
||||
* that accepts each message type and returns true if it handled the
|
||||
@@ -694,6 +832,10 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) {
|
||||
handleCreated(deps, msg)
|
||||
return true
|
||||
}
|
||||
if (msg.type === "agentManager.scriptTerminals") {
|
||||
handleScriptTerminals(deps, msg)
|
||||
return true
|
||||
}
|
||||
if (msg.type === "agentManager.terminal.closed") {
|
||||
const removed = deps.state.remove(msg.terminalId)
|
||||
if (deps.state.activeId() === msg.terminalId) deps.state.setActiveId(undefined)
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SessionMentionPicker(props: Props) {
|
||||
<List<SessionSearchItem>
|
||||
items={props.sessions}
|
||||
key={(item) => item.id}
|
||||
filterKeys={["title"]}
|
||||
filterKeys={["title", "worktreeName"]}
|
||||
search={{ placeholder: "Search sessions", autofocus: true }}
|
||||
onSelect={(item) => {
|
||||
if (item) props.onSelect(item)
|
||||
@@ -52,6 +52,7 @@ export function SessionMentionPicker(props: Props) {
|
||||
<span class="session-mention-item">
|
||||
<Icon name="history" class="file-mention-icon" />
|
||||
<span class="session-mention-title">{item.title}</span>
|
||||
{item.worktreeName && <span class="session-mention-worktree">{item.worktreeName}</span>}
|
||||
<span class="session-mention-time">{formatRelativeDate(new Date(item.updated).toISOString())}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { Component, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { Component, Show, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { Switch } from "@kilocode/kilo-ui/switch"
|
||||
import { Card } from "@kilocode/kilo-ui/card"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useConfig } from "../../context/config"
|
||||
import type { BrowserSettings } from "../../types/messages"
|
||||
import SettingsRow from "./SettingsRow"
|
||||
|
||||
const Header: Component<{ title: string }> = (props) => (
|
||||
<h4
|
||||
style={{
|
||||
margin: "0 0 12px",
|
||||
"padding-bottom": "10px",
|
||||
"border-bottom": "1px solid var(--vscode-panel-border)",
|
||||
color: "var(--text-base, var(--vscode-foreground))",
|
||||
"font-size": "var(--kilo-font-size-18, 18px)",
|
||||
"font-weight": 600,
|
||||
"line-height": "1.4",
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</h4>
|
||||
)
|
||||
|
||||
const BrowserTab: Component = () => {
|
||||
const { postMessage, onMessage } = useVSCode()
|
||||
const { t } = useLanguage()
|
||||
const { globalConfig, projectConfig, updateGlobalConfig } = useConfig()
|
||||
|
||||
const [settings, setSettings] = createSignal<BrowserSettings>({
|
||||
enabled: false,
|
||||
@@ -33,6 +51,12 @@ const BrowserTab: Component = () => {
|
||||
postMessage({ type: "updateSetting", key: `browserAutomation.${key}`, value })
|
||||
}
|
||||
|
||||
const updateWebsearch = (checked: boolean) => {
|
||||
updateGlobalConfig({ web_search: checked })
|
||||
}
|
||||
|
||||
const overridden = () => projectConfig().web_search !== undefined
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||
{/* Info text */}
|
||||
@@ -52,43 +76,90 @@ const BrowserTab: Component = () => {
|
||||
"line-height": "1.5",
|
||||
}}
|
||||
>
|
||||
{t("settings.browser.description")}
|
||||
{t("settings.webTools.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{/* Enable toggle */}
|
||||
<SettingsRow title={t("settings.browser.enable.title")} description={t("settings.browser.enable.description")}>
|
||||
<Switch checked={settings().enabled} onChange={(checked: boolean) => update("enabled", checked)} hideLabel>
|
||||
{t("settings.browser.enable.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Use System Chrome */}
|
||||
<SettingsRow
|
||||
title={t("settings.browser.systemChrome.title")}
|
||||
description={t("settings.browser.systemChrome.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings().useSystemChrome}
|
||||
onChange={(checked: boolean) => update("useSystemChrome", checked)}
|
||||
hideLabel
|
||||
<div>
|
||||
<Header title={t("settings.webTools.webSearch.title")} />
|
||||
<Card>
|
||||
<SettingsRow
|
||||
title={t("settings.webTools.webSearch.enable")}
|
||||
description={t("settings.webTools.webSearch.description")}
|
||||
tag={() => t("settings.config.scope.global")}
|
||||
last={!overridden()}
|
||||
>
|
||||
{t("settings.browser.systemChrome.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
<Switch checked={globalConfig().web_search ?? false} onChange={updateWebsearch} hideLabel>
|
||||
{t("settings.webTools.webSearch.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
<Show when={overridden()}>
|
||||
<SettingsRow
|
||||
title={t("settings.webTools.webSearch.enable")}
|
||||
tag={() => t("settings.config.scope.local")}
|
||||
last
|
||||
>
|
||||
<Switch checked={projectConfig().web_search ?? false} disabled hideLabel>
|
||||
{`${t("settings.webTools.webSearch.title")} (${t("settings.config.scope.local")})`}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Headless mode */}
|
||||
<SettingsRow
|
||||
title={t("settings.browser.headless.title")}
|
||||
description={t("settings.browser.headless.description")}
|
||||
last
|
||||
<div>
|
||||
<Header title={t("settings.webTools.browserAutomation")} />
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
"font-size": "var(--kilo-font-size-12)",
|
||||
"line-height": "1.5",
|
||||
}}
|
||||
>
|
||||
<Switch checked={settings().headless} onChange={(checked: boolean) => update("headless", checked)} hideLabel>
|
||||
{t("settings.browser.headless.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
</Card>
|
||||
{t("settings.browser.description")}
|
||||
</p>
|
||||
<Card>
|
||||
{/* Enable toggle */}
|
||||
<SettingsRow
|
||||
title={t("settings.browser.enable.title")}
|
||||
description={t("settings.browser.enable.description")}
|
||||
>
|
||||
<Switch checked={settings().enabled} onChange={(checked: boolean) => update("enabled", checked)} hideLabel>
|
||||
{t("settings.browser.enable.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Use System Chrome */}
|
||||
<SettingsRow
|
||||
title={t("settings.browser.systemChrome.title")}
|
||||
description={t("settings.browser.systemChrome.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings().useSystemChrome}
|
||||
onChange={(checked: boolean) => update("useSystemChrome", checked)}
|
||||
hideLabel
|
||||
>
|
||||
{t("settings.browser.systemChrome.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Headless mode */}
|
||||
<SettingsRow
|
||||
title={t("settings.browser.headless.title")}
|
||||
description={t("settings.browser.headless.description")}
|
||||
last
|
||||
>
|
||||
<Switch
|
||||
checked={settings().headless}
|
||||
onChange={(checked: boolean) => update("headless", checked)}
|
||||
hideLabel
|
||||
>
|
||||
{t("settings.browser.headless.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,9 +177,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
<Icon name="checklist" />
|
||||
<span class="label">{language.t("settings.autoApprove.title")}</span>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="browser" aria-label={language.t("settings.browser.title")}>
|
||||
<Tabs.Trigger value="browser" aria-label={language.t("settings.webTools.title")}>
|
||||
<Icon name="window-cursor" />
|
||||
<span class="label">{language.t("settings.browser.title")}</span>
|
||||
<span class="label">{language.t("settings.webTools.title")}</span>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="checkpoints" aria-label={language.t("settings.checkpoints.title")}>
|
||||
<Icon name="branch" />
|
||||
@@ -249,7 +249,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
<AutoApproveTab />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="browser">
|
||||
<h3>{language.t("settings.browser.title")}</h3>
|
||||
<h3>{language.t("settings.webTools.title")}</h3>
|
||||
<BrowserTab />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="checkpoints">
|
||||
|
||||
@@ -34,6 +34,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [
|
||||
"compaction",
|
||||
"commit_message",
|
||||
"tools",
|
||||
"web_search",
|
||||
"auto_collapse_reasoning",
|
||||
"terminal_command_display",
|
||||
"code_edit_display",
|
||||
|
||||
+6
-1
@@ -648,7 +648,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "سلوك الوكيل",
|
||||
"settings.autoApprove.title": "الموافقة التلقائية",
|
||||
"settings.browser.title": "المتصفح",
|
||||
"settings.webTools.title": "أدوات الويب",
|
||||
"settings.webTools.description": "اضبط البحث على الويب وأتمتة المتصفح.",
|
||||
"settings.webTools.webSearch.enable": "تمكين لجميع المزوّدين",
|
||||
"settings.webTools.browserAutomation": "أتمتة المتصفح",
|
||||
"settings.webTools.webSearch.title": "البحث على الويب",
|
||||
"settings.webTools.webSearch.description": "اجعل البحث على الويب متاحًا لنماذج جميع المزوّدين.",
|
||||
"settings.checkpoints.title": "نقاط التحقق",
|
||||
"settings.display.title": "العرض",
|
||||
"settings.autocomplete.title": "الإكمال التلقائي",
|
||||
|
||||
+6
-1
@@ -664,7 +664,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Comportamento do Agente",
|
||||
"settings.autoApprove.title": "Aprovação Automática",
|
||||
"settings.browser.title": "Navegador",
|
||||
"settings.webTools.title": "Ferramentas da Web",
|
||||
"settings.webTools.description": "Configure a pesquisa na web e a automação do navegador.",
|
||||
"settings.webTools.webSearch.enable": "Ativar para todos os provedores",
|
||||
"settings.webTools.browserAutomation": "Automação do navegador",
|
||||
"settings.webTools.webSearch.title": "Pesquisa na Web",
|
||||
"settings.webTools.webSearch.description": "Disponibilize a pesquisa na web para modelos de todos os provedores.",
|
||||
"settings.checkpoints.title": "Pontos de Verificação",
|
||||
"settings.display.title": "Exibição",
|
||||
"settings.autocomplete.title": "Autocompletar",
|
||||
|
||||
+6
-1
@@ -704,7 +704,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Ponašanje agenta",
|
||||
"settings.autoApprove.title": "Automatsko odobravanje",
|
||||
"settings.browser.title": "Preglednik",
|
||||
"settings.webTools.title": "Web alati",
|
||||
"settings.webTools.description": "Konfigurišite web pretragu i automatizaciju preglednika.",
|
||||
"settings.webTools.webSearch.enable": "Omogući za sve pružaoce",
|
||||
"settings.webTools.browserAutomation": "Automatizacija preglednika",
|
||||
"settings.webTools.webSearch.title": "Web pretraga",
|
||||
"settings.webTools.webSearch.description": "Omogućite web pretragu modelima svih pružalaca.",
|
||||
"settings.checkpoints.title": "Kontrolne tačke",
|
||||
"settings.display.title": "Prikaz",
|
||||
"settings.autocomplete.title": "Automatsko dovršavanje",
|
||||
|
||||
+6
-1
@@ -702,7 +702,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Agentadfærd",
|
||||
"settings.autoApprove.title": "Automatisk godkendelse",
|
||||
"settings.browser.title": "Browser",
|
||||
"settings.webTools.title": "Webværktøjer",
|
||||
"settings.webTools.description": "Konfigurer websøgning og browserautomatisering.",
|
||||
"settings.webTools.webSearch.enable": "Aktivér for alle udbydere",
|
||||
"settings.webTools.browserAutomation": "Browserautomatisering",
|
||||
"settings.webTools.webSearch.title": "Websøgning",
|
||||
"settings.webTools.webSearch.description": "Gør websøgning tilgængelig for modeller fra alle udbydere.",
|
||||
"settings.checkpoints.title": "Kontrolpunkter",
|
||||
"settings.display.title": "Visning",
|
||||
"settings.autocomplete.title": "Autofuldførelse",
|
||||
|
||||
@@ -715,7 +715,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Agentenverhalten",
|
||||
"settings.autoApprove.title": "Automatisch genehmigen",
|
||||
"settings.browser.title": "Browser",
|
||||
"settings.webTools.title": "Web-Tools",
|
||||
"settings.webTools.description": "Konfigurieren Sie Websuche und Browserautomatisierung.",
|
||||
"settings.webTools.webSearch.enable": "Für alle Anbieter aktivieren",
|
||||
"settings.webTools.browserAutomation": "Browserautomatisierung",
|
||||
"settings.webTools.webSearch.title": "Websuche",
|
||||
"settings.webTools.webSearch.description": "Machen Sie die Websuche für Modelle aller Anbieter verfügbar.",
|
||||
"settings.checkpoints.title": "Prüfpunkte",
|
||||
"settings.display.title": "Anzeige",
|
||||
"settings.autocomplete.title": "Autovervollständigung",
|
||||
|
||||
@@ -621,7 +621,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Agent Behaviour",
|
||||
"settings.autoApprove.title": "Auto-Approve",
|
||||
"settings.browser.title": "Browser",
|
||||
"settings.webTools.title": "Web Tools",
|
||||
"settings.webTools.description": "Configure web search and browser automation.",
|
||||
"settings.webTools.webSearch.enable": "Enable for All Providers",
|
||||
"settings.webTools.browserAutomation": "Browser Automation",
|
||||
"settings.webTools.webSearch.title": "Web Search",
|
||||
"settings.webTools.webSearch.description": "Make web search available to models from all providers.",
|
||||
"settings.checkpoints.title": "Checkpoints",
|
||||
"settings.display.title": "Display",
|
||||
"settings.autocomplete.title": "Autocomplete",
|
||||
|
||||
+6
-1
@@ -709,7 +709,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Comportamiento del agente",
|
||||
"settings.autoApprove.title": "Aprobación automática",
|
||||
"settings.browser.title": "Navegador",
|
||||
"settings.webTools.title": "Herramientas web",
|
||||
"settings.webTools.description": "Configura la búsqueda web y la automatización del navegador.",
|
||||
"settings.webTools.webSearch.enable": "Habilitar para todos los proveedores",
|
||||
"settings.webTools.browserAutomation": "Automatización del navegador",
|
||||
"settings.webTools.webSearch.title": "Búsqueda web",
|
||||
"settings.webTools.webSearch.description": "Permite que los modelos de todos los proveedores usen la búsqueda web.",
|
||||
"settings.checkpoints.title": "Puntos de control",
|
||||
"settings.display.title": "Pantalla",
|
||||
"settings.autocomplete.title": "Autocompletado",
|
||||
|
||||
+6
-1
@@ -624,7 +624,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "رفتار عامل",
|
||||
"settings.autoApprove.title": "تأیید خودکار",
|
||||
"settings.browser.title": "مرورگر",
|
||||
"settings.webTools.title": "ابزارهای وب",
|
||||
"settings.webTools.description": "جستجوی وب و اتوماسیون مرورگر را پیکربندی کنید.",
|
||||
"settings.webTools.webSearch.enable": "فعالسازی برای همه ارائهدهندگان",
|
||||
"settings.webTools.browserAutomation": "اتوماسیون مرورگر",
|
||||
"settings.webTools.webSearch.title": "جستجوی وب",
|
||||
"settings.webTools.webSearch.description": "جستجوی وب را برای مدلهای همه ارائهدهندگان در دسترس قرار دهید.",
|
||||
"settings.checkpoints.title": "نقاط بازیابی",
|
||||
"settings.display.title": "نمایش",
|
||||
"settings.autocomplete.title": "تکمیل خودکار",
|
||||
|
||||
+7
-1
@@ -715,7 +715,13 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Comportement de l'agent",
|
||||
"settings.autoApprove.title": "Approbation automatique",
|
||||
"settings.browser.title": "Navigateur",
|
||||
"settings.webTools.title": "Outils web",
|
||||
"settings.webTools.description": "Configurez la recherche web et l’automatisation du navigateur.",
|
||||
"settings.webTools.webSearch.enable": "Activer pour tous les fournisseurs",
|
||||
"settings.webTools.browserAutomation": "Automatisation du navigateur",
|
||||
"settings.webTools.webSearch.title": "Recherche web",
|
||||
"settings.webTools.webSearch.description":
|
||||
"Rendez la recherche web disponible pour les modèles de tous les fournisseurs.",
|
||||
"settings.checkpoints.title": "Points de contrôle",
|
||||
"settings.display.title": "Affichage",
|
||||
"settings.autocomplete.title": "Autocomplétion",
|
||||
|
||||
+6
-1
@@ -528,7 +528,12 @@ export const dict = {
|
||||
"profile.action.logout": "Esci",
|
||||
"settings.agentBehaviour.title": "Comportamento agente",
|
||||
"settings.autoApprove.title": "Approvazione automatica",
|
||||
"settings.browser.title": "Browser",
|
||||
"settings.webTools.title": "Strumenti web",
|
||||
"settings.webTools.description": "Configura la ricerca web e l'automazione del browser.",
|
||||
"settings.webTools.webSearch.enable": "Abilita per tutti i provider",
|
||||
"settings.webTools.browserAutomation": "Automazione del browser",
|
||||
"settings.webTools.webSearch.title": "Ricerca web",
|
||||
"settings.webTools.webSearch.description": "Rendi disponibile la ricerca web ai modelli di tutti i provider.",
|
||||
"settings.checkpoints.title": "Checkpoint",
|
||||
"settings.display.title": "Visualizzazione",
|
||||
"settings.autocomplete.title": "Autocompletamento",
|
||||
|
||||
+6
-1
@@ -696,7 +696,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "エージェントの動作",
|
||||
"settings.autoApprove.title": "自動承認",
|
||||
"settings.browser.title": "ブラウザ",
|
||||
"settings.webTools.title": "ウェブツール",
|
||||
"settings.webTools.description": "ウェブ検索とブラウザ自動化を設定します。",
|
||||
"settings.webTools.webSearch.enable": "すべてのプロバイダーで有効化",
|
||||
"settings.webTools.browserAutomation": "ブラウザ自動化",
|
||||
"settings.webTools.webSearch.title": "ウェブ検索",
|
||||
"settings.webTools.webSearch.description": "すべてのプロバイダーのモデルでウェブ検索を利用できるようにします。",
|
||||
"settings.checkpoints.title": "チェックポイント",
|
||||
"settings.display.title": "表示",
|
||||
"settings.autocomplete.title": "オートコンプリート",
|
||||
|
||||
+6
-1
@@ -656,7 +656,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "에이전트 동작",
|
||||
"settings.autoApprove.title": "자동 승인",
|
||||
"settings.browser.title": "브라우저",
|
||||
"settings.webTools.title": "웹 도구",
|
||||
"settings.webTools.description": "웹 검색 및 브라우저 자동화를 구성합니다.",
|
||||
"settings.webTools.webSearch.enable": "모든 제공업체에 사용",
|
||||
"settings.webTools.browserAutomation": "브라우저 자동화",
|
||||
"settings.webTools.webSearch.title": "웹 검색",
|
||||
"settings.webTools.webSearch.description": "모든 제공업체의 모델에서 웹 검색을 사용할 수 있도록 합니다.",
|
||||
"settings.checkpoints.title": "체크포인트",
|
||||
"settings.display.title": "디스플레이",
|
||||
"settings.autocomplete.title": "자동 완성",
|
||||
|
||||
+6
-1
@@ -657,7 +657,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Agent Gedrag",
|
||||
"settings.autoApprove.title": "Automatisch Goedkeuren",
|
||||
"settings.browser.title": "Browser",
|
||||
"settings.webTools.title": "Webtools",
|
||||
"settings.webTools.description": "Configureer zoeken op internet en browserautomatisering.",
|
||||
"settings.webTools.webSearch.enable": "Inschakelen voor alle providers",
|
||||
"settings.webTools.browserAutomation": "Browserautomatisering",
|
||||
"settings.webTools.webSearch.title": "Zoeken op internet",
|
||||
"settings.webTools.webSearch.description": "Maak zoeken op internet beschikbaar voor modellen van alle providers.",
|
||||
"settings.checkpoints.title": "Controlepunten",
|
||||
"settings.display.title": "Weergave",
|
||||
"settings.autocomplete.title": "Automatisch Aanvullen",
|
||||
|
||||
+6
-1
@@ -664,7 +664,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Agentoppførsel",
|
||||
"settings.autoApprove.title": "Automatisk godkjenning",
|
||||
"settings.browser.title": "Nettleser",
|
||||
"settings.webTools.title": "Nettverktøy",
|
||||
"settings.webTools.description": "Konfigurer nettsøk og nettleserautomatisering.",
|
||||
"settings.webTools.webSearch.enable": "Aktiver for alle leverandører",
|
||||
"settings.webTools.browserAutomation": "Nettleserautomatisering",
|
||||
"settings.webTools.webSearch.title": "Nettsøk",
|
||||
"settings.webTools.webSearch.description": "Gjør nettsøk tilgjengelig for modeller fra alle leverandører.",
|
||||
"settings.checkpoints.title": "Kontrollpunkter",
|
||||
"settings.display.title": "Visning",
|
||||
"settings.autocomplete.title": "Autofullfør",
|
||||
|
||||
+6
-1
@@ -660,7 +660,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Zachowanie agenta",
|
||||
"settings.autoApprove.title": "Automatyczne zatwierdzanie",
|
||||
"settings.browser.title": "Przeglądarka",
|
||||
"settings.webTools.title": "Narzędzia internetowe",
|
||||
"settings.webTools.description": "Skonfiguruj wyszukiwanie w sieci i automatyzację przeglądarki.",
|
||||
"settings.webTools.webSearch.enable": "Włącz dla wszystkich dostawców",
|
||||
"settings.webTools.browserAutomation": "Automatyzacja przeglądarki",
|
||||
"settings.webTools.webSearch.title": "Wyszukiwanie w sieci",
|
||||
"settings.webTools.webSearch.description": "Udostępnij wyszukiwanie w sieci modelom wszystkich dostawców.",
|
||||
"settings.checkpoints.title": "Punkty kontrolne",
|
||||
"settings.display.title": "Wyświetlanie",
|
||||
"settings.autocomplete.title": "Autouzupełnianie",
|
||||
|
||||
+6
-1
@@ -701,7 +701,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Поведение агента",
|
||||
"settings.autoApprove.title": "Автоодобрение",
|
||||
"settings.browser.title": "Браузер",
|
||||
"settings.webTools.title": "Веб-инструменты",
|
||||
"settings.webTools.description": "Настройте веб-поиск и автоматизацию браузера.",
|
||||
"settings.webTools.webSearch.enable": "Включить для всех провайдеров",
|
||||
"settings.webTools.browserAutomation": "Автоматизация браузера",
|
||||
"settings.webTools.webSearch.title": "Веб-поиск",
|
||||
"settings.webTools.webSearch.description": "Сделайте веб-поиск доступным для моделей всех провайдеров.",
|
||||
"settings.checkpoints.title": "Контрольные точки",
|
||||
"settings.display.title": "Отображение",
|
||||
"settings.autocomplete.title": "Автодополнение",
|
||||
|
||||
+6
-1
@@ -694,7 +694,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "พฤติกรรมของเอเจนต์",
|
||||
"settings.autoApprove.title": "อนุมัติอัตโนมัติ",
|
||||
"settings.browser.title": "เบราว์เซอร์",
|
||||
"settings.webTools.title": "เครื่องมือเว็บ",
|
||||
"settings.webTools.description": "กำหนดค่าการค้นหาเว็บและระบบอัตโนมัติของเบราว์เซอร์",
|
||||
"settings.webTools.webSearch.enable": "เปิดใช้สำหรับผู้ให้บริการทั้งหมด",
|
||||
"settings.webTools.browserAutomation": "ระบบอัตโนมัติของเบราว์เซอร์",
|
||||
"settings.webTools.webSearch.title": "ค้นหาเว็บ",
|
||||
"settings.webTools.webSearch.description": "ทำให้โมเดลจากผู้ให้บริการทั้งหมดใช้การค้นหาเว็บได้",
|
||||
"settings.checkpoints.title": "จุดตรวจสอบ",
|
||||
"settings.display.title": "การแสดงผล",
|
||||
"settings.autocomplete.title": "เติมข้อความอัตโนมัติ",
|
||||
|
||||
+7
-1
@@ -652,7 +652,13 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Ajan Davranışı",
|
||||
"settings.autoApprove.title": "Otomatik Onay",
|
||||
"settings.browser.title": "Tarayıcı",
|
||||
"settings.webTools.title": "Web Araçları",
|
||||
"settings.webTools.description": "Web aramasını ve tarayıcı otomasyonunu yapılandırın.",
|
||||
"settings.webTools.webSearch.enable": "Tüm Sağlayıcılar İçin Etkinleştir",
|
||||
"settings.webTools.browserAutomation": "Tarayıcı Otomasyonu",
|
||||
"settings.webTools.webSearch.title": "Web Araması",
|
||||
"settings.webTools.webSearch.description":
|
||||
"Web aramasını tüm sağlayıcıların modelleri için kullanılabilir hale getirin.",
|
||||
"settings.checkpoints.title": "Kontrol Noktaları",
|
||||
"settings.display.title": "Görünüm",
|
||||
"settings.autocomplete.title": "Otomatik Tamamlama",
|
||||
|
||||
+6
-1
@@ -654,7 +654,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Поведінка агента",
|
||||
"settings.autoApprove.title": "Автоматичне схвалення",
|
||||
"settings.browser.title": "Браузер",
|
||||
"settings.webTools.title": "Вебінструменти",
|
||||
"settings.webTools.description": "Налаштуйте вебпошук і автоматизацію браузера.",
|
||||
"settings.webTools.webSearch.enable": "Увімкнути для всіх постачальників",
|
||||
"settings.webTools.browserAutomation": "Автоматизація браузера",
|
||||
"settings.webTools.webSearch.title": "Вебпошук",
|
||||
"settings.webTools.webSearch.description": "Зробіть вебпошук доступним для моделей усіх постачальників.",
|
||||
"settings.checkpoints.title": "Контрольні точки",
|
||||
"settings.display.title": "Відображення",
|
||||
"settings.autocomplete.title": "Автодоповнення",
|
||||
|
||||
+6
-1
@@ -678,7 +678,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "智能体行为",
|
||||
"settings.autoApprove.title": "自动审批",
|
||||
"settings.browser.title": "浏览器",
|
||||
"settings.webTools.title": "网络工具",
|
||||
"settings.webTools.description": "配置网页搜索和浏览器自动化。",
|
||||
"settings.webTools.webSearch.enable": "为所有提供商启用",
|
||||
"settings.webTools.browserAutomation": "浏览器自动化",
|
||||
"settings.webTools.webSearch.title": "网页搜索",
|
||||
"settings.webTools.webSearch.description": "让所有提供商的模型都可使用网页搜索。",
|
||||
"settings.checkpoints.title": "检查点",
|
||||
"settings.display.title": "显示",
|
||||
"settings.autocomplete.title": "自动补全",
|
||||
|
||||
+6
-1
@@ -638,7 +638,12 @@ export const dict = {
|
||||
|
||||
"settings.agentBehaviour.title": "Agent 行為",
|
||||
"settings.autoApprove.title": "自動核准",
|
||||
"settings.browser.title": "瀏覽器",
|
||||
"settings.webTools.title": "網路工具",
|
||||
"settings.webTools.description": "設定網頁搜尋和瀏覽器自動化。",
|
||||
"settings.webTools.webSearch.enable": "為所有供應商啟用",
|
||||
"settings.webTools.browserAutomation": "瀏覽器自動化",
|
||||
"settings.webTools.webSearch.title": "網頁搜尋",
|
||||
"settings.webTools.webSearch.description": "讓所有供應商的模型都可使用網頁搜尋。",
|
||||
"settings.checkpoints.title": "檢查點",
|
||||
"settings.display.title": "顯示",
|
||||
"settings.autocomplete.title": "自動完成",
|
||||
|
||||
@@ -138,6 +138,16 @@
|
||||
font-size: var(--kilo-font-size-11);
|
||||
}
|
||||
|
||||
.session-mention-worktree {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
font-size: var(--kilo-font-size-11);
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Slash Command Dropdown
|
||||
============================================ */
|
||||
|
||||
@@ -163,6 +163,7 @@ export interface Config {
|
||||
compaction?: CompactionConfig
|
||||
commit_message?: CommitMessageConfig
|
||||
tools?: Record<string, boolean>
|
||||
web_search?: boolean
|
||||
auto_collapse_reasoning?: boolean
|
||||
experimental?: ExperimentalConfig
|
||||
sandbox?: SandboxConfig
|
||||
|
||||
@@ -478,6 +478,8 @@ export interface SessionSearchItem {
|
||||
id: string
|
||||
title: string
|
||||
updated: number
|
||||
/** Name of the worktree the session runs in, when listed across the worktree family. */
|
||||
worktreeName?: string
|
||||
}
|
||||
|
||||
export interface SessionSearchResultMessage {
|
||||
@@ -810,6 +812,24 @@ export interface AgentManagerTerminalDestinationChangedMessage {
|
||||
destination: TerminalDestination
|
||||
}
|
||||
|
||||
/** Provider-owned Run script terminal. Full snapshots replace only this terminal kind. */
|
||||
export interface ScriptTerminalView {
|
||||
terminalId: string
|
||||
/** null for LOCAL, worktree id otherwise */
|
||||
worktreeId: string | null
|
||||
kind: "run"
|
||||
title: "Run"
|
||||
wsUrl: string
|
||||
state: "running" | "stopping" | "exited" | "failed"
|
||||
exitCode?: number
|
||||
font: TerminalFont
|
||||
}
|
||||
|
||||
export interface AgentManagerScriptTerminalsMessage {
|
||||
type: "agentManager.scriptTerminals"
|
||||
terminals: ScriptTerminalView[]
|
||||
}
|
||||
|
||||
export interface AgentManagerRunStatusMessage extends RunStatus {
|
||||
type: "agentManager.runStatus"
|
||||
}
|
||||
@@ -1346,6 +1366,7 @@ export type ExtensionMessage =
|
||||
| AgentManagerTerminalClosedMessage
|
||||
| AgentManagerTerminalErrorMessage
|
||||
| AgentManagerTerminalDestinationChangedMessage
|
||||
| AgentManagerScriptTerminalsMessage
|
||||
// legacy-migration start
|
||||
| MigrationStateMessage
|
||||
| MigrationDataMessage
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { MessageLoadMode } from "./sessions"
|
||||
import type { PermissionFileDiff } from "./permissions"
|
||||
import type { ModelSelection, ProviderConfig } from "./providers"
|
||||
import type { Config } from "./config"
|
||||
import type { ModelAllocation, ReviewComment, TerminalPlacement } from "./agent-manager"
|
||||
import type { ModelAllocation, ReviewComment, TerminalDestination, TerminalPlacement } from "./agent-manager"
|
||||
import type { ReviewMessageData } from "../../../../src/shared/review-comments"
|
||||
import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets"
|
||||
import type { AnacondaDesktopWebviewMessage } from "../../../../src/shared/anaconda-desktop-messages"
|
||||
@@ -749,6 +749,7 @@ export interface RunScriptRequest {
|
||||
type: "agentManager.runScript"
|
||||
projectId?: string
|
||||
worktreeId: string
|
||||
destination: TerminalDestination
|
||||
}
|
||||
|
||||
export interface StopRunScriptRequest {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change
|
||||
import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
|
||||
import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
// kilocode_change start - Kilo implementations (sdk client, cloud-session) are
|
||||
// dynamically imported inside the handler so other CLI commands don't pay their
|
||||
// module cost at startup.
|
||||
// kilocode_change end
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
@@ -57,6 +59,7 @@ export const AttachCommand = cmd({
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
const { importCloudSession, validateCloudFork } = await import("@/kilocode/cloud-session")
|
||||
const cloudForkError = validateCloudFork(args)
|
||||
if (cloudForkError) {
|
||||
UI.error(cloudForkError)
|
||||
@@ -79,6 +82,7 @@ export const AttachCommand = cmd({
|
||||
// kilocode_change start - import cloud session before TUI renders
|
||||
if (args.cloudFork && args.session) {
|
||||
UI.println("Importing session from cloud...")
|
||||
const { createKiloClient } = await import("@kilocode/sdk/v2")
|
||||
const sdk = createKiloClient({
|
||||
baseUrl: args.url,
|
||||
directory,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// kilocode_change - new file
|
||||
import { EOL } from "os"
|
||||
import { Config } from "../../config/config"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "../ui"
|
||||
|
||||
// Keep the top-level import graph light: this module is registered eagerly at CLI
|
||||
// startup, so implementation dependencies are imported inside the handler (same
|
||||
// deferral pattern as upstream opencode#30453).
|
||||
export const ConfigCommand = cmd({
|
||||
command: "config",
|
||||
describe: "configuration tools",
|
||||
@@ -15,6 +15,9 @@ export const ConfigCommand = cmd({
|
||||
command: "check",
|
||||
describe: "check configuration for warnings and errors",
|
||||
async handler() {
|
||||
const { bootstrap } = await import("../bootstrap")
|
||||
const { AppRuntime } = await import("../../effect/app-runtime")
|
||||
const { Config } = await import("../../config/config")
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const list = await AppRuntime.runPromise(Config.Service.use((svc) => svc.warnings()))
|
||||
if (list.length === 0) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import { KilocodeMcpConfig } from "@/kilocode/cli/cmd/mcp" // kilocode_change
|
||||
// kilocode_change - KilocodeMcpConfig is dynamically imported in addMcpToConfig to keep startup fast
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
@@ -447,7 +447,10 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configP
|
||||
const edits = modify(text, ["mcp", name], mcpConfig, {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
})
|
||||
const result = KilocodeMcpConfig.format(configPath, applyEdits(text, edits)) // kilocode_change
|
||||
// kilocode_change start - lazy import keeps the CLI startup graph light
|
||||
const { KilocodeMcpConfig } = await import("@/kilocode/cli/cmd/mcp")
|
||||
const result = KilocodeMcpConfig.format(configPath, applyEdits(text, edits))
|
||||
// kilocode_change end
|
||||
|
||||
await Filesystem.write(configPath, result)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Process } from "@/util/process"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Effect, Option } from "effect"
|
||||
import { remove as removeAuth } from "@/kilocode/auth/remove" // kilocode_change
|
||||
// kilocode_change - @/kilocode/auth/remove is dynamically imported in the logout handler to keep startup fast
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
@@ -538,7 +538,10 @@ export const ProvidersLogoutCommand = effectCmd({
|
||||
}),
|
||||
)
|
||||
if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`)
|
||||
yield* removeAuth(provider) // kilocode_change
|
||||
// kilocode_change start - lazy import keeps the CLI startup graph light
|
||||
const { remove: removeAuth } = yield* Effect.promise(() => import("@/kilocode/auth/remove"))
|
||||
yield* removeAuth(provider)
|
||||
// kilocode_change end
|
||||
yield* Prompt.outro("Logout successful")
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
// kilocode_change - new file
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
|
||||
import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement"
|
||||
import { context } from "@/project/instance-context"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Instance } from "@/kilocode/instance"
|
||||
|
||||
// Re-export so existing unit tests that import from this module keep working.
|
||||
export { buildInstanceAdvertisement }
|
||||
|
||||
// Keep the top-level import graph light: this module is registered eagerly at CLI
|
||||
// startup, so implementation dependencies are imported inside the handler (same
|
||||
// deferral pattern as upstream opencode#30453).
|
||||
export const RemoteCommand = cmd({
|
||||
command: "remote",
|
||||
describe: "enable remote connection for real-time session relay",
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
const { bootstrap } = await import("../bootstrap")
|
||||
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
|
||||
const { context } = await import("@/project/instance-context")
|
||||
const { InstanceRuntime } = await import("@/project/instance-runtime")
|
||||
const { Instance } = await import("@/kilocode/instance")
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
// kilocode_change - K1 W1: advertise this instance on the relay
|
||||
// heartbeat so the cloud side can show it as a spawn-capable instance.
|
||||
|
||||
@@ -20,18 +20,15 @@ import { pathToFileURL } from "url"
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { buildRunMessage } from "@/kilocode/cli/cmd/run-message" // kilocode_change
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { createKiloClient, type KiloClient, type Session, type ToolPart } from "@kilocode/sdk/v2"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import type { KiloClient, Session, ToolPart } from "@kilocode/sdk/v2"
|
||||
import { FormatError, FormatUnknownError } from "../error"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
||||
import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
|
||||
import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change
|
||||
import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change
|
||||
import { KiloRun, KiloRunDaemon } from "@/kilocode/cli/cmd/run" // kilocode_change
|
||||
// kilocode_change start - Kilo implementations (createKiloClient, run-message,
|
||||
// cloud-session, run-auto, headless, KiloRun) are dynamically imported inside the
|
||||
// handler so other CLI commands don't pay their module cost at startup.
|
||||
// kilocode_change end
|
||||
|
||||
type ModelInput = Parameters<KiloClient["session"]["prompt"]>[0]["model"]
|
||||
|
||||
@@ -266,6 +263,14 @@ export const RunCommand = effectCmd({
|
||||
const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags"))
|
||||
const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref"))
|
||||
const { ServerAuth } = yield* Effect.promise(() => import("@/server/auth"))
|
||||
// kilocode_change start - lazy Kilo implementations (see top-of-file note)
|
||||
const { createKiloClient } = yield* Effect.promise(() => import("@kilocode/sdk/v2"))
|
||||
const { buildRunMessage } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run-message"))
|
||||
const { importCloudSession, validateCloudFork } = yield* Effect.promise(() => import("@/kilocode/cloud-session"))
|
||||
const { KiloRunAuto } = yield* Effect.promise(() => import("@/kilocode/cli/run-auto"))
|
||||
const { KiloHeadless } = yield* Effect.promise(() => import("@/kilocode/permission/headless"))
|
||||
const { KiloRun, KiloRunDaemon } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run"))
|
||||
// kilocode_change end
|
||||
const agentSvc = yield* Agent.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const localInstance = yield* InstanceRef
|
||||
|
||||
@@ -2,9 +2,6 @@ import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change
|
||||
import { startParentWatchdog } from "../../kilocode/parent-watchdog" // kilocode_change
|
||||
import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change
|
||||
|
||||
export const ServeCommand = effectCmd({
|
||||
command: "serve",
|
||||
@@ -31,6 +28,9 @@ export const ServeCommand = effectCmd({
|
||||
|
||||
// kilocode_change start - graceful signal shutdown
|
||||
// yield* Effect.never
|
||||
const { InstanceRuntime } = yield* Effect.promise(() => import("../../project/instance-runtime"))
|
||||
const { startParentWatchdog } = yield* Effect.promise(() => import("../../kilocode/parent-watchdog"))
|
||||
const { KiloSessions } = yield* Effect.promise(() => import("@/kilo-sessions/kilo-sessions"))
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
|
||||
@@ -11,11 +11,8 @@ import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@kilocode/sdk/v2"
|
||||
import type { EventSource } from "@opencode-ai/tui/context/sdk"
|
||||
import { importCloudSession, localSessionID, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
|
||||
import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { KiloTuiThreadDaemon, type StartInput } from "@/kilocode/cli/cmd/tui/thread" // kilocode_change
|
||||
import { preload } from "@/kilocode/cli/cmd/tui" // kilocode_change
|
||||
import type { StartInput } from "@/kilocode/cli/cmd/tui/thread" // kilocode_change - runtime imports deferred into handlers
|
||||
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
// kilocode_change start - correlate the TUI worker with its parent process
|
||||
@@ -26,7 +23,7 @@ import {
|
||||
sanitizedProcessEnv,
|
||||
} from "@opencode-ai/core/util/opencode-process"
|
||||
// kilocode_change end
|
||||
import { createParentRemoteExitBridge, type RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change
|
||||
import type { RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change - runtime import deferred
|
||||
import type { Exit } from "@opencode-ai/tui/context/exit" // kilocode_change
|
||||
|
||||
declare global {
|
||||
@@ -46,6 +43,7 @@ export async function runEmbeddedRemoteExitBridge(input: {
|
||||
done: Promise<unknown>
|
||||
timeoutMs?: number
|
||||
}) {
|
||||
const { createParentRemoteExitBridge } = await import("@/kilocode/cli/cmd/tui/remote-exit-bridge")
|
||||
const timeoutMs = input.timeoutMs ?? 5_000
|
||||
const bridge = createParentRemoteExitBridge(input.client, input.exit)
|
||||
let ready = false
|
||||
@@ -179,6 +177,12 @@ export const TuiThreadCommand = cmd({
|
||||
describe: "agent to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
// kilocode_change start - lazy Kilo implementations so other CLI commands
|
||||
// don't pay their module cost at startup
|
||||
const { importCloudSession, localSessionID, validateCloudFork } = await import("@/kilocode/cloud-session")
|
||||
const { KiloTuiThreadDaemon } = await import("@/kilocode/cli/cmd/tui/thread")
|
||||
const { preload } = await import("@/kilocode/cli/cmd/tui")
|
||||
// kilocode_change end
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
const shutdown = {
|
||||
pending: undefined as Promise<void> | undefined,
|
||||
@@ -205,9 +209,8 @@ export const TuiThreadCommand = cmd({
|
||||
const next = resolveThreadDirectory(args.project)
|
||||
const file = await target()
|
||||
// kilocode_change start
|
||||
const preloads = preload(
|
||||
typeof KILO_WORKER_PATH !== "undefined",
|
||||
() => import.meta.resolve("@opentui/solid/preload"),
|
||||
const preloads = preload(typeof KILO_WORKER_PATH !== "undefined", () =>
|
||||
import.meta.resolve("@opentui/solid/preload"),
|
||||
)
|
||||
// kilocode_change end
|
||||
try {
|
||||
@@ -354,6 +357,7 @@ export const TuiThreadCommand = cmd({
|
||||
// kilocode_change start - import cloud session before TUI renders
|
||||
if (args.cloudFork && args.session) {
|
||||
UI.println("Importing session from cloud...")
|
||||
const { createKiloClient } = await import("@kilocode/sdk/v2")
|
||||
const sdk = createKiloClient({
|
||||
baseUrl: transport.url,
|
||||
fetch: transport.fetch,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change
|
||||
import open from "open"
|
||||
|
||||
export const WebCommand = effectCmd({
|
||||
@@ -33,17 +32,14 @@ export const WebCommand = effectCmd({
|
||||
}
|
||||
|
||||
if (opts.mdns) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`${opts.mdnsDomain}:${server.port}`,
|
||||
)
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, `${opts.mdnsDomain}:${server.port}`)
|
||||
}
|
||||
|
||||
open(urls.local).catch(() => {})
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - graceful signal shutdown
|
||||
const { InstanceRuntime } = yield* Effect.promise(() => import("../../project/instance-runtime"))
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
|
||||
@@ -207,6 +207,13 @@ function globalConfigFile() {
|
||||
|
||||
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
|
||||
if (!isRecord(patch)) {
|
||||
// kilocode_change start - jsonc-parser throws when deleting a path whose
|
||||
// parent does not exist in the document; absent keys are already "unset"
|
||||
if (patch === null) {
|
||||
const tree = parseTree(input)
|
||||
if (!tree || !findNodeAtLocation(tree, path)) return input
|
||||
}
|
||||
// kilocode_change end
|
||||
const edits = modify(input, path, patch === null ? undefined : patch, {
|
||||
// kilocode_change
|
||||
formattingOptions: {
|
||||
@@ -1015,20 +1022,28 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||
const patch = writableGlobal(config)
|
||||
// Reads merge every global config file, so delete sentinels must be
|
||||
// removed from all of them, not just the primary write target.
|
||||
const propagated = yield* KilocodeConfig.propagateUnset({
|
||||
fs,
|
||||
files: KilocodeConfig.GLOBAL_CONFIG_FILES.map((name) => path.join(Global.Path.config, name)),
|
||||
exclude: file,
|
||||
patch,
|
||||
})
|
||||
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(before, file), file)
|
||||
const next = KilocodeConfig.mergeConfig(writable(existing), patch)
|
||||
const serialized = JSON.stringify(next, null, 2)
|
||||
const changed = serialized !== before
|
||||
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
|
||||
const changed = serialized !== before || propagated
|
||||
if (serialized !== before) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
|
||||
return { next, changed }
|
||||
}
|
||||
|
||||
const updated = patchJsonc(before, patch)
|
||||
const next = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(updated, file), file)
|
||||
const changed = updated !== before
|
||||
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
const changed = updated !== before || propagated
|
||||
if (updated !== before) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
return { next, changed }
|
||||
}),
|
||||
`config:global:${path.resolve(Global.Path.config)}`,
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { dlopen, ptr } from "bun:ffi"
|
||||
|
||||
export namespace WindowsJob {
|
||||
const LIMITS = 9
|
||||
const MEMBERS = 3
|
||||
const KILL_ON_CLOSE = 0x00002000
|
||||
const PROCESS_TERMINATE = 0x0001
|
||||
const PROCESS_SET_QUOTA = 0x0100
|
||||
const MORE_DATA = 234
|
||||
|
||||
function kernel() {
|
||||
return dlopen("kernel32.dll", {
|
||||
CreateJobObjectW: { args: ["ptr", "ptr"], returns: "u64" },
|
||||
SetInformationJobObject: { args: ["u64", "u32", "ptr", "u32"], returns: "i32" },
|
||||
OpenProcess: { args: ["u32", "i32", "u32"], returns: "u64" },
|
||||
AssignProcessToJobObject: { args: ["u64", "u64"], returns: "i32" },
|
||||
QueryInformationJobObject: { args: ["u64", "u32", "ptr", "u32", "ptr"], returns: "i32" },
|
||||
TerminateJobObject: { args: ["u64", "u32"], returns: "i32" },
|
||||
CloseHandle: { args: ["u64"], returns: "i32" },
|
||||
GetLastError: { args: [], returns: "u32" },
|
||||
})
|
||||
}
|
||||
|
||||
export function create() {
|
||||
const lib = (() => {
|
||||
try {
|
||||
return kernel()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!lib) return
|
||||
const handle = lib.symbols.CreateJobObjectW(null, null)
|
||||
if (handle === 0n) {
|
||||
const code = lib.symbols.GetLastError()
|
||||
lib.close()
|
||||
throw new Error(`CreateJobObjectW failed with Windows error ${code}`)
|
||||
}
|
||||
const limits = new Uint8Array(144)
|
||||
new DataView(limits.buffer).setUint32(16, KILL_ON_CLOSE, true)
|
||||
if (lib.symbols.SetInformationJobObject(handle, LIMITS, ptr(limits), limits.byteLength) === 0) {
|
||||
const code = lib.symbols.GetLastError()
|
||||
lib.symbols.CloseHandle(handle)
|
||||
lib.close()
|
||||
throw new Error(`SetInformationJobObject failed with Windows error ${code}`)
|
||||
}
|
||||
let closed = false
|
||||
return {
|
||||
assign(pid: number) {
|
||||
const proc = lib.symbols.OpenProcess(PROCESS_TERMINATE | PROCESS_SET_QUOTA, 0, pid)
|
||||
if (proc === 0n) throw new Error(`OpenProcess failed with Windows error ${lib.symbols.GetLastError()}`)
|
||||
const assigned = lib.symbols.AssignProcessToJobObject(handle, proc)
|
||||
const code = assigned === 0 ? lib.symbols.GetLastError() : 0
|
||||
lib.symbols.CloseHandle(proc)
|
||||
if (assigned === 0) throw new Error(`AssignProcessToJobObject failed with Windows error ${code}`)
|
||||
},
|
||||
members() {
|
||||
let size = 4 * 1024
|
||||
while (true) {
|
||||
const info = new Uint8Array(size)
|
||||
const ok = lib.symbols.QueryInformationJobObject(handle, MEMBERS, ptr(info), info.byteLength, null)
|
||||
const code = ok === 0 ? lib.symbols.GetLastError() : 0
|
||||
const view = new DataView(info.buffer)
|
||||
const assigned = view.getUint32(0, true)
|
||||
const count = view.getUint32(4, true)
|
||||
if (ok !== 0 && count === assigned) {
|
||||
return Array.from({ length: count }, (_, index) => Number(view.getBigUint64(8 + index * 8, true)))
|
||||
}
|
||||
if (ok === 0 && code !== MORE_DATA) {
|
||||
throw new Error(`QueryInformationJobObject failed with Windows error ${code}`)
|
||||
}
|
||||
size = Math.max(size * 2, 8 + assigned * 8)
|
||||
}
|
||||
},
|
||||
terminate() {
|
||||
if (lib.symbols.TerminateJobObject(handle, 1) === 0) {
|
||||
throw new Error(`TerminateJobObject failed with Windows error ${lib.symbols.GetLastError()}`)
|
||||
}
|
||||
},
|
||||
close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
lib.symbols.CloseHandle(handle)
|
||||
lib.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import type { Argv } from "yargs"
|
||||
import { Effect } from "effect"
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { effectCmd } from "@/cli/effect-cmd"
|
||||
import { CloudCommands } from "@/kilocode/cloud/commands"
|
||||
|
||||
// Keep the top-level import graph light: this module is registered eagerly at CLI
|
||||
// startup, so the cloud implementation is imported inside handlers (same deferral
|
||||
// pattern as upstream opencode#30453).
|
||||
const cloud = Effect.promise(() => import("@/kilocode/cloud/commands").then((m) => m.CloudCommands))
|
||||
|
||||
export const CloudStartCommand = effectCmd({
|
||||
command: "start",
|
||||
@@ -44,6 +48,7 @@ export const CloudStartCommand = effectCmd({
|
||||
describe: "connect to the WebSocket stream and print events as JSONL",
|
||||
}),
|
||||
handler: Effect.fn("Cli.cloud.start")(function* (args) {
|
||||
const CloudCommands = yield* cloud
|
||||
yield* CloudCommands.start({
|
||||
prompt: args.prompt,
|
||||
...(args.repo === undefined ? {} : { repo: args.repo }),
|
||||
@@ -74,6 +79,7 @@ export const CloudSendCommand = effectCmd({
|
||||
describe: "follow-up prompt for the Cloud Agent",
|
||||
}),
|
||||
handler: Effect.fn("Cli.cloud.send")(function* (args) {
|
||||
const CloudCommands = yield* cloud
|
||||
yield* CloudCommands.send({ sessionID: args.sessionId, prompt: args.prompt })
|
||||
}),
|
||||
})
|
||||
@@ -95,6 +101,7 @@ export const CloudStatusCommand = effectCmd({
|
||||
describe: "Cloud Agent message ID",
|
||||
}),
|
||||
handler: Effect.fn("Cli.cloud.status")(function* (args) {
|
||||
const CloudCommands = yield* cloud
|
||||
yield* CloudCommands.status({ sessionID: args.sessionId, messageID: args.messageId })
|
||||
}),
|
||||
})
|
||||
@@ -116,6 +123,7 @@ export const CloudResultCommand = effectCmd({
|
||||
describe: "Cloud Agent message ID",
|
||||
}),
|
||||
handler: Effect.fn("Cli.cloud.result")(function* (args) {
|
||||
const CloudCommands = yield* cloud
|
||||
yield* CloudCommands.result({ sessionID: args.sessionId, messageID: args.messageId })
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import open from "open"
|
||||
import type { Argv } from "yargs"
|
||||
import type { Daemon } from "@/kilocode/daemon/daemon"
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { explicitNetworkOptions, withNetworkOptions } from "@/cli/network"
|
||||
import { serverUrls } from "@/kilocode/cli/server-urls"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Daemon } from "@/kilocode/daemon/daemon"
|
||||
import { warnPort } from "@/kilocode/cli/port-warning"
|
||||
import { hasDisplay } from "@/kilocode/cli/cmd/tui/util/display"
|
||||
import { StopCommand } from "@/kilocode/cli/cmd/daemon"
|
||||
|
||||
// Keep the top-level import graph light: this module is registered eagerly at CLI
|
||||
// startup, so implementation dependencies are imported inside handlers (same
|
||||
// deferral pattern as upstream opencode#30453).
|
||||
function withCredentials(base: string, state: Daemon.State) {
|
||||
const url = new URL("/console", base)
|
||||
url.username = state.username
|
||||
@@ -17,6 +17,7 @@ function withCredentials(base: string, state: Daemon.State) {
|
||||
}
|
||||
|
||||
async function launch(url: string) {
|
||||
const { default: open } = await import("open")
|
||||
const child = await open(url)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 500)
|
||||
@@ -46,9 +47,10 @@ const OpenCommand = cmd({
|
||||
type: "boolean",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { Daemon } = await import("@/kilocode/daemon/daemon")
|
||||
const { warnedNetworkOptions } = await import("@/kilocode/cli/port-warning")
|
||||
const run = async (signal?: AbortSignal) => {
|
||||
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
|
||||
warnPort(opts.port)
|
||||
const opts = await warnedNetworkOptions(args)
|
||||
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
|
||||
const state = daemon.result.state
|
||||
if (!state) throw new Error("Kilo daemon did not provide connection state")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user