diff --git a/.changeset/smart-pandas-remain.md b/.changeset/smart-pandas-remain.md new file mode 100644 index 0000000000..cab4570c52 --- /dev/null +++ b/.changeset/smart-pandas-remain.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Run Agent Manager worktree setup scripts in the terminal selected by the toolbar dropdown. Agent Manager panel shows live setup output in a named `Setup` side tab that reveals itself while provisioning and hides again on success unless you engaged with the panel, while VS Code terminal retains the integrated task flow. diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 13184a8a12..30e223faac 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -139,6 +139,16 @@ describe("pty", () => { }), ) + // (script terminals forward raw output to xterm without transcoding). + ptyTest("round-trips non-ASCII output byte-identically", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const marker = "café-über-北京-🚀" + const info = yield* createPty("sh", ["-c", `printf '${marker}\\n'`]) + const attached = yield* attachCollecting(info.id) + expect(yield* waitForOutput(attached.output, marker)).toContain(marker) + }), + ) ptyTest("terminates background descendants outside the shell process group", () => Effect.gen(function* () { const pty = yield* Pty.Service diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 284eb2a1e2..a6b566c47a 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -243,6 +243,8 @@ Create a script file in `.kilo/` using the appropriate filename for your platfor Kilo runs the script automatically whenever a new worktree is created. It uses `sh` for POSIX scripts, PowerShell for `.ps1`, and `cmd.exe` for `.cmd` / `.bat`, so executable permissions are not required. +Where the script runs follows the terminal destination dropdown in the Agent Manager toolbar. **Agent Manager panel** shows live output in a named `Setup` tab in the side terminal panel. After success, the panel returns to its previous state unless you interacted with it; the retained tab remains available for review. Failures keep the panel open. **VS Code terminal** runs setup as a task in the integrated terminal. The script keeps the existing five-minute timeout; when it expires, the setup process tree is terminated and the failed tab retains its partial output. + Two extra variables are injected into the setup script's environment: | Variable | Value | @@ -265,7 +267,7 @@ if [ -f "$REPO_PATH/apps/web/.env.local" ] && [ ! -f "$WORKTREE_PATH/apps/web/.e fi ``` -If the setup script fails, Agent Manager shows the failure and keeps the worktree available so you can inspect it, fix the script, or run setup steps manually. +If the setup script fails, Agent Manager shows the failure (a failed `Setup` tab in the side terminal panel, or the task output in the integrated terminal) and keeps the worktree available so you can inspect it, fix the script, or run setup steps manually. ### Environment File Copying diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 4c8bb44e1f..1a4346ce71 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -30,15 +30,15 @@ import { GitOps } from "./GitOps" import { versionedName } from "./branch-name" import { BranchNamingController } from "./branch-naming" import { SetupScriptService } from "./SetupScriptService" -import { SetupScriptRunner } from "./SetupScriptRunner" import { copyEnvFiles } from "./env-copy" import { SessionTerminalManager } from "./SessionTerminalManager" import { createTerminalHost } from "./terminal-host" import { TerminalRouter } from "./terminal-routing" import { executeVscodeTask } from "./task-runner" +import { runWorktreeSetupScript } from "./setup-script-task" import { RunController } from "./run/controller" import { handleRunMessage } from "./run/message" -import { createRunController, createScriptTerminalRuntime } from "./script-terminal-runtime" +import { createRunController, createScriptTerminalRuntime, clearScriptTerminals } from "./script-terminal-runtime" import { forkSession } from "./fork-session" import { AgentManagerVisiblePresence } from "./am-visible-presence" import { continueInWorktree } from "./continue-in-worktree" @@ -58,7 +58,7 @@ import { pruneSubagents } from "./prune-subagents" import { startSession } from "./mcp-warmup" import { readTerminalFont, watchTerminalFont } from "./terminal-font" -import { readTerminalDestination, watchTerminalDestination } from "./terminal-destination" +import { DestinationState, handleDestination, watchTerminalDestination } from "./terminal-destination" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" import { ensureSandbox } from "./sandbox-bootstrap" @@ -107,6 +107,7 @@ export class AgentManagerProvider implements Disposable { /** Scratch set returned when no active context exists; mutations are discarded. */ private readonly staleScratch = new Set() private unsubDestination: (() => void) | undefined + private destination = new DestinationState() private closing: Promise | undefined private onVisibilityChange: ((visible: boolean) => void) | undefined // Tracks sessions owned by this panel until they are explicitly closed. @@ -148,12 +149,14 @@ export class AgentManagerProvider implements Disposable { this.scripts.manager.snapshot() }) this.unsubDestination = watchTerminalDestination((destination) => { + this.destination.sync(destination) this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination }) }) this.run = createRunController({ manager: this.scripts.manager, root: () => this.getRoot(), state: () => this.getStateManager(), + project: (id) => this.projectForScript(id), open: (file) => this.host.openDocument(file), trusted: () => this.host.isTrusted(), post: (message) => this.postRunMessage(message), @@ -674,6 +677,7 @@ export class AgentManagerProvider implements Disposable { void this.configureSetupScript() return null } + if (handleDestination(this.destination, m, (msg) => this.log("[XTerm]", msg))) return null if (handleRunMessage(this.run, m, (id) => this.runKey(id))) return null if (m.type === "agentManager.showTerminal") { this.terminalManager.showTerminal(m.sessionId, this.state) @@ -1193,21 +1197,21 @@ export class AgentManagerProvider implements Disposable { await copyEnvFiles(root, worktreePath, (msg) => this.outputChannel.appendLine(`[EnvCopy] ${msg}`)) try { - const service = this.getSetupScriptService() - if (!service || !service.hasScript()) return - this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Running setup script...", - branch, - worktreeId, - }) - const runner = new SetupScriptRunner( - (msg) => this.outputChannel.appendLine(`[SetupScriptRunner] ${msg}`), - service, - executeVscodeTask, + await runWorktreeSetupScript( + { + service: this.getSetupScriptService(), + destination: this.destination.value(), + projectId: this.context?.id, + worktreeId, + branch, + trusted: () => this.host.isTrusted(), + manager: this.scripts.manager, + vscode: executeVscodeTask, + log: (msg) => this.outputChannel.appendLine(`[SetupScript] ${msg}`), + post: (message) => this.postToWebview(message), + }, + { worktreePath, repoPath: root }, ) - await runner.runIfConfigured({ worktreePath, repoPath: root }) } catch (error) { const msg = error instanceof Error ? error.message : String(error) this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`) @@ -1215,6 +1219,7 @@ export class AgentManagerProvider implements Disposable { type: "agentManager.worktreeSetup", status: "error", message: `Setup script failed: ${msg}`, + projectId: this.context?.id, branch, worktreeId, }) @@ -1367,7 +1372,7 @@ export class AgentManagerProvider implements Disposable { sidebarCollapsed: state.getSidebarCollapsed(), reviewDiffStyle: state.getReviewDiffStyle(), reviewMarkdownRender: getDiffMarkdownRender(), - terminalDestination: readTerminalDestination(), + terminalDestination: this.destination.value(), isGitRepo: true, defaultBaseBranch: state.getDefaultBaseBranch(), activeTarget: state.getActiveTarget(), @@ -1393,7 +1398,7 @@ export class AgentManagerProvider implements Disposable { staleWorktreeIds: [], reviewDiffStyle: "unified", reviewMarkdownRender: getDiffMarkdownRender(), - terminalDestination: readTerminalDestination(), + terminalDestination: this.destination.value(), isGitRepo: false, runStatuses: [], runScriptConfigured: false, @@ -1421,7 +1426,7 @@ export class AgentManagerProvider implements Disposable { unskipStats: (id) => this.statsPoller.unskipWorktree(id), removePR: (id) => this.prBridge.remove(id), removeRun: (id) => this.run.remove(id), - clearRun: (id) => this.scripts.manager.clear("run", id), + clearRun: (id) => clearScriptTerminals(this.scripts.manager, id, this.context?.id), forgetName: (id) => this.naming.forget(id), stopDiffs: (path, orphaned) => { if (this.diffs.shouldStopForWorktree(path, orphaned)) this.diffs.stop() @@ -1491,6 +1496,12 @@ export class AgentManagerProvider implements Disposable { return `${ctx.id}:local` } + /** Resolve the project bucket that owns a provider-wide script key. */ + private projectForScript(worktreeId: string): string | undefined { + if (worktreeId.endsWith(":local") && worktreeId !== "local") return worktreeId.slice(0, -":local".length) + return this.contexts.byWorktree(worktreeId)?.id ?? this.context?.id + } + /** Run state for one project's payload: its worktrees and its own local key, un-namespaced. */ private runStateFor(ctx: ProjectContext): ReturnType { const state = this.run.state() @@ -1606,7 +1617,11 @@ export class AgentManagerProvider implements Disposable { } private postToWebview(message: AgentManagerOutMessage): void { - this.panel?.postMessage(message) + if (message.type !== "agentManager.worktreeSetup" || message.projectId) { + this.panel?.postMessage(message) + return + } + this.panel?.postMessage({ ...message, projectId: this.context?.id }) } /** diff --git a/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts index 91450ef452..b1c438cb12 100644 --- a/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts +++ b/packages/kilo-vscode/src/agent-manager/ScriptTerminalManager.ts @@ -2,10 +2,13 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" import type { TerminalFont } from "./terminal-font" import type { RunHandle } from "./run/manager" -type ScriptTerminalKind = "run" +export type ScriptTerminalKind = "run" | "setup" type ScriptTerminalState = "running" | "stopping" | "exited" | "failed" +const TITLE: Record = { run: "Run", setup: "Setup" } + interface ScriptTerminalConfig { + projectId?: string worktreeId: string command: string args: string[] @@ -21,10 +24,11 @@ interface ScriptTerminalExit { export interface ScriptTerminalView { terminalId: string + projectId?: string /** null for the LOCAL workspace; RunController retains its internal "local" key. */ worktreeId: string | null kind: ScriptTerminalKind - title: "Run" + title: "Run" | "Setup" wsUrl: string state: ScriptTerminalState exitCode?: number @@ -46,6 +50,7 @@ interface Entry { kind: ScriptTerminalKind terminalId: string ptyID: string + projectId?: string worktreeId: string cwd: string wsUrl: string @@ -77,8 +82,8 @@ function missing(error: unknown): boolean { return data.status === 404 || data._tag === "PtyNotFoundError" } -function key(kind: ScriptTerminalKind, worktreeId: string): string { - return `${kind}:${worktreeId}` +function key(kind: ScriptTerminalKind, worktreeId: string, projectId?: string): string { + return `${projectId ?? "single"}:${kind}:${worktreeId}` } function terminalId(): string { @@ -101,17 +106,18 @@ export class ScriptTerminalManager { config: ScriptTerminalConfig, done: (exit: ScriptTerminalExit) => void, ): Promise { - const id = key(kind, config.worktreeId) + const id = key(kind, config.worktreeId, config.projectId) const prior = this.entries.get(id) if (prior) { - if (prior.state === "running" || prior.state === "stopping") throw new Error("Run terminal is already active") + if (prior.state === "running" || prior.state === "stopping") + throw new Error(`${TITLE[kind]} terminal is already active`) await this.remove(prior, false) - if (this.entries.has(id)) throw new Error("Failed to remove previous Run terminal") + if (this.entries.has(id)) throw new Error(`Failed to remove previous ${TITLE[kind]} terminal`) } const client = await this.deps.getClientAsync(config.cwd).catch((error) => { const detail = message(error) - this.deps.log(`Run terminal create failed: ${detail}`) + this.deps.log(`${TITLE[kind]} terminal create failed: ${detail}`) throw new Error(detail) }) const created = await client.v2.pty @@ -121,26 +127,27 @@ export class ScriptTerminalManager { args: config.args, cwd: config.cwd, env: config.env, - title: "Run", + title: TITLE[kind], }) .catch((error) => { const detail = message(error) - this.deps.log(`Run terminal create failed: ${detail}`) + this.deps.log(`${TITLE[kind]} 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}`) + this.deps.log(`${TITLE[kind]} terminal create failed: ${detail}`) + throw new Error(`Failed to create ${TITLE[kind]} terminal: ${detail}`) } - const wsUrl = await this.url(client, pty.id, config.cwd) + const wsUrl = await this.url(client, kind, pty.id, config.cwd) const entry: Entry = { key: id, kind, terminalId: terminalId(), ptyID: pty.id, + projectId: config.projectId, worktreeId: config.worktreeId, cwd: config.cwd, wsUrl, @@ -157,6 +164,7 @@ export class ScriptTerminalManager { return { stop: () => this.stop(entry), + kill: (reason) => this.kill(entry, reason), } } @@ -170,6 +178,14 @@ export class ScriptTerminalManager { }) return true } + if (msg.type === "agentManager.terminal.stop") { + // Deliberate user stop: always allowed, even for a running Setup + // script whose accidental close is blocked. + void this.close(id, true).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) @@ -186,13 +202,19 @@ export class ScriptTerminalManager { const entry = this.ptys.get(ptyID) if (!entry) return const state = entry.state + if (state === "failed") { + // Retained failure (e.g. after a timeout kill): the backend PTY is + // gone now, but the tab and its output stay until the user closes it. + this.ptys.delete(ptyID) + return + } 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" }) + if (state === "running") this.done(entry, { error: `${TITLE[entry.kind]} terminal was removed before it exited` }) } snapshot(): void { @@ -203,11 +225,17 @@ export class ScriptTerminalManager { return this.ptys.has(ptyID) } + /** True while a script of this kind is running or stopping for the worktree. */ + active(kind: ScriptTerminalKind, worktreeId: string, projectId?: string): boolean { + const entry = this.entries.get(key(kind, worktreeId, projectId)) + return entry?.state === "running" || entry?.state === "stopping" + } + 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)}`) + this.deps.log(`Failed to reconnect ${TITLE[entry.kind]} terminal: ${message(error)}`) return undefined }) if (client) await this.reconcile(entry, client) @@ -215,15 +243,24 @@ export class ScriptTerminalManager { ) } - async clear(kind: ScriptTerminalKind, worktreeId: string): Promise { - const entry = this.entries.get(key(kind, worktreeId)) + async clear(kind: ScriptTerminalKind, worktreeId: string, projectId?: string): Promise { + const entry = this.entries.get(key(kind, worktreeId, projectId)) if (!entry) return true - return this.close(entry.terminalId) + return this.close(entry.terminalId, true) } - async close(terminalId: string): Promise { + /** + * User-initiated tab close. A running Setup script must keep its output + * and finish (or time out) on its own, so only forced paths (worktree + * deletion, shutdown, timeout) may tear it down early. + */ + async close(terminalId: string, force = false): Promise { const entry = this.terminals.get(terminalId) if (!entry) return true + if (!force && entry.kind === "setup" && (entry.state === "running" || entry.state === "stopping")) { + this.deps.log(`Ignored close for ${TITLE[entry.kind]} terminal while it is running`) + return false + } if (entry.state === "running") { await this.stop(entry) return !this.terminals.has(terminalId) @@ -247,14 +284,14 @@ export class ScriptTerminalManager { size: { cols, rows }, }) if (!result.error) return - this.deps.log(`Run terminal resize failed (${terminalId}): ${message(result.error)}`) + this.deps.log(`${TITLE[entry.kind]} terminal resize failed (${terminalId}): ${message(result.error)}`) } catch (error) { - this.deps.log(`Run terminal resize failed (${terminalId}): ${message(error)}`) + this.deps.log(`${TITLE[entry.kind]} terminal resize failed (${terminalId}): ${message(error)}`) } } async dispose(): Promise { - await Promise.all([...this.terminals.keys()].map((terminalId) => this.close(terminalId))) + await Promise.all([...this.terminals.keys()].map((terminalId) => this.close(terminalId, true))) } private async reconcile(entry: Entry, client: KiloClient): Promise { @@ -263,12 +300,15 @@ export class ScriptTerminalManager { 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")}`) + this.missing( + entry, + `${TITLE[entry.kind]} 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)}`) + this.deps.log(`Failed to read ${TITLE[entry.kind]} terminal: ${message(error)}`) } } @@ -308,27 +348,28 @@ export class ScriptTerminalManager { if (stopped) this.done(entry, { stopped: true }) return } - this.failed(entry, `Failed to remove Run terminal: ${message(result.error)}`) + this.failed(entry, `Failed to remove ${TITLE[entry.kind]} 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)}`) + this.failed(entry, `Failed to remove ${TITLE[entry.kind]} terminal: ${message(error)}`) } } - private async url(client: KiloClient, ptyID: string, cwd: string): Promise { + private async url(client: KiloClient, kind: ScriptTerminalKind, 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)}`) + this.deps.log(`Failed to build ${TITLE[kind]} 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)}`) + if (result.error) + this.deps.log(`Failed to remove ${TITLE[kind]} terminal after URL failure: ${message(result.error)}`) } catch (cleanup) { - this.deps.log(`Failed to remove Run terminal after URL failure: ${message(cleanup)}`) + this.deps.log(`Failed to remove ${TITLE[kind]} terminal after URL failure: ${message(cleanup)}`) } throw error } @@ -350,6 +391,28 @@ export class ScriptTerminalManager { this.done(entry, { error }) } + /** + * Kill the process tree but retain the terminal as failed with its + * partial output. Timeouts use this so the user can see how far the + * script got; user-initiated stops use stop() and drop the tab instead. + */ + private kill(entry: Entry, reason: string): void { + if (!this.current(entry) || (entry.state !== "running" && entry.state !== "stopping")) return + entry.state = "failed" + this.emit() + void this.deps + .getClientAsync(entry.cwd) + .then(async (client) => { + const result = await client.v2.pty.remove({ ptyID: entry.ptyID, location: { directory: entry.cwd } }) + if (result.error) this.deps.log(`Failed to kill ${TITLE[entry.kind]} terminal: ${message(result.error)}`) + }) + .catch((error) => { + this.deps.log(`Failed to kill ${TITLE[entry.kind]} terminal: ${message(error)}`) + }) + this.deps.log(reason) + this.done(entry, { error: reason }) + } + private missing(entry: Entry, error: string): void { if (!this.current(entry)) return this.deps.log(error) @@ -378,11 +441,13 @@ export class ScriptTerminalManager { private emit(): void { const terminals: ScriptTerminalView[] = [] for (const entry of this.entries.values()) { + const local = entry.worktreeId === "local" || entry.worktreeId.endsWith(":local") const terminal: ScriptTerminalView = { terminalId: entry.terminalId, - worktreeId: entry.worktreeId === "local" ? null : entry.worktreeId, + ...(entry.projectId ? { projectId: entry.projectId } : {}), + worktreeId: local ? null : entry.worktreeId, kind: entry.kind, - title: "Run", + title: TITLE[entry.kind], wsUrl: entry.wsUrl, state: entry.state, font: this.deps.getTerminalFont(), diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts index 7aca15168c..6aa46ff789 100644 --- a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts @@ -52,6 +52,7 @@ export class SetupScriptRunner { private readonly log: (msg: string) => void, private readonly service: SetupScriptService, private readonly run: RunTask, + private readonly failed: (message: string) => void = () => undefined, ) {} /** @@ -92,6 +93,7 @@ export class SetupScriptRunner { } catch (error) { const msg = error instanceof Error ? error.message : String(error) this.log(`Setup script execution failed: ${msg}`) + this.failed(msg) return true // Script was attempted } } diff --git a/packages/kilo-vscode/src/agent-manager/run/manager.ts b/packages/kilo-vscode/src/agent-manager/run/manager.ts index 60af10cfa5..c3984db5fe 100644 --- a/packages/kilo-vscode/src/agent-manager/run/manager.ts +++ b/packages/kilo-vscode/src/agent-manager/run/manager.ts @@ -13,6 +13,12 @@ export interface RunStatus { export interface RunHandle { stop(): void | Promise + /** + * Kill the process but retain the terminal as failed with its output, + * instead of dropping it (stop). Used for timeouts, where the partial + * output must stay reviewable. + */ + kill?(reason: string): void dispose?(): void } diff --git a/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts index da2f3be9be..9aa9c2a86f 100644 --- a/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts +++ b/packages/kilo-vscode/src/agent-manager/script-terminal-runtime.ts @@ -53,6 +53,7 @@ interface RunInput { manager: ScriptTerminalManager root(): string | undefined state(): WorktreeStateManager | undefined + project?(worktreeId: string): string | undefined open(path: string): Promise trusted(): boolean post(message: AgentManagerOutMessage): void @@ -60,6 +61,17 @@ interface RunInput { refresh(): void } +/** Stop and remove any Run/Setup script terminals owned by a worktree. */ +export async function clearScriptTerminals( + manager: ScriptTerminalManager, + worktreeId: string, + projectId?: string, +): Promise { + const run = await manager.clear("run", worktreeId, projectId) + const setup = await manager.clear("setup", worktreeId, projectId) + return run && setup +} + export function createRunController(input: RunInput) { return new RunController({ root: input.root, @@ -69,7 +81,7 @@ export function createRunController(input: RunInput) { if (!input.trusted()) throw new Error("Trust the workspace before running scripts") return pickRunStart( config.destination, - (cfg, cb) => input.manager.start("run", cfg, cb), + (cfg, cb) => input.manager.start("run", { ...cfg, projectId: input.project?.(cfg.worktreeId) }, cb), startVscodeRunTask, )(config, done) }, diff --git a/packages/kilo-vscode/src/agent-manager/setup-script-task.ts b/packages/kilo-vscode/src/agent-manager/setup-script-task.ts new file mode 100644 index 0000000000..f001955976 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/setup-script-task.ts @@ -0,0 +1,176 @@ +/** + * Embedded setup-script execution through the canonical PTY script-terminal + * runtime. Selected by the Agent Manager terminal destination; the VS Code + * task runner (task-runner.ts) remains the integrated-terminal path. Remove + * the integrated path together with the "VS Code terminal" dropdown option + * once the embedded path is the only one. + * + * Exit-code semantics mirror task-runner.ts: resolve with the exit code + * (undefined when unknown), reject on execution errors and timeout. The + * SetupScriptRunner treats every outcome as best-effort, so worktree + * creation continues even when the script fails. + */ + +import { getShellEnvironment } from "./shell-env" +import { SetupScriptRunner, type RunTask } from "./SetupScriptRunner" +import type { SetupScriptService } from "./SetupScriptService" +import type { RunHandle } from "./run/manager" +import type { ScriptTerminalManager } from "./ScriptTerminalManager" +import type { TerminalDestination } from "./terminal-destination" +import type { AgentManagerOutMessage } from "./types" + +const TIMEOUT_MS = 5 * 60 * 1000 + +/** Reconcile cadence while a script runs: a lost exit event otherwise + * stalls the awaited setup (and its tab) until the full timeout. */ +const WATCHDOG_MS = 15_000 + +interface Input { + manager: ScriptTerminalManager + projectId?: string + worktreeId: string + trusted(): boolean + log(msg: string): void + /** Test hook; defaults to the same five minutes as task-runner.ts. */ + timeoutMs?: number + /** Test hook; defaults to a 15s reconcile cadence while the script runs. */ + watchdogMs?: number + /** Test hook; defaults to the login-shell (posix) / extension-host (win32) environment. */ + env?: () => Promise> +} + +interface PickInput extends Omit { + destination: TerminalDestination + worktreeId: string | undefined + /** Integrated-terminal task runner, used whenever the embedded path is off. */ + vscode: RunTask +} + +/** + * Pick where a worktree setup script executes. The terminal destination + * dropdown owns the choice. The embedded path enforces workspace trust + * itself; it never silently redirects a selected destination to VS Code. + */ +export function pickSetupTask(input: PickInput): RunTask { + const worktreeId = input.worktreeId + if (input.destination !== "agentManager" || !worktreeId) return input.vscode + return createSetupScriptTask({ + manager: input.manager, + projectId: input.projectId, + worktreeId, + trusted: input.trusted, + log: input.log, + timeoutMs: input.timeoutMs, + env: input.env, + }) +} + +interface FlowInput extends PickInput { + service: SetupScriptService | undefined + branch?: string + post(message: AgentManagerOutMessage): void +} + +/** Run the configured setup script for a worktree and wait for it to finish. */ +export async function runWorktreeSetupScript( + input: FlowInput, + env: { worktreePath: string; repoPath: string }, +): Promise { + const service = input.service + if (!service || !service.hasScript()) return + input.post({ + type: "agentManager.worktreeSetup", + projectId: input.projectId, + status: "creating", + message: "Running setup script...", + branch: input.branch, + worktreeId: input.worktreeId, + }) + const runner = new SetupScriptRunner(input.log, service, pickSetupTask(input), (message) => { + if (message === "Setup script was stopped") return + input.post({ + type: "agentManager.worktreeSetup", + projectId: input.projectId, + status: "error", + message, + branch: input.branch, + worktreeId: input.worktreeId, + }) + }) + await runner.runIfConfigured(env) +} + +export function createSetupScriptTask(input: Input): RunTask { + return async (config) => { + if (!input.trusted()) throw new Error("Trust the workspace before running setup scripts") + const env = { ...(await (input.env ?? getShellEnvironment)()), ...config.env } + const ms = input.timeoutMs ?? TIMEOUT_MS + return new Promise((resolve, reject) => { + let handle: RunHandle | undefined + let settled = false + let expired = false + const settle = (action: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + clearInterval(watchdog) + action() + } + const halt = (target: RunHandle, reason: string) => { + // Keep the tab with its partial output when the handle can kill; + // only fall back to dropping it for handles without kill support. + if (target.kill) { + target.kill(reason) + return + } + void Promise.resolve(target.stop()).catch((error) => { + input.log(`Failed to stop Setup terminal: ${error instanceof Error ? error.message : String(error)}`) + }) + } + const expire = () => { + expired = true + settle(() => reject(new Error("Setup script timed out after 5 minutes"))) + if (handle) halt(handle, "Setup script timed out after 5 minutes") + } + // The first budget covers connect + create; once the PTY is running + // the script itself gets a fresh full budget, so a slow backend start + // never eats into its five minutes. + let timer = setTimeout(expire, ms) + // Exit events are the primary signal; reconcile periodically so a + // lost event cannot leave the script (and worktree creation) stuck. + const watchdog = setInterval(() => { + void input.manager.sync().catch((error) => { + input.log(`Setup terminal reconcile failed: ${error instanceof Error ? error.message : String(error)}`) + }) + }, input.watchdogMs ?? WATCHDOG_MS) + + input.manager + .start("setup", { ...config, env, projectId: input.projectId, worktreeId: input.worktreeId }, (exit) => { + if (exit.error) { + settle(() => reject(new Error(exit.error))) + return + } + if (exit.stopped) { + settle(() => reject(new Error("Setup script was stopped"))) + return + } + settle(() => resolve(exit.exitCode)) + }) + .then( + (created) => { + handle = created + if (settled) { + // Only an expired timer may kill a late handle. A natural + // settle (fast exit observed during creation) must keep the + // exited terminal and its retained output alive. + if (expired) halt(created, "Setup script timed out after 5 minutes") + return + } + clearTimeout(timer) + timer = setTimeout(expire, ms) + }, + (error) => settle(() => reject(error instanceof Error ? error : new Error(String(error)))), + ) + }) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/shell-env.ts b/packages/kilo-vscode/src/agent-manager/shell-env.ts index 1b6e61d7f1..9644f65d2f 100644 --- a/packages/kilo-vscode/src/agent-manager/shell-env.ts +++ b/packages/kilo-vscode/src/agent-manager/shell-env.ts @@ -61,6 +61,15 @@ function parseEnvOutput(stdout: string): Record { * Results are cached for 1 minute (10 seconds when the fallback was used). */ export async function getShellEnvironment(): Promise> { + // Windows has no login shell to resolve; the extension-host environment + // already is the user's environment. + if (process.platform === "win32") { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") env[key] = value + } + return env + } const now = Date.now() const ttl = wasFallback ? FALLBACK_TTL : TTL if (cached && now - cacheTime < ttl) return { ...cached } diff --git a/packages/kilo-vscode/src/agent-manager/terminal-destination.ts b/packages/kilo-vscode/src/agent-manager/terminal-destination.ts index f6fbceb591..fc334b7c6b 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-destination.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-destination.ts @@ -24,6 +24,45 @@ export function readTerminalDestination(): TerminalDestination { return resolveTerminalDestination(config.get("terminalButtonDestination")) } +async function writeTerminalDestination(destination: TerminalDestination): Promise { + const config = vscode.workspace.getConfiguration("kilo-code.new.agentManager") + await config.update("terminalButtonDestination", destination, vscode.ConfigurationTarget.Global) +} + +/** Per-panel destination source of truth; local choices beat setting echoes. */ +export class DestinationState { + private local = false + + constructor(private destination = readTerminalDestination()) {} + + value(): TerminalDestination { + return this.destination + } + + sync(destination: TerminalDestination): void { + if (!this.local) this.destination = destination + } + + select(destination: TerminalDestination): void { + this.local = true + this.destination = destination + } +} + +export function handleDestination( + state: DestinationState, + message: { type: string; destination?: unknown }, + log: (message: string) => void, +): boolean { + if (message.type !== "agentManager.terminal.destinationSelected") return false + const destination = resolveTerminalDestination(message.destination) + state.select(destination) + void writeTerminalDestination(destination).catch((error) => { + log(`Failed to persist terminal destination: ${error instanceof Error ? error.message : String(error)}`) + }) + return true +} + export function affectsTerminalDestination(e: vscode.ConfigurationChangeEvent): boolean { return e.affectsConfiguration(KEY) } diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 6ca6491c7a..0de5399600 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -45,7 +45,10 @@ export interface TerminalRoutingDeps { /** True iff the message belongs to the terminal-tab subsystem. */ function isTerminalMessage( m: AgentManagerInMessage, -): m is Extract { +): m is Exclude< + Extract, + { type: "agentManager.terminal.stop" | "agentManager.terminal.destinationSelected" } +> { return ( m.type === "agentManager.terminal.create" || m.type === "agentManager.terminal.close" || diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index a5e90119c8..450a22ee01 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -115,6 +115,8 @@ interface LocalStatsMessage { interface WorktreeSetupMessage { type: "agentManager.worktreeSetup" + /** Owning project; absent in single-project mode. */ + projectId?: string status: "creating" | "starting" | "ready" | "error" message: string sessionId?: string @@ -919,6 +921,11 @@ interface TerminalCloseIn { terminalId: string } +interface TerminalStopIn { + type: "agentManager.terminal.stop" + terminalId: string +} + interface TerminalResizeIn { type: "agentManager.terminal.resize" terminalId: string @@ -926,6 +933,11 @@ interface TerminalResizeIn { rows: number } +interface TerminalDestinationSelectedIn { + type: "agentManager.terminal.destinationSelected" + destination: TerminalDestination +} + /** All messages the Agent Manager expects from the webview (onMessage input). */ export type AgentManagerInMessage = | CreateWorktreeIn @@ -1006,4 +1018,6 @@ export type AgentManagerInMessage = | MoveSectionIn | TerminalCreateIn | TerminalCloseIn + | TerminalStopIn | TerminalResizeIn + | TerminalDestinationSelectedIn diff --git a/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts new file mode 100644 index 0000000000..17809d05e8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { LOCAL } from "../../webview-ui/agent-manager/navigate" +import { ambientDecision, createAmbientSetup } from "../../webview-ui/agent-manager/terminal/ambient" +import { createTerminalState } from "../../webview-ui/agent-manager/terminal/state" + +describe("ambientDecision", () => { + it("waits while setup is still running", () => { + expect(ambientDecision(undefined, "wt-1", "wt-1")).toBe("wait") + expect(ambientDecision({ state: "running", kind: "setup" }, "wt-1", "wt-1")).toBe("wait") + expect(ambientDecision({ state: "stopping", kind: "setup" }, "wt-1", "wt-1")).toBe("wait") + }) + + it("hides the panel after a clean exit in the revealed context", () => { + expect(ambientDecision({ state: "exited", exitCode: 0, kind: "setup" }, "wt-1", "wt-1")).toBe("hide") + }) + + it("keeps the panel when setup failed", () => { + expect(ambientDecision({ state: "exited", exitCode: 1, kind: "setup" }, "wt-1", "wt-1")).toBe("keep") + expect(ambientDecision({ state: "failed", kind: "setup" }, "wt-1", "wt-1")).toBe("keep") + }) + + it("keeps the panel when the user switched context before settle", () => { + expect(ambientDecision({ state: "exited", exitCode: 0, kind: "setup" }, LOCAL, "wt-1")).toBe("keep") + }) +}) + +describe("createAmbientSetup tracking", () => { + function scene(panelOpen: boolean) { + const [selection] = createSignal("wt-1") + const [panel] = createSignal<"diff" | "terminal" | null>(panelOpen ? "terminal" : null) + const terms = createTerminalState(selection) + const ambient = createAmbientSetup({ terms, selection, sidePanel: panel, setSidePanel: () => undefined }) + return ambient + } + + it("remembers an ambient reveal only when the panel was closed", () => { + createRoot((dispose) => { + const closed = scene(false) + closed.reveal("wt-1", "script:setup") + expect(closed.pending()).toBeDefined() + const open = scene(true) + open.reveal("wt-1", "script:setup") + expect(open.pending()).toBeUndefined() + dispose() + }) + }) + + it("reveal and cancel drive the pending auto-hide", () => { + createRoot((dispose) => { + const ambient = scene(false) + ambient.reveal("wt-1", "script:setup") + expect(ambient.pending()).toEqual({ contextKey: "wt-1", terminalId: "script:setup" }) + ambient.cancel() + expect(ambient.pending()).toBeUndefined() + dispose() + }) + }) +}) 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 a3a805fccb..353070c085 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -550,7 +550,7 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(dest).not.toContain("getConfiguration") }) - it("clears retained Run terminals before removing worktree state", () => { + it("clears retained script terminals before removing worktree state", () => { for (const name of ["onDeleteWorktree", "onRemoveStaleWorktree"]) { const text = body(name) expect(text).toContain("host.clearRun(worktreeId)") @@ -558,6 +558,9 @@ describe("Agent Manager Provider — onMessage routing", () => { } const deleted = body("onDeleteWorktree") expect(deleted.indexOf("host.skipStats")).toBeLessThan(deleted.indexOf("host.removeRun")) + const helper = fs.readFileSync(path.join(ROOT, "src/agent-manager/script-terminal-runtime.ts"), "utf-8") + expect(helper).toContain('manager.clear("run", worktreeId, projectId)') + expect(helper).toContain('manager.clear("setup", worktreeId, projectId)') }) // -- onDeleteWorktree invariants ------------------------------------------- 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 index eff96950cd..c35a6a53e9 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-chrome.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { terminalChrome } from "../../webview-ui/agent-manager/terminal/chrome" +import { terminalChrome, terminalClosable, terminalStoppable } from "../../webview-ui/agent-manager/terminal/chrome" describe("Agent Manager Run terminal chrome", () => { it("keeps the console icon for user terminals", () => { @@ -7,16 +7,44 @@ describe("Agent Manager Run terminal chrome", () => { }) 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({ + expect(terminalChrome("Run", { state: "running", kind: "run" })).toEqual({ + icon: "spinner", + tooltip: "Run (Running)", + }) + expect(terminalChrome("Run", { state: "stopping", kind: "run" })).toEqual({ + icon: "spinner", + tooltip: "Run (Stopping)", + }) + expect(terminalChrome("Run", { state: "exited", exitCode: 0, kind: "run" })).toEqual({ icon: "success", tooltip: "Run (Exited, code 0)", }) - expect(terminalChrome("Run", { state: "exited", exitCode: 1 })).toEqual({ + expect(terminalChrome("Run", { state: "exited", exitCode: 1, kind: "run" })).toEqual({ icon: "failure", tooltip: "Run (Exited, code 1)", }) - expect(terminalChrome("Run", { state: "failed" })).toEqual({ icon: "failure", tooltip: "Run (Failed)" }) + expect(terminalChrome("Run", { state: "failed", kind: "run" })).toEqual({ + icon: "failure", + tooltip: "Run (Failed)", + }) + }) + + it("keeps a running Setup tab unclosable until the script settles", () => { + expect(terminalClosable(undefined)).toBe(true) + expect(terminalClosable({ state: "running", kind: "run" })).toBe(true) + expect(terminalClosable({ state: "running", kind: "setup" })).toBe(false) + expect(terminalClosable({ state: "stopping", kind: "setup" })).toBe(false) + expect(terminalClosable({ state: "exited", exitCode: 0, kind: "setup" })).toBe(true) + expect(terminalClosable({ state: "exited", exitCode: 1, kind: "setup" })).toBe(true) + expect(terminalClosable({ state: "failed", kind: "setup" })).toBe(true) + }) + + it("offers a deliberate stop only while Setup is running", () => { + expect(terminalStoppable(undefined)).toBe(false) + expect(terminalStoppable({ state: "running", kind: "run" })).toBe(false) + expect(terminalStoppable({ state: "running", kind: "setup" })).toBe(true) + expect(terminalStoppable({ state: "stopping", kind: "setup" })).toBe(false) + expect(terminalStoppable({ state: "exited", exitCode: 1, kind: "setup" })).toBe(false) + expect(terminalStoppable({ state: "failed", kind: "setup" })).toBe(false) }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts index 40330aa29c..9bc9472b49 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test" -import { affectsTerminalDestination, resolveTerminalDestination } from "../../src/agent-manager/terminal-destination" +import { + DestinationState, + affectsTerminalDestination, + resolveTerminalDestination, +} from "../../src/agent-manager/terminal-destination" function event(key: string) { return { @@ -19,4 +23,13 @@ describe("Agent Manager terminal destination", () => { expect(affectsTerminalDestination(event("kilo-code.new.agentManager.terminalButtonDestination"))).toBe(true) expect(affectsTerminalDestination(event("terminal.integrated.fontFamily"))).toBe(false) }) + + it("lets a panel-local choice beat later setting echoes", () => { + const state = new DestinationState("vscode") + state.sync("agentManager") + expect(state.value()).toBe("agentManager") + state.select("agentManager") + state.sync("vscode") + expect(state.value()).toBe("agentManager") + }) }) 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 70e245f2cc..a3fbb00fec 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 @@ -195,7 +195,7 @@ describe("Agent Manager side terminal controller", () => { item.ctl.choose("agentManager") expect(item.ctl.destination()).toBe("agentManager") expect(item.calls.posted).toEqual([ - { type: "updateSetting", key: "agentManager.terminalButtonDestination", value: "agentManager" }, + { type: "agentManager.terminal.destinationSelected", destination: "agentManager" }, ]) expect(item.calls.persisted).toEqual(["agentManager"]) }) @@ -224,6 +224,9 @@ describe("Agent Manager side terminal controller", () => { it("restores a saved panel choice and ignores remote defaults", () => { const item = scene({ saved: "agentManager" }) expect(item.ctl.destination()).toBe("agentManager") + expect(item.calls.posted).toEqual([ + { type: "agentManager.terminal.destinationSelected", destination: "agentManager" }, + ]) item.ctl.syncDefault("vscode") expect(item.ctl.destination()).toBe("agentManager") }) 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 37a17a7e45..737a90e54c 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 @@ -132,7 +132,7 @@ describe("Agent Manager terminal state", () => { 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(item.state.scriptStatus("script:run")).toEqual({ state: "running", kind: "run" }) expect(isTerminalTabId("script:run")).toBe(true) item.state.setTitle("script:run", "npm test") @@ -140,7 +140,7 @@ describe("Agent Manager terminal state", () => { 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.scriptStatus("script:run")).toEqual({ state: "exited", exitCode: 0, kind: "run" }) 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" }]) @@ -163,6 +163,112 @@ describe("Agent Manager terminal state", () => { }) }) + it("hydrates Setup snapshots with their semantic title and kind", () => { + createRoot((dispose) => { + const item = scene() + item.dispatch({ + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId: "script:setup", + worktreeId: "wt-1", + kind: "setup", + title: "Setup", + wsUrl: "ws://script:setup", + state: "running", + font, + }, + ], + } satisfies ExtensionMessage) + + const setup = item.state.sidesForContext("wt-1").find((term) => term.id === "script:setup") + expect(setup).toMatchObject({ title: "Setup", placement: "side", kind: "setup", contextKey: "wt-1" }) + expect(item.events.running).toEqual([{ contextKey: "wt-1", terminalId: "script:setup" }]) + expect(item.state.scriptStatus("script:setup")).toEqual({ state: "running", kind: "setup" }) + expect(item.state.isScript("script:setup")).toBe(true) + + item.state.setTitle("script:setup", "bash") + expect(item.state.title("script:setup")).toBe("Setup") + + item.dispatch({ type: "agentManager.scriptTerminals", terminals: [] } satisfies ExtensionMessage) + expect(item.state.sidesForContext("wt-1")).toEqual([]) + expect(item.state.scriptStatus("script:setup")).toBeUndefined() + dispose() + }) + }) + + it("activates a Setup terminal that hydrates before its worktree is selected", () => { + createRoot((dispose) => { + const item = scene(LOCAL) + item.dispatch({ + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId: "script:setup-background", + worktreeId: "wt-background", + kind: "setup", + title: "Setup", + wsUrl: "ws://script:setup-background", + state: "running", + font, + }, + ], + } satisfies ExtensionMessage) + + expect(item.state.sideActiveFor("wt-background")).toBe("script:setup-background") + expect(item.events.running).toEqual([{ contextKey: "wt-background", terminalId: "script:setup-background" }]) + dispose() + }) + }) + + it("does not replace an existing side-terminal selection during background hydration", () => { + createRoot((dispose) => { + const item = scene(LOCAL) + item.dispatch({ + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId: "script:run-background", + worktreeId: "wt-background", + kind: "run", + title: "Run", + wsUrl: "ws://script:run-background", + state: "running", + font, + }, + ], + } satisfies ExtensionMessage) + item.state.setSideActive("wt-background", "script:run-background") + + item.dispatch({ + type: "agentManager.scriptTerminals", + terminals: [ + { + terminalId: "script:run-background", + worktreeId: "wt-background", + kind: "run", + title: "Run", + wsUrl: "ws://script:run-background", + state: "running", + font, + }, + { + terminalId: "script:setup-background", + worktreeId: "wt-background", + kind: "setup", + title: "Setup", + wsUrl: "ws://script:setup-background", + state: "running", + font, + }, + ], + } satisfies ExtensionMessage) + + expect(item.state.sideActiveFor("wt-background")).toBe("script:run-background") + dispose() + }) + }) + it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => { createRoot((dispose) => { const item = scene() diff --git a/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts index 01bea86435..8feae7e2fa 100644 --- a/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/script-terminal-manager.test.ts @@ -355,3 +355,112 @@ describe("ScriptTerminalManager", () => { expect(ctx.snapshots.at(-1)).toEqual(first) }) }) + +describe("ScriptTerminalManager Setup kind", () => { + it("creates a Setup PTY labeled Setup and tracks its kind", async () => { + const ctx = harness() + + await ctx.manager.start("setup", config, () => undefined) + + expect(ctx.calls.create[0]?.title).toBe("Setup") + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ kind: "setup", title: "Setup", state: "running" })]) + expect(ctx.manager.active("setup", "wt-1")).toBe(true) + expect(ctx.manager.active("run", "wt-1")).toBe(false) + }) + + it("ignores a user close while Setup is running", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("setup", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Setup terminal") + + expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true) + await wait() + + expect(ctx.calls.remove).toEqual([]) + expect(ctx.closed).toEqual([]) + expect(done).toEqual([]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "running" })]) + expect(ctx.logs.some((msg) => msg.includes("Ignored close"))).toBe(true) + }) + + it("force-stops a running Setup when the worktree is cleared", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("setup", config, (exit) => done.push(exit)) + + expect(await ctx.manager.clear("setup", "wt-1")).toBe(true) + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(done).toEqual([{ stopped: true }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + expect(ctx.manager.active("setup", "wt-1")).toBe(false) + }) + + it("closes an exited Setup terminal and reports inactive", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("setup", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Setup terminal") + ctx.manager.exited("pty-1", 0) + + expect(ctx.manager.active("setup", "wt-1")).toBe(false) + expect(await ctx.manager.close(terminalId)).toBe(true) + expect(done).toEqual([{ exitCode: 0 }]) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("retains the tab as failed when killed, and survives the backend deleted event", async () => { + let removes = 0 + const ctx = harness({ + remove: async () => { + removes += 1 + // The first remove (the kill itself) succeeds; the PTY is gone for + // any later remove, which must still close the retained tab. + return removes > 1 ? { error: { _tag: "PtyNotFoundError" } } : { data: undefined } + }, + }) + const done: unknown[] = [] + + const handle = await ctx.manager.start("setup", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Setup terminal") + + handle.kill?.("Setup script timed out after 5 minutes") + await wait() + + // The process tree is killed but the tab keeps its partial output. + expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }]) + expect(done).toEqual([{ error: "Setup script timed out after 5 minutes" }]) + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })]) + + // The backend confirming the deletion must not drop the retained tab. + ctx.manager.deleted("pty-1") + expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })]) + + // Closing the retained tab still works when the PTY is already gone. + expect(await ctx.manager.close(terminalId)).toBe(true) + expect(ctx.snapshots.at(-1)).toEqual([]) + }) + + it("stops a running Setup when the user deliberately stops it", async () => { + const ctx = harness() + const done: unknown[] = [] + + await ctx.manager.start("setup", config, (exit) => done.push(exit)) + const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId + if (!terminalId) throw new Error("missing Setup terminal") + + expect(ctx.manager.intercept({ type: "agentManager.terminal.stop", 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([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/setup-script-task.test.ts b/packages/kilo-vscode/tests/unit/setup-script-task.test.ts new file mode 100644 index 0000000000..1b961d23d9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/setup-script-task.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import type { ScriptTerminalManager } from "../../src/agent-manager/ScriptTerminalManager" +import { createSetupScriptTask, pickSetupTask, runWorktreeSetupScript } from "../../src/agent-manager/setup-script-task" +import { SetupScriptService } from "../../src/agent-manager/SetupScriptService" +import type { RunTask } from "../../src/agent-manager/SetupScriptRunner" +import type { AgentManagerOutMessage } from "../../src/agent-manager/types" + +interface StartCall { + kind: string + config: { worktreeId: string; command: string; args: string[]; cwd: string; env: Record } + done: (exit: { exitCode?: number; stopped?: boolean; error?: string }) => void +} + +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 harness(opts?: { + trusted?: boolean + timeoutMs?: number + gate?: ReturnType> + startError?: Error +}) { + const starts: StartCall[] = [] + const stops: string[] = [] + const manager = { + start: (kind: string, config: StartCall["config"], done: StartCall["done"]) => { + starts.push({ kind, config, done }) + if (opts?.startError) return Promise.reject(opts.startError) + if (opts?.gate) return opts.gate.promise + stops.length = 0 + return Promise.resolve({ + stop: () => { + stops.push(config.worktreeId) + }, + }) + }, + } as unknown as ScriptTerminalManager + const logs: string[] = [] + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => opts?.trusted ?? true, + log: (msg) => logs.push(msg), + timeoutMs: opts?.timeoutMs, + env: async () => ({ PATH: "/bin" }), + }) + return { task, starts, stops, logs, manager } +} + +const config = { + command: "sh", + args: ["/repo/.kilo/setup-script"], + cwd: "/repo/worktree", + env: { WORKTREE_PATH: "/repo/worktree", REPO_PATH: "/repo" }, +} + +describe("createSetupScriptTask", () => { + it("starts a setup script terminal with the composed environment", async () => { + const ctx = harness() + const result = ctx.task(config) + await wait() + + expect(ctx.starts).toHaveLength(1) + expect(ctx.starts[0]?.kind).toBe("setup") + expect(ctx.starts[0]?.config).toEqual({ + worktreeId: "wt-1", + command: "sh", + args: ["/repo/.kilo/setup-script"], + cwd: "/repo/worktree", + env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree", REPO_PATH: "/repo" }, + }) + + ctx.starts[0]?.done({ exitCode: 0 }) + await expect(result).resolves.toBe(0) + }) + + it("resolves a nonzero exit code so the runner can report it", async () => { + const ctx = harness() + const result = ctx.task(config) + await wait() + ctx.starts[0]?.done({ exitCode: 42 }) + + await expect(result).resolves.toBe(42) + }) + + it("rejects when the terminal reports an execution error", async () => { + const ctx = harness() + const result = ctx.task(config) + await wait() + ctx.starts[0]?.done({ error: "Setup terminal was removed before it exited" }) + + await expect(result).rejects.toThrow("Setup terminal was removed before it exited") + }) + + it("rejects when the terminal is stopped externally", async () => { + const ctx = harness() + const result = ctx.task(config) + await wait() + ctx.starts[0]?.done({ stopped: true }) + + await expect(result).rejects.toThrow("Setup script was stopped") + }) + + it("rejects when the backend refuses to create the terminal", async () => { + const ctx = harness({ startError: new Error("Not connected to CLI backend") }) + const result = ctx.task(config) + + await expect(result).rejects.toThrow("Not connected to CLI backend") + }) + + it("times out, rejects, and stops the process tree", async () => { + const ctx = harness({ timeoutMs: 5 }) + const result = ctx.task(config) + await wait() + + await expect(result).rejects.toThrow("Setup script timed out after 5 minutes") + expect(ctx.stops).toEqual(["wt-1"]) + + // A late exit after the timeout must not settle the promise again. + ctx.starts[0]?.done({ exitCode: 0 }) + }) + + it("times out by killing with the reason so the tab stays reviewable", async () => { + const kills: string[] = [] + const stops: string[] = [] + const manager = { + start: async (kind: string, cfg: StartCall["config"], done: StartCall["done"]) => ({ + stop: () => { + stops.push(cfg.worktreeId) + }, + kill: (reason: string) => { + kills.push(reason) + }, + }), + } as unknown as ScriptTerminalManager + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => true, + log: () => undefined, + timeoutMs: 5, + env: async () => ({}), + }) + const result = task(config) + + await expect(result).rejects.toThrow("Setup script timed out after 5 minutes") + expect(kills).toEqual(["Setup script timed out after 5 minutes"]) + expect(stops).toEqual([]) + }) + + it("stops a handle that arrives after the timeout already fired", async () => { + const gate = deferred<{ stop(): void }>() + const stops: string[] = [] + const starts: StartCall[] = [] + const manager = { + start: (kind: string, config: StartCall["config"], done: StartCall["done"]) => { + starts.push({ kind, config, done }) + return gate.promise + }, + } as unknown as ScriptTerminalManager + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => true, + log: () => undefined, + timeoutMs: 5, + env: async () => ({}), + }) + const result = task(config) + + await expect(result).rejects.toThrow("Setup script timed out after 5 minutes") + gate.resolve({ + stop: () => { + stops.push("wt-1") + }, + }) + await wait() + expect(stops).toEqual(["wt-1"]) + }) + + it("keeps the retained terminal when the script exits during creation", async () => { + const stops: string[] = [] + const manager = { + start: async (kind: string, config: StartCall["config"], done: StartCall["done"]) => { + // Fast scripts: the PTY exit is reconciled before start() resolves. + done({ exitCode: 0 }) + return { + stop: () => { + stops.push(config.worktreeId) + }, + } + }, + } as unknown as ScriptTerminalManager + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => true, + log: () => undefined, + timeoutMs: 60_000, + env: async () => ({}), + }) + + await expect(task(config)).resolves.toBe(0) + await wait() + // The exited PTY must not be stopped: its tab retains the output. + expect(stops).toEqual([]) + }) + + it("recovers a lost exit event through the reconcile watchdog", async () => { + let syncs = 0 + const starts: StartCall[] = [] + const manager = { + start: async (kind: string, cfg: StartCall["config"], done: StartCall["done"]) => { + starts.push({ kind, config: cfg, done }) + return { stop: () => undefined } + }, + sync: async () => { + syncs += 1 + // The reconcile discovers the exit the event stream missed. + if (syncs === 2) starts[0]?.done({ exitCode: 7 }) + }, + } as unknown as ScriptTerminalManager + const task = createSetupScriptTask({ + manager, + worktreeId: "wt-1", + trusted: () => true, + log: () => undefined, + timeoutMs: 60_000, + watchdogMs: 5, + env: async () => ({}), + }) + + await expect(task(config)).resolves.toBe(7) + expect(syncs).toBeGreaterThanOrEqual(2) + // The watchdog stops once the script settles. + const seen = syncs + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(syncs).toBe(seen) + }) + + it("refuses to run in an untrusted workspace without touching the backend", async () => { + const ctx = harness({ trusted: false }) + + await expect(ctx.task(config)).rejects.toThrow("Trust the workspace before running setup scripts") + expect(ctx.starts).toEqual([]) + }) +}) + +describe("pickSetupTask", () => { + function pick(opts: { destination: "vscode" | "agentManager"; worktreeId?: string; trusted?: boolean }) { + const vscode: RunTask = async () => 0 + const ctx = harness({ trusted: opts.trusted }) + const task = pickSetupTask({ + destination: opts.destination, + worktreeId: opts.worktreeId, + trusted: () => opts.trusted ?? true, + manager: ctx.manager, + log: () => undefined, + vscode, + env: async () => ({}), + }) + return { task, vscode, ctx } + } + + it("uses the integrated task runner when the destination is the VS Code terminal", () => { + const { task, vscode, ctx } = pick({ destination: "vscode", worktreeId: "wt-1" }) + expect(task).toBe(vscode) + expect(ctx.starts).toEqual([]) + }) + + it("uses the integrated task runner without a worktree id", () => { + const { task, vscode } = pick({ destination: "agentManager" }) + expect(task).toBe(vscode) + }) + + it("does not redirect an untrusted embedded selection to VS Code", async () => { + const { task, vscode } = pick({ destination: "agentManager", worktreeId: "wt-1", trusted: false }) + expect(task).not.toBe(vscode) + await expect(task(config)).rejects.toThrow("Trust the workspace before running setup scripts") + }) + + it("uses the embedded script terminal when the Agent Manager panel is selected", async () => { + const { task, vscode, ctx } = pick({ destination: "agentManager", worktreeId: "wt-1" }) + expect(task).not.toBe(vscode) + + const result = task(config) + await wait() + expect(ctx.starts[0]?.kind).toBe("setup") + ctx.starts[0]?.done({ exitCode: 0 }) + await expect(result).resolves.toBe(0) + }) +}) + +describe("runWorktreeSetupScript", () => { + function root(script: boolean): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-setup-flow-")) + if (!script) return dir + fs.mkdirSync(path.join(dir, ".kilo"), { recursive: true }) + fs.writeFileSync(path.join(dir, ".kilo", "setup-script"), "#!/bin/sh\nexit 0\n") + return dir + } + + function flow(script: boolean, opts?: { destination?: "vscode" | "agentManager"; code?: number }) { + const posted: AgentManagerOutMessage[] = [] + const runs: string[] = [] + const ctx = harness() + const vscode: RunTask = async (cfg) => { + runs.push(cfg.cwd) + return opts?.code ?? 0 + } + const input = { + service: new SetupScriptService(root(script)), + destination: opts?.destination ?? ("vscode" as const), + worktreeId: "wt-1", + trusted: () => true, + manager: ctx.manager, + log: () => undefined, + vscode, + env: async () => ({}), + post: (message: AgentManagerOutMessage) => posted.push(message), + } + return { input, posted, runs, ctx } + } + + it("posts progress and executes through the picked runner", async () => { + const scene = flow(true, { code: 0 }) + await runWorktreeSetupScript(scene.input, { worktreePath: "/repo/worktree", repoPath: "/repo" }) + + expect(scene.posted).toEqual([ + { + type: "agentManager.worktreeSetup", + status: "creating", + message: "Running setup script...", + worktreeId: "wt-1", + }, + ]) + expect(scene.runs).toEqual(["/repo/worktree"]) + }) + + it("stays silent when no setup script is configured", async () => { + const scene = flow(false) + await runWorktreeSetupScript(scene.input, { worktreePath: "/repo/worktree", repoPath: "/repo" }) + + expect(scene.posted).toEqual([]) + expect(scene.runs).toEqual([]) + }) + + it("keeps worktree creation best-effort when the script fails", async () => { + const scene = flow(true, { code: 3 }) + await expect( + runWorktreeSetupScript(scene.input, { worktreePath: "/repo/worktree", repoPath: "/repo" }), + ).resolves.toBeUndefined() + expect(scene.runs).toEqual(["/repo/worktree"]) + expect(scene.posted).toContainEqual({ + type: "agentManager.worktreeSetup", + status: "error", + message: "Setup script exited with code 3", + worktreeId: "wt-1", + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index cb792ff441..ce48962504 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -129,6 +129,8 @@ import { createTerminalHandlers, createTerminalMessageHandler, createSideTerminal, + createAmbientSetup, + hasSetupTerminal, readSavedDestination, resolveRunScriptRequest, resolveVscodeTerminalRequest, @@ -373,6 +375,18 @@ const AgentManagerContent: Component = () => { return sel === null ? null : nsKey(sel) }) + // Ambient setup reveal restores the panel after success unless the user engaged. + const ambientSetup = createAmbientSetup({ + terms, + selection: () => { + const sel = selection() + return sel === null ? null : nsKey(sel) + }, + sidePanel, + setSidePanel, + }) + const cancelAmbientSetup = ambientSetup.cancel + // Inline delete confirmation: tracks which worktree is awaiting a second click/press const [pendingDelete, setPendingDelete] = createSignal(null) let pendingDeleteTimer: ReturnType | undefined @@ -664,9 +678,17 @@ const AgentManagerContent: Component = () => { return false }) + // The empty state now lives inside this stack so the side terminal can + // render beside it during setup. Only the unassigned/history views are + // exclusive; every selected context keeps the stack mounted. + const showDetailStack = createMemo(() => !history() && selection() !== null) + const overlay = createMemo((): SetupState | null => { const state = setup() const sel = selection() + // A live Setup script terminal shows progress and failures on its own + // tab; never cover it with the blocking overlay. + if (typeof sel === "string" && sel !== LOCAL && hasSetupTerminal(nsKey(sel), terms.sides())) return null if (state.active && (!state.worktreeId || sel === state.worktreeId)) return state if (typeof sel !== "string" || sel === LOCAL) return null const busy = busyWorktrees().get(sel) @@ -679,6 +701,15 @@ const AgentManagerContent: Component = () => { } }) + /** The selected worktree is provisioning: block session CTAs, keep selection put. */ + const settingUpSelection = createMemo(() => { + const sel = selection() + if (typeof sel !== "string" || sel === LOCAL) return undefined + const busy = busyWorktrees().get(sel) + if (busy?.reason !== "setting-up") return undefined + return busy + }) + createEffect(() => { const sel = selection() if (sel === null) { @@ -998,7 +1029,7 @@ const AgentManagerContent: Component = () => { } markdown.setRender(state.reviewMarkdownRender === true) const current = session.currentSessionID() - if (current) { + if (current && !settingUpSelection()) { const ms = state.sessions.find((s) => s.id === current) if (ms?.worktreeId) setSelection(ms.worktreeId) } @@ -1238,6 +1269,14 @@ const AgentManagerContent: Component = () => { }, onScriptRunning: (contextKey, terminalId) => { if (terms.sideKey() !== contextKey) return + // Setup output is informational: reveal without stealing focus, and + // remember an ambient reveal so the panel can restore itself later. + if (terms.scriptStatus(terminalId)?.kind === "setup") { + ambientSetup.reveal(contextKey, terminalId) + showSideTerminal() + terms.setSideActive(contextKey, terminalId) + return + } showSideTerminal() terms.setSideActive(contextKey, terminalId) terms.requestFocus(terminalId) @@ -1257,9 +1296,12 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.worktreeSetup") { const ev = msg as AgentManagerWorktreeSetupMessage + const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() + const updateBusy: Setter> = (value) => store.setBusy(value) if (ev.status === "ready" || ev.status === "error") { const error = ev.status === "error" - if (ev.worktreeId) setBusyWorktrees((prev) => new Map([...prev].filter(([k]) => k !== ev.worktreeId))) + if (ev.worktreeId) updateBusy((prev) => new Map([...prev].filter(([k]) => k !== ev.worktreeId))) + if (!isActivePayload(ev.projectId)) return setSetup({ active: true, message: ev.message, @@ -1278,14 +1320,17 @@ const AgentManagerContent: Component = () => { } else { // Track this worktree as setting up and auto-select it in the sidebar if (ev.worktreeId) { - setBusyWorktrees( + updateBusy( (prev) => new Map([...prev, [ev.worktreeId!, { reason: "setting-up", message: ev.message, branch: ev.branch }]]), ) + if (!isActivePayload(ev.projectId)) return setSelection(ev.worktreeId) } - // Close diff/review panels — nothing to show during setup - setSidePanel(null) + if (!isActivePayload(ev.projectId)) return + // Close diff/review panels — nothing to show during setup. + // Terminal panels keep live setup output, so they stay open. + if (sidePanel() === "diff") setSidePanel(null) setReviewActive(false) setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId }) } @@ -1854,6 +1899,8 @@ const AgentManagerContent: Component = () => { const handleAddSession = () => { const sel = selection() + // Setup is still provisioning this worktree; the Setup tab shows progress. + if (settingUpSelection()) return expandSidebar() if (sel === LOCAL) return addPendingTab() if (sel) { @@ -1945,7 +1992,10 @@ const AgentManagerContent: Component = () => { handlers: termHandlers, visible: () => sidePanel() === "terminal" && !history() && !reviewActive(), focusedId: () => terms.sideFocusedId(), - hide: () => setSidePanel(null), + hide: () => { + cancelAmbientSetup() + setSidePanel(null) + }, refocus: () => window.dispatchEvent(new Event("focusPrompt")), postMessage: (msg) => vscode.postMessage(msg as never), track: (button, surface, properties) => metrics.track(button, surface, properties), @@ -2310,7 +2360,10 @@ const AgentManagerContent: Component = () => { terminalDestination={sideCtl.destination} terminalDestinationActive={() => sidePanel() === "terminal"} terminalKeybind={() => kb().showTerminal ?? ""} - onTerminalDestinationOpen={() => sideCtl.openPreferred("tab_toolbar")} + onTerminalDestinationOpen={() => { + cancelAmbientSetup() + sideCtl.openPreferred("tab_toolbar") + }} onTerminalDestinationChoose={sideCtl.choose} track={metrics.click} /> @@ -2376,7 +2429,7 @@ const AgentManagerContent: Component = () => { worktreeSessionIds={activeWorktreeSessionIds} /> - + {/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
{/* Chat/terminal + side diff panel. Keep it mounted under the @@ -2387,68 +2440,97 @@ const AgentManagerContent: Component = () => {
{/* Keep terminal tabs mounted so output streams across worktree switches. */} {renderTerminalLayer({ state: terms })} -
- { - if (addSessionToCurrentWorktree(id)) return - if (localSessionIDs().includes(id)) { - session.selectSession(id) - if (selection() === null) setSelection(LOCAL) - return + {/* Session-less context (e.g. a worktree mid-provisioning): the + empty state lives in the main pane so the side terminal + panel can render next to it. */} + +
+ + +
+ {settingUpSelection()?.message ?? t("agentManager.setup.settingUp")} +
+ } - // Navigate to owning worktree instead of forcing into local mode - if (worktreeSessionIds().has(id)) { - const ms = managedSessions().find((s) => s.id === id) - if (ms?.worktreeId) { - selectWorktree(ms.worktreeId) + > +
+ +
+
{t("agentManager.session.noSessions")}
+ +
+
+
+ +
+ { + if (addSessionToCurrentWorktree(id)) return + if (localSessionIDs().includes(id)) { session.selectSession(id) - setReviewActive(false) + if (selection() === null) setSelection(LOCAL) return } - } - openLocally(id) - }} - onShowHistory={() => setHistory(true)} - onForkMessage={readOnly() ? undefined : handleForkSession} - onForkSession={readOnly() ? undefined : handleForkSession} - readonly={readOnly()} - continueInWorktree={selection() === LOCAL} - promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} - pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} - /> - -
- - {t("agentManager.session.readonly")} - - -
-
-
+ // Navigate to owning worktree instead of forcing into local mode + if (worktreeSessionIds().has(id)) { + const ms = managedSessions().find((s) => s.id === id) + if (ms?.worktreeId) { + selectWorktree(ms.worktreeId) + session.selectSession(id) + setReviewActive(false) + return + } + } + openLocally(id) + }} + onShowHistory={() => setHistory(true)} + onForkMessage={readOnly() ? undefined : handleForkSession} + onForkSession={readOnly() ? undefined : handleForkSession} + readonly={readOnly()} + continueInWorktree={selection() === LOCAL} + promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} + pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} + /> + +
+ + {t("agentManager.session.readonly")} + + +
+
+
+
{/* One inspector host for all right-side modes. It stays mounted while a side terminal is alive — hidden via @@ -2511,9 +2593,22 @@ const AgentManagerContent: Component = () => { contextKey={terms.sideKey} visible={() => sidePanel() === "terminal"} onSelect={(id) => termHandlers.selectSide(id)} - onClose={(id) => termHandlers.closeSide(id)} - onCloseOthers={(id) => termHandlers.closeSideOthers(id)} - onStart={() => termHandlers.addSide()} + onClose={(id) => { + cancelAmbientSetup() + termHandlers.closeSide(id) + }} + onCloseOthers={(id) => { + cancelAmbientSetup() + termHandlers.closeSideOthers(id) + }} + onStart={() => { + cancelAmbientSetup() + termHandlers.addSide() + }} + onStop={(id) => { + cancelAmbientSetup() + termHandlers.stopSide(id) + }} />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 9d2e849abc..f80d0d2d36 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -63,6 +63,9 @@ export const dict = { "agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية", "agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء", + "agentManager.terminal.setupFailed": "فشل البرنامج النصي للإعداد", + "agentManager.terminal.setupFailedCode": "فشل البرنامج النصي للإعداد برمز الخروج", + "agentManager.terminal.stopSetup": "إيقاف البرنامج النصي للإعداد", "agentManager.terminal.connectionError": "خطأ في اتصال المحطة الطرفية", "agentManager.terminal.add": "محطة طرفية جديدة", "agentManager.terminal.empty": "لا توجد محطة طرفية هنا بعد", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index bb14a09480..cf38d011df 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -64,6 +64,9 @@ export const dict = { "agentManager.terminal.new": "Nova aba de terminal", "agentManager.terminal.ended": "terminal encerrado — feche a aba para dispensar", + "agentManager.terminal.setupFailed": "falha no script de configuração", + "agentManager.terminal.setupFailedCode": "falha no script de configuração com código de saída", + "agentManager.terminal.stopSetup": "Parar o script de configuração", "agentManager.terminal.connectionError": "erro de conexão do terminal", "agentManager.terminal.add": "Novo terminal", "agentManager.terminal.empty": "Ainda não há terminal aqui", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 2bf32237a1..c2fe0a23d9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -63,6 +63,9 @@ export const dict = { "agentManager.terminal.new": "Nova kartica terminala", "agentManager.terminal.ended": "terminal je završen — zatvorite karticu da biste odbacili", + "agentManager.terminal.setupFailed": "skripta za postavljanje nije uspjela", + "agentManager.terminal.setupFailedCode": "skripta za postavljanje nije uspjela s izlaznim kodom", + "agentManager.terminal.stopSetup": "Zaustavi skriptu za postavljanje", "agentManager.terminal.connectionError": "greška u vezi terminala", "agentManager.terminal.add": "Novi terminal", "agentManager.terminal.empty": "Ovdje još nema terminala", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index 4e3246f28d..3af20c04b9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -65,6 +65,9 @@ export const dict = { "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal afsluttet — luk fanen for at fjerne", + "agentManager.terminal.setupFailed": "opsætningsscript mislykkedes", + "agentManager.terminal.setupFailedCode": "opsætningsscript mislykkedes med exitkode", + "agentManager.terminal.stopSetup": "Stop opsætningsscript", "agentManager.terminal.connectionError": "forbindelsesfejl til terminal", "agentManager.terminal.add": "Ny terminal", "agentManager.terminal.empty": "Ingen terminal her endnu", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index a395f7d63b..7a4a9d30cb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -64,6 +64,9 @@ export const dict = { "agentManager.terminal.new": "Neuer Terminal-Tab", "agentManager.terminal.ended": "Terminal beendet — Tab schließen zum Verwerfen", + "agentManager.terminal.setupFailed": "Setup-Skript fehlgeschlagen", + "agentManager.terminal.setupFailedCode": "Setup-Skript mit Exit-Code fehlgeschlagen", + "agentManager.terminal.stopSetup": "Setup-Skript stoppen", "agentManager.terminal.connectionError": "Verbindungsfehler im Terminal", "agentManager.terminal.add": "Neues Terminal", "agentManager.terminal.empty": "Hier ist noch kein Terminal", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 2325aa58b9..46f9bf8ea1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -68,6 +68,9 @@ export const dict = { "agentManager.terminal.new": "New Terminal Tab", "agentManager.terminal.add": "New terminal", "agentManager.terminal.ended": "terminal ended — close tab to dismiss", + "agentManager.terminal.setupFailed": "setup script failed", + "agentManager.terminal.setupFailedCode": "setup script failed with exit code", + "agentManager.terminal.stopSetup": "Stop setup script", "agentManager.terminal.connectionError": "terminal connection error", "agentManager.terminal.empty": "No terminal here yet", "agentManager.terminal.start": "Start terminal", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 8f3901496f..f8433dedc9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -64,6 +64,9 @@ export const dict = { "agentManager.terminal.new": "Nueva pestaña de terminal", "agentManager.terminal.ended": "terminal finalizado — cierra la pestaña para descartar", + "agentManager.terminal.setupFailed": "el script de configuración falló", + "agentManager.terminal.setupFailedCode": "el script de configuración falló con el código de salida", + "agentManager.terminal.stopSetup": "Detener el script de configuración", "agentManager.terminal.connectionError": "error de conexión del terminal", "agentManager.terminal.add": "Nuevo terminal", "agentManager.terminal.empty": "Aún no hay ningún terminal aquí", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index d346fefa38..83d29f71a4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -68,6 +68,9 @@ export const dict = { "agentManager.terminal.new": "تب ترمینال جدید", "agentManager.terminal.add": "ترمینال جدید", "agentManager.terminal.ended": "ترمینال پایان یافت — برای بستن، تب را ببندید", + "agentManager.terminal.setupFailed": "اسکریپت راه‌اندازی ناموفق بود", + "agentManager.terminal.setupFailedCode": "اسکریپت راه‌اندازی با کد خروجی ناموفق بود", + "agentManager.terminal.stopSetup": "توقف اسکریپت راه‌اندازی", "agentManager.terminal.connectionError": "خطای اتصال ترمینال", "agentManager.terminal.empty": "هنوز ترمینالی اینجا وجود ندارد", "agentManager.terminal.start": "شروع ترمینال", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 71498efffb..35fadb06db 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -64,6 +64,9 @@ export const dict = { "agentManager.terminal.new": "Nouvel onglet de terminal", "agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer", + "agentManager.terminal.setupFailed": "échec du script de configuration", + "agentManager.terminal.setupFailedCode": "échec du script de configuration avec le code de sortie", + "agentManager.terminal.stopSetup": "Arrêter le script de configuration", "agentManager.terminal.connectionError": "erreur de connexion du terminal", "agentManager.terminal.add": "Nouveau terminal", "agentManager.terminal.empty": "Aucun terminal ici pour l'instant", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 35ec63638f..8a1d4648e2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -68,6 +68,9 @@ export const dict = { "agentManager.terminal.new": "Nuova scheda terminale", "agentManager.terminal.ended": "terminale terminato - chiudi la scheda per nasconderlo", + "agentManager.terminal.setupFailed": "script di configurazione non riuscito", + "agentManager.terminal.setupFailedCode": "script di configurazione non riuscito con codice di uscita", + "agentManager.terminal.stopSetup": "Interrompi lo script di configurazione", "agentManager.terminal.connectionError": "errore di connessione del terminale", "agentManager.terminal.add": "Nuovo terminale", "agentManager.terminal.empty": "Qui non c'è ancora un terminale", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 11e62a534b..27df39abdc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -64,6 +64,9 @@ export const dict = { "agentManager.terminal.new": "新しいターミナルタブ", "agentManager.terminal.ended": "ターミナルが終了しました — タブを閉じて破棄", + "agentManager.terminal.setupFailed": "セットアップスクリプトが失敗しました", + "agentManager.terminal.setupFailedCode": "セットアップスクリプトが終了コードで失敗しました", + "agentManager.terminal.stopSetup": "セットアップスクリプトを停止", "agentManager.terminal.connectionError": "ターミナル接続エラー", "agentManager.terminal.add": "新しいターミナル", "agentManager.terminal.empty": "ここにはまだターミナルがありません", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 8539db5ed9..0442087391 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -63,6 +63,9 @@ export const dict = { "agentManager.terminal.new": "새 터미널 탭", "agentManager.terminal.ended": "터미널 종료됨 — 탭을 닫아 해제", + "agentManager.terminal.setupFailed": "설정 스크립트 실패", + "agentManager.terminal.setupFailedCode": "종료 코드로 설정 스크립트 실패", + "agentManager.terminal.stopSetup": "설정 스크립트 중지", "agentManager.terminal.connectionError": "터미널 연결 오류", "agentManager.terminal.add": "새 터미널", "agentManager.terminal.empty": "아직 여기에 터미널이 없습니다", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 1dce95efd1..92a5e9814f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -67,6 +67,9 @@ export const dict = { "agentManager.terminal.new": "Nieuw terminaltabblad", "agentManager.terminal.ended": "terminal beëindigd — sluit tabblad om te negeren", + "agentManager.terminal.setupFailed": "installatiescript mislukt", + "agentManager.terminal.setupFailedCode": "installatiescript mislukt met exitcode", + "agentManager.terminal.stopSetup": "Installatiescript stoppen", "agentManager.terminal.connectionError": "terminalverbindingsfout", "agentManager.terminal.add": "Nieuwe terminal", "agentManager.terminal.empty": "Hier is nog geen terminal", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index ec45ed8043..e56d09608f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -63,6 +63,9 @@ export const dict = { "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal avsluttet — lukk fanen for å avvise", + "agentManager.terminal.setupFailed": "oppsettskript mislyktes", + "agentManager.terminal.setupFailedCode": "oppsettskript mislyktes med avslutningskode", + "agentManager.terminal.stopSetup": "Stopp oppsettskriptet", "agentManager.terminal.connectionError": "tilkoblingsfeil for terminal", "agentManager.terminal.add": "Ny terminal", "agentManager.terminal.empty": "Ingen terminal her ennå", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 34659ded11..1071932dae 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -64,6 +64,9 @@ export const dict = { "agentManager.terminal.new": "Nowa karta terminala", "agentManager.terminal.ended": "terminal zakończony — zamknij kartę, aby zamknąć", + "agentManager.terminal.setupFailed": "skrypt konfiguracji nie powiódł się", + "agentManager.terminal.setupFailedCode": "skrypt konfiguracji nie powiódł się z kodem wyjścia", + "agentManager.terminal.stopSetup": "Zatrzymaj skrypt konfiguracji", "agentManager.terminal.connectionError": "błąd połączenia terminala", "agentManager.terminal.add": "Nowy terminal", "agentManager.terminal.empty": "Nie ma tu jeszcze terminala", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 02d9addc63..7eb79540e7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -64,6 +64,9 @@ export const dict = { "agentManager.terminal.new": "Новая вкладка терминала", "agentManager.terminal.ended": "терминал завершен — закройте вкладку, чтобы скрыть", + "agentManager.terminal.setupFailed": "сбой скрипта настройки", + "agentManager.terminal.setupFailedCode": "сбой скрипта настройки с кодом выхода", + "agentManager.terminal.stopSetup": "Остановить скрипт настройки", "agentManager.terminal.connectionError": "ошибка подключения к терминалу", "agentManager.terminal.add": "Новый терминал", "agentManager.terminal.empty": "Здесь пока нет терминала", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 37a16683ea..2237b04a88 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -63,6 +63,9 @@ export const dict = { "agentManager.terminal.new": "แท็บเทอร์มินัลใหม่", "agentManager.terminal.ended": "เทอร์มินัลสิ้นสุด — ปิดแท็บเพื่อยกเลิก", + "agentManager.terminal.setupFailed": "สคริปต์ติดตั้งล้มเหลว", + "agentManager.terminal.setupFailedCode": "สคริปต์ติดตั้งล้มเหลวด้วยรหัสออก", + "agentManager.terminal.stopSetup": "หยุดสคริปต์ติดตั้ง", "agentManager.terminal.connectionError": "ข้อผิดพลาดการเชื่อมต่อเทอร์มินัล", "agentManager.terminal.add": "เทอร์มินัลใหม่", "agentManager.terminal.empty": "ยังไม่มีเทอร์มินัลที่นี่", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index a1ee4ecea1..44d0301d69 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -68,6 +68,9 @@ export const dict = { "agentManager.terminal.new": "Yeni Terminal Sekmesi", "agentManager.terminal.ended": "terminal sona erdi — kapatmak için sekmeyi kapatın", + "agentManager.terminal.setupFailed": "kurulum betiği başarısız oldu", + "agentManager.terminal.setupFailedCode": "kurulum betiği çıkış koduyla başarısız oldu", + "agentManager.terminal.stopSetup": "Kurulum betiğini durdur", "agentManager.terminal.connectionError": "terminal bağlantı hatası", "agentManager.terminal.add": "Yeni terminal", "agentManager.terminal.empty": "Burada henüz terminal yok", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 859758807c..c97792074c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -68,6 +68,9 @@ export const dict = { "agentManager.terminal.new": "Нова вкладка термінала", "agentManager.terminal.ended": "термінал завершено — закрийте вкладку, щоб відхилити", + "agentManager.terminal.setupFailed": "помилка скрипта налаштування", + "agentManager.terminal.setupFailedCode": "помилка скрипта налаштування з кодом виходу", + "agentManager.terminal.stopSetup": "Зупинити скрипт налаштування", "agentManager.terminal.connectionError": "помилка з'єднання термінала", "agentManager.terminal.add": "Новий термінал", "agentManager.terminal.empty": "Тут ще немає термінала", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index c4d5a5546f..6cc4159d9f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -63,6 +63,9 @@ export const dict = { "agentManager.terminal.new": "新建终端标签页", "agentManager.terminal.ended": "终端已结束 — 关闭标签页以消除", + "agentManager.terminal.setupFailed": "设置脚本失败", + "agentManager.terminal.setupFailedCode": "设置脚本失败,退出代码为", + "agentManager.terminal.stopSetup": "停止设置脚本", "agentManager.terminal.connectionError": "终端连接错误", "agentManager.terminal.add": "新建终端", "agentManager.terminal.empty": "此处尚无终端", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 8f50e9c5a6..25afbc3b41 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -63,6 +63,9 @@ export const dict = { "agentManager.terminal.new": "新增終端分頁", "agentManager.terminal.ended": "終端已結束 — 關閉分頁以消除", + "agentManager.terminal.setupFailed": "設定腳本失敗", + "agentManager.terminal.setupFailedCode": "設定腳本失敗,退出代碼為", + "agentManager.terminal.stopSetup": "停止設定腳本", "agentManager.terminal.connectionError": "終端連線錯誤", "agentManager.terminal.add": "新增終端機", "agentManager.terminal.empty": "此處尚無終端機", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx index 8185296085..772cf76e20 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -57,6 +57,8 @@ interface Props { onCloseOthers: (terminalId: string) => void /** Create a new side terminal for this context. */ onStart: () => void + /** Deliberately stop a running script terminal. */ + onStop: (terminalId: string) => void } export const SideTerminalPanel: Component = (props) => { @@ -185,6 +187,10 @@ export const SideTerminalPanel: Component = (props) => { close(term.id) }} onCloseOthers={() => props.onCloseOthers(term.id)} + onStop={(e: MouseEvent) => { + e.stopPropagation() + props.onStop(term.id) + }} /> )} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx index 2ec034bbc6..03d6fdaec8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SortableTerminalTab.tsx @@ -18,7 +18,7 @@ import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { useLanguage } from "../../src/context/language" import { SortableTabContainer } from "../../src/components/chat/TabDnd" import { parseBindingTokens } from "../keybind-tokens" -import { terminalChrome } from "./chrome" +import { terminalChrome, terminalClosable, terminalStoppable } from "./chrome" import type { ScriptTerminalStatus } from "./state" export const TerminalTabChrome: Component<{ @@ -35,6 +35,7 @@ export const TerminalTabChrome: Component<{ onSelect: () => void onMiddleClick?: (e: MouseEvent) => void onClose: (e: MouseEvent) => void + onStop?: (e: MouseEvent) => void }> = (props) => { const { t } = useLanguage() const chrome = () => terminalChrome(props.tooltip, props.status) @@ -74,24 +75,46 @@ export const TerminalTabChrome: Component<{ - - - + + + + + + + + + + ) } @@ -112,6 +135,7 @@ export const SortableTerminalTab: Component<{ onMiddleClick: (e: MouseEvent) => void onClose: (e: MouseEvent) => void onCloseOthers: () => void + onStop?: (e: MouseEvent) => void }> = (props) => { const { t } = useLanguage() return ( @@ -132,6 +156,7 @@ export const SortableTerminalTab: Component<{ onSelect={props.onSelect} onMiddleClick={props.onMiddleClick} onClose={props.onClose} + onStop={props.onStop} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index c4aaa5af11..8f82b4f383 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -20,7 +20,7 @@ import "@xterm/xterm/css/xterm.css" import { useVSCode } from "../../src/context/vscode" import { useLanguage } from "../../src/context/language" import { formatReviewCommentsMarkdown } from "../../src/utils/review-comment-markdown" -import type { TerminalFont } from "./state" +import type { ScriptTerminalStatus, TerminalFont } from "./state" interface Props { terminalId: string @@ -51,6 +51,9 @@ interface Props { * command, oh-my-zsh to user@host:cwd, vim to the file name. The * state layer mirrors it into the tab label. */ onTitleChange?: (title: string) => void + /** Provider-owned script status (Run/Setup), used to annotate the + * output when a script ends in failure. */ + status?: () => ScriptTerminalStatus | undefined } /** How long the ResizeObserver waits after the last size change before @@ -219,10 +222,35 @@ export const TerminalTab: Component = (props) => { const ws = new WebSocket(props.wsUrl) ws.binaryType = "arraybuffer" let closed = false + let streamed = false + // The failure line must not depend on event ordering: the stream can + // close before the exited snapshot lands (fast failures), or stay open + // when a background child outlives the script. Write it exactly once, + // from whichever signal arrives first. + let failureWritten = false + const noteFailure = () => { + if (failureWritten || (!streamed && !closed)) return + const status = props.status?.() + if (status?.kind !== "setup") return + if (status.state === "failed") { + failureWritten = true + term.writeln(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailed")}]\x1b[0m`) + return + } + if (status.state === "exited" && status.exitCode !== 0) { + failureWritten = true + term.writeln(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailedCode")} ${status.exitCode ?? "?"}]\x1b[0m`) + } + } + createEffect(() => { + props.status?.() + noteFailure() + }) const disposeData = term.onData((data) => { if (ws.readyState === WebSocket.OPEN) ws.send(data) }) ws.onmessage = (event) => { + streamed = true // Text frames carry PTY output; binary frames starting with 0x00 // are control metadata (cursor position). See pty/index.ts:46. if (typeof event.data === "string") { @@ -242,6 +270,7 @@ export const TerminalTab: Component = (props) => { ws.onclose = () => { if (closed) return closed = true + noteFailure() term.writeln(`\r\n\x1b[90m[${t("agentManager.terminal.ended")}]\x1b[0m`) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts new file mode 100644 index 0000000000..a683e0fe9c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts @@ -0,0 +1,71 @@ +/** + * Ambient setup reveal for the side terminal panel. + * + * A worktree setup script runs automatically during provisioning, so the + * side panel opens itself to show live progress. On a clean exit the + * panel hides again and restores the previous layout; failures stay + * visible. Any user engagement with the panel (explicit open/close, + * adding or closing a terminal) cancels the pending auto-hide, so user + * content is never pulled away. The exited Setup tab itself stays in + * terminal state and can be reopened anytime. + */ + +import { createEffect, createSignal, type Accessor } from "solid-js" +import type { ScriptTerminalStatus, TerminalStateControls } from "./state" + +interface AmbientSetupDeps { + terms: TerminalStateControls + selection: Accessor + sidePanel: Accessor + setSidePanel(panel: null): void +} + +export type AmbientDecision = "wait" | "hide" | "keep" + +/** + * What happens to an ambiently revealed panel when the setup status + * changes: still running means wait, failure or a context switch means + * keep the panel as it is, a clean exit restores the previous layout. + */ +export function ambientDecision( + status: ScriptTerminalStatus | undefined, + selection: string | null, + contextKey: string, +): AmbientDecision { + if (!status || status.state === "running" || status.state === "stopping") return "wait" + if (status.state !== "exited" || status.exitCode !== 0) return "keep" + // The user moved on to another context; the panel shows other content. + if (selection !== contextKey) return "keep" + return "hide" +} + +export function createAmbientSetup(deps: AmbientSetupDeps) { + const [pending, setPending] = createSignal<{ contextKey: string; terminalId: string } | undefined>() + + createEffect(() => { + const ambient = pending() + if (!ambient) return + // The terminal was removed (e.g. deliberately stopped); nothing to hide. + if (!deps.terms.sides().some((term) => term.id === ambient.terminalId)) { + setPending(undefined) + return + } + const decision = ambientDecision(deps.terms.scriptStatus(ambient.terminalId), deps.selection(), ambient.contextKey) + if (decision === "wait") return + setPending(undefined) + if (decision === "hide") deps.setSidePanel(null) + }) + + return { + /** Called when a running setup terminal hydrates: reveal is ambient only if the panel was closed. */ + reveal(contextKey: string, terminalId: string): void { + if (deps.sidePanel() === null) setPending({ contextKey, terminalId }) + }, + /** User engagement with the panel cancels the pending auto-hide. */ + cancel(): void { + setPending(undefined) + }, + /** Test hook: the pending ambient reveal, if any. */ + pending, + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/chrome.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/chrome.ts index cd55e3fecb..0f8ced649b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/chrome.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/chrome.ts @@ -21,3 +21,22 @@ export function terminalChrome(title: string, status: ScriptTerminalStatus | und tooltip: `${title} (Failed${status.exitCode === undefined ? "" : `, code ${status.exitCode}`})`, } } + +/** + * A running Setup script must finish (or time out) on its own: closing its + * tab would silently kill worktree provisioning. Run tabs stay closable + * because close means stop there. + */ +export function terminalClosable(status: ScriptTerminalStatus | undefined): boolean { + if (status?.kind !== "setup") return true + return status.state !== "running" && status.state !== "stopping" +} + +/** + * A running Setup script can always be stopped deliberately: the stop + * action kills the process tree and worktree creation continues without + * it, which is the escape hatch for scripts that run too long. + */ +export function terminalStoppable(status: ScriptTerminalStatus | undefined): boolean { + return status?.kind === "setup" && status.state === "running" +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts index 94e1bdae26..fef737648e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts @@ -26,3 +26,4 @@ export { TerminalDestinationButton } from "./TerminalDestinationButton" export { createSideTerminal, readSavedDestination, resolveRunScriptRequest, resolveVscodeTerminalRequest } from "./side" export { TerminalTab } from "./TerminalTab" export { SortableTerminalTab } from "./SortableTerminalTab" +export { createAmbientSetup } from "./ambient" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx index 5cb1b42a5b..80b12d23a9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx @@ -154,6 +154,7 @@ export function renderSideTerminalLayer(props: { active={active()} focusSerial={focusSerial(props.state, term.id)} font={term.font} + status={() => props.state.scriptStatus(term.id)} onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)} onTitleChange={(title) => props.state.setTitle(term.id, title)} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts index 63895bdf3f..907625dd16 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts @@ -92,6 +92,7 @@ export interface SideTerminalDeps { export function createSideTerminal(deps: SideTerminalDeps) { const [local, setLocal] = createSignal(deps.saved) const [destination, setDestination] = createSignal(deps.saved ?? "vscode") + if (deps.saved) deps.postMessage({ type: "agentManager.terminal.destinationSelected", destination: deps.saved }) /** * Hiding while the terminal holds focus would strand the cursor on @@ -147,17 +148,15 @@ export function createSideTerminal(deps: SideTerminalDeps) { * Dropdown pick. The choice is panel-local and sticky: it is kept in * webview state and beats later `terminal.destinationChanged` echoes * caused by other windows rewriting the shared application-scoped - * setting. The setting is still written so it stays the default for - * panels that never picked a destination (and new panels). - * The key is relative to the `kilo-code.new` section, matching every - * other `updateSetting` sender. + * setting. The provider still persists it as the default for new panels, + * while setup uses this panel-local value immediately. */ const choose = (target: TerminalDestination) => { deps.track("terminal_destination", "tab_toolbar", { destination: target }) setLocal(target) setDestination(target) deps.save(target) - deps.postMessage({ type: "updateSetting", key: "agentManager.terminalButtonDestination", value: target }) + deps.postMessage({ type: "agentManager.terminal.destinationSelected", destination: target }) } /** diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts index 38b1915ba5..f5b4d4003d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -15,7 +15,11 @@ import { createSignal } from "solid-js" import type { Accessor } from "solid-js" import { LOCAL } from "../navigate" -import type { ExtensionMessage, ScriptTerminalView } from "../../src/types/messages/extension-messages" +import type { + ExtensionMessage, + ScriptTerminalKind, + ScriptTerminalView, +} from "../../src/types/messages/extension-messages" import type { TerminalDestination, TerminalFont, TerminalPlacement } from "../../src/types/messages/agent-manager" export type { TerminalFont } @@ -28,7 +32,7 @@ export const isTerminalTabId = (id: string): boolean => id.startsWith(TERMINAL_PREFIX) || id.startsWith(SCRIPT_TERMINAL_PREFIX) /** Status is separate from mounted xterm records so snapshot updates never remount them. */ -export type ScriptTerminalStatus = Pick +export type ScriptTerminalStatus = Pick /** One row in `terminalsByContext`. `wsUrl` is short-lived and never persisted. */ export interface TerminalTabState { @@ -37,8 +41,8 @@ export interface TerminalTabState { wsUrl: string font: TerminalFont placement: TerminalPlacement - /** Provider-owned Run terminal, never created through the webview create flow. */ - kind?: "run" + /** Provider-owned script terminal, never created through the webview create flow. */ + kind?: ScriptTerminalKind } /** Terminal row enriched with the sidebar context it belongs to. Used by @@ -70,11 +74,11 @@ export interface TerminalStateControls { remove(terminalId: string): TerminalTabStateWithContext | undefined /** Resolve the context key a terminal lives in, if any. */ contextFor(terminalId: string): string | undefined - /** Whether a terminal belongs to a provider-owned Run script. */ + /** Whether a terminal belongs to a provider-owned script (Run/Setup). */ isScript(terminalId: string): boolean - /** Reactive Run state, kept apart from stable xterm terminal records. */ + /** Reactive script state, kept apart from stable xterm terminal records. */ scriptStatus(terminalId: string): ScriptTerminalStatus | undefined - /** Reconcile a complete provider-owned Run terminal snapshot. Returns newly hydrated records. */ + /** Reconcile a complete provider-owned script terminal snapshot. Returns newly hydrated records. */ syncScripts(views: ScriptTerminalView[]): TerminalTabStateWithContext[] /** All tab terminals for the given sidebar selection. */ forSelection(selection: string | null): TerminalTabStateWithContext[] @@ -246,9 +250,9 @@ export function createTerminalState(selection: Accessor): Termina if (!key) return undefined const term = terminalsByContext()[key]?.find((t) => t.id === terminalId) if (!term) return undefined - // Run terminals always retain their semantic title, even when their + // Script terminals always retain their semantic title, even when their // command emits OSC title sequences. - if (term.kind === "run") return term.title + if (term.kind) return term.title return titles()[terminalId] ?? term.title } @@ -270,7 +274,7 @@ export function createTerminalState(selection: Accessor): Termina const isScript = (terminalId: string): boolean => { const key = contextFor(terminalId) - return terminalsByContext()[key ?? ""]?.some((term) => term.id === terminalId && term.kind === "run") ?? false + return terminalsByContext()[key ?? ""]?.some((term) => term.id === terminalId && term.kind !== undefined) ?? false } const scriptStatus = (terminalId: string): ScriptTerminalStatus | undefined => { @@ -324,7 +328,7 @@ export function createTerminalState(selection: Accessor): Termina return next }) } - if (removed?.kind === "run" && scripts()[terminalId] !== undefined) { + if (removed?.kind && scripts()[terminalId] !== undefined) { setScripts((prev) => { const next = { ...prev } delete next[terminalId] @@ -344,7 +348,7 @@ export function createTerminalState(selection: Accessor): Termina const next: Record = {} for (const [key, list] of Object.entries(prev)) { const kept = list.filter((term) => { - if (term.kind !== "run" || ids.has(term.id)) return true + if (term.kind === undefined || ids.has(term.id)) return true removed.push(term) changed = true return false @@ -352,16 +356,17 @@ export function createTerminalState(selection: Accessor): Termina if (kept.length > 0) next[key] = kept } for (const view of views) { - const key = view.worktreeId ?? LOCAL + const target = view.worktreeId ?? LOCAL + const key = view.projectId ? `${view.projectId}:${target}` : target const list = next[key] ?? [] if (list.some((term) => term.id === view.terminalId)) continue const term: TerminalTabStateWithContext = { id: view.terminalId, - title: "Run", + title: view.title, wsUrl: view.wsUrl, font: view.font, placement: "side", - kind: "run", + kind: view.kind, contextKey: key, } next[key] = [...list, term] @@ -373,7 +378,7 @@ export function createTerminalState(selection: Accessor): Termina const states: Record = {} for (const view of views) { - const status: ScriptTerminalStatus = { state: view.state } + const status: ScriptTerminalStatus = { state: view.state, kind: view.kind } if (view.exitCode !== undefined) status.exitCode = view.exitCode states[view.terminalId] = status } @@ -383,11 +388,25 @@ export function createTerminalState(selection: Accessor): Termina for (const id of keys) { const before = prev[id] const after = states[id] - if (before?.state !== after?.state || before?.exitCode !== after?.exitCode) return states + if (before?.state !== after?.state || before?.exitCode !== after?.exitCode || before?.kind !== after?.kind) + return states } return prev }) + if (added.length > 0) { + setActives((prev) => { + let changed = false + const next = { ...prev } + for (const term of added) { + if (next[term.contextKey]) continue + next[term.contextKey] = term.id + changed = true + } + return changed ? next : prev + }) + } + if (removed.length > 0) { const removedIds = new Set(removed.map((term) => term.id)) if (focusedId() && removedIds.has(focusedId()!)) setFocusedId(undefined) @@ -666,7 +685,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { } const closeTerminal = (terminalId: string) => { - // Run terminals transition through a provider-owned stopping snapshot. + // Script terminals transition through a provider-owned stopping snapshot. // Keep their xterm mounted until closure is confirmed by a snapshot or // terminal.closed message so live output is never discarded early. if (deps.state.isScript(terminalId)) { @@ -716,7 +735,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { // unmount its xterm while the backend PTY leaks (no close sent). const term = deps.state.sides().find((t) => t.id === terminalId) if (!term) return false - if (term.kind === "run") { + if (term.kind) { deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true } @@ -741,6 +760,14 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { deps.state.requestFocus(terminalId) } + /** Deliberately stop a running script terminal: kills its process tree. */ + const stopSide = (terminalId: string): boolean => { + const term = deps.state.sides().find((t) => t.id === terminalId) + if (!term?.kind) return false + deps.postMessage({ type: "agentManager.terminal.stop", terminalId }) + return true + } + /** Make a side terminal the visible one in its panel and focus it. */ const selectSide = (terminalId: string) => { const key = deps.state.contextFor(terminalId) @@ -767,6 +794,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { closeTerminal, closeSide, closeSideOthers, + stopSide, selectSide, middleClick, activate, @@ -800,7 +828,7 @@ export interface TerminalMessageHandlerDeps { onSideError?: (contextKey: string) => void /** Side terminal was closed (locally or by the extension). */ onSideClosed?: (contextKey: string) => void - /** A newly hydrated running Run terminal belongs to the selected context. */ + /** A newly hydrated running script terminal belongs to the selected context. */ onScriptRunning?: (contextKey: string, terminalId: string) => void /** The destination setting changed (live settings sync). */ onDestinationChanged?: (destination: TerminalDestination) => void diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 9596a9baee..9c465120c0 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -957,6 +957,7 @@ export const SideTerminalPanelEmpty: Story = { onClose={() => undefined} onCloseOthers={() => undefined} onStart={() => undefined} + onStop={() => undefined} /> @@ -998,6 +999,7 @@ export const SideTerminalPanelTabs: Story = { onClose={() => undefined} onCloseOthers={() => undefined} onStart={() => undefined} + onStop={() => undefined} /> diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 666a044e40..d711fd4965 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -686,6 +686,8 @@ export interface AgentManagerRepoInfoMessage { // Agent Manager worktree setup progress export interface AgentManagerWorktreeSetupMessage { type: "agentManager.worktreeSetup" + /** Owning project; absent in single-project mode. */ + projectId?: string status: "creating" | "starting" | "ready" | "error" message: string sessionId?: string @@ -812,13 +814,17 @@ export interface AgentManagerTerminalDestinationChangedMessage { destination: TerminalDestination } -/** Provider-owned Run script terminal. Full snapshots replace only this terminal kind. */ +/** Provider-owned script terminal (Run/Setup). Full snapshots replace only these terminal kinds. */ +export type ScriptTerminalKind = "run" | "setup" + export interface ScriptTerminalView { terminalId: string + /** Owning project; absent in single-project mode. */ + projectId?: string /** null for LOCAL, worktree id otherwise */ worktreeId: string | null - kind: "run" - title: "Run" + kind: ScriptTerminalKind + title: "Run" | "Setup" wsUrl: string state: "running" | "stopping" | "exited" | "failed" exitCode?: number diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 460e44d8a2..32b2ffb445 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -808,6 +808,17 @@ export interface AgentManagerTerminalCloseRequest { terminalId: string } +// Deliberately stop a running script terminal (kills its process tree) +export interface AgentManagerTerminalStopRequest { + type: "agentManager.terminal.stop" + terminalId: string +} + +export interface AgentManagerTerminalDestinationSelectedRequest { + type: "agentManager.terminal.destinationSelected" + destination: TerminalDestination +} + // Notify the extension of an xterm resize so it can update the backend PTY dimensions export interface AgentManagerTerminalResizeRequest { type: "agentManager.terminal.resize" @@ -1559,6 +1570,8 @@ export type WebviewMessage = | OpenContentRequest | AgentManagerTerminalCreateRequest | AgentManagerTerminalCloseRequest + | AgentManagerTerminalStopRequest + | AgentManagerTerminalDestinationSelectedRequest | AgentManagerTerminalResizeRequest | RequestImageModelsMessage