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:
marius-kilocode
2026-07-30 14:13:31 +02:00
128 changed files with 3256 additions and 407 deletions
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Run Agent Manager project scripts in the terminal selected by the existing toolbar dropdown. Agent Manager panel uses the named side terminal, while VS Code terminal retains the integrated task flow.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-telemetry": patch
---
Reduce CLI startup time by deferring Kilo-specific module loading until commands actually run, caching the telemetry profile lookup across invocations, and uploading telemetry in the background so process exit is not delayed by a network round trip
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix settings snapping back to their previous value after being cleared to "Not set" when multiple config files exist (e.g. both `kilo.json` and `kilo.jsonc`)
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Allow users to enable web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix the `@` "Past chats" picker in Agent Manager showing only the current session's directory. It now lists previous sessions across the whole worktree family — the local workspace and every Agent Manager worktree — each labeled with its worktree name, matching the Agent Manager session search. Any listed session can be attached as context, including chats from other worktrees of the same repository.
@@ -0,0 +1,179 @@
import { spawn } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
import type { Proc } from "../../pty/pty"
import { Log } from "../../util/log"
const log = Log.create({ service: "pty.termination" })
const GRACE_MS = 200
const SPAWN_TIMEOUT_MS = 5_000
export type Process = Pick<Proc, "pid" | "onExit" | "kill">
export type Runtime = {
readonly platform: NodeJS.Platform
readonly taskkill: (
file: string,
args: string[],
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
) => Promise<boolean>
readonly tree: () => Promise<Array<{ pid: number; parent: number }>>
readonly alive: (pid: number) => boolean
readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void
readonly sleep: (ms: number) => Promise<void>
}
const runtime: Runtime = {
platform: process.platform,
taskkill,
tree,
alive: (pid) => {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
},
signal: (pid, signal) => process.kill(pid, signal),
sleep,
}
function direct(proc: Process, signal?: "SIGTERM" | "SIGKILL") {
try {
proc.kill(signal)
} catch (err) {
log.warn("failed to kill PTY directly", { err, pid: proc.pid, signal })
}
}
function descendants(root: number, rows: Array<{ pid: number; parent: number }>) {
const children = new Map<number, number[]>()
for (const row of rows) {
const list = children.get(row.parent) ?? []
list.push(row.pid)
children.set(row.parent, list)
}
const seen = new Set<number>()
const collect = (pid: number): number[] => {
const result: number[] = []
for (const child of children.get(pid) ?? []) {
if (seen.has(child)) continue
seen.add(child)
result.push(...collect(child), child)
}
return result
}
return collect(root)
}
async function family(root: number, input: Runtime) {
const rows = await input.tree().catch((err) => {
log.debug("failed to inspect PTY process tree", { err, pid: root })
return []
})
return [...descendants(root, rows), root]
}
function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", input: Runtime) {
for (const pid of pids) {
let sent = false
for (const target of [-pid, pid]) {
try {
input.signal(target, value)
sent = true
} catch (err) {
log.debug("failed to signal PTY process", { err, pid: target, signal: value })
}
}
if (pid === proc.pid && !sent) direct(proc, value)
}
}
async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) {
return await new Promise<Array<{ pid: number; parent: number }>>((resolve) => {
try {
const child = spawn(file, args, {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
timeout: SPAWN_TIMEOUT_MS,
killSignal: "SIGKILL",
})
const chunks: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk))
child.once("error", () => resolve([]))
child.once("close", (code) => {
if (code !== 0) return resolve([])
const rows = Buffer.concat(chunks)
.toString("utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => line.trim().split(/\s+/).map(Number))
.filter(([pid, parent]) => Number.isSafeInteger(pid) && Number.isSafeInteger(parent))
.map(([pid, parent]) => ({ pid: pid!, parent: parent! }))
resolve(rows)
})
} catch {
resolve([])
}
})
}
async function taskkill(
file: string,
args: string[],
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
) {
return await new Promise<boolean>((resolve) => {
try {
const child = spawn(file, args, opts)
child.once("exit", (code) => resolve(code === 0))
child.once("error", (err) => {
log.warn("taskkill failed", { err })
resolve(false)
})
} catch (err) {
log.warn("failed to start taskkill", { err })
resolve(false)
}
})
}
export async function terminate(proc: Process, input: Runtime = runtime): Promise<void> {
const state = { exited: false }
const listener = proc.onExit(() => {
state.exited = true
})
try {
if (!proc.pid) {
direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
return
}
if (input.platform === "win32") {
const killed = await input.taskkill("taskkill", ["/pid", String(proc.pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
timeout: SPAWN_TIMEOUT_MS,
})
if (!killed && !state.exited) direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
return
}
const initial = await family(proc.pid, input)
signal(proc, initial, "SIGTERM", input)
await input.sleep(GRACE_MS)
const remaining = new Set(initial.filter(input.alive))
if (input.alive(proc.pid)) for (const pid of await family(proc.pid, input)) remaining.add(pid)
if (remaining.size > 0) {
signal(proc, [...remaining], "SIGKILL", input)
await input.sleep(GRACE_MS)
}
} finally {
listener.dispose()
}
}
export * as KiloPtyTermination from "./termination"
+37 -24
View File
@@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema" // kilocode_change
import { Shell } from "./shell"
import { lazy } from "./util/lazy"
import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change
import { KiloPtyTermination } from "./kilocode/pty/termination" // kilocode_change
const BUFFER_LIMIT = 1024 * 1024 * 2
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
@@ -35,6 +36,7 @@ type Active = {
cursor: number
subscribers: Map<object, Subscriber>
listeners: Disp[]
stopping: boolean // kilocode_change
}
export const Info = Schema.Struct({
@@ -83,6 +85,8 @@ export type AttachInput = {
readonly onData: (chunk: string) => void
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
readonly onEnd: (event: { exitCode?: number }) => void
// Canonical routes can replay retained output after exit; legacy callers retain the former error.
readonly allowExited?: boolean // kilocode_change
}
export type Attachment = {
@@ -147,23 +151,25 @@ export const layer = Layer.effect(
session.subscribers.clear()
}
function teardown(session: Active) {
// kilocode_change start - terminate the complete PTY tree before reporting removal.
async function teardown(session: Active) {
session.stopping = true
if (session.info.status === "running") await KiloPtyTermination.terminate(session.process)
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
if (session.info.status === "running") {
try {
session.process.kill()
} catch {}
}
notifyEnd(session, {})
notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {})
}
// kilocode_change end
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) teardown(session)
sessions.clear()
exitOrder.length = 0
}),
yield* Effect.addFinalizer(
() =>
// kilocode_change start - wait for process-tree termination during async service teardown.
Effect.promise(async () => {
await Promise.all(Array.from(sessions.values()).map(teardown))
sessions.clear()
exitOrder.length = 0
}),
// kilocode_change end
)
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
@@ -173,14 +179,18 @@ export const layer = Layer.effect(
})
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
// kilocode_change start - removal and its deleted event are one uninterruptible lifecycle transition.
yield* Effect.gen(function* () {
const session = sessions.get(id)
if (!session) return
yield* Effect.logInfo("removing session", { id })
yield* Effect.promise(() => teardown(session))
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* events.publish(Event.Deleted, { id: session.info.id })
}).pipe(Effect.uninterruptible)
// kilocode_change end
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
@@ -204,9 +214,10 @@ export const layer = Layer.effect(
args: input.args ? [...input.args] : undefined,
cwd: input.cwd,
})
const implicit = !resolved.command
const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
const base = resolved.args ?? []
const args = Shell.login(command) ? [...base, "-l"] : [...base]
const args = implicit && Shell.login(command) ? [...base, "-l"] : [...base]
const cwd = resolved.cwd || location.directory
// kilocode_change end
const env = {
@@ -246,6 +257,7 @@ export const layer = Layer.effect(
cursor: 0,
subscribers: new Map(),
listeners: [],
stopping: false, // kilocode_change
}
sessions.set(id, session)
session.listeners.push(
@@ -269,7 +281,7 @@ export const layer = Layer.effect(
session.bufferCursor += excess
}),
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
if (session.info.status === "exited" || session.stopping) return // kilocode_change
session.info.status = "exited"
session.info.exitCode = exitCode
notifyEnd(session, { exitCode })
@@ -309,7 +321,7 @@ export const layer = Layer.effect(
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
if (session.info.status !== "running" && !input.allowExited) return yield* new ExitedError({ ptyID: id }) // kilocode_change
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
const token = {}
const subscriber: Subscriber = {
@@ -318,6 +330,7 @@ export const layer = Layer.effect(
active: false,
detached: false,
pending: [],
end: session.info.status === "exited" ? { exitCode: session.info.exitCode } : undefined, // kilocode_change
}
session.subscribers.set(token, subscriber)
const start = session.bufferCursor
+3
View File
@@ -229,6 +229,9 @@ export const Info = Schema.Struct({
layout: Schema.optional(ConfigLayoutV1.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(ConfigPermissionV1.Info),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
web_search: Schema.optional(Schema.Boolean).annotate({
description: "Make web search available to models from all providers (default: false)",
}), // kilocode_change
attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({
description: "Attachment processing configuration, including image size limits and resizing behavior",
}),
@@ -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")
})
})
+21
View File
@@ -82,4 +82,25 @@ export namespace Client {
}
}
}
// Flush queued events in the background without blocking the caller. The
// flush is delayed slightly so commands that exit immediately pay only the
// single shutdown() flush instead of an in-flight flush plus a follow-up
// flush for CLI_EXIT. For commands that outlive the delay, the upload
// overlaps with execution, so by the time shutdown() runs the queue is
// usually empty (or the connection is still warm) and process exit is not
// delayed by a network round trip. The unref'd timer never keeps a process
// alive on its own. The authoritative, error-handled flush still happens in
// shutdown(); failures here are retried there, so they are only surfaced
// when debug logging is on.
export function flushInBackground(delayMs = 300): void {
if (!enabled || !client) return
const timer = setTimeout(() => {
if (!client) return
client.flush().catch((err) => {
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry background flush failed", err)
})
}, delayMs)
timer.unref?.()
}
}
+69
View File
@@ -1,4 +1,6 @@
import * as path from "path"
import { createHash } from "crypto"
import { writeFile, chmod, rename, rm } from "fs/promises"
import { fetchProfile } from "@kilocode/kilo-gateway"
export namespace Identity {
@@ -7,6 +9,21 @@ export namespace Identity {
let organizationId: string | null = null
let dataPath = ""
// Cache the email resolved from the auth token so CLI startup does not block on
// a profile request for every invocation. Keyed by token hash; refreshed when
// the token changes. Stale entries (older than a week) are still used for the
// current run and refreshed on a best-effort basis for a later run: the
// background refresh is not awaited, so short-lived invocations may exit before
// it completes and simply retry next time.
const CACHE_FILE = "telemetry-profile.json"
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000
interface Cache {
token: string
email: string
fetchedAt: number
}
export function setDataPath(p: string) {
dataPath = p
}
@@ -51,6 +68,45 @@ export namespace Identity {
organizationId = orgId
}
function digest(token: string): string {
return createHash("sha256").update(token).digest("hex")
}
async function read(): Promise<Cache | null> {
if (!dataPath) return null
const file = Bun.file(path.join(dataPath, CACHE_FILE))
if (!(await file.exists())) return null
const parsed = await file.json().catch(() => null)
if (!parsed || typeof parsed.token !== "string" || typeof parsed.email !== "string") return null
if (typeof parsed.fetchedAt !== "number") return null
return parsed as Cache
}
async function write(cache: Cache): Promise<void> {
if (!dataPath) return
const filepath = path.join(dataPath, CACHE_FILE)
// The cache stores the user's email and a token verifier, so keep it
// readable only by the owner, including when replacing an existing file.
// Write to a temp file and rename so concurrent invocations or a mid-write
// kill cannot leave a truncated cache behind (POSIX rename is atomic).
const tmp = `${filepath}.${process.pid}.tmp`
try {
await writeFile(tmp, JSON.stringify(cache), { mode: 0o600 })
await chmod(tmp, 0o600)
await rename(tmp, filepath)
} catch (err) {
await rm(tmp, { force: true }).catch((rmErr) => {
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache temp cleanup failed", rmErr)
})
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache write failed", err)
}
}
async function refresh(token: string, tokenHash: string): Promise<void> {
const profile = await fetchProfile(token).catch(() => null)
if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() })
}
export async function updateFromKiloAuth(token: string | null, accountId?: string): Promise<void> {
organizationId = accountId || null
@@ -59,8 +115,21 @@ export namespace Identity {
return
}
const tokenHash = digest(token)
const cached = await read()
if (cached && cached.token === tokenHash) {
userId = cached.email
if (Date.now() - cached.fetchedAt > CACHE_TTL) {
refresh(token, tokenHash).catch((err) => {
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile refresh failed", err)
})
}
return
}
const profile = await fetchProfile(token).catch(() => null)
userId = profile?.email || null
if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() })
}
export function reset() {
+6
View File
@@ -135,6 +135,12 @@ export namespace Telemetry {
track(TelemetryEvent.CLI_START)
}
// Upload queued events without blocking. Call after bootstrap so the flush
// overlaps with command execution and shutdown() stays fast (#10242).
export function flushInBackground() {
Client.flushInBackground()
}
export function trackCliExit(exitCode?: number) {
track(TelemetryEvent.CLI_EXIT, {
duration: Date.now() - startTime,
+1 -1
View File
@@ -678,7 +678,7 @@
"command": "kilo-code.new.agentManager.showTerminal",
"key": "ctrl+/",
"mac": "cmd+/",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' && !kilo-code.new.sidebarFocused"
},
{
"command": "kilo-code.new.agentManager.runScript",
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 lautomatisation 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+6 -2
View File
@@ -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,
+6 -3
View File
@@ -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) {
+5 -2
View File
@@ -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)
+5 -2
View File
@@ -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")
}),
})
+8 -5
View File
@@ -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.
+13 -8
View File
@@ -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
+3 -3
View File
@@ -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) => {
+12 -8
View File
@@ -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,
+2 -6
View File
@@ -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) => {
+19 -4
View File
@@ -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