diff --git a/.changeset/caffeination.md b/.changeset/caffeination.md new file mode 100644 index 000000000000..8fc9712eb7bf --- /dev/null +++ b/.changeset/caffeination.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a default-off Keep Awake toggle for busy agent sessions without keeping the display on or disabling screen locking. Remember the safety notice after its first acceptance. Keep multi-project toolbar controls visible in narrow sidebars. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-200-chromium-linux.png new file mode 100644 index 000000000000..5f72f36d327b --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-200-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b73ee185c14bb01004d91e8325b3fc706d4637112149171352a45aa6bc82fcdb +size 31700 diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index 5a263ec877d1..f5dc3aed05da 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -26,6 +26,10 @@ const icons: Record = { viewBox: "0 0 24 24", path: ``, }, + coffee: { + viewBox: "0 0 24 24", + path: ``, + }, "circuit-board": { viewBox: "0 0 16 16", path: ``, diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 3c840eb68102..1ca90156a997 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -206,6 +206,11 @@ "title": "Toggle Project Memory", "category": "Kilo Code" }, + { + "command": "kilo-code.new.toggleCaffeination", + "title": "Toggle Keep Awake", + "category": "Kilo Code" + }, { "command": "kilo-code.new.autocomplete.generateSuggestions", "title": "Generate Suggested Edits", diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index c651ee896db8..0e4156094ddf 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -18,6 +18,7 @@ import { openFileInEditor, getWorkspaceRoot } from "../review-utils" import { TelemetryProxy, type TelemetryEventName } from "../services/telemetry" import type { AutoApproveController } from "../commands/toggle-auto-approve" import type { RemoteStatusService } from "../services/RemoteStatusService" +import type { CaffeinationService } from "../services/caffeination" const INTRO_KEY = "kilo.agentManager.introDismissed" @@ -36,6 +37,7 @@ export class VscodeHost implements Host { private readonly connectionService: KiloConnectionService, private readonly context: vscode.ExtensionContext, private readonly remoteService: RemoteStatusService, + private readonly caffeination?: Pick, ) {} setDiffVirtualProvider(provider: DiffVirtualProvider): void { @@ -137,8 +139,23 @@ export class VscodeHost implements Host { provider.setDiffVirtualProvider(this.diffVirtual) } provider.setRemoteService(this.remoteService) + const snapshot = () => { + if (this.caffeination) { + void panel.webview.postMessage({ type: "agentManager.caffeination", ...this.caffeination.getState() }) + } + } + const unsubscribe = this.caffeination?.onChange(snapshot) + panel.onDidDispose(() => unsubscribe?.()) provider.attachToWebview(panel.webview, { onBeforeMessage: async (msg) => { + if (msg.type === "agentManager.setCaffeination") { + if (typeof msg.enabled === "boolean") await this.caffeination?.setEnabled(msg.enabled) + return null + } + if (msg.type === "agentManager.requestCaffeination") { + snapshot() + return null + } if (msg.type !== "agentManager.setIntroDismissed") return opts.onBeforeMessage(msg) if (typeof msg.dismissed === "boolean") await this.context.globalState.update(INTRO_KEY, msg.dismissed) return null diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 3b92e5a100c3..ce07c676935a 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -17,6 +17,9 @@ import { registerAutocompleteProvider } from "./services/autocomplete" import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-backend" import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager" import { AttentionService } from "./services/attention" +import { CaffeinationService } from "./services/caffeination" +import { confirmCaffeination } from "./services/caffeination/confirm" +import { createCaffeinationDriver } from "./services/caffeination/inhibitor" import { BrowserBroker } from "./services/browser-automation" import { TelemetryEventName, TelemetryProxy } from "./services/telemetry" import { registerCommitMessageService } from "./services/commit-message" @@ -30,6 +33,7 @@ import { createGitExecutable } from "./util/git-executable" import { isCursorHost } from "./utils" let agentManager: AgentManagerProvider | undefined +let caffeination: CaffeinationService | undefined let shuttingDown = false const RESTORE_KEY = "kilo.workbench.restore" @@ -161,7 +165,38 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(kiloClawProvider) // Create Agent Manager provider for editor panel - const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService) + const reason = vscode.env.remoteName ? "Keep Awake is only available in a local VS Code window." : undefined + const awake = new CaffeinationService(connectionService, createCaffeinationDriver({ reason })) + caffeination = awake + const toggle = confirmCaffeination(awake, async () => { + if (!vscode.workspace.isTrusted) { + await vscode.window.showWarningMessage("Trust this workspace before enabling Keep Awake.") + return false + } + if (context.globalState.get("caffeination.confirmed") === true) return true + const detail = [ + "Keep Awake prevents system sleep while Kilo sessions are in progress, including some waits for approval. It does not keep the display on or disable screen locking. It turns off when this VS Code window reloads.", + "Agents may continue to access files, network services, and available credentials while the computer is locked. Enable only if your organization's device policy permits it.", + ...(process.platform === "linux" + ? ["On Linux, this can also block manual suspend. Turn Keep Awake off before suspending."] + : []), + ].join("\n\n") + const answer = await vscode.window.showWarningMessage( + "Keep this computer awake while Kilo agents work?", + { modal: true, detail }, + "Enable Keep Awake", + ) + if (answer !== "Enable Keep Awake") return false + await context.globalState.update("caffeination.confirmed", true).then(undefined, (error: unknown) => { + console.warn("[Kilo New] Could not save Keep Awake confirmation:", error) + }) + return true + }) + const controls = { + getState: () => awake.getState(), + onChange: awake.onChange.bind(awake), + setEnabled: toggle, + } const git = createGitExecutable({ preferred: async () => { const extension = vscode.extensions.getExtension("vscode.git") @@ -172,6 +207,7 @@ export async function activate(context: vscode.ExtensionContext) { log: (message) => console.warn(`[Kilo New] ${message}`), }) const binary = process.platform === "win32" ? await git() : git + const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService, controls) const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary, browserBroker) agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible })) agentManager = agentManagerProvider @@ -472,6 +508,14 @@ export async function activate(context: vscode.ExtensionContext) { await target.waitForReady() await target.toggleMemory() }), + vscode.commands.registerCommand("kilo-code.new.toggleCaffeination", (enabled?: boolean) => { + const state = awake.getState() + const next = typeof enabled === "boolean" ? enabled : !(state.enabled || state.active) + if (next && !state.available) { + return vscode.window.showWarningMessage(state.error ?? "Keep Awake is unavailable on this platform.") + } + return toggle(next) + }), vscode.commands.registerCommand("kilo-code.new.generateTerminalCommand", async () => { const input = await vscode.window.showInputBox({ prompt: "Describe the terminal command you want to generate", @@ -640,6 +684,9 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push({ dispose: () => { shuttingDown = true + void caffeination?.dispose().catch((error: unknown) => { + console.warn("[Kilo New] Keep-awake cleanup failed:", error) + }) unsubscribeStateChange() attention.dispose() browserBroker.dispose() @@ -652,7 +699,10 @@ export async function activate(context: vscode.ExtensionContext) { export async function deactivate() { shuttingDown = true - await agentManager?.shutdown() + const results = await Promise.allSettled([caffeination?.dispose(), agentManager?.shutdown()]) + for (const result of results) { + if (result.status === "rejected") console.warn("[Kilo New] Extension shutdown failed:", result.reason) + } TelemetryProxy.getInstance().shutdown() } diff --git a/packages/kilo-vscode/src/services/caffeination/confirm.ts b/packages/kilo-vscode/src/services/caffeination/confirm.ts new file mode 100644 index 000000000000..d81f2d65ee8a --- /dev/null +++ b/packages/kilo-vscode/src/services/caffeination/confirm.ts @@ -0,0 +1,37 @@ +import type { CaffeinationService } from "./service" + +export function confirmCaffeination( + service: Pick, + ask: () => Promise, +) { + let accepted = false + let pending: Promise | undefined + let revision = 0 + + return (enabled: boolean): Promise => { + if (!enabled) { + revision++ + return service.setEnabled(false) + } + const state = service.getState() + if (state.enabled || !state.available) return Promise.resolve() + if (accepted) return service.setEnabled(true) + if (pending) return pending + + const current = revision + pending = Promise.resolve() + .then(ask) + .then((answer) => { + if (!answer || current !== revision) return + accepted = true + return service.setEnabled(true) + }) + .catch((error: unknown) => { + console.warn("[Kilo New] Keep-awake confirmation failed:", error) + }) + .finally(() => { + pending = undefined + }) + return pending + } +} diff --git a/packages/kilo-vscode/src/services/caffeination/feed.ts b/packages/kilo-vscode/src/services/caffeination/feed.ts new file mode 100644 index 000000000000..37bf4e743a67 --- /dev/null +++ b/packages/kilo-vscode/src/services/caffeination/feed.ts @@ -0,0 +1,133 @@ +import { realpathSync } from "node:fs" +import type { SessionStatus } from "@kilocode/sdk/v2/client" +import type { SSEPayload } from "../cli-backend/sdk-sse-adapter" + +type Snapshot = Record> +type Update = Extract +type State = { values: Set; request?: { events: Update[]; promise: Promise } } + +function key(dir: string): string { + const path = dir.replace(/\\/g, "/").replace(/\/+$/u, "") || "/" + return process.platform === "win32" || process.platform === "darwin" ? path.toLowerCase() : path +} + +function busy(status: Pick): boolean { + return status.type === "busy" || status.type === "retry" +} + +function apply(values: Set, event: Update): void { + const id = event.properties.sessionID ?? (event.type === "session.deleted" ? event.properties.info?.id : undefined) + if (!id) return + if (event.type === "session.status" && busy(event.properties.status)) { + values.add(id) + return + } + values.delete(id) +} + +export function feed(opts: { + paths: () => string[] + watching: () => boolean + load: (dir: string) => Promise + post: (busy: boolean) => void +}) { + const dirs = new Map() + const states = new Map() + const aliases = new Map() + const resolve = (dir: string): string => { + const path = key(dir) + const prior = aliases.get(path) + if (prior || !opts.watching()) return prior ?? path + try { + const id = key(realpathSync.native(dir)) + aliases.set(path, id) + aliases.set(id, id) + return id + } catch { + aliases.set(path, path) + return path + } + } + const publish = () => opts.post([...states.values()].some((state) => state.values.size > 0)) + const clear = () => { + states.clear() + aliases.clear() + publish() + } + const get = (dir: string, force = false): State => { + const id = resolve(dir) + const prior = states.get(id) + const state: State = prior ?? { values: new Set() } + states.set(id, state) + if (state.request || (prior && !force)) return state + const request: NonNullable = { + events: [], + promise: Promise.resolve() + .then(() => (opts.watching() ? opts.load(dir) : {})) + .catch((error: unknown) => { + console.warn(`[Kilo New] Keep-awake status refresh failed for ${dir}:`, error) + return {} + }) + .then((snapshot) => { + if (states.get(id) !== state || state.request !== request) return + state.values = new Set( + Object.entries(snapshot) + .filter(([, status]) => busy(status)) + .map(([id]) => id), + ) + for (const event of request.events) apply(state.values, event) + publish() + }) + .finally(() => { + if (state.request === request) state.request = undefined + }), + } + state.request = request + return state + } + const sync = async () => { + if (!opts.watching()) return + for (const dir of opts.paths()) if (dir) dirs.set(key(dir), dir) + const paths = [...dirs.values()] + dirs.clear() + for (const dir of paths) dirs.set(resolve(dir), dir) + await Promise.all([...dirs.values()].map((dir) => get(dir, true).request?.promise)) + } + return { + sync, + clear, + event(event: SSEPayload, directory?: string): void { + if (event.type === "server.instance.disposed") { + const id = resolve(event.properties.directory) + for (const [alias, target] of aliases) if (target === id) aliases.delete(alias) + dirs.delete(id) + states.delete(id) + publish() + return + } + if ( + event.type !== "session.status" && + event.type !== "session.idle" && + event.type !== "session.deleted" && + event.type !== "session.error" + ) + return + if (directory) dirs.set(resolve(directory), directory) + if (!opts.watching()) return + if (!directory && event.type === "session.status" && busy(event.properties.status)) { + clear() + void sync() + return + } + for (const state of directory ? [get(directory)] : states.values()) { + state.request?.events.push(event) + apply(state.values, event) + } + publish() + }, + dispose(): void { + clear() + dirs.clear() + }, + } +} diff --git a/packages/kilo-vscode/src/services/caffeination/index.ts b/packages/kilo-vscode/src/services/caffeination/index.ts new file mode 100644 index 000000000000..eb8a97167973 --- /dev/null +++ b/packages/kilo-vscode/src/services/caffeination/index.ts @@ -0,0 +1,3 @@ +export { CaffeinationService } from "./service" +export type { CaffeinationState } from "./service" +export type { CaffeinationDriver } from "./inhibitor" diff --git a/packages/kilo-vscode/src/services/caffeination/inhibitor.ts b/packages/kilo-vscode/src/services/caffeination/inhibitor.ts new file mode 100644 index 000000000000..1496607e5437 --- /dev/null +++ b/packages/kilo-vscode/src/services/caffeination/inhibitor.ts @@ -0,0 +1,212 @@ +import { existsSync } from "node:fs" +import { delimiter, isAbsolute, join } from "node:path" +import type { ChildProcess } from "node:child_process" +import { spawn } from "../../util/process" + +export interface CaffeinationDriver { + readonly available: boolean + readonly reason?: string + start(pid: number, onExit: (err?: Error) => void): Promise + stop(): Promise +} + +const START_TIMEOUT = 10_000 +const STOP_TIMEOUT = 1_000 +const LIMIT = 4_096 +const READY = "KILO_CAFFEINATION_READY" + +type Inhibitor = { + child: ChildProcess + ready: Promise + closed: Promise + cancel: () => void + stopped: boolean + finished: boolean + cleanup?: Promise +} + +function locate(name: string): string | undefined { + if (isAbsolute(name)) return existsSync(name) ? name : undefined + const dirs = [ + ...(process.env.PATH ?? "").split(delimiter).filter(Boolean), + ...(process.platform === "win32" ? [] : ["/usr/bin", "/bin"]), + ] + return dirs.map((dir) => join(dir, name)).find((path) => existsSync(path)) +} + +async function stopChild(state: Inhibitor, group: boolean): Promise { + const child = state.child + const kill = (signal: NodeJS.Signals) => { + if (!child.pid) return + try { + if (group) { + process.kill(-child.pid, signal) + return + } + if (child.exitCode === null && child.signalCode === null) child.kill(signal) + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ESRCH") return + throw err + } + } + const wait = () => { + const timeout = Promise.withResolvers() + const timer = setTimeout(() => timeout.resolve(false), STOP_TIMEOUT) + return Promise.race([state.closed.then(() => true), timeout.promise]).finally(() => clearTimeout(timer)) + } + + kill("SIGTERM") + if (await wait()) { + if (group) kill("SIGKILL") + return + } + kill("SIGKILL") + if (!(await wait())) throw new Error("The keep-awake process did not exit after SIGKILL") +} + +function args(platform: NodeJS.Platform, pid: number, shell?: string): string[] { + if (platform === "darwin") return ["-i", "-w", String(pid)] + if (platform === "linux") + return [ + "--what=sleep", + "--who=Kilo Code", + "--why=Kilo agent running", + "--mode=block", + shell ?? "/bin/sh", + "-c", + `printf '%s\\n' '${READY}'; while kill -0 "$1" 2>/dev/null; do sleep 1 || exit; done`, + "kilo-caffeination", + String(pid), + ] + const script = `$ErrorActionPreference = 'Stop' +try { + $type = Add-Type -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint flags);' -Name 'KiloCaffeination' -Namespace 'Kilo' -PassThru + if ($type::SetThreadExecutionState([uint32]2147483649) -eq 0) { throw 'SetThreadExecutionState failed' } + [Console]::Out.WriteLine('${READY}'); [Console]::Out.Flush() + while (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 1 } +} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }` + return ["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script] +} + +export function createCaffeinationDriver( + opts: { reason?: string; platform?: NodeJS.Platform; locate?: typeof locate; spawn?: typeof spawn } = {}, +): CaffeinationDriver { + const platform = opts.platform ?? process.platform + const find = opts.locate ?? locate + const unsupported = + opts.reason || + (!["darwin", "linux", "win32"].includes(platform) ? `Caffeination is not supported on ${platform}` : undefined) + const name = + platform === "darwin" ? "/usr/bin/caffeinate" : platform === "win32" ? "powershell.exe" : "systemd-inhibit" + const root = process.env.SystemRoot + const command = unsupported + ? undefined + : ((platform === "win32" && root + ? find(join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")) + : undefined) ?? find(name)) + const shell = !unsupported && platform === "linux" ? find("sh") : undefined + const reason = + unsupported ?? + (!command + ? `The ${name} command is not available` + : platform === "linux" && !shell + ? "The sh command is not available" + : undefined) + let current: Inhibitor | undefined + + function release(state: Inhibitor): Promise { + return (state.cleanup ??= stopChild(state, platform === "linux").then( + () => { + if (current === state) current = undefined + }, + (err: unknown) => { + state.cleanup = undefined + throw err + }, + )) + } + + const driver: CaffeinationDriver = { + available: !reason, + reason, + async start(pid, onExit) { + if (!Number.isInteger(pid) || pid <= 0 || pid > 2_147_483_647) throw new Error("Invalid parent process ID") + if (reason) throw new Error(reason) + if (current && (current.stopped || current.finished)) { + await release(current) + return driver.start(pid, onExit) + } + if (current) return current.ready + + const child = (opts.spawn ?? spawn)(command!, args(platform, pid, shell), { + stdio: ["ignore", "pipe", "pipe"], + detached: platform === "linux", + }) + const ready = Promise.withResolvers() + const closed = Promise.withResolvers() + const state: Inhibitor = { + child, + ready: ready.promise, + closed: closed.promise, + stopped: false, + finished: false, + cancel: () => { + clearTimeout(timer) + ready.reject(new Error("The keep-awake process was stopped before starting")) + }, + } + current = state + let acquired = false + let output = "" + let stderr = "" + const confirm = () => { + if (state.finished || state.stopped) return + acquired = true + clearTimeout(timer) + ready.resolve() + } + const finish = async (err: Error) => { + if (state.finished) return + state.finished = true + clearTimeout(timer) + const failure = await release(state).catch((err: unknown) => (err instanceof Error ? err.message : String(err))) + const error = new Error([err.message, stderr.trim(), failure].filter(Boolean).join(": ")) + if (!acquired) return ready.reject(error) + if (!state.stopped) onExit(error) + } + const timer = setTimeout( + () => void finish(new Error("Timed out while starting the keep-awake process")), + START_TIMEOUT, + ) + + child.stdout?.setEncoding("utf8") + child.stdout?.on("data", (chunk: string) => { + if (acquired || platform === "darwin") return + const lines = (output + chunk).split(/\r?\n/) + output = (lines.pop() ?? "").slice(-LIMIT) + if (lines.includes(READY)) confirm() + }) + child.stderr?.setEncoding("utf8") + child.stderr?.on("data", (chunk: string) => { + stderr = (stderr + chunk).slice(-LIMIT) + }) + child.once("spawn", () => { + if (platform === "darwin") confirm() + }) + child.once("close", () => closed.resolve()) + child.once("error", finish) + child.once("exit", (code, signal) => { + const detail = signal ? `signal ${signal}` : `code ${code ?? "unknown"}` + void finish(new Error(`Caffeination process exited with ${detail}`)) + }) + return ready.promise + }, + stop() { + if (!current) return Promise.resolve() + current.stopped = true + current.cancel() + return release(current) + }, + } + return driver +} diff --git a/packages/kilo-vscode/src/services/caffeination/service.ts b/packages/kilo-vscode/src/services/caffeination/service.ts new file mode 100644 index 000000000000..4ea5ef742ec0 --- /dev/null +++ b/packages/kilo-vscode/src/services/caffeination/service.ts @@ -0,0 +1,182 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { ConnectionState } from "../cli-backend/connection-service" +import type { SSEPayload } from "../cli-backend/sdk-sse-adapter" +import { feed } from "./feed" +import { createCaffeinationDriver, type CaffeinationDriver } from "./inhibitor" + +export interface CaffeinationState { + enabled: boolean + active: boolean + available: boolean + error?: string +} + +type Listener = (state: CaffeinationState) => void +type Run = { epoch: number; stopping: boolean } + +interface Connection { + onEvent(listener: (event: SSEPayload, directory?: string) => void): () => void + onStateChange(listener: (state: ConnectionState) => void): () => void + getConnectionState(): ConnectionState + getKnownDirectories(): string[] + getClient(): KiloClient +} + +export class CaffeinationService { + private readonly projection: ReturnType + private readonly listeners = new Set() + private readonly unsubscribe: (() => void)[] + private work = Promise.resolve() + private state: CaffeinationState + private busy = false + private disposed = false + private epoch = 0 + private run: Run | undefined + private closing: Promise | undefined + + constructor( + private readonly connection: Connection, + private readonly driver: CaffeinationDriver = createCaffeinationDriver(), + ) { + this.state = { + enabled: false, + active: false, + available: driver.available, + error: driver.available ? undefined : driver.reason, + } + this.projection = feed({ + paths: () => connection.getKnownDirectories(), + watching: () => this.watching(), + load: async (dir) => { + const result = await connection.getClient().session.status({ directory: dir }, { throwOnError: true }) + return result.data ?? {} + }, + post: (busy) => { + if (this.busy === busy) return + this.busy = busy + void this.queue() + }, + }) + this.unsubscribe = [ + connection.onEvent((event, directory) => this.projection.event(event, directory)), + connection.onStateChange((state) => { + if (state === "connected") { + void this.refresh() + return + } + this.epoch++ + this.projection.clear() + void this.queue() + }), + ] + } + + getState(): CaffeinationState { + return this.state + } + + onChange(listener: Listener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + setEnabled(enabled: boolean): Promise { + if (this.disposed) return this.closing ?? this.work + if (this.state.enabled === enabled && (enabled || !this.run)) return this.work + this.epoch++ + this.update({ + enabled, + available: this.driver.available, + error: this.driver.available ? undefined : this.driver.reason, + }) + if (enabled) return this.refresh() + this.projection.clear() + return this.queue() + } + + async refresh(): Promise { + if (!this.watching()) return + await this.projection.sync() + await this.queue() + } + + dispose(): Promise { + if (this.closing) return this.closing + this.disposed = true + this.epoch++ + for (const unsubscribe of this.unsubscribe) unsubscribe() + this.projection.dispose() + this.listeners.clear() + this.update({ enabled: false }) + this.closing = this.work.then(() => this.stop()) + return this.closing + } + + private watching(): boolean { + return ( + !this.disposed && + this.state.enabled && + this.driver.available && + this.connection.getConnectionState() === "connected" + ) + } + + private wants(): boolean { + return this.watching() && this.busy + } + + private queue(): Promise { + this.work = this.work.then(() => this.reconcile()).catch((error: unknown) => this.fail(error)) + return this.work + } + + private async reconcile(): Promise { + if (this.run && (!this.wants() || !this.state.available || this.run.epoch !== this.epoch)) await this.stop() + if (this.run || !this.wants() || !this.state.available) return + const run = { epoch: this.epoch, stopping: false } + this.run = run + try { + await this.driver.start(process.pid, (error) => { + if (this.run !== run || run.stopping) return + this.update({ active: false }) + if (this.wants()) this.fail(error ?? new Error("The keep-awake process exited unexpectedly")) + if (!this.disposed) void this.queue() + }) + } catch (error) { + if (run.epoch === this.epoch && this.wants()) this.fail(error) + await this.stop() + return + } + if (run.epoch !== this.epoch || !this.wants() || !this.state.available) { + await this.stop() + return + } + this.update({ active: true, error: undefined }) + } + + private async stop(): Promise { + if (!this.run) return + this.run.stopping = true + await this.driver.stop() + this.run = undefined + this.update({ active: false }) + } + + private fail(error: unknown): void { + this.update({ available: false, error: error instanceof Error ? error.message : String(error) }) + } + + private update(next: Partial): void { + const state = { ...this.state, ...next } + if ( + Object.keys(state).every( + (key) => state[key as keyof CaffeinationState] === this.state[key as keyof CaffeinationState], + ) + ) + return + this.state = state + for (const listener of this.listeners) listener(state) + } +} + +export type { CaffeinationDriver } diff --git a/packages/kilo-vscode/tests/fixtures/caffeination-button.tsx b/packages/kilo-vscode/tests/fixtures/caffeination-button.tsx new file mode 100644 index 000000000000..418bb54e7644 --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/caffeination-button.tsx @@ -0,0 +1,67 @@ +import assert from "node:assert/strict" +import { Window } from "happy-dom" +import type { WebviewMessage } from "../../webview-ui/src/types/messages" + +const window = new Window({ url: "https://kilo.test" }) +Object.defineProperty(window, "origin", { value: window.location.origin }) +Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + Element: window.Element, + Node: window.Node, + MessageEvent: window.MessageEvent, + MutationObserver: window.MutationObserver, + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + getComputedStyle: window.getComputedStyle.bind(window), +}) +const { render } = await import("solid-js/web") +const { post } = await import("../../webview-ui/src/utils/webview-message") +const { VSCodeProvider } = await import("../../webview-ui/src/context/vscode") +const { CaffeinationButton } = await import("../../webview-ui/agent-manager/CaffeinationButton") +const messages: WebviewMessage[] = [] +Object.defineProperty(globalThis, "acquireVsCodeApi", { + value: () => ({ + postMessage: (message: WebviewMessage) => { + messages.push(message) + if (message.type === "agentManager.requestCaffeination") + post({ type: "agentManager.caffeination", enabled: false, active: false, available: true }) + }, + getState: () => undefined, + setState: () => {}, + }), +}) +const root = document.createElement("div") +document.body.append(root) +const dispose = render( + () => ( + + "Keep computer awake"} /> + + ), + root, +) +try { + await Promise.resolve() + assert(messages.some((message) => message.type === "agentManager.requestCaffeination")) + const button = root.querySelector("button") + assert(button) + assert.equal(button.getAttribute("aria-label"), "Keep computer awake") + assert.equal(button.getAttribute("aria-pressed"), "false") + assert.equal(button.disabled, false) + button.click() + assert.deepEqual(messages.at(-1), { type: "agentManager.setCaffeination", enabled: true }) + post({ type: "agentManager.caffeination", enabled: false, active: true, available: false, error: "Cleanup failed" }) + assert.equal(button.classList.contains("am-caffeination-active"), true) + assert.equal(button.getAttribute("aria-pressed"), "true") + assert.equal(button.disabled, false) + button.click() + assert.deepEqual(messages.at(-1), { type: "agentManager.setCaffeination", enabled: false }) + post({ type: "agentManager.caffeination", enabled: false, active: false, available: false }) + assert.equal(button.disabled, true) +} finally { + dispose() + await window.happyDOM.close() +} diff --git a/packages/kilo-vscode/tests/unit/caffeination-button.test.ts b/packages/kilo-vscode/tests/unit/caffeination-button.test.ts new file mode 100644 index 000000000000..a4099e787146 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/caffeination-button.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test" +import path from "node:path" +import { build } from "esbuild" +import { solidPlugin } from "esbuild-plugin-solid" + +const root = path.resolve(import.meta.dir, "../..") + +test("coffee button is named and keeps cleanup retries available", async () => { + const solid = path.dirname(Bun.resolveSync("solid-js/package.json", root)) + const result = await build({ + entryPoints: [path.join(root, "tests/fixtures/caffeination-button.tsx")], + bundle: true, + conditions: ["browser"], + external: ["happy-dom"], + format: "esm", + platform: "node", + loader: { ".css": "empty" }, + logLevel: "silent", + alias: { + "solid-js": path.join(solid, "dist/solid.js"), + "solid-js/web": path.join(solid, "web/dist/web.js"), + "solid-js/store": path.join(solid, "store/dist/store.js"), + }, + plugins: [solidPlugin()], + target: "es2022", + write: false, + }) + const child = Bun.spawnSync([process.execPath, "run", "-"], { + cwd: root, + stdin: result.outputFiles.at(0)!.contents, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0) +}) diff --git a/packages/kilo-vscode/tests/unit/caffeination-inhibitor.test.ts b/packages/kilo-vscode/tests/unit/caffeination-inhibitor.test.ts new file mode 100644 index 000000000000..d9da1846bfec --- /dev/null +++ b/packages/kilo-vscode/tests/unit/caffeination-inhibitor.test.ts @@ -0,0 +1,199 @@ +import { afterEach, expect, it } from "bun:test" +import { once } from "node:events" +import type { ChildProcess, SpawnOptions } from "node:child_process" +import { createCaffeinationDriver, type CaffeinationDriver } from "../../src/services/caffeination/inhibitor" +import { spawn } from "../../src/util/process" + +const READY = "KILO_CAFFEINATION_READY" +const HOLD = "process.stdin.resume()" +const drivers = new Set() + +function setup(platform: NodeJS.Platform, script: string | typeof spawn = `console.log('${READY}'); ${HOLD}`) { + const calls: { command: string; args: string[]; opts: SpawnOptions; child: ChildProcess }[] = [] + const ended = Promise.withResolvers() + const driver = createCaffeinationDriver({ + platform, + locate: (name) => name, + spawn: (command, args, opts = {}) => { + const child = + typeof script === "string" + ? spawn(process.execPath, ["-e", script], { ...opts, stdio: ["pipe", "pipe", "pipe"] }) + : script(command, args, opts) + calls.push({ command, args, opts, child }) + return child + }, + }) + drivers.add(driver) + return { + ...driver, + calls, + ended: ended.promise, + start: (pid = process.pid) => driver.start(pid, ended.resolve), + get child() { + return calls.at(-1)!.child + }, + } +} + +afterEach(async () => { + await Promise.all([...drivers].map((driver) => driver.stop())) + drivers.clear() +}) + +it.each( + ( + [ + ["darwin", "/usr/bin/caffeinate", ["-i", "-w", String(process.pid)]], + ["linux", "systemd-inhibit", ["--what=sleep", "--mode=block", "sh", "-c", String(process.pid)]], + ["win32", "powershell.exe", ["-NoProfile", "-NonInteractive", "-Command"]], + ] as const + ).filter(([platform]) => platform !== "linux" || process.platform !== "win32"), +)("%s uses safe arguments and validates PIDs", async (platform, command, flags) => { + const run = setup(platform) + for (const pid of [0, -1, 1.5, NaN, Infinity, 2_147_483_648, "1; exit 0" as unknown as number]) { + await expect(run.start(pid)).rejects.toThrow("Invalid parent process ID") + } + expect(run.calls).toHaveLength(0) + await run.start() + const call = run.calls.at(0)! + expect(call.command).toEndWith(command) + expect(call.opts).toEqual({ detached: platform === "linux", stdio: ["ignore", "pipe", "pipe"] }) + expect(call.args).toEqual(expect.arrayContaining([...flags])) + if (platform === "darwin") expect(call.args).toHaveLength(3) + if (platform === "linux") expect(call.args.filter((arg) => arg.startsWith("--what="))).toEqual(["--what=sleep"]) + if (platform === "win32") + expect(call.args.at(-1)).toMatch( + /'Stop'[\s\S]*\[uint32\]2147483649\) -eq 0[\s\S]*throw[\s\S]*KILO_CAFFEINATION_READY[\s\S]*catch.*exit 1/, + ) +}) + +it.each(["darwin", "linux", "win32", "freebsd", "remote"] as const)( + "rejects unavailable %s hosts", + async (platform) => { + const reason = platform === "remote" ? "Remote host" : undefined + const driver = createCaffeinationDriver({ + platform: platform === "remote" ? "darwin" : platform, + reason, + locate: (name) => (reason ? name : undefined), + }) + expect(driver.available).toBe(false) + await expect(driver.start(process.pid, () => {})).rejects.toThrow(reason ?? driver.reason!) + await driver.stop() + }, +) + +it.each([false, true])("shares startup, waits for a full ack, and awaits stop (ack=%s)", async (ack) => { + const run = setup("win32", "process.stdin.on('data', data => { process.stdout.write(data); console.error('input') })") + const first = run.start().catch((err: Error) => err) + const second = run.start().catch((err: Error) => err) + const echoed = once(run.child.stderr!, "data") + run.child.stdin!.write(`noise\n${READY}`) + await echoed + expect(Bun.peek.status(first)).toBe("pending") + expect(run.calls).toHaveLength(1) + if (ack) { + run.child.stdin!.write("\r\n") + await Promise.all([first, second]) + } + const stopping = run.stop() + expect(run.stop()).toBe(stopping) + await stopping + expect(await first).toEqual( + ack ? undefined : expect.objectContaining({ message: expect.stringContaining("stopped before starting") }), + ) + expect(Bun.peek.status(run.ended)).toBe("pending") + expect(run.child.signalCode ?? run.child.exitCode).not.toBeNull() +}) + +it.each([ + [/ENOENT/, () => spawn("/kilo-missing-inhibitor", [])], + [ + /code 7: x+acquisition denied$/, + "process.stderr.write('x'.repeat(20000) + 'acquisition denied', () => process.exit(7))", + ], + [/Timed out while starting/, HOLD], +] as const)( + "bounds errors and cleans up %s", + async (message, script) => { + const run = setup("win32", script) + const error = await run.start().catch((err: Error) => err) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(message) + expect((error as Error).message.length).toBeLessThan(4_200) + if (run.child.pid) expect(run.child.signalCode ?? run.child.exitCode).not.toBeNull() + }, + 15_000, +) + +it.skipIf(process.platform === "win32")("retains failed cleanup and kills before replacement", async () => { + const run = setup("win32", `process.on('SIGTERM', () => {}); console.log('${READY}'); ${HOLD}`) + await run.start() + const child = run.child + const kill = child.kill.bind(child) + child.kill = () => { + throw new Error("signal denied") + } + try { + child.emit("error", new Error("native failure")) + expect((await run.ended)?.message).toContain("native failure: signal denied") + await expect(run.start()).rejects.toThrow("signal denied") + child.kill = () => false + await expect(run.stop()).rejects.toThrow("did not exit after SIGKILL") + } finally { + child.kill = kill + } + const stopping = run.stop() + const starting = run.start() + expect(run.calls).toHaveLength(1) + await Promise.all([stopping, starting]) + expect(child.signalCode).toBe("SIGKILL") + expect(run.calls).toHaveLength(2) +}) + +it.skipIf(process.platform === "win32")("ends the real Linux watcher on parent exit", async () => { + const parent = setup("win32") + await parent.start() + const run = setup("linux", (_command, args, opts) => spawn(args.at(4)!, args.slice(5), opts)) + await run.start(parent.child.pid!) + await parent.stop() + expect((await run.ended)?.message).toContain("code 0") + expect(run.child.exitCode).toBe(0) +}) + +it.skipIf(process.platform === "win32")("kills Linux helpers after their group leader exits", async () => { + const script = `process.on('SIGTERM', () => {}); console.error(process.pid); console.log('${READY}'); setInterval(() => {}, 1000)` + const run = setup( + "linux", + `Bun.spawn([process.execPath, '-e', ${JSON.stringify(script)}], { stdout: 'inherit', stderr: 'inherit' }); ${HOLD}`, + ) + const starting = run.start() + const [output] = await once(run.child.stderr!, "data") + const pid = Number(String(output).trim()) + expect(Number.isInteger(pid)).toBe(true) + await starting + await run.stop() + const status = Bun.spawnSync(["ps", "-p", String(pid), "-o", "stat="]) + expect([0, 1]).toContain(status.exitCode) + expect(status.stdout.toString().trim()).toMatch(/^Z|^$/) +}) + +it.skipIf(process.platform !== "win32")( + "checks native PowerShell acquisition", + async () => { + for (const result of [1, 0, "error"] as const) { + const prelude = + result === "error" + ? "function Add-Type { Write-Error 'compilation denied' }; " + : `$probe = Microsoft.PowerShell.Utility\\Add-Type -TypeDefinition 'public class Probe { public static uint SetThreadExecutionState(uint flags) { if (flags != 2147483649u) throw new System.Exception("wrong flags"); return ${result}u; } }' -PassThru; function Add-Type { $probe }; ` + const run = setup("win32", (command, args, opts) => + spawn(command, [...args.slice(0, -1), prelude + args.at(-1)], opts), + ) + const starting = run.start() + if (result === 1) await starting + if (result !== 1) + await expect(starting).rejects.toThrow(result === 0 ? "SetThreadExecutionState failed" : "compilation denied") + await run.stop() + } + }, + 25_000, +) diff --git a/packages/kilo-vscode/tests/unit/caffeination.test.ts b/packages/kilo-vscode/tests/unit/caffeination.test.ts new file mode 100644 index 000000000000..9703d86ddf0d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/caffeination.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "bun:test" +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" +import type { ConnectionState } from "../../src/services/cli-backend/connection-service" +import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" +import { CaffeinationService } from "../../src/services/caffeination" +import { confirmCaffeination } from "../../src/services/caffeination/confirm" + +type Snapshot = Record> +const root = "/workspace" +const tree = "/workspace/tree" + +function setup(data: Record = {}) { + const events = new Set<(event: SSEPayload, dir?: string) => void>() + const states = new Set<(state: ConnectionState) => void>() + const connection = { + state: "connected" as ConnectionState, + dirs: Object.keys(data).length ? Object.keys(data) : [root], + calls: [] as string[], + load: async (dir: string): Promise => data[dir] ?? {}, + onEvent: (listener: (event: SSEPayload, dir?: string) => void) => { + events.add(listener) + return () => events.delete(listener) + }, + onStateChange: (listener: (state: ConnectionState) => void) => { + states.add(listener) + return () => states.delete(listener) + }, + getConnectionState: () => connection.state, + getKnownDirectories: () => connection.dirs, + getClient: () => + ({ + session: { + status: async ({ directory }: { directory: string }) => { + connection.calls.push(directory) + return { data: await connection.load(directory) } + }, + }, + }) as unknown as KiloClient, + } + const driver = { + available: true, + held: false, + starts: 0, + stops: 0, + exit: undefined as ((error?: Error) => void) | undefined, + open: () => Promise.resolve(), + close: () => Promise.resolve(), + async start(_pid: number, exit: (error?: Error) => void) { + driver.starts++ + driver.held = true + driver.exit = exit + await driver.open() + }, + async stop() { + driver.stops++ + await driver.close() + driver.held = false + }, + } + const service = new CaffeinationService(connection, driver) + const emit = (event: SSEPayload, dir = root) => { + for (const listener of events) listener(event, dir) + } + const status = (id: string, type: SessionStatus["type"], dir = root) => + emit( + { id: "status", type: "session.status", properties: { sessionID: id, status: { type } as SessionStatus } }, + dir, + ) + const change = (state: ConnectionState) => { + connection.state = state + for (const listener of states) listener(state) + } + return { service, driver, connection, status, emit, change, events, states } +} + +describe("keep-awake", () => { + it("does no work by default and follows all busy/retry sessions without inspecting prompts", async () => { + const test = setup({ [root]: { one: { type: "busy" } }, [tree]: { two: { type: "retry" } } }) + test.status("one", "busy") + await test.service.refresh() + expect(test.driver.starts).toBe(0) + expect(test.connection.calls).toEqual([]) + await test.service.setEnabled(true) + expect(test.driver.starts).toBe(1) + test.emit({ id: "question", type: "question.asked", properties: { id: "q", sessionID: "one", questions: [] } }) + test.status("two", "idle", tree) + await Bun.sleep(0) + expect(test.driver.held).toBe(true) + test.status("one", "offline") + await Bun.sleep(0) + expect(test.driver.held).toBe(false) + await test.service.dispose() + }) + + it("replays live updates over stale snapshots and includes newly observed directories", async () => { + const test = setup() + const gate = Promise.withResolvers() + test.connection.load = (dir) => (dir === root ? gate.promise : Promise.resolve({ two: { type: "busy" } })) + const enabled = test.service.setEnabled(true) + test.status("one", "idle") + test.status("two", "busy", tree) + gate.resolve({ one: { type: "busy" } }) + await enabled + expect(test.driver.held).toBe(true) + test.emit({ id: "deleted", type: "session.deleted", properties: { sessionID: "two" } } as SSEPayload, tree) + await Bun.sleep(0) + expect(test.driver.held).toBe(false) + await test.service.dispose() + }) + + it.each(["connecting", "disconnected", "error"] as const)( + "releases on %s and ignores old snapshots", + async (state) => { + const test = setup({ [root]: { one: { type: "busy" } } }) + await test.service.setEnabled(true) + const gate = Promise.withResolvers() + test.connection.load = () => gate.promise + const refresh = test.service.refresh() + await Bun.sleep(0) + test.change(state) + test.connection.load = async () => ({}) + test.change("connected") + gate.resolve({ one: { type: "busy" } }) + await refresh + await Bun.sleep(0) + expect(test.driver.held).toBe(false) + expect(test.driver.starts).toBe(1) + await test.service.dispose() + }, + ) + + it.each([false, true])("canonicalizes snapshot and live-event aliases (reverse=%s)", async (reverse) => { + const dir = await mkdtemp(join(tmpdir(), "keep-awake-")) + const actual = join(dir, "actual") + const alias = join(dir, "alias") + await mkdir(actual) + await symlink(actual, alias, process.platform === "win32" ? "junction" : "dir") + const real = await realpath(actual) + const source = reverse ? real : alias + const target = reverse ? alias : real + const test = setup({ [source]: { one: { type: "busy" } } }) + try { + await test.service.setEnabled(true) + await test.service.setEnabled(false) + await test.service.setEnabled(true) + test.status("one", "idle", target) + await Bun.sleep(0) + expect(test.driver.held).toBe(false) + expect(test.connection.calls).toEqual([source, source]) + } finally { + await test.service.dispose() + await rm(dir, { recursive: true, force: true }) + } + }) + + it.each(["disable", "disconnect", "dispose"] as const)("cleans up a late acquisition after %s", async (action) => { + const test = setup({ [root]: { one: { type: "busy" } } }) + const gate = Promise.withResolvers() + test.driver.open = () => gate.promise + const enabled = test.service.setEnabled(true) + await Bun.sleep(0) + expect(test.driver.starts).toBe(1) + const stopped = + action === "dispose" ? test.service.dispose() : action === "disable" ? test.service.setEnabled(false) : undefined + if (action === "disconnect") test.change("connecting") + gate.resolve() + await Promise.all([enabled, stopped]) + expect(test.driver.held).toBe(false) + expect(test.service.getState().active).toBe(false) + await test.service.dispose() + expect(test.events.size + test.states.size).toBe(0) + }) + + it("reports process failure without automatic restart and allows explicit retry", async () => { + const test = setup({ [root]: { one: { type: "busy" } } }) + test.driver.open = async () => { + throw new Error("start failed") + } + await test.service.setEnabled(true) + expect(test.driver.starts).toBe(1) + expect(test.driver.held).toBe(false) + expect(test.service.getState().error).toBe("start failed") + test.driver.open = () => Promise.resolve() + await test.service.setEnabled(false) + await test.service.setEnabled(true) + test.driver.held = false + test.driver.exit?.(new Error("process exited")) + await Bun.sleep(0) + expect(test.driver.starts).toBe(2) + expect(test.service.getState()).toMatchObject({ active: false, available: false, error: "process exited" }) + await test.service.dispose() + }) + + it("retains failed cleanup for another off attempt and awaits idempotent disposal", async () => { + const test = setup({ [root]: { one: { type: "busy" } } }) + await test.service.setEnabled(true) + test.driver.close = async () => { + throw new Error("stop failed") + } + await test.service.setEnabled(false) + expect(test.service.getState()).toMatchObject({ enabled: false, active: true, available: false }) + test.driver.close = () => Promise.resolve() + await test.service.setEnabled(false) + expect(test.driver.held).toBe(false) + await test.service.setEnabled(true) + const gate = Promise.withResolvers() + test.driver.close = () => gate.promise + const closing = test.service.dispose() + expect(test.service.dispose()).toBe(closing) + gate.resolve() + await closing + expect(test.driver.held).toBe(false) + }) + + it("shares consent, remembers acceptance, and does not enable after a cancelled request", async () => { + const test = setup() + const answer = Promise.withResolvers() + let prompts = 0 + const toggle = confirmCaffeination(test.service, () => { + prompts++ + return answer.promise + }) + const pending = toggle(true) + expect(toggle(true)).toBe(pending) + expect(test.service.getState().enabled).toBe(false) + await toggle(false) + answer.resolve(true) + await pending + expect(test.service.getState().enabled).toBe(false) + await toggle(true) + await toggle(false) + await toggle(true) + expect(prompts).toBe(2) + expect(test.service.getState().enabled).toBe(true) + await test.service.dispose() + }) + + it("leaves keep-awake off when consent is declined", async () => { + const test = setup() + await confirmCaffeination(test.service, async () => false)(true) + expect(test.service.getState().enabled).toBe(false) + expect(test.driver.starts).toBe(0) + await test.service.dispose() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index e410526bbba4..671b1d1aac79 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -313,7 +313,7 @@ describe("Extension — Agent Manager remote wiring", () => { const host = fs.readFileSync(VSCODE_HOST_FILE, "utf-8") it("passes the shared remote service to Agent Manager", () => { - expect(ext).toContain("new VscodeHost(context.extensionUri, connectionService, context, remoteService)") + expect(ext).toContain("new VscodeHost(context.extensionUri, connectionService, context, remoteService, controls)") }) it("wires the remote service before attaching the Agent Manager webview", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/CaffeinationButton.css b/packages/kilo-vscode/webview-ui/agent-manager/CaffeinationButton.css new file mode 100644 index 000000000000..238c17c0b1ff --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/CaffeinationButton.css @@ -0,0 +1,8 @@ +.am-caffeination-active { + background: var(--surface-interactive-base) !important; + color: var(--text-on-interactive-base) !important; +} + +.am-caffeination-active [data-slot="icon-svg"] { + color: var(--text-on-interactive-base) !important; +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/CaffeinationButton.tsx b/packages/kilo-vscode/webview-ui/agent-manager/CaffeinationButton.tsx new file mode 100644 index 000000000000..a2d3586d51b3 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/CaffeinationButton.tsx @@ -0,0 +1,42 @@ +import { createSignal, onCleanup, onMount, type Component } from "solid-js" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { useVSCode } from "../src/context/vscode" +import type { LanguageContextValue } from "../src/context/language" +import type { CaffeinationState } from "../src/types/messages" +import "./CaffeinationButton.css" + +export const CaffeinationButton: Component<{ t: LanguageContextValue["t"] }> = (props) => { + const vscode = useVSCode() + const [state, setState] = createSignal({ enabled: false, active: false, available: false }) + onCleanup( + vscode.onMessage((message) => { + if (message.type === "agentManager.caffeination") setState(message) + }), + ) + onMount(() => vscode.postMessage({ type: "agentManager.requestCaffeination" })) + const label = () => { + const value = state() + if (value.error) return value.error + if (!value.available) return props.t("agentManager.caffeination.unavailable") + if (value.active) return props.t("agentManager.caffeination.active") + if (value.enabled) return props.t("agentManager.caffeination.armed") + return props.t("agentManager.caffeination.toggle") + } + return ( + + + vscode.postMessage({ type: "agentManager.setCaffeination", enabled: !(state().enabled || state().active) }) + } + /> + + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index 5b2e9164b622..3776376398f1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -23,6 +23,7 @@ import { LOCAL } from "./navigate" import { NewWorktreeDialog } from "./NewWorktreeDialog" import type { ProjectStore } from "./project/store" import type { ModeRouter } from "./mode-router" +import { CaffeinationButton } from "./CaffeinationButton" const place = (state: AgentManagerStateMessage, session: ProjectSessionInfo, local: string) => { const wt = state.worktrees.find((item) => item.id === session.worktreeId) @@ -191,6 +192,7 @@ export const ProjectList: Component = (props) => { }} onSelect={selectSearch} /> + = (props) => ( class="am-section-header" label={} actions={ -
+ <> = (props) => ( onClick={props.onAdd} /> {props.tools} -
+ } />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx index d82961c712d7..2e2836583e87 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx @@ -7,6 +7,7 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { WorktreeCreate, type WorktreeCreateProps } from "./ProjectActions" import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu" import type { SidebarSearchItem } from "./sidebar-search" +import { CaffeinationButton } from "./CaffeinationButton" import { label } from "../src/utils/session-activity" interface WorktreeSectionActionsProps extends WorktreeCreateProps { @@ -52,6 +53,9 @@ export const WorktreeSectionActions: Component = (p onClick={props.onShortcuts} /> + + +