diff --git a/.changeset/agent-manager-diff-base-override.md b/.changeset/agent-manager-diff-base-override.md new file mode 100644 index 00000000000..5d38f0e6cbc --- /dev/null +++ b/.changeset/agent-manager-diff-base-override.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Apply the Agent Manager base branch picker selection to the active diff immediately. Changing the base branch now refreshes the diff against the new base instead of keeping the previous comparison until the scope or session changed. diff --git a/.changeset/agent-manager-diff-scope-selector.md b/.changeset/agent-manager-diff-scope-selector.md new file mode 100644 index 00000000000..341e5ee7684 --- /dev/null +++ b/.changeset/agent-manager-diff-scope-selector.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged. diff --git a/.changeset/agent-manager-terminal-shortcut-focus.md b/.changeset/agent-manager-terminal-shortcut-focus.md new file mode 100644 index 00000000000..8d0b7181983 --- /dev/null +++ b/.changeset/agent-manager-terminal-shortcut-focus.md @@ -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. diff --git a/.changeset/agent-manager-terminal-shortcut-platform.md b/.changeset/agent-manager-terminal-shortcut-platform.md new file mode 100644 index 00000000000..dbd2a126cb2 --- /dev/null +++ b/.changeset/agent-manager-terminal-shortcut-platform.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Match the Agent Manager terminal shortcut fallback to the platform modifier (Cmd on macOS, Ctrl elsewhere) and consume the extension echo once per keypress so unrelated invocations are no longer swallowed. diff --git a/.changeset/calm-run-terminals.md b/.changeset/calm-run-terminals.md new file mode 100644 index 00000000000..df2a4ace3d7 --- /dev/null +++ b/.changeset/calm-run-terminals.md @@ -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. diff --git a/.changeset/cli-startup-lazy-loading.md b/.changeset/cli-startup-lazy-loading.md new file mode 100644 index 00000000000..38571e58380 --- /dev/null +++ b/.changeset/cli-startup-lazy-loading.md @@ -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 diff --git a/.changeset/config-unset-propagation.md b/.changeset/config-unset-propagation.md new file mode 100644 index 00000000000..82418a56350 --- /dev/null +++ b/.changeset/config-unset-propagation.md @@ -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`) diff --git a/.changeset/enable-websearch-config.md b/.changeset/enable-websearch-config.md new file mode 100644 index 00000000000..cd47a9e9fb5 --- /dev/null +++ b/.changeset/enable-websearch-config.md @@ -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. diff --git a/.changeset/long-session-prompt-navigation.md b/.changeset/long-session-prompt-navigation.md new file mode 100644 index 00000000000..967dde901f2 --- /dev/null +++ b/.changeset/long-session-prompt-navigation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Navigate long conversations from a compact prompt rail that loads earlier history as you scroll. diff --git a/.changeset/past-chats-worktree-family.md b/.changeset/past-chats-worktree-family.md new file mode 100644 index 00000000000..395bee2b5a7 --- /dev/null +++ b/.changeset/past-chats-worktree-family.md @@ -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. diff --git a/.changeset/quiet-terminals-switch.md b/.changeset/quiet-terminals-switch.md new file mode 100644 index 00000000000..0306eb34755 --- /dev/null +++ b/.changeset/quiet-terminals-switch.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open an embedded terminal automatically when switching to a worktree without one. diff --git a/.changeset/ripgrep-error-detail.md b/.changeset/ripgrep-error-detail.md new file mode 100644 index 00000000000..6afe556432c --- /dev/null +++ b/.changeset/ripgrep-error-detail.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Include the underlying reason in search execution failures instead of showing a bare "ripgrep execution failed" message. diff --git a/.changeset/side-terminal-tab-parity.md b/.changeset/side-terminal-tab-parity.md new file mode 100644 index 00000000000..715a1bfe212 --- /dev/null +++ b/.changeset/side-terminal-tab-parity.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Make Agent Manager panel terminals behave like session tabs: right-click Close and Close Others, arrow-key tab navigation, overflow scrolling with edge fades, and stable tab widths while closing. The new-terminal button now sits directly next to the last terminal tab instead of the far edge of the panel. diff --git a/.changeset/skill-shell-execution.md b/.changeset/skill-shell-execution.md new file mode 100644 index 00000000000..8689613673f --- /dev/null +++ b/.changeset/skill-shell-execution.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running. diff --git a/packages/core/src/kilocode/pty/termination.ts b/packages/core/src/kilocode/pty/termination.ts new file mode 100644 index 00000000000..bb1fd30be7f --- /dev/null +++ b/packages/core/src/kilocode/pty/termination.ts @@ -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 + +export type Runtime = { + readonly platform: NodeJS.Platform + readonly taskkill: ( + file: string, + args: string[], + opts: { stdio: "ignore"; windowsHide: true; timeout: number }, + ) => Promise + readonly tree: () => Promise> + readonly alive: (pid: number) => boolean + readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void + readonly sleep: (ms: number) => Promise +} + +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() + for (const row of rows) { + const list = children.get(row.parent) ?? [] + list.push(row.pid) + children.set(row.parent, list) + } + const seen = new Set() + 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>((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((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 { + 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" diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 28e48f6b051..0157c0d8d1d 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -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 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 diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 12d4c0eb0ea..e1545195606 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -165,11 +165,13 @@ export const layer = Layer.effect( ) const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program return abortable.pipe( - Effect.mapError((cause) => - cause instanceof Error || cause instanceof InvalidPatternError - ? cause - : failure("ripgrep execution failed", cause), - ), + // kilocode_change start - surface the underlying reason instead of a bare wrapper message + Effect.mapError((cause) => { + if (cause instanceof Error || cause instanceof InvalidPatternError) return cause + const detail = cause instanceof globalThis.Error && cause.message.trim() ? `: ${cause.message.trim()}` : "" + return failure(`ripgrep execution failed${detail}`, cause) + }), + // kilocode_change end ) } diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 233f6238379..77f5d55160d 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -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", }), diff --git a/packages/core/src/v1/permission.ts b/packages/core/src/v1/permission.ts index b241ccd9077..8c410562e61 100644 --- a/packages/core/src/v1/permission.ts +++ b/packages/core/src/v1/permission.ts @@ -45,6 +45,8 @@ export type Reply = typeof Reply.Type export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.String.pipe(Schema.optional), + // kilocode_change - set by clients when a human answered the prompt; the server refuses machine approvals of skill-shell batches + interactive: Schema.Boolean.pipe(Schema.optional), }).annotate({ identifier: "PermissionReplyBody" }) export type ReplyBody = typeof ReplyBody.Type diff --git a/packages/core/test/kilocode/pty-termination.test.ts b/packages/core/test/kilocode/pty-termination.test.ts new file mode 100644 index 00000000000..a6162cfbe0e --- /dev/null +++ b/packages/core/test/kilocode/pty-termination.test.ts @@ -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 = [] + 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" }) + }) +}) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 6b78ec4d13c..13184a8a12b 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -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, "")).toContain("") + }), + ) + + 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 "" "$!"; wait']) + const attached = yield* attachCollecting(info.id) + const output = yield* waitForOutput(attached.output, ">") + const match = output.match(//) + 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") diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index 677ecc2dd6a..b8e19e39ea2 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -61,4 +61,18 @@ describe("Ripgrep", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) + + // kilocode_change start - surfaced error keeps the underlying reason + it.live("includes the underlying reason in execution failures", () => + Effect.gen(function* () { + const ripgrep = yield* Ripgrep.Service + const controller = new AbortController() + controller.abort() + const error = yield* ripgrep + .find({ cwd: process.cwd(), pattern: "*", limit: 1, signal: controller.signal }) + .pipe(Effect.flip) + expect(error.message).toMatch(/^ripgrep execution failed: .+/) + }), + ) + // kilocode_change end }) diff --git a/packages/kilo-console/src/routes/config/ToolsRoute.tsx b/packages/kilo-console/src/routes/config/ToolsRoute.tsx index 1aca8a16b84..1dd1001e66c 100644 --- a/packages/kilo-console/src/routes/config/ToolsRoute.tsx +++ b/packages/kilo-console/src/routes/config/ToolsRoute.tsx @@ -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." > + +
+
+

Web search

+

Control web search availability for models from providers that do not enable it by default.

+
+ + + +
+
+ +
+
+ ): PermissionRequestDto? = + permissions.lastOrNull { it.metadata["skillShell"] == "true" } + private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) { assertEdt() val current = model.state @@ -1156,11 +1173,15 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child } if (permissions.isEmpty()) return LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" } - if (autoApprove) { + // A skill-shell request must surface as a card even under auto-approve (replyAll + // skips it); prefer it over the last pending so a human can answer. + val show = if (autoApprove) { replyAll(permissions) - return + skillShellCard(permissions) ?: return + } else { + skillShellCard(permissions) ?: permissions.last() } - val last = toPermission(permissions.last()) + val last = toPermission(show) runEdt { if (disposed) return@runEdt if (child !in childIds) return@runEdt @@ -1201,9 +1222,12 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id } val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id } val status = sessions.statuses.value[id] + // replyAll auto-approves the ordinary permissions and skips skill-shell ones. A + // skill-shell request must then fall through to a human card rather than go Busy. + val skillCard = skillShellCard(permissions) if (permissions.isNotEmpty() && autoApprove) { val count = replyAll(permissions) - if (count > 0) { + if (count > 0 && skillCard == null) { runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -1226,7 +1250,8 @@ class SessionController( if (sid != id) return@runEdt updateModel { if (permissions.isNotEmpty()) { - model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) + // Prefer a skill-shell request (needs a human) over the last pending. + model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last()))) } else if (questions.isNotEmpty()) { model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last()))) } else if (status != null) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index d5323dd58a3..0ae649ef54e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -356,7 +356,7 @@ class PermissionView( card.setActionEnabled(ID_RUN, false) card.setActionEnabled(ID_DENY, false) rules.setControlsEnabled(false) - reply(id, PermissionReplyDto(reply = "once"), rulePayload()) + reply(id, PermissionReplyDto(reply = "once", interactive = true), rulePayload()) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 9d5e4225e15..768c9e36b39 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -254,6 +254,23 @@ class PromptLifecycleTest : SessionControllerTestBase() { ) } + fun `test auto approve does not machine-reply a skill shell batch`() { + val (m, _, _) = prompted() + + edt { m.setAutoApprove(true) } + // skill-shell batches must be answered by a human; auto-approve must show the card + // instead of sending a non-interactive reply the server would refuse. + emit( + ChatEventDto.PermissionAsked( + "ses_test", + permission("perm1").copy(metadata = mapOf("skillShell" to "true")), + ), + ) + + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + fun `test disabling auto approve before reply restores awaiting permission`() { val (m, _, _) = prompted() @@ -310,6 +327,31 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("once", rpc.permissionReplies[0].third.reply) } + fun `test enabling auto approve surfaces a pending skill shell as a card`() { + val (m, _, _) = prompted() + rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true"))) + + edt { m.setAutoApprove(true) } + flush() + + // skill-shell must not be machine-approved; it surfaces as a human card instead + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + + fun `test recovery surfaces a pending skill shell as a card under auto approve`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true"))) + edt { KiloPluginSettings.setAutoApprove(true) } + + val m = controller("ses_test") + flush() + + assertTrue(rpc.permissionReplies.isEmpty()) + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + fun `test auto approve drains pending permissions during recovery`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5")) projectRpc.state.value = workspaceReady() diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index edbbed7b02b..f4e222bbb23 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -328,6 +328,8 @@ data class ToolRefDto( data class PermissionReplyDto( val reply: String, val message: String? = null, + // Set when a human answered the prompt; the CLI ignores machine approvals of skill-shell batches. + val interactive: Boolean = false, ) @Serializable diff --git a/packages/kilo-telemetry/src/__tests__/identity.test.ts b/packages/kilo-telemetry/src/__tests__/identity.test.ts new file mode 100644 index 00000000000..3754c047f07 --- /dev/null +++ b/packages/kilo-telemetry/src/__tests__/identity.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-telemetry/src/client.ts b/packages/kilo-telemetry/src/client.ts index 24c902b5435..f5baa1ec54e 100644 --- a/packages/kilo-telemetry/src/client.ts +++ b/packages/kilo-telemetry/src/client.ts @@ -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?.() + } } diff --git a/packages/kilo-telemetry/src/identity.ts b/packages/kilo-telemetry/src/identity.ts index c75fac4d29d..974a32120df 100644 --- a/packages/kilo-telemetry/src/identity.ts +++ b/packages/kilo-telemetry/src/identity.ts @@ -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 { + 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 { + 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 { + 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 { 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() { diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 4d16051c2d8..496bf1ad4c9 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -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, diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index df9e75a70bc..381f28c2570 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -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", diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index e8e668b4b50..5dd9b1987cd 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -4,10 +4,12 @@ import type { KiloClient, Session } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend" import { getErrorMessage } from "../kilo-provider-utils" import { resolveLocalDiffTarget } from "../diff/shared/target" +import { DiffSourceCatalog } from "../diff/sources/catalog" import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager" +import { composeDiffId, normalizeScope } from "./diff-scope" import { handleSection } from "./section-handler" import { normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller" @@ -22,9 +24,9 @@ import { SessionTerminalManager } from "./SessionTerminalManager" import { createTerminalHost } from "./terminal-host" import { TerminalRouter } from "./terminal-routing" import { executeVscodeTask } from "./task-runner" -import { startVscodeRunTask } from "./run/task" import { RunController } from "./run/controller" import { handleRunMessage } from "./run/message" +import { createRunController, createScriptTerminalRuntime } from "./script-terminal-runtime" import { forkSession } from "./fork-session" import { AgentManagerVisiblePresence } from "./am-visible-presence" import { continueInWorktree } from "./continue-in-worktree" @@ -64,6 +66,7 @@ export class AgentManagerProvider implements Disposable { private importer: WorktreeImporter private terminalManager: SessionTerminalManager private terminalRouter: TerminalRouter + private scripts: ReturnType private run: RunController private stateReady: Promise | undefined private statsPoller: GitStatsPoller @@ -71,6 +74,7 @@ export class AgentManagerProvider implements Disposable { private orchestration: AgentManagerOrchestrationBridge private gitOps: GitOps private diffs: WorktreeDiffController + private diffCatalog: DiffSourceCatalog private naming: BranchNamingController private staleWorktreeIds = new Set() private toolRequests = new Set() @@ -111,19 +115,25 @@ export class AgentManagerProvider implements Disposable { post: (msg) => this.postToWebview(msg), getTerminalFont: () => readTerminalFont(), }) + this.scripts = createScriptTerminalRuntime({ + connection: this.connectionService, + output: this.outputChannel, + post: (message) => this.postToWebview(message), + }) this.unsubFont = watchTerminalFont((font) => { this.postToWebview({ type: "agentManager.terminal.fontChanged", font }) + this.scripts.manager.snapshot() }) this.unsubDestination = watchTerminalDestination((destination) => { this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination }) }) - this.run = new RunController({ + this.run = createRunController({ + manager: this.scripts.manager, root: () => this.getRoot(), state: () => this.getStateManager(), open: (file) => this.host.openDocument(file), - start: startVscodeRunTask, - post: (status) => this.postToWebview({ type: "agentManager.runStatus", ...status }), - error: (message) => this.postToWebview({ type: "error", message }), + trusted: () => this.host.isTrusted(), + post: (message) => this.postToWebview(message), log: (msg) => this.outputChannel.appendLine(`[RunScript] ${msg}`), refresh: () => this.pushState(), }) @@ -149,12 +159,13 @@ export class AgentManagerProvider implements Disposable { log: (msg) => this.log(msg), }) const local = createLocalDiff(this.gitOps, (...args) => this.log(...args)) + this.diffCatalog = new DiffSourceCatalog(this.connectionService) this.diffs = new WorktreeDiffController({ getState: () => this.getStateManager(), getRoot: () => this.getRoot(), getStateReady: () => this.stateReady, + catalog: this.diffCatalog, git: this.gitOps, - localDiff: local.summary, localDiffFile: local.file, post: (msg) => this.postToWebview(msg), log: (...args) => this.log(...args), @@ -412,6 +423,7 @@ export class AgentManagerProvider implements Disposable { if (diff !== undefined) return diff const bridge = this.onBridgeMessage(m) if (bridge !== undefined) return bridge + if (this.scripts.manager.intercept(m)) return null if (this.terminalRouter.handle(m)) return null return msg @@ -684,11 +696,11 @@ export class AgentManagerProvider implements Disposable { private onDiffMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestWorktreeDiff") { - void this.diffs.request(m.sessionId) + void this.diffs.request(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null } if (m.type === "agentManager.requestWorktreeDiffFile") { - void this.diffs.requestFile(m.sessionId, m.file) + void this.diffs.requestFile(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file) return null } if (m.type === "agentManager.applyWorktreeDiff") { @@ -696,23 +708,53 @@ export class AgentManagerProvider implements Disposable { return null } if (m.type === "agentManager.revertWorktreeFile") { - void this.diffs.revert(m.sessionId, m.file) + void this.diffs.revert(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.file) return null } if (m.type === "agentManager.startDiffWatch") { - this.diffs.start(m.sessionId) + this.diffs.start(composeDiffId(m.sessionId, normalizeScope(m.scope))) return null } if (m.type === "agentManager.stopDiffWatch") { this.diffs.stop() return null } + if (m.type === "agentManager.requestDiffBranches") { + void this.sendDiffBranches(m.sessionId, m.scope) + return null + } + if (m.type === "agentManager.setDiffBaseBranch") { + void this.diffs + .setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch) + .catch((err) => this.log("Failed to set diff base:", err instanceof Error ? err.message : String(err))) + .then(() => void this.sendDiffBranches(m.sessionId, m.scope)) + return null + } if (m.type === "agentManager.openFile") { this.openWorktreeFile(m.sessionId, m.filePath, m.line, m.column) return null } } + private async sendDiffBranches(sessionId: string, scope?: string): Promise { + const id = composeDiffId(sessionId, normalizeScope(scope)) + const result = await this.diffs.branches(id).catch((err) => { + this.log("Failed to list diff branches:", err instanceof Error ? err.message : String(err)) + return undefined + }) + if (!result) return + this.postToWebview({ + type: "agentManager.diffBranches", + sessionId: id, + branches: result.branches, + defaultBranch: result.defaultBranch, + autoBase: result.autoBase, + currentBase: result.currentBase, + isAuto: result.isAuto, + currentBranch: result.currentBranch, + }) + } + private onBridgeMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type !== "openFile") return undefined @@ -732,6 +774,7 @@ export class AgentManagerProvider implements Disposable { // the panel itself is disposed. In-flight creates from the dying // instance are reaped by the router's generation guard. void this.terminalRouter.dispose() + this.scripts.manager.snapshot() void this.stateReady ?.then(() => { // When the folder is not a git repo (or has no folder open), @@ -1023,11 +1066,16 @@ export class AgentManagerProvider implements Disposable { this.log(`Worktree ${worktreeId} not found in state`) return null } + this.statsPoller.skipWorktree(worktreeId) + await this.run.remove(worktreeId) + if (!(await this.scripts.manager.clear("run", worktreeId))) { + this.statsPoller.unskipWorktree(worktreeId) + this.postToWebview({ type: "error", message: "Failed to stop the Run script before deleting the worktree" }) + return null + } // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree. // Pre-emptive skip covers any in-flight poll that already captured getWorktrees(). - this.statsPoller.skipWorktree(worktreeId) this.prBridge.remove(worktreeId) - this.run.remove(worktreeId) this.naming.forget(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { @@ -1062,6 +1110,11 @@ export class AgentManagerProvider implements Disposable { return null } + await this.run.remove(worktreeId) + if (!(await this.scripts.manager.clear("run", worktreeId))) { + this.postToWebview({ type: "error", message: "Failed to stop the Run script before removing the worktree" }) + return null + } this.naming.forget(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { @@ -1924,9 +1977,11 @@ export class AgentManagerProvider implements Disposable { this.unsubStatus?.() this.unsubFont?.() this.unsubDestination?.() + await this.scripts.dispose() this.orchestration.dispose() this.visiblePresence.clear() this.diffs.stop() + this.diffCatalog.dispose() this.naming.dispose() this.statsPoller.stop() this.gitOps.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index ce386a858a1..405557d50d0 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -102,6 +102,10 @@ export class GitStatsPoller { this.skipWorktreeIds.add(id) } + unskipWorktree(id: string): void { + this.skipWorktreeIds.delete(id) + } + setEnabled(enabled: boolean): void { if (enabled) { if (this.active) return diff --git a/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts new file mode 100644 index 00000000000..91450ef452c --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts @@ -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 +} + +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 + 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 +} + +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 + if (value.status === 404 || value._tag === "PtyNotFoundError") return true + if (!value.data || typeof value.data !== "object") return false + const data = value.data as Record + 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() + private readonly terminals = new Map() + private readonly ptys = new Map() + + constructor(private readonly deps: ScriptTerminalDeps) {} + + async start( + kind: ScriptTerminalKind, + config: ScriptTerminalConfig, + done: (exit: ScriptTerminalExit) => void, + ): Promise { + 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 { + 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 { + const entry = this.entries.get(key(kind, worktreeId)) + if (!entry) return true + return this.close(entry.terminalId) + } + + async close(terminalId: string): Promise { + 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 { + 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 { + await Promise.all([...this.terminals.keys()].map((terminalId) => this.close(terminalId))) + } + + private async reconcile(entry: Entry, client: KiloClient): Promise { + 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts index 7766476185b..92c6c1a6612 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -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 } activeSessionId: string | undefined naming: { prompt: ReturnType } + scripts: { intercept: ReturnType; snapshot: ReturnType } terminalRouter: { handle: ReturnType } stateReady: Promise | undefined contextTarget: ReturnType @@ -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() diff --git a/packages/kilo-vscode/src/agent-manager/diff-scope.ts b/packages/kilo-vscode/src/agent-manager/diff-scope.ts new file mode 100644 index 00000000000..21d2e007935 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/diff-scope.ts @@ -0,0 +1,57 @@ +/** + * Composite diff-source keying for Agent Manager. + * + * Agent Manager keys diff sources by *context* (a session id, or the `local` + * workspace pseudo-context) while the standalone Changes viewer keys by + * *scope* (branch / staged / unstaged / session). To expose scopes in Agent + * Manager we compose the two into a single id the SourceController can build. + * + * ctx = "local" | "" + * scope = "branch" | "staged" | "unstaged" | "session" + * id = `${ctx}#${scope}` + * + * `ctx#branch` is the default and reproduces the pre-scope behavior exactly. + */ + +export type DiffScope = "branch" | "staged" | "unstaged" | "session" + +export const DEFAULT_DIFF_SCOPE: DiffScope = "branch" + +const SEP = "#" + +export function composeDiffId(ctx: string, scope: DiffScope): string { + return `${ctx}${SEP}${scope}` +} + +/** + * Split a composite id back into context and scope. Tolerates a bare context + * id (no separator) by assuming the default branch scope, which keeps the + * pre-scope messages working unchanged. + */ +export function parseDiffId(id: string): { ctx: string; scope: DiffScope } { + const idx = id.lastIndexOf(SEP) + if (idx === -1) return { ctx: id, scope: DEFAULT_DIFF_SCOPE } + const scope = id.slice(idx + SEP.length) + if (isDiffScope(scope)) return { ctx: id.slice(0, idx), scope } + return { ctx: id, scope: DEFAULT_DIFF_SCOPE } +} + +export function isDiffScope(value: string): value is DiffScope { + return value === "branch" || value === "staged" || value === "unstaged" || value === "session" +} + +export function normalizeScope(value: unknown): DiffScope { + return typeof value === "string" && isDiffScope(value) ? value : DEFAULT_DIFF_SCOPE +} + +/** + * Map a scope to the underlying standalone-viewer source id the catalog knows + * how to build. `branch` maps to the workspace source; `session` is handled + * separately because it needs the session id embedded in the source id. + */ +export function scopeToSourceId(scope: DiffScope, ctx: string): string { + if (scope === "staged") return "staged" + if (scope === "unstaged") return "unstaged" + if (scope === "session") return `session:${ctx}` + return "workspace" +} diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index ed8b1f2f633..2916efbf62b 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -104,6 +104,9 @@ export interface Host { /** Get the workspace/project root path. */ workspacePath(): string | undefined + /** Whether the workspace permits executing configured scripts. */ + isTrusted(): boolean + /** Read the user's automatic branch naming preferences. */ autoBranchNaming(): { enabled: boolean; prefix: string } diff --git a/packages/kilo-vscode/src/agent-manager/run/controller.ts b/packages/kilo-vscode/src/agent-manager/run/controller.ts index b5db0320e77..614436975aa 100644 --- a/packages/kilo-vscode/src/agent-manager/run/controller.ts +++ b/packages/kilo-vscode/src/agent-manager/run/controller.ts @@ -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 } -interface TaskExit { +export interface RunTaskExit { exitCode?: number + stopped?: boolean + error?: string } -type StartTask = (config: RunTaskConfig, done: (exit: TaskExit) => void) => Promise +export type StartTask = (config: RunTaskConfig, done: (exit: RunTaskExit) => void) => Promise interface Options { root: () => string | undefined @@ -60,7 +64,7 @@ export class RunController { this.opts.refresh?.() } - async run(worktreeId: string): Promise { + async run(worktreeId: string, destination: RunTerminalDestination): Promise { 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 { + return this.manager.remove(worktreeId) } dispose(): void { diff --git a/packages/kilo-vscode/src/agent-manager/run/destination.ts b/packages/kilo-vscode/src/agent-manager/run/destination.ts new file mode 100644 index 00000000000..5945da16b7a --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/run/destination.ts @@ -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(destination: RunTerminalDestination, embedded: T, integrated: T): T { + return destination === "vscode" ? integrated : embedded +} diff --git a/packages/kilo-vscode/src/agent-manager/run/manager.ts b/packages/kilo-vscode/src/agent-manager/run/manager.ts index a1b92e35c9a..60af10cfa5c 100644 --- a/packages/kilo-vscode/src/agent-manager/run/manager.ts +++ b/packages/kilo-vscode/src/agent-manager/run/manager.ts @@ -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 dispose?(): void } interface Entry { status: RunStatus handle?: RunHandle + task?: Promise + released?: boolean + stopping?: Promise } interface FinishOptions { exitCode?: number + stopped?: boolean signal?: string error?: string } @@ -42,6 +47,7 @@ export class RunScriptManager { ) {} async start(worktreeId: string, start: () => Promise): Promise { + 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 { 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 { 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 { + 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 { + 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 + } } diff --git a/packages/kilo-vscode/src/agent-manager/run/message.ts b/packages/kilo-vscode/src/agent-manager/run/message.ts index 1e8f21a936d..6b51050a3e3 100644 --- a/packages/kilo-vscode/src/agent-manager/run/message.ts +++ b/packages/kilo-vscode/src/agent-manager/run/message.ts @@ -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") { diff --git a/packages/kilo-vscode/src/agent-manager/run/task.ts b/packages/kilo-vscode/src/agent-manager/run/task.ts index 0835b216af3..664baf4753d 100644 --- a/packages/kilo-vscode/src/agent-manager/run/task.ts +++ b/packages/kilo-vscode/src/agent-manager/run/task.ts @@ -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" diff --git a/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts new file mode 100644 index 00000000000..da2f3be9bea --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts @@ -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 + 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, + }) +} diff --git a/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts b/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts new file mode 100644 index 00000000000..8151b460ee8 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/script-terminal-url.ts @@ -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()}` +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index d386e36acc8..aaf3fb0fb6d 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -16,6 +16,7 @@ import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import" import type { RunStatus } from "./run/manager" import type { TerminalFont } from "./terminal-font" import type { TerminalDestination } from "./terminal-destination" +import type { ScriptTerminalView } from "./ScriptTerminalManager" export type { TerminalFont } @@ -177,6 +178,11 @@ interface TerminalFontChangedMessage { font: TerminalFont } +interface ScriptTerminalsMessage { + type: "agentManager.scriptTerminals" + terminals: ScriptTerminalView[] +} + interface ErrorOutMessage { type: "error" message: string @@ -286,6 +292,18 @@ interface RevertWorktreeFileResultMessage { message: string } +/** Branch picker data for a context's diff directory. */ +interface DiffBranchesMessage { + type: "agentManager.diffBranches" + sessionId: string + branches: BranchListItem[] + defaultBranch: string + autoBase?: string + currentBase?: string + isAuto: boolean + currentBranch?: string +} + interface PRStatusOutMessage { type: "agentManager.prStatus" worktreeId: string @@ -324,6 +342,7 @@ export type AgentManagerOutMessage = | WorktreeDiffMessage | WorktreeDiffFileMessage | RevertWorktreeFileResultMessage + | DiffBranchesMessage | PRStatusOutMessage | ActionOutMessage | RunStatusMessage @@ -332,6 +351,7 @@ export type AgentManagerOutMessage = | TerminalErrorMessage | TerminalDestinationChangedMessage | TerminalFontChangedMessage + | ScriptTerminalsMessage // --------------------------------------------------------------------------- // Webview → Extension messages (onMessage) @@ -398,6 +418,7 @@ interface ConfigureRunScriptIn { interface RunScriptIn { type: "agentManager.runScript" worktreeId: string + destination: TerminalDestination } interface StopRunScriptIn { @@ -517,6 +538,7 @@ interface ImportFromPRIn { interface RequestWorktreeDiffIn { type: "agentManager.requestWorktreeDiff" sessionId: string + scope?: string } interface ApplyWorktreeDiffIn { @@ -529,11 +551,13 @@ interface RequestWorktreeDiffFileIn { type: "agentManager.requestWorktreeDiffFile" sessionId: string file: string + scope?: string } interface StartDiffWatchIn { type: "agentManager.startDiffWatch" sessionId: string + scope?: string } interface StopDiffWatchIn { @@ -544,6 +568,20 @@ interface RevertWorktreeFileIn { type: "agentManager.revertWorktreeFile" sessionId: string file: string + scope?: string +} + +interface RequestDiffBranchesIn { + type: "agentManager.requestDiffBranches" + sessionId: string + scope?: string +} + +interface SetDiffBaseBranchIn { + type: "agentManager.setDiffBaseBranch" + sessionId: string + scope?: string + branch?: string } interface RefreshPRIn { @@ -809,6 +847,8 @@ export type AgentManagerInMessage = | StartDiffWatchIn | StopDiffWatchIn | RevertWorktreeFileIn + | RequestDiffBranchesIn + | SetDiffBaseBranchIn | RefreshPRIn | OpenPRIn | OpenSessionsIn diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index fc5dfd01078..4280c064a3f 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -173,6 +173,10 @@ export class VscodeHost implements Host { return getWorkspaceRoot() } + isTrusted(): boolean { + return vscode.workspace.isTrusted + } + autoBranchNaming(): { enabled: boolean; prefix: string } { const cfg = vscode.workspace.getConfiguration("kilo-code.new.agentManager") return { diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 75d538cc35c..9a95ae5f96a 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,12 +1,13 @@ import { SourceController } from "../diff/SourceController" import { resolveLocalDiffTarget } from "../diff/shared/target" import { WorktreeDiffReverter, type StatusResolver } from "../diff/shared/reverter" -import type { DiffFile } from "../diff/types" -import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types" +import type { DiffFile, PanelContext } from "../diff/types" +import type { DiffSource } from "../diff/sources/types" +import type { DiffSourceCatalog } from "../diff/sources/catalog" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" -import { Semaphore } from "./semaphore" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" +import { parseDiffId, scopeToSourceId } from "./diff-scope" import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types" const LOCAL_DIFF_ID = "local" as const @@ -19,14 +20,11 @@ export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined getRoot: () => string | undefined getStateReady: () => Promise | undefined - /** - * In-process diff paths deliberately bypass the SDK client to keep git spawns - * out of the Bun `kilo serve` process (see oven-sh/bun#18265). - */ + /** Builds the underlying per-scope diff sources (workspace/staged/unstaged/session). */ + catalog: DiffSourceCatalog + /** Shared git ops, injected into sources so they don't spawn their own channels. */ git: GitOps - /** In-process diff summary (replaces client.worktree.diffSummary). */ - localDiff: (dir: string, base: string) => Promise - /** In-process single-file diff (replaces client.worktree.diffFile). */ + /** In-process single-file diff (replaces client.worktree.diffFile). Used by revert. */ localDiffFile: (dir: string, base: string, file: string) => Promise post: (msg: AgentManagerOutMessage) => void log: (...args: unknown[]) => void @@ -34,13 +32,16 @@ export interface WorktreeDiffControllerContext { export class WorktreeDiffController { private readonly controller: SourceController - private readonly details = new Semaphore(3) private target: Target | undefined private applying: string | undefined + /** Intended watch mode for the active context; isPolling lags the initial fetch. */ + private poll = false + /** Ephemeral per-context base override, keyed by context id. */ + private baseOverrides = new Map() constructor(private readonly ctx: WorktreeDiffControllerContext) { this.controller = new SourceController( - (id) => this.source(id), + (id, ctx) => this.source(id, ctx), () => [], (msg) => this.ctx.post(msg as AgentManagerOutMessage), { @@ -80,7 +81,11 @@ export class WorktreeDiffController { } public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean { - return shouldStopDiffPolling(path, sessions, this.target, this.controller.currentId) + // Pass the parsed context id, not the composite id, so the orphaned-session + // check matches real session ids. + const current = this.controller.currentId + const ctxId = current ? parseDiffId(current).ctx : undefined + return shouldStopDiffPolling(path, sessions, this.target, ctxId) } public async apply(worktreeId: string, value?: unknown): Promise { @@ -144,131 +149,161 @@ export class WorktreeDiffController { } } - public async revert(sessionId: string, file: string): Promise { + public async revert(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - const result = await this.revertFile(sessionId, file) - this.postRevertResult(sessionId, file, result) + if (this.controller.currentId !== id) { + const result = await this.revertFile(id, file) + this.postRevertResult(id, file, result) return } await this.controller.revertFile(file) } - public async request(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) { - await this.activate(sessionId, false, true) + public async request(id: string): Promise { + if (this.controller.currentId !== id) { + await this.activate(id, false, true) return } this.target = undefined await this.controller.refresh() } - public async requestFile(sessionId: string, file: string): Promise { + public async requestFile(id: string, file: string): Promise { if (!file) return - if (this.controller.currentId !== sessionId) { - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) + if (this.controller.currentId !== id) { + this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId: id, file, diff: null }) return } await this.controller.requestFile(file) } - public start(sessionId: string): void { - if (this.controller.isPolling && this.controller.currentId === sessionId) return - this.ctx.log(`Starting diff polling for session ${sessionId}`) - void this.activate(sessionId, true, true) + public start(id: string): void { + if (this.controller.isPolling && this.controller.currentId === id) return + this.ctx.log(`Starting diff polling for ${id}`) + void this.activate(id, true, true) } public stop(): void { this.controller.stop() this.target = undefined + this.poll = false } - private async activate(sessionId: string, poll: boolean, fetch: boolean): Promise { + /** + * Set or clear an ephemeral base override for a context (worktree or local), + * then re-activate the current source so it refetches against the new base. + * Passing undefined clears the override and falls back to the recorded parent. + */ + public async setBase(id: string, branch: string | undefined): Promise { + const { ctx } = parseDiffId(id) + if (branch) this.baseOverrides.set(ctx, branch) + else this.baseOverrides.delete(ctx) + // Nothing to rebuild when the context isn't active; the override is + // picked up the next time start()/request() resolves it. + if (this.controller.currentId !== id) return + // Route through activate() so the base is re-resolved and pushed via + // setContext() — SourceController.reactivate() alone would rebuild the + // source against the stale context captured by the last activate(). The + // recorded poll intent preserves watch mode even when the initial fetch + // is still in flight (isPolling only turns true once it resolves). + await this.activate(id, this.poll, true) + } + + /** Branch picker data for a context's directory, using any active override. */ + public async branches(id: string) { + await this.ready("stateReady rejected, continuing diff branches resolve:") + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) + if (!target) return undefined + return await this.ctx.catalog.listWorkspaceBranches(this.baseOverrides.get(ctx), target.directory) + } + + private async activate(id: string, poll: boolean, fetch: boolean): Promise { this.target = undefined - this.controller.setContext({ workspaceRoot: this.ctx.getRoot() }) - await this.controller.activate(sessionId, { poll, fetch }) + this.poll = poll + await this.ready("stateReady rejected, continuing diff activate:") + const { ctx } = parseDiffId(id) + const resolved = await this.resolve(ctx) + this.target = resolved ? { sessionId: id, ...resolved } : undefined + this.controller.setContext({ + workspaceRoot: this.ctx.getRoot(), + dir: resolved?.directory, + // The resolved base already bakes in any ephemeral override (see + // resolve()), so pass it as the explicit base and leave + // baseBranchOverride unset to avoid double resolution. + baseBranch: resolved?.baseBranch, + // Agent Manager always knows its intended directory (LOCAL resolves to + // the root). Never fall back to the workspace root for an unresolvable + // worktree context — return an empty diff instead. + strictDir: true, + git: this.ctx.git, + log: (...args) => this.ctx.log(...args), + }) + await this.controller.activate(id, { poll, fetch }) } - private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { - if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocal() + private async resolve(ctxId: string): Promise<{ directory: string; baseBranch: string } | undefined> { + if (ctxId === LOCAL_DIFF_ID) return await this.resolveLocal() const state = this.ctx.getState() if (!state) { - this.ctx.log(`resolveDiffTarget: no state manager for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: no state manager for context ${ctxId}`) return undefined } - const session = state.getSession(sessionId) + const session = state.getSession(ctxId) if (!session) { this.ctx.log( - `resolveDiffTarget: session ${sessionId} not found in state (${state.getSessions().length} total sessions)`, + `resolveDiffTarget: session ${ctxId} not found in state (${state.getSessions().length} total sessions)`, ) return undefined } if (!session.worktreeId) { - this.ctx.log(`resolveDiffTarget: session ${sessionId} has no worktreeId (local session)`) + this.ctx.log(`resolveDiffTarget: session ${ctxId} has no worktreeId (local session)`) return undefined } const worktree = state.getWorktree(session.worktreeId) if (!worktree) { - this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${sessionId}`) + this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${ctxId}`) return undefined } - return { directory: worktree.path, baseBranch: remoteRef(worktree) } + const base = this.baseOverrides.get(ctxId) ?? remoteRef(worktree) + return { directory: worktree.path, baseBranch: base } } private async resolveLocal(): Promise<{ directory: string; baseBranch: string } | undefined> { - return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot()) + const root = this.ctx.getRoot() + if (!root) return undefined + const override = this.baseOverrides.get(LOCAL_DIFF_ID) + if (override) { + return { directory: root, baseBranch: override } + } + return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), root) } private async ready(msg: string): Promise { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } - private source(sessionId: string): DiffSource { - const descriptor: DiffSourceDescriptor = { - id: sessionId, - type: "workspace", - group: "Git", - capabilities: { revert: true, comments: true }, - } - + /** + * Build the active source for a composite id by delegating to the catalog. + * The composite id (ctx#scope) is preserved as the descriptor id so the + * webview keys diff data by context+scope. Context resolution (dir/base) + * already happened in activate() and is carried by the PanelContext. + */ + private source(id: string, panelCtx: PanelContext): DiffSource { + const { ctx, scope } = parseDiffId(id) + const built = this.ctx.catalog.build(scopeToSourceId(scope, ctx), panelCtx) return { - descriptor, - fetch: () => this.fetch(sessionId), - fetchFile: (file) => this.fetchFile(sessionId, file), - revert: (file) => this.revertFile(sessionId, file), + ...built, + descriptor: { ...built.descriptor, id }, } } - private async fetch(sessionId: string): Promise { - await this.ready("stateReady rejected, continuing diff resolve:") - const target = await this.ensureTarget(sessionId) - if (!target) return { diffs: [], stopPolling: true } - - const files = await this.ctx.localDiff(target.directory, target.baseBranch) - this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) - return { diffs: files as AgentManagerDiffFile[] } - } - - private async fetchFile(sessionId: string, file: string): Promise { - await this.ready("stateReady rejected, continuing diff detail resolve:") - return this.details.run(async () => { - const target = await this.ensureTarget(sessionId) - if (!target) return null - - try { - return (await this.ctx.localDiffFile(target.directory, target.baseBranch, file)) as AgentManagerDiffFile | null - } catch (error) { - this.ctx.log("Failed to fetch worktree diff file:", error) - return null - } - }) - } - - private async revertFile(sessionId: string, file: string): Promise<{ ok: boolean; message: string }> { + private async revertFile(id: string, file: string): Promise<{ ok: boolean; message: string }> { await this.ready("stateReady rejected, continuing revert resolve:") - const target = await this.resolveTarget(sessionId) + const { ctx } = parseDiffId(id) + const target = await this.resolve(ctx) if (!target) return { ok: false, message: "Could not resolve diff target" } try { @@ -285,19 +320,6 @@ export class WorktreeDiffController { } } - private async ensureTarget(sessionId: string): Promise { - if (this.controller.currentId !== sessionId) return undefined - if (this.target?.sessionId === sessionId) return this.target - return await this.resolveTarget(sessionId) - } - - private async resolveTarget(sessionId: string): Promise { - const target = await this.resolve(sessionId) - if (!target) return undefined - this.target = { sessionId, ...target } - return this.target - } - private postRevertResult(sessionId: string, file: string, result: { ok: boolean; message: string }): void { this.ctx.post({ type: "agentManager.revertWorktreeFileResult", diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index fa36aaecf2d..8eb2d0ffdfb 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -90,12 +90,17 @@ export class DiffSourceCatalog implements vscode.Disposable { } build(id: string, ctx: PanelContext): DiffSource { + const opts = { dir: () => ctx.dir, strictDir: ctx.strictDir, git: ctx.git, log: ctx.log } if (id === WORKSPACE_SOURCE_ID) { - return createWorktreeDiffSource({ baseBranchOverride: ctx.baseBranchOverride }) + return createWorktreeDiffSource({ + ...opts, + baseBranchOverride: ctx.baseBranchOverride, + baseBranch: ctx.baseBranch, + }) } - if (id === STAGED_SOURCE_ID) return createStagedDiffSource() - if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource() + if (id === STAGED_SOURCE_ID) return createStagedDiffSource(opts) + if (id === UNSTAGED_SOURCE_ID) return createUnstagedDiffSource(opts) if (id.startsWith(TURN_PREFIX)) { const [sessionId, messageId] = id.slice(TURN_PREFIX.length).split(":") @@ -108,14 +113,22 @@ export class DiffSourceCatalog implements vscode.Disposable { if (id.startsWith(SESSION_PREFIX)) { const sessionId = id.slice(SESSION_PREFIX.length) if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) - return createSessionDiffSource(sessionId, this.sessionFetch, ctx.workspaceRoot, this.checkSnapshotsEnabled) + return createSessionDiffSource( + sessionId, + this.sessionFetch, + ctx.dir ?? ctx.workspaceRoot, + this.checkSnapshotsEnabled, + ) } throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) } - async listWorkspaceBranches(override: string | undefined): Promise { - const root = getWorkspaceRoot() + async listWorkspaceBranches( + override: string | undefined, + dir?: string, + ): Promise { + const root = dir ?? getWorkspaceRoot() if (!root) return undefined const git = this.ensureBranchGit() diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts index d21a508902b..8cd250743cb 100644 --- a/packages/kilo-vscode/src/diff/sources/staged.ts +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -34,17 +34,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface StagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the git index and HEAD — what `git diff --cached` would show. * Polls on the standard interval; revert isn't supported (use `git reset` from * a real git client). Read-only view. */ -export function createStagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Staged") - const log = (...args: unknown[]) => appendOutput(output, "StagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Staged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "StagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listEntries = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -150,8 +172,10 @@ export function createStagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts index b4d0c996161..5fcb03793d2 100644 --- a/packages/kilo-vscode/src/diff/sources/unstaged.ts +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -40,17 +40,39 @@ function stamp(entry: FileEntry, before: string, after: string): FileEntry { return { ...entry, stamp: `${entry.status}:${before}:${after}` } } +export interface UnstagedDiffSourceOptions { + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. + */ + strictDir?: boolean + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void +} + /** * Diff between the working tree and the index — what `git diff` shows for * tracked files, plus untracked files (treated as fully-added). Read-only; * polls on the standard interval. */ -export function createUnstagedDiffSource(): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Unstaged") - const log = (...args: unknown[]) => appendOutput(output, "UnstagedDiffSource", ...args) - const git = new GitOps({ log }) +export function createUnstagedDiffSource(opts: UnstagedDiffSourceOptions = {}): DiffSource { + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Unstaged") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "UnstagedDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) - const root = (): string | undefined => getWorkspaceRoot() + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } const listTracked = async (dir: string): Promise => { const [nameStatus, numstat, raw] = await Promise.all([ @@ -192,8 +214,10 @@ export function createUnstagedDiffSource(): DiffSource { }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() }, } } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 1f3d8ee25a4..89e750a1c87 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -23,6 +23,28 @@ export interface WorktreeDiffSourceOptions { * the current branch — only the comparison target changes. Reset on dispose. */ baseBranchOverride?: string + /** + * Resolve the directory to diff. Defaults to the VS Code workspace root. + * Agent Manager passes a worktree path so the source diffs inside the + * worktree rather than the main checkout. + */ + dir?: () => string | undefined + /** + * When true, a `dir` that resolves to undefined yields an empty diff rather + * than falling back to the workspace root. Prevents an unresolvable + * worktree context from silently diffing the main checkout. + */ + strictDir?: boolean + /** + * Explicit base branch to diff against. When set, the source skips + * auto-resolution (tracking → default) and diffs against this ref directly. + * Agent Manager passes the worktree's recorded parent so a worktree always + * compares against its own base even when the workspace default differs. + */ + baseBranch?: string + /** Shared GitOps / log so sources don't each spawn their own channel. */ + git?: GitOps + log?: (...args: unknown[]) => void } /** @@ -32,9 +54,16 @@ export interface WorktreeDiffSourceOptions { * extension host — no `kilo serve` round-trip. */ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): DiffSource { - const output = vscode.window.createOutputChannel("Kilo Diff: Workspace") - const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args) - const git = new GitOps({ log }) + const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace") + const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args)) + const git = opts.git ?? new GitOps({ log }) + + const root = (): string | undefined => { + const dir = opts.dir?.() + if (dir) return dir + if (opts.strictDir) return undefined + return getWorkspaceRoot() + } // Cached between fetches so repeated polling doesn't re-resolve the base // branch every tick. Reset only on dispose (when the source is swapped out). @@ -42,22 +71,32 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const resolveTarget = async (): Promise => { if (target) return target + if (opts.baseBranch) { + const dir = root() + if (!dir) { + log("Local diff: no directory (explicit base mode)") + return + } + target = { directory: dir, baseBranch: opts.baseBranch } + log(`Local diff: using explicit base=${opts.baseBranch} dir=${dir}`) + return target + } if (opts.baseBranchOverride) { - const root = getWorkspaceRoot() - if (!root) { + const dir = root() + if (!dir) { log("Local diff: no workspace root (override mode)") return } - const resolved = await resolveOverrideRef(git, root, opts.baseBranchOverride, log) + const resolved = await resolveOverrideRef(git, dir, opts.baseBranchOverride, log) if (!resolved) { log(`Local diff: override base="${opts.baseBranchOverride}" could not be resolved, falling back to auto`) } else { - target = { directory: root, baseBranch: resolved } + target = { directory: dir, baseBranch: resolved } log(`Local diff: using override base=${resolved}`) return target } } - target = await resolveLocalDiffTarget(git, log, getWorkspaceRoot()) + target = await resolveLocalDiffTarget(git, log, root()) return target } @@ -109,8 +148,10 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): }, dispose(): void { - git.dispose() - output.dispose() + // Only dispose resources we own (created here). Injected git/log are + // owned by the caller. + if (!opts.git) git.dispose() + output?.dispose() target = undefined }, } diff --git a/packages/kilo-vscode/src/diff/types.ts b/packages/kilo-vscode/src/diff/types.ts index 913d4560a31..3ff72904e13 100644 --- a/packages/kilo-vscode/src/diff/types.ts +++ b/packages/kilo-vscode/src/diff/types.ts @@ -10,6 +10,27 @@ export interface PanelContext { hidePicker?: boolean /** User-picked base branch for the workspace source. Undefined = auto. */ baseBranchOverride?: string + /** + * Explicit directory to diff inside, overriding the workspace root lookup. + * Agent Manager passes a worktree path so its sources operate in the + * worktree rather than the main checkout. + */ + dir?: string + /** + * When true, a source whose `dir` resolves to undefined returns an empty + * diff instead of falling back to the workspace root. Agent Manager sets + * this so an unresolvable worktree context never silently diffs the main + * checkout. + */ + strictDir?: boolean + /** + * Explicit base ref for the workspace source, skipping auto-resolution. + * Agent Manager passes the worktree's recorded parent ref. + */ + baseBranch?: string + /** Shared GitOps / log injected by Agent Manager to avoid per-source channels. */ + git?: import("../agent-manager/GitOps").GitOps + log?: (...args: unknown[]) => void } export type DiffImageError = "too-large" | "unreadable" diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts index 2b6e3608cac..52008fdab43 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts @@ -106,7 +106,7 @@ export async function handlePermissionResponse( } const replyResult = await ctx.client.permission - .reply({ requestID: permissionId, reply: response, directory: dir }, { throwOnError: true }) + .reply({ requestID: permissionId, reply: response, directory: dir, interactive: true }, { throwOnError: true }) .then(() => "ok" as const) .catch((error: unknown) => { if (isNotFoundError(error)) return "stale" as const diff --git a/packages/kilo-vscode/src/kilo-provider/session-search.ts b/packages/kilo-vscode/src/kilo-provider/session-search.ts index 7552fc68020..e525b5a854a 100644 --- a/packages/kilo-vscode/src/kilo-provider/session-search.ts +++ b/packages/kilo-vscode/src/kilo-provider/session-search.ts @@ -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 { const client = input.client @@ -39,10 +43,18 @@ export async function handleSessionSearch(input: Input): Promise { 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) diff --git a/packages/kilo-vscode/tests/settings-accessibility.spec.ts b/packages/kilo-vscode/tests/settings-accessibility.spec.ts index 03073ea4c5a..5e2b62082fb 100644 --- a/packages/kilo-vscode/tests/settings-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/settings-accessibility.spec.ts @@ -6,7 +6,7 @@ const NAMES = [ "Providers", "Agent Behaviour", "Auto-Approve", - "Browser", + "Web Tools", "Checkpoints", "Display", "Autocomplete", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index ef69f9f3171..7ba0115d1a1 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -61,6 +61,10 @@ const IMPORTER_FILE = path.join(ROOT, "src/agent-manager/worktree-importer.ts") const SETUP_SCRIPT_RUNNER_FILE = path.join(ROOT, "src/agent-manager/SetupScriptRunner.ts") const RUN_MESSAGE_FILE = path.join(ROOT, "src/agent-manager/run/message.ts") const TERMINAL_ROUTING_FILE = path.join(ROOT, "src/agent-manager/terminal-routing.ts") +const SCRIPT_TERMINAL_FILE = path.join(ROOT, "src/agent-manager/ScriptTerminalManager.ts") +const SCRIPT_TERMINAL_RUNTIME_FILE = path.join(ROOT, "src/agent-manager/script-terminal-runtime.ts") +const RUN_TASK_FILE = path.join(ROOT, "src/agent-manager/run/task.ts") +const RUN_DESTINATION_FILE = path.join(ROOT, "src/agent-manager/run/destination.ts") function readAllCss(): string { return CSS_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n") @@ -455,6 +459,52 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).not.toContain("agentManager.requestState") }) + it("routes script terminal close and resize messages before user terminals", () => { + const text = body("onMessage") + expect(text.indexOf("this.scripts.manager.intercept(m)")).toBeLessThan( + text.indexOf("this.terminalRouter.handle(m)"), + ) + }) + + it("runs scripts through the vscode-free canonical PTY manager", () => { + const text = fs.readFileSync(SCRIPT_TERMINAL_FILE, "utf-8") + expect(text).toMatch(/client\.v2\.pty\s*\.create/) + expect(text).toContain("client.v2.pty.get") + expect(text).toContain("client.v2.pty.update") + expect(text).toContain("client.v2.pty.remove") + expect(text).not.toContain("vscode") + }) + + it("selects the Run adapter from the panel dropdown message", () => { + const text = fs.readFileSync(SCRIPT_TERMINAL_RUNTIME_FILE, "utf-8") + expect(text).toContain("pickRunStart") + expect(text).toContain("config.destination") + expect(text).not.toContain("readRunTerminalDestination") + expect(text.indexOf("pickRunStart")).toBeLessThan(text.indexOf("config.destination")) + }) + + it("keeps the legacy integrated Run adapter isolated and removable", () => { + const task = fs.readFileSync(RUN_TASK_FILE, "utf-8") + expect(task).toContain("vscode.tasks.executeTask") + expect(task).toContain("Remove this") + const dest = fs.readFileSync(RUN_DESTINATION_FILE, "utf-8") + expect(dest).not.toContain('from "vscode"') + expect(dest).toContain("pickRunStart") + expect(dest).not.toContain("getConfiguration") + }) + + it("clears retained Run terminals before removing worktree state", () => { + for (const name of ["onDeleteWorktree", "onRemoveStaleWorktree"]) { + const text = body(name) + expect(text).toContain('this.scripts.manager.clear("run", worktreeId)') + expect(text.indexOf('this.scripts.manager.clear("run", worktreeId)')).toBeLessThan( + text.indexOf("state.removeWorktree"), + ) + } + const deleted = body("onDeleteWorktree") + expect(deleted.indexOf("statsPoller.skipWorktree")).toBeLessThan(deleted.indexOf("this.run.remove")) + }) + // -- onDeleteWorktree invariants ------------------------------------------- /** @@ -572,7 +622,9 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).toContain("class WorktreeDiffController") expect(text).toContain("buildWorktreePatch") expect(text).toContain("revertFile") - expect(text).toContain("diffSummary") + // Summary/detail diff data comes from the shared DiffSourceCatalog sources + // (workspace/staged/unstaged/session), not a bespoke in-controller pipeline. + expect(text).toContain("catalog.build") expect(text).toContain("shouldStopDiffPolling") expect(providerText).toContain("this.diffs") }) @@ -842,9 +894,6 @@ const VSCODE_ALLOWED: Record = { "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", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts new file mode 100644 index 00000000000..eff96950cd9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts @@ -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)" }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts index 9a071e5c150..70e245f2ccd 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test" import { createSideTerminal, readSavedDestination, + resolveRunScriptRequest, resolveVscodeTerminalRequest, } from "../../webview-ui/agent-manager/terminal/side" @@ -11,10 +12,12 @@ function scene( saved?: "vscode" | "agentManager" visible?: boolean focusedId?: string + mac?: boolean } = {}, ) { const calls = { requestSide: 0, + ensureSide: 0, closed: [] as string[], hide: 0, refocus: 0, @@ -31,6 +34,7 @@ function scene( calls.requestSide++ visible = true }, + ensureSide: () => calls.ensureSide++, closeSide: (terminalId) => { calls.closed.push(terminalId) focusedId = undefined @@ -49,6 +53,7 @@ function scene( openVscode: () => calls.openVscode++, saved: opts.saved, save: (destination) => calls.persisted.push(destination), + mac: opts.mac, }) if (opts.destination) ctl.syncDefault(opts.destination) return { ctl, calls } @@ -72,6 +77,29 @@ describe("Agent Manager side terminal controller", () => { expect(hidden.calls.hide).toBe(0) }) + it("ensures an open terminal panel has a terminal after switching contexts", async () => { + const visible = scene({ visible: true }) + visible.ctl.syncContext("wt-2", "wt-1") + await Promise.resolve() + expect(visible.calls.ensureSide).toBe(1) + + visible.ctl.syncContext("wt-2", "wt-2") + visible.ctl.syncContext("wt-2", undefined) + await Promise.resolve() + expect(visible.calls.ensureSide).toBe(2) + expect(visible.calls.requestSide).toBe(0) + + const hidden = scene() + hidden.ctl.syncContext("wt-2", "wt-1") + expect(hidden.calls.ensureSide).toBe(0) + + const closed = scene({ visible: true }) + closed.ctl.syncContext("wt-2", "wt-1") + closed.ctl.toggle() + await Promise.resolve() + expect(closed.calls.ensureSide).toBe(0) + }) + it("kills the focused terminal and refocuses the chat", () => { const focused = scene({ focusedId: "terminal:two" }) expect(focused.ctl.close()).toBe(true) @@ -98,6 +126,70 @@ describe("Agent Manager side terminal controller", () => { expect(panelFirst.calls.openVscode).toBe(0) }) + it("handles the platform terminal shortcut locally and dedupes the extension echo", () => { + const press = (opts: Partial = {}) => + ({ key: "/", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...opts }) as KeyboardEvent + + // macOS: the workbench binding is Cmd+/, so only Cmd is accepted. + const mac = scene({ destination: "agentManager", mac: true }) + expect(mac.ctl.press(press({ metaKey: true }))).toBe(true) + expect(mac.calls.requestSide).toBe(1) + expect(mac.ctl.press(press({ ctrlKey: true }))).toBe(false) + expect(mac.ctl.press(press({ metaKey: true, ctrlKey: true }))).toBe(false) + expect(mac.calls.requestSide).toBe(1) + + // Windows/Linux: the workbench binding is Ctrl+/, so only Ctrl is accepted. + const win = scene({ destination: "agentManager", mac: false }) + expect(win.ctl.press(press({ ctrlKey: true }))).toBe(true) + expect(win.calls.requestSide).toBe(1) + expect(win.ctl.press(press({ metaKey: true }))).toBe(false) + expect(win.calls.requestSide).toBe(1) + + // Unrelated keys and modifier combinations are not the shortcut. + expect(win.ctl.press(press({ key: "?" }))).toBe(false) + expect(win.ctl.press(press({ ctrlKey: true, shiftKey: true }))).toBe(false) + expect(win.ctl.press(press({ ctrlKey: true, altKey: true }))).toBe(false) + expect(win.calls.requestSide).toBe(1) + + // The extension echoes each locally handled keypress back as an action + // message; one echo is consumed per press, then invocations run again. + expect(mac.ctl.echo()).toBe(true) + expect(mac.ctl.echo()).toBe(false) + }) + + it("consumes one echo per press, even for rapid repeated presses", () => { + const item = scene({ destination: "agentManager", mac: true }) + const press = () => item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + press() + press() + // Two presses toggled the panel open and closed again; both echoes + // must still be consumed so neither press toggles a third time. + expect(item.calls.requestSide).toBe(1) + expect(item.calls.hide).toBe(1) + expect(item.ctl.echo()).toBe(true) + expect(item.ctl.echo()).toBe(true) + expect(item.ctl.echo()).toBe(false) + }) + + it("drops a never-arriving echo after the timeout safety valve", async () => { + const item = scene({ destination: "agentManager", mac: true }) + item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + await new Promise((resolve) => setTimeout(resolve, 550)) + expect(item.ctl.echo()).toBe(false) + expect(item.ctl.echo()).toBe(false) + }) + + it("expires a dropped echo's backlog at the next spaced press", async () => { + const item = scene({ destination: "agentManager", mac: true }) + // First press's echo never arrives (dropped forwarding); its backlog + // must not outlive the echo window into the next press. + item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + await new Promise((resolve) => setTimeout(resolve, 550)) + item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent) + expect(item.ctl.echo()).toBe(true) + expect(item.ctl.echo()).toBe(false) + }) + it("persists the picked destination with a section-relative settings key", () => { const item = scene() item.ctl.choose("agentManager") @@ -147,6 +239,21 @@ describe("readSavedDestination", () => { }) }) +describe("resolveRunScriptRequest", () => { + it("carries the current panel dropdown destination with every Run request", () => { + expect(resolveRunScriptRequest("wt-1", "agentManager")).toEqual({ + type: "agentManager.runScript", + worktreeId: "wt-1", + destination: "agentManager", + }) + expect(resolveRunScriptRequest("local", "vscode")).toEqual({ + type: "agentManager.runScript", + worktreeId: "local", + destination: "vscode", + }) + }) +}) + describe("resolveVscodeTerminalRequest", () => { const sessions = new Map([ ["wt-1", "session-a"], diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts index db0acb769f5..37a17a7e450 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -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(initial) const state = createTerminalState(selection) const posted: Array> = [] - 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), + onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }), }) return { state, selection, setSelection, posted, events, handlers, dispatch } } @@ -58,6 +67,28 @@ function createdSide(createId: string, terminalId: string, title = "Terminal 1") } satisfies ExtensionMessage } +function script( + terminalId: string, + state: "running" | "stopping" | "exited" | "failed" = "running", + exitCode?: number, +) { + return { + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId, + worktreeId: null, + kind: "run", + title: "Run", + wsUrl: `ws://${terminalId}`, + state, + ...(exitCode === undefined ? {} : { exitCode }), + font, + }, + ], + } satisfies ExtensionMessage +} + describe("Agent Manager terminal state", () => { it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => { createRoot((dispose) => { @@ -91,6 +122,47 @@ describe("Agent Manager terminal state", () => { }) }) + it("hydrates complete Run snapshots without create ids and preserves mounted terminal records", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" }) + const user = item.state.sidesForContext(LOCAL)[0]! + + expect(item.dispatch(script("script:run"))).toBe(true) + const run = item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run") + expect(run).toMatchObject({ title: "Run", placement: "side", kind: "run", contextKey: LOCAL }) + expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }]) + expect(item.state.scriptStatus("script:run")).toEqual({ state: "running" }) + expect(isTerminalTabId("script:run")).toBe(true) + + item.state.setTitle("script:run", "npm test") + expect(item.state.title("script:run")).toBe("Run") + + item.dispatch(script("script:run", "exited", 0)) + expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "script:run")).toBe(run) + expect(item.state.scriptStatus("script:run")).toEqual({ state: "exited", exitCode: 0 }) + expect(item.state.sidesForContext(LOCAL).find((term) => term.id === "terminal:user")).toBe(user) + // Existing snapshots update status only; they do not re-open the inspector. + expect(item.events.running).toEqual([{ contextKey: LOCAL, terminalId: "script:run" }]) + + item.dispatch({ type: "agentManager.scriptTerminals", terminals: [] } satisfies ExtensionMessage) + expect(item.state.sidesForContext(LOCAL)).toEqual([user]) + expect(item.state.scriptStatus("script:run")).toBeUndefined() + dispose() + }) + }) + + it("maps Local Run snapshots to LOCAL and does not reveal exited terminals", () => { + createRoot((dispose) => { + const item = scene() + item.dispatch(script("script:exit", "exited", 2)) + + expect(item.state.sidesForContext(LOCAL)[0]).toMatchObject({ id: "script:exit", contextKey: LOCAL }) + expect(item.events.running).toEqual([]) + dispose() + }) + }) + it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => { createRoot((dispose) => { const item = scene() @@ -114,6 +186,23 @@ describe("Agent Manager terminal state", () => { }) }) + it("ensures a side terminal without revealing the panel", () => { + createRoot((dispose) => { + const item = scene("wt-1") + item.handlers.ensureSide() + item.handlers.ensureSide() + + expect(item.events.shown).toEqual([]) + expect(item.posted).toHaveLength(1) + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", + placement: "side", + worktreeId: "wt-1", + }) + dispose() + }) + }) + it("supports several side terminals per context with newest active", () => { createRoot((dispose) => { const item = scene() @@ -170,6 +259,50 @@ describe("Agent Manager terminal state", () => { }) }) + it("keeps only the target side terminal on close others", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" }) + // Another context must survive untouched: "others" is per context. + item.state.add("wt-1", { id: "terminal:other", title: "Other", wsUrl: "ws://other", font, placement: "side" }) + item.state.setSideActive(LOCAL, "terminal:one") + + item.handlers.closeSideOthers("terminal:two") + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:two"]) + expect(item.state.sidesForContext("wt-1").map((term) => term.id)).toEqual(["terminal:other"]) + // The survivor becomes visible and focused, like selecting its tab. + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two") + expect(item.state.focusRequest()?.id).toBe("terminal:two") + expect(item.posted).toEqual([ + { type: "agentManager.terminal.close", terminalId: "terminal:one" }, + { type: "agentManager.terminal.close", terminalId: "terminal:three" }, + ]) + dispose() + }) + }) + + it("waits for Run closure confirmation while user terminal closes stay optimistic", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:user", title: "Terminal 1", wsUrl: "ws://user", font, placement: "side" }) + item.dispatch(script("script:run")) + + expect(item.handlers.closeSide("script:run")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:user", "script:run"]) + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "script:run" }]) + + expect(item.handlers.closeSide("terminal:user")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["script:run"]) + expect(item.posted).toEqual([ + { type: "agentManager.terminal.close", terminalId: "script:run" }, + { type: "agentManager.terminal.close", terminalId: "terminal:user" }, + ]) + dispose() + }) + }) + it("closes a stale side answer whose create request is unknown", () => { createRoot((dispose) => { const item = scene() diff --git a/packages/kilo-vscode/tests/unit/diff-scope.test.ts b/packages/kilo-vscode/tests/unit/diff-scope.test.ts new file mode 100644 index 00000000000..7714ae4fa24 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-scope.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "bun:test" +import { + composeDiffId, + parseDiffId, + isDiffScope, + normalizeScope, + scopeToSourceId, + DEFAULT_DIFF_SCOPE, +} from "../../src/agent-manager/diff-scope" + +describe("diff-scope composite ids", () => { + it("round-trips context and scope", () => { + expect(parseDiffId(composeDiffId("local", "branch"))).toEqual({ ctx: "local", scope: "branch" }) + expect(parseDiffId(composeDiffId("ses_abc", "staged"))).toEqual({ ctx: "ses_abc", scope: "staged" }) + expect(parseDiffId(composeDiffId("ses_abc", "unstaged"))).toEqual({ ctx: "ses_abc", scope: "unstaged" }) + expect(parseDiffId(composeDiffId("ses_abc", "session"))).toEqual({ ctx: "ses_abc", scope: "session" }) + }) + + it("parses session ids containing no separator as default branch scope", () => { + expect(parseDiffId("ses_abc")).toEqual({ ctx: "ses_abc", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("treats an unknown trailing segment as part of the context, not a scope", () => { + // A session id that happens to contain '#' but not a valid scope keeps the + // full id as context and falls back to branch. + expect(parseDiffId("ses_a#bogus")).toEqual({ ctx: "ses_a#bogus", scope: DEFAULT_DIFF_SCOPE }) + }) + + it("isDiffScope guards the closed enum", () => { + expect(isDiffScope("branch")).toBe(true) + expect(isDiffScope("staged")).toBe(true) + expect(isDiffScope("unstaged")).toBe(true) + expect(isDiffScope("session")).toBe(true) + expect(isDiffScope("turn")).toBe(false) + expect(isDiffScope("")).toBe(false) + }) + + it("normalizeScope falls back to branch for unknown input", () => { + expect(normalizeScope("staged")).toBe("staged") + expect(normalizeScope("nope")).toBe("branch") + expect(normalizeScope(undefined)).toBe("branch") + expect(normalizeScope(42)).toBe("branch") + }) + + it("maps scopes to catalog source ids", () => { + expect(scopeToSourceId("branch", "ses_abc")).toBe("workspace") + expect(scopeToSourceId("staged", "ses_abc")).toBe("staged") + expect(scopeToSourceId("unstaged", "ses_abc")).toBe("unstaged") + expect(scopeToSourceId("session", "ses_abc")).toBe("session:ses_abc") + expect(scopeToSourceId("branch", "local")).toBe("workspace") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 3e8e4d30677..160823e513d 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -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", diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index df7a44d25d6..406076ec606 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -141,7 +141,9 @@ describe("handlePermissionResponse", () => { await handlePermissionResponse(fake, "p1", "s1", "once", [], []) - expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) }) it("saves selected rules and replies in the recorded SSE directory", async () => { @@ -158,7 +160,9 @@ describe("handlePermissionResponse", () => { deniedAlways: ["rm *"], }, ]) - expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) }) it("treats an SDK-wrapped 404 while saving rules as stale", async () => { @@ -192,7 +196,9 @@ describe("handlePermissionResponse", () => { await handlePermissionResponse(fake, "p1", "s1", "once", [], []) - expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(replies).toEqual([ + { requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true }, + ]) expect(permDirs.has("p1")).toBe(false) expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }]) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts index d9854b52261..18c800e1bba 100644 --- a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from "bun:test" import { messageTurns } from "../../webview-ui/src/context/session-queue" import { transcriptRows } from "../../webview-ui/src/context/transcript-rows" import type { Message, Part, TextPart } from "../../webview-ui/src/types/messages" -import { capacity, previewText, promptItems, railItems } from "../../webview-ui/src/components/chat/prompt-rail" +import { + capacity, + historyAction, + previewText, + promptItems, + railEntries, +} from "../../webview-ui/src/components/chat/prompt-rail" const base = { sessionID: "session", @@ -157,18 +163,38 @@ describe("promptItems", () => { }) describe("capacity", () => { - it("counts how many worst-case rows fit the transcript height", () => { - expect(capacity(24 + 76 * 5)).toBe(5) - expect(capacity(100)).toBe(1) + it("counts how many ticks fit the transcript height", () => { + expect(capacity(24 + 7 * 5)).toBe(5) + expect(capacity(31)).toBe(1) + }) + + it("fits far more ticks than the navigator lists rows", () => { + // A tick is a hairline, so a sidebar-height transcript holds a whole + // session's prompts rather than the handful of card rows that fit. + expect(capacity(724)).toBe(100) }) it("returns nothing usable for unmeasured or tiny transcripts", () => { expect(capacity(0)).toBeLessThan(1) - expect(capacity(99)).toBeLessThan(1) + expect(capacity(30)).toBeLessThan(1) }) }) -describe("railItems", () => { +describe("historyAction", () => { + it("loads the next page only after the previous page made progress", () => { + expect(historyAction(80, 160, true)).toBe("load") + }) + + it("jumps after the final page", () => { + expect(historyAction(160, 200, false)).toBe("jump") + }) + + it("stops instead of retrying a page that made no progress", () => { + expect(historyAction(160, 160, true)).toBe("stop") + }) +}) + +describe("railEntries", () => { const items = Array.from({ length: 5 }, (_, i) => ({ key: `k${i}`, turn: `t${i}`, @@ -178,15 +204,36 @@ describe("railItems", () => { })) it("passes through when everything fits", () => { - expect(railItems(items, 5)).toEqual(items) - expect(railItems(items, 10)).toEqual(items) + expect(railEntries(items, 5)).toEqual(items.map((item, index) => ({ type: "prompt", item, index }))) + expect(railEntries(items, 10)).toEqual(items.map((item, index) => ({ type: "prompt", item, index }))) }) - it("keeps the newest items when capacity is smaller", () => { - expect(railItems(items, 2)).toEqual(items.slice(-2)) + it("keeps the first and latest prompts at minimal capacity", () => { + expect(railEntries(items, 2)).toEqual([ + { type: "prompt", item: items[0], index: 0 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("summarizes hidden loaded prompts between the first and recent prompts", () => { + expect(railEntries(items, 4)).toEqual([ + { type: "prompt", item: items[0], index: 0 }, + { type: "overflow", count: 2, index: 1 }, + { type: "prompt", item: items[3], index: 3 }, + { type: "prompt", item: items[4], index: 4 }, + ]) + }) + + it("reserves the first entry for unloaded history", () => { + expect(railEntries(items, 4, true)).toEqual([ + { type: "history" }, + { type: "overflow", count: 3, index: 0 }, + { type: "prompt", item: items[3], index: 3 }, + { type: "prompt", item: items[4], index: 4 }, + ]) }) it("returns nothing at zero capacity", () => { - expect(railItems(items, 0)).toEqual([]) + expect(railEntries(items, 0)).toEqual([]) }) }) diff --git a/packages/kilo-vscode/tests/unit/run-message.test.ts b/packages/kilo-vscode/tests/unit/run-message.test.ts new file mode 100644 index 00000000000..2f122026dab --- /dev/null +++ b/packages/kilo-vscode/tests/unit/run-message.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/run-script-manager.test.ts b/packages/kilo-vscode/tests/unit/run-script-manager.test.ts index baaab57e3f6..59bd3c2df81 100644 --- a/packages/kilo-vscode/tests/unit/run-script-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/run-script-manager.test.ts @@ -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() + 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 () => ({ diff --git a/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts b/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts new file mode 100644 index 00000000000..d2a16dee95f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/run-terminal-destination.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts new file mode 100644 index 00000000000..01bea86435a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts @@ -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 + 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 { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((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 + get?: () => Promise + 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() + 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() + 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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-search.test.ts b/packages/kilo-vscode/tests/unit/session-search.test.ts new file mode 100644 index 00000000000..0bae8bb6824 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-search.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test" +import { handleSessionSearch } from "../../src/kilo-provider/session-search" + +type Query = Record + +function stub(data: Array> | 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" }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-diff-controller.test.ts b/packages/kilo-vscode/tests/unit/worktree-diff-controller.test.ts new file mode 100644 index 00000000000..7c3078f7a02 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/worktree-diff-controller.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "bun:test" +import { WorktreeDiffController } from "../../src/agent-manager/worktree-diff-controller" +import type { DiffSourceCatalog } from "../../src/diff/sources/catalog" +import type { DiffSource } from "../../src/diff/sources/types" +import type { PanelContext } from "../../src/diff/types" +import type { GitOps } from "../../src/agent-manager/GitOps" +import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +// Records every PanelContext handed to catalog.build so tests can assert which +// base branch the active source was (re)built with. The controller, scope +// resolution, and SourceController lifecycle under test are all real. +function make(onFetch?: (n: number) => Promise) { + const builds: { id: string; ctx: PanelContext }[] = [] + let fetches = 0 + const catalog = { + build: (id: string, ctx: PanelContext): DiffSource => { + builds.push({ id, ctx }) + return { + descriptor: { id, type: "workspace", group: "Git", capabilities: { revert: true, comments: true } }, + async fetch() { + await onFetch?.(++fetches) + return { diffs: [] } + }, + } + }, + } as unknown as DiffSourceCatalog + + const state = { + getSession: (id: string) => (id === "s1" ? { id: "s1", worktreeId: "w1", createdAt: "" } : undefined), + getWorktree: (id: string) => + id === "w1" ? { id: "w1", path: "/wt", parentBranch: "main", remote: "origin" } : undefined, + } as unknown as WorktreeStateManager + + const controller = new WorktreeDiffController({ + getState: () => state, + getRoot: () => "/repo", + getStateReady: () => undefined, + catalog, + git: {} as GitOps, + localDiffFile: async () => null, + post: () => {}, + log: () => {}, + }) + return { controller, builds } +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + +async function waitFor(cond: () => boolean): Promise { + for (let i = 0; i < 50; i++) { + if (cond()) return + await tick() + } + throw new Error("waitFor timed out") +} + +describe("WorktreeDiffController.setBase", () => { + it("rebuilds the active source against the overridden base branch", async () => { + const { controller, builds } = make() + controller.start("s1#branch") + await waitFor(() => builds.length === 1) + expect(builds[0]!.ctx.dir).toBe("/wt") + expect(builds[0]!.ctx.baseBranch).toBe("origin/main") + + await controller.setBase("s1#branch", "feature-x") + expect(builds.length).toBe(2) + expect(builds[1]!.ctx.dir).toBe("/wt") + expect(builds[1]!.ctx.baseBranch).toBe("feature-x") + + // Clearing the override falls back to the recorded parent ref. + await controller.setBase("s1#branch", undefined) + expect(builds.length).toBe(3) + expect(builds[2]!.ctx.baseBranch).toBe("origin/main") + + controller.stop() + }) + + it("stores the override without rebuilding when the context isn't active", async () => { + const { controller, builds } = make() + + await controller.setBase("s1#branch", "feature-x") + expect(builds.length).toBe(0) + + // The next activation of that context resolves the stored override. + controller.start("s1#branch") + await waitFor(() => builds.length === 1) + expect(builds[0]!.ctx.baseBranch).toBe("feature-x") + + controller.stop() + }) + + it("keeps watching when the base changes during the initial fetch", async () => { + // Hold the first activation's fetch in flight, simulating a slow worktree + // diff. isPolling is still false in this window, but the watch intent must + // survive the base change rather than downgrading the panel to one-shot. + let release: () => void = () => {} + const gate = new Promise((resolve) => (release = resolve)) + const { controller, builds } = make(async (n) => { + if (n === 1) await gate + }) + + controller.start("s1#branch") + await waitFor(() => builds.length === 1) + + const change = controller.setBase("s1#branch", "feature-x") + release() + await change + expect(builds.length).toBe(2) + expect(builds[1]!.ctx.baseBranch).toBe("feature-x") + + // Polling survives: start() early-returns for an id that is already + // watched. A downgraded one-shot panel would re-activate and rebuild here. + controller.start("s1#branch") + await tick() + expect(builds.length).toBe(2) + + controller.stop() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 8397077e5d8..dac2895596f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -23,6 +23,7 @@ import type { AgentManagerWorktreeDiffMessage, AgentManagerWorktreeDiffFileMessage, AgentManagerWorktreeDiffLoadingMessage, + AgentManagerDiffBranchesMessage, AgentManagerApplyWorktreeDiffResultMessage, AgentManagerWorktreeStatsMessage, AgentManagerLocalStatsMessage, @@ -38,6 +39,7 @@ import type { SessionInfo, SessionCreatedMessage, BranchInfo, + TerminalDestination, } from "../src/types/messages" import { DragDropProvider, @@ -105,6 +107,7 @@ import { createTerminalMessageHandler, createSideTerminal, readSavedDestination, + resolveRunScriptRequest, resolveVscodeTerminalRequest, } from "./terminal" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" @@ -136,6 +139,9 @@ import { } from "./section-helpers" import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" +import { DiffScopeControls } from "../diff-viewer/DiffScopeControls" +import { scopeCapabilities } from "./diff-scope-state" +import { createDiffReviewScope } from "./diff-review-scope" import { initialMessage, seedInitialVariant } from "./initial-message" import { createMarkdownRender } from "./review-preferences" import { createSidebarCollapse } from "./sidebar-collapse" @@ -398,20 +404,20 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel }) } - const runWorktree = (id: string) => { + const runWorktree = (id: string, destination: TerminalDestination) => { const state = runStatuses()[id]?.state ?? "idle" if (state === "running" || state === "stopping") { vscode.postMessage({ type: "agentManager.stopRunScript", worktreeId: id }) return } - vscode.postMessage({ type: "agentManager.runScript", worktreeId: id }) + vscode.postMessage(resolveRunScriptRequest(id, destination)) } const configureRunScript = () => vscode.postMessage({ type: "agentManager.configureRunScript" }) const runSelected = () => { const sel = selection() - if (sel) runWorktree(sel) + if (sel) runWorktree(sel, sideCtl.destination()) } const isPending = (id: string) => id.startsWith(PENDING_PREFIX) @@ -929,7 +935,7 @@ const AgentManagerContent: Component = () => { requestAnimationFrame(() => sidebarSearchMenu?.open()) } } else if (msg.action === "showTerminal") { - sideCtl.openPreferred("keyboard_shortcut") + if (!sideCtl.echo()) sideCtl.openPreferred("keyboard_shortcut") } else if (msg.action === "toggleDiff") { if (reviewActive()) { closeReviewTab() @@ -987,6 +993,13 @@ const AgentManagerContent: Component = () => { } window.addEventListener("keydown", preventDefaults, true) + // Cmd/Ctrl+/ toggles the terminal even when VS Code's webview keybinding + // forwarding drops the key before it reaches the workbench (reported with + // the prompt input focused). When forwarding does work, the extension + // echoes the shortcut back as an action message and sideCtl dedupes it. + const shortcut = (e: KeyboardEvent) => sideCtl.press(e) + window.addEventListener("keydown", shortcut, true) + // Delete/Backspace on a selected worktree triggers inline delete confirmation. // Pressing the key twice in a row (within the 2500ms window) confirms the delete. const deleteKeyHandler = (e: KeyboardEvent) => { @@ -1086,7 +1099,15 @@ const AgentManagerContent: Component = () => { onSideCreated: (contextKey, terminalId) => { // Focus only when the user is still looking at this panel — // a slow create landing after a mode switch must not steal it. - if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId) + if (sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) { + terms.requestFocus(terminalId) + } + }, + onScriptRunning: (contextKey, terminalId) => { + if (terms.sideKey() !== contextKey) return + showSideTerminal() + terms.setSideActive(contextKey, terminalId) + terms.requestFocus(terminalId) }, onDestinationChanged: (destination) => sideCtl.syncDefault(destination), }) @@ -1308,6 +1329,10 @@ const AgentManagerContent: Component = () => { diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage) } + if (msg.type === "agentManager.diffBranches") { + review.onBranches(msg as AgentManagerDiffBranchesMessage) + } + if (msg.type === "agentManager.applyWorktreeDiffResult") { apply.onApplyResult(msg as AgentManagerApplyWorktreeDiffResultMessage) } @@ -1336,6 +1361,7 @@ const AgentManagerContent: Component = () => { onCleanup(() => { window.removeEventListener("message", handler) window.removeEventListener("keydown", preventDefaults, true) + window.removeEventListener("keydown", shortcut, true) window.removeEventListener("keydown", deleteKeyHandler) window.removeEventListener("keydown", modTrack, true) window.removeEventListener("keyup", modTrack, true) @@ -1379,15 +1405,47 @@ const AgentManagerContent: Component = () => { const currentDiffSessionId = createMemo(selectedDiffSessionId) - // Start/stop diff watch when panel opens/closes, review tab opens, or session changes + // Diff scope + base branch state, shared by the side panel and review tab. + const review = createDiffReviewScope({ + ctx: currentDiffSessionId, + panelOpen: diffOpen, + reviewActive, + local: LOCAL, + vscode, + }) + // The composite id (ctx#scope) the extension keys diff data by. + const diffScopeId = review.id + + // Shared scope + base-picker controls for the side panel and review tab. + const diffScopeControls = (compact: boolean) => ( + + ) + + // Start/stop diff watch when panel opens/closes, review tab opens, scope + // changes, or session changes. createEffect(() => { const panel = diffOpen() - const review = reviewActive() + const active = reviewActive() + const scope = review.scope() - if (panel || review) { + if (panel || active) { const id = currentDiffSessionId() if (id) { - vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id }) + vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id, scope }) return } vscode.postMessage({ type: "agentManager.stopDiffWatch" }) @@ -1432,33 +1490,17 @@ const AgentManagerContent: Component = () => { tabFocus.restore() } - // Data for the review tab: use local diff data for local context, - // current session for selected worktree context, or first available in that worktree. + // Data for the review tab / side panel: keyed by the composite diff id + // (ctx#scope) the extension pushes, so each scope keeps its own file set and + // switching back to a fetched scope is instant. const reviewDiffs = createMemo(() => { const data = diffDatas() - const sel = selection() - const id = session.currentSessionID() - if (sel === LOCAL) return data[LOCAL] ?? [] - if (id && data[id]) { - const current = managedSessions().find((s) => s.id === id) - if (sel && current?.worktreeId === sel) return data[id]! - } - if (!sel) return [] - const ids = managedSessions() - .filter((s) => s.worktreeId === sel) - .map((s) => s.id) - for (const sid of ids) { - if (data[sid]) return data[sid]! - } - return [] + const key = diffScopeId() + if (!key) return [] + return data[key] ?? [] }) - const diffSessionKey = createMemo(() => { - const sel = selection() - if (sel === LOCAL) return `local:${LOCAL}` - if (sel === null) return `session:${session.currentSessionID() ?? ""}` - return `worktree:${sel}` - }) + const diffSessionKey = createMemo(() => diffScopeId() ?? "") const setSharedDiffStyle = (style: "unified" | "split") => { if (reviewDiffStyle() === style) return @@ -1467,14 +1509,14 @@ const AgentManagerContent: Component = () => { } const requestDiffFile = (file: string) => { - const sessionId = currentDiffSessionId() - if (!sessionId) return - diffs.requestDiffFile(sessionId, file) + const id = diffScopeId() + if (!id) return + diffs.requestDiffFile(id, file) } - const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(currentDiffSessionId)) + const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId)) - const revertCtl = createRevertFile(currentDiffSessionId, vscode, showToast, t) + const revertCtl = createRevertFile(diffScopeId, currentDiffSessionId, () => review.scope(), vscode, showToast, t) const handleConfigureSetupScript = () => { vscode.postMessage({ type: "agentManager.configureSetupScript" }) @@ -1820,7 +1862,7 @@ const AgentManagerContent: Component = () => { const sideCtl = createSideTerminal({ handlers: termHandlers, - visible: () => sidePanel() === "terminal", + visible: () => sidePanel() === "terminal" && !history() && !reviewActive(), focusedId: () => terms.sideFocusedId(), hide: () => setSidePanel(null), refocus: () => window.dispatchEvent(new Event("focusPrompt")), @@ -1838,6 +1880,7 @@ const AgentManagerContent: Component = () => { ) as never, ), }) + createEffect(on(terms.sideKey, (key, previous) => sideCtl.syncContext(key, previous), { defer: true })) const handleReviewTabMouseDown = (e: MouseEvent) => { if (e.button !== 1) return @@ -2475,12 +2518,19 @@ const AgentManagerContent: Component = () => { {t("agentManager.open.button")} - + + } + > +
+ + {(opt, index) => ( + <> + {(text) =>
{text()}
}
+ + + )} +
+
+ + ) +} + +/** Compact unified/split picker. Replaces the wide radio group in tight rows. */ +export const DiffStyleSelect: Component<{ + value: "unified" | "split" + onSelect: (value: "unified" | "split") => void + unifiedLabel: string + splitLabel: string + title: string +}> = (props) => ( + +) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index d2daa6389b1..e5aa67b906d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -71,7 +71,7 @@ import { type TranscriptRow, } from "../../context/transcript-rows" import { PromptRail } from "./PromptRail" -import { capacity, promptItems, railItems, type PromptRailItem } from "./prompt-rail" +import { capacity, historyAction, promptItems, railEntries, type PromptRailItem } from "./prompt-rail" import { onTimelineHighlight, type TimelineHighlight } from "../../utils/timeline/highlight" import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search" import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight" @@ -909,8 +909,8 @@ export const MessageList: Component = (props) => { // entirely to the precise per-occurrence check in paintHighlights, // which only scrolls when the exact match actually needs it. if (!mounted) { - const index = keys().indexOf(match.key) - if (index >= 0) { + const index = indexes().get(match.key) + if (index !== undefined) { virtualizer()?.scrollToIndex(index, { align: "center" }) } } @@ -946,23 +946,65 @@ export const MessageList: Component = (props) => { const tail = createMemo(() => partition().direct.map((row) => row.key)) const lookup = createMemo(() => new Map(partition().direct.map((row) => [row.key, row]))) const keys = createMemo(() => partition().virtual.map((row) => row.key)) + const indexes = createMemo(() => new Map(keys().map((key, index) => [key, index]))) const fingerprint = createMemo(() => rowFingerprint(keys())) + const [pending, setPending] = createSignal<{ sid: string; key: string }>() + // Scrolls the transcript to a row by key. Virtualized rows jump through // the virtualizer; direct/live/queued rows are mounted, so they use // scrollIntoView. Pauses auto-follow first so the jump isn't snapped back. const jump = (key: string) => { autoScroll.pause() - const index = keys().indexOf(key) - if (index >= 0) { - virtualizer()?.scrollToIndex(index, { align: "start" }) + const index = indexes().get(key) + if (index !== undefined) { + const handle = virtualizer() + if (handle) { + setPending(undefined) + handle.scrollToIndex(index, { align: "start" }) + return + } + const sid = session.currentSessionID() + if (sid) setPending({ sid, key }) return } const el = scrollEl() const target = el?.querySelector(`[data-row-key="${CSS.escape(key)}"]`) - target?.scrollIntoView({ block: "start" }) + if (target) { + setPending(undefined) + target.scrollIntoView({ block: "start" }) + return + } + const sid = session.currentSessionID() + if (sid) setPending({ sid, key }) } + // Keep unresolved targets by stable row key. Virtual rows resolve once + // Virtua installs its handle; direct/live rows resolve once Solid mounts + // their DOM node. + createEffect(() => { + const target = pending() + if (!target) return + if (target.sid !== session.currentSessionID()) { + setPending(undefined) + return + } + const index = indexes().get(target.key) + const handle = virtualizer() + if (index !== undefined && handle) { + setPending(undefined) + autoScroll.pause() + handle.scrollToIndex(index, { align: "start" }) + return + } + const el = scrollEl() + const row = el?.querySelector(`[data-row-key="${CSS.escape(target.key)}"]`) + if (!row) return + setPending(undefined) + autoScroll.pause() + row.scrollIntoView({ block: "start" }) + }) + // Clicking a bar in the task timeline scrolls the transcript to that message. // Jumps land instantly (no smooth animation): while pinned at the bottom, a // smooth scroll's initial frames sit within createAutoScroll's near-bottom @@ -985,12 +1027,67 @@ export const MessageList: Component = (props) => { const items = createMemo(() => promptItems(rows())) // Until the transcript is measured there is no height to cap against, and // rendering every prompt would spill ticks past the rail on long sessions. - const shown = createMemo(() => railItems(items(), capacity(height()))) + const entries = createMemo(() => railEntries(items(), capacity(height()), session.hasOlderMessages())) const [activeTurn, setActiveTurn] = createSignal() - const railActiveKey = createMemo(() => shown().find((item) => item.turn === activeTurn())?.key) + const railActiveKey = createMemo(() => items().find((item) => item.turn === activeTurn())?.key) + + const [seek, setSeek] = createSignal<{ sid: string; count: number }>() + let paging = false + + const first = () => { + const item = items()[0] + if (!session.hasOlderMessages()) { + if (item) jump(item.key) + return + } + const sid = session.currentSessionID() + if (!sid || session.loadingOlderMessages()) return + setSeek({ sid, count: session.messages().length }) + if (!session.loadOlderMessages()) setSeek(undefined) + } + + // Loading the first prompt is deliberate and progressive: each completed + // prepend advances the existing page cursor, while hover/open remains free + // of network and full-history work. Stop if a request makes no progress so + // backend failures cannot turn into a retry loop. + createEffect(() => { + const loading = session.loadingOlderMessages() + const target = seek() + if (!target) { + paging = loading + return + } + if (target.sid !== session.currentSessionID()) { + paging = false + setSeek(undefined) + return + } + if (loading) { + paging = true + return + } + if (!paging) return + paging = false + const count = session.messages().length + const action = historyAction(target.count, count, session.hasOlderMessages()) + if (action === "stop") { + const item = items()[0] + setSeek(undefined) + if (item) jump(item.key) + return + } + if (action === "load") { + setSeek({ sid: target.sid, count }) + if (!session.loadOlderMessages()) setSeek(undefined) + return + } + const item = items()[0] + setSeek(undefined) + if (item) jump(item.key) + }) const trackActive = () => { - const list = shown() + const list = items() if (list.length === 0) return setActiveTurn(undefined) const handle = virtualizer() const offset = handle?.scrollOffset @@ -998,6 +1095,11 @@ export const MessageList: Component = (props) => { const row = partition().virtual[handle.findItemIndex(offset)] if (row) return setActiveTurn(row.turn) } + const el = scrollEl() + if (handle && el && el.scrollHeight > el.clientHeight + 1) { + const row = partition().virtual[0] + if (row) return setActiveTurn(row.turn) + } setActiveTurn(list.at(-1)?.turn) } let activeFrame: number | undefined @@ -1014,7 +1116,7 @@ export const MessageList: Component = (props) => { // Re-derive the active turn whenever the transcript changes so the rail // reflects a newly started turn even before any scrolling happens. createEffect(() => { - shown() + items() partition() scheduleActive() }) @@ -1263,14 +1365,25 @@ export const MessageList: Component = (props) => { railActiveKey()} onSelect={(item: PromptRailItem) => jump(item.key)} + onFirst={first} + onLatest={() => { + const item = items().at(-1) + if (item) jump(item.key) + }} + onLoadOlder={() => session.loadOlderMessages()} onWheel={(deltaY: number) => { const el = scrollEl() if (el) el.scrollTop += deltaY }} height={height} + hasOlder={session.hasOlderMessages} + loadingOlder={session.loadingOlderMessages} + prepending={() => session.messageMutation() === "prepend"} + seeking={() => Boolean(seek())} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx index 555cc337a02..857e4fa634e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx @@ -2,50 +2,84 @@ /** * PromptRail component - * Thin vertical tick rail on the left edge of the transcript, one tick per - * user prompt. Hovering/focusing the rail opens a floating card listing the - * prompts with a short answer preview each; clicking jumps the transcript. + * Thin vertical summary rail on the left edge of the transcript. Hovering or + * focusing opens a bounded navigator for every loaded prompt; clicking jumps + * the virtualized transcript without mounting the intervening rows. */ +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js" import { Portal } from "solid-js/web" +import { VList, type VListHandle } from "virtua/solid" import { useLanguage } from "../../context/language" -import { RAIL_INSET, ROW_HEIGHT, type PromptRailItem } from "./prompt-rail" +import { RAIL_INSET, ROW_HEIGHT, TICK_MIN, TICK_STEP, type PromptRailEntry, type PromptRailItem } from "./prompt-rail" interface PromptRailProps { + entries: Accessor items: Accessor /** Row key of the item whose turn is currently at the top of the transcript. */ active: Accessor onSelect: (item: PromptRailItem) => void + onFirst: () => void + onLatest: () => void + onLoadOlder: () => void /** Forwards wheel events so scrolling over a tick scrolls the transcript. */ onWheel: (deltaY: number) => void /** Transcript height, used to spread the ticks. */ height: Accessor + hasOlder: Accessor + loadingOlder: Accessor + prepending: Accessor + seeking: Accessor } const CLOSE_DELAY = 120 -const TICK_STEP = 14 const EDGE = 12 const GAP = 8 +const VIRTUAL_LIMIT = 30 +const CARD_CHROME = 44 +const NEAR_TOP = 200 export function PromptRail(props: PromptRailProps) { const language = useLanguage() const [open, setOpen] = createSignal(false) - const [hover, setHover] = createSignal() - const [anchor, setAnchor] = createSignal<{ top: number; left: number }>() + const [hover, setHover] = createSignal() + const [focused, setFocused] = createSignal() + const [anchor, setAnchor] = createSignal<{ top: number; left: number; height: number }>() let rail: HTMLElement | undefined let card: HTMLDivElement | undefined + let list: VListHandle | undefined let timer: ReturnType | undefined + let frame: number | undefined + let revealing = false const items = createMemo(() => props.items()) + const entries = createMemo(() => props.entries()) + const virtualized = createMemo(() => items().length > VIRTUAL_LIMIT) // Ticks are spread over the available height, tightening as prompts pile up - // but never growing past their natural step. + // but never growing past their natural step nor packing tighter than a tick + // can still be aimed at. const step = createMemo(() => { - const count = items().length + const count = entries().length if (count === 0) return TICK_STEP - return Math.min(TICK_STEP, Math.floor((props.height() - RAIL_INSET) / count)) + return Math.max(TICK_MIN, Math.min(TICK_STEP, Math.floor((props.height() - RAIL_INSET) / count))) }) + // Reaching the top of the navigator pages older history in, the same way the + // transcript itself loads earlier messages when scrolled near its top. Opening + // the card scrolls the hovered prompt into view, which would otherwise look + // like a scroll to the top and fetch on hover, so programmatic reveals are + // excluded and only scrolling the user drove pages. + const offset = () => (virtualized() ? (list?.scrollOffset ?? 0) : (card?.scrollTop ?? 0)) + + const page = (value: number) => { + if (revealing || value > NEAR_TOP) return + if (!props.hasOlder() || props.loadingOlder() || props.seeking()) return + props.onLoadOlder() + } + // Centers the card on the tick group so each row sits beside its own tick, // then keeps it inside the transcript and the viewport. The rail spans the // transcript exactly (top/bottom 0), so its own rect doubles as those bounds @@ -55,13 +89,17 @@ export function PromptRail(props: PromptRailProps) { const place = () => { if (!rail) return const rect = rail.getBoundingClientRect() - const height = card?.offsetHeight ?? Math.min(items().length * ROW_HEIGHT + EDGE, rect.height) + const limit = Math.max(0, Math.min(window.innerHeight - EDGE * 2, rect.height - 8)) + if (limit === 0) return + const estimate = Math.min(items().length * ROW_HEIGHT + CARD_CHROME, limit) + const height = virtualized() ? limit : (card?.offsetHeight ?? estimate) const min = Math.max(EDGE, rect.top + 4) const max = Math.min(window.innerHeight - EDGE, rect.bottom - 4) - height const center = rect.top + rect.height / 2 - height / 2 setAnchor({ top: max < min ? min : Math.min(Math.max(center, min), max), left: rect.right + GAP, + height: limit, }) } @@ -70,11 +108,35 @@ export function PromptRail(props: PromptRailProps) { timer = undefined } + const reveal = (index: number) => { + if (frame !== undefined) cancelAnimationFrame(frame) + revealing = true + frame = requestAnimationFrame(() => { + frame = undefined + if (virtualized()) { + list?.scrollToIndex(index, { align: "center" }) + return + } + const row = card?.querySelector(`[data-prompt-index="${index}"]`) + if (!row || !card) return + card.scrollTop = Math.max(0, row.offsetTop - card.clientHeight / 2 + row.offsetHeight / 2) + }) + } + + const entryItem = (entry: PromptRailEntry) => { + if (entry.type === "prompt") return entry.item + return items()[entry.type === "overflow" ? entry.index : 0] + } + const openCard = (index: number) => { cancelClose() - setHover(index) + const entry = entries()[index] + const item = entry && entryItem(entry) + setFocused(index) + setHover(item?.key) place() setOpen(true) + if (item) reveal(items().findIndex((candidate) => candidate.key === item.key)) } const closeCard = () => { @@ -86,6 +148,9 @@ export function PromptRail(props: PromptRailProps) { } onCleanup(cancelClose) + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + }) // Resizing the panel moves the rail out from under an open card. createEffect(() => { @@ -103,9 +168,20 @@ export function PromptRail(props: PromptRailProps) { onCleanup(() => cancelAnimationFrame(frame)) }) + let seeking = false + createEffect(() => { + const next = props.seeking() + if (seeking && !next && !props.hasOlder()) { + const item = items()[0] + setHover(item?.key) + if (item) reveal(0) + } + seeking = next + }) + const onKeyDown = (event: KeyboardEvent) => { - const list = items() - const current = hover() ?? 0 + const values = entries() + const current = focused() ?? 0 if (event.key === "Escape") { event.preventDefault() cancelClose() @@ -115,19 +191,22 @@ export function PromptRail(props: PromptRailProps) { } if (event.key === "Enter" || event.key === " ") { event.preventDefault() - const item = list[current] - if (item) props.onSelect(item) + const entry = values[current] + if (!entry) return + if (entry.type === "prompt") props.onSelect(entry.item) + if (entry.type === "history") props.onFirst() + if (entry.type === "overflow") openCard(current) return } const next = event.key === "ArrowDown" - ? Math.min(list.length - 1, current + 1) + ? Math.min(values.length - 1, current + 1) : event.key === "ArrowUp" ? Math.max(0, current - 1) : event.key === "Home" ? 0 : event.key === "End" - ? list.length - 1 + ? values.length - 1 : undefined if (next === undefined) return event.preventDefault() @@ -139,8 +218,58 @@ export function PromptRail(props: PromptRailProps) { const label = (item: PromptRailItem, index: number) => language.t("session.prompts.tick", { index: index + 1, total: items().length, prompt: item.prompt }) + const entryLabel = (entry: PromptRailEntry) => { + if (entry.type === "prompt") return label(entry.item, entry.index) + if (entry.type === "history") return language.t("session.prompts.first") + return language.t("session.prompts.overflow", { count: entry.count }) + } + + const entryActive = (entry: PromptRailEntry) => { + if (entry.type === "prompt") return entry.item.key === props.active() + if (entry.type === "history") return false + const index = items().findIndex((item) => item.key === props.active()) + return index >= entry.index && index < entry.index + entry.count + } + + const selectFirst = () => { + const item = items()[0] + setHover(item?.key) + if (item) reveal(0) + props.onFirst() + } + + const selectLatest = () => { + const index = items().length - 1 + const item = items()[index] + setHover(item?.key) + if (item) reveal(index) + props.onLatest() + } + + const row = (item: PromptRailItem, index: Accessor) => ( + + ) + return ( - = 2}> + = 2}>