fix(vscode): restore Agent Manager terminals (#12720)

This commit is contained in:
Marius
2026-07-31 11:16:36 +02:00
committed by GitHub
parent 7cbe92e396
commit 57a9a6012c
11 changed files with 151 additions and 13 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Agent Manager embedded terminals not opening in the side panel or as a tab.
@@ -134,6 +134,7 @@ export class AgentManagerProvider implements Disposable {
getServerConfig: () => this.connectionService.getServerConfig() ?? undefined,
getRoot: () => this.getRoot(),
getWorktreePath: (id) => this.getStateManager()?.getWorktree(id)?.path,
getProjectId: () => this.projectScope.current()?.id ?? this.contexts.active()?.id,
log: (...args) => this.log("[XTerm]", ...args),
post: (msg) => this.postToWebview(msg),
getTerminalFont: () => readTerminalFont(),
@@ -34,6 +34,10 @@ export interface TerminalRoutingDeps {
getRoot(): string | undefined
/** Resolve a worktree id to its on-disk path, or undefined if unknown. */
getWorktreePath(worktreeId: string): string | undefined
/** Project the current message is dispatched for; stamped onto
* `terminal.created` so the webview can namespace its per-project
* terminal state (worktree ids collide across projects). */
getProjectId(): string | undefined
/** Output channel log — prefixed by the caller. */
log(...args: unknown[]): void
/** Send a message back to the webview. */
@@ -115,6 +119,9 @@ export class TerminalRouter {
const generation = this.generation
const manager = this.manager
const cwd = this.resolveCwd(worktreeId)
// Captured synchronously: the project scope is only current while the
// dispatch runs, not when the async create settles.
const pid = this.deps.getProjectId()
if (!cwd) {
this.deps.post({
type: "agentManager.terminal.error",
@@ -141,6 +148,7 @@ export class TerminalRouter {
createId,
placement,
worktreeId: created.worktreeId,
...(pid ? { projectId: pid } : {}),
terminalId: created.terminalId,
title: created.title,
wsUrl: created.wsUrl,
@@ -190,6 +190,9 @@ interface TerminalCreatedMessage {
placement: TerminalPlacement
/** null for LOCAL, worktree id otherwise */
worktreeId: string | null
/** Project that owns the create; the webview namespaces its per-project
* terminal state with it (mirrors `ScriptTerminalView.projectId`). */
projectId?: string
terminalId: string
title: string
wsUrl: string
@@ -57,6 +57,7 @@ describe("Agent Manager terminal font", () => {
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
getProjectId: () => "prj-1",
log: () => undefined,
post: resolve,
getTerminalFont: () => font,
@@ -25,6 +25,7 @@ describe("Agent Manager terminal routing", () => {
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: (id) => (id === "wt-1" ? "/workspace/wt-1" : undefined),
getProjectId: () => "prj-1",
log: () => undefined,
post: (message) => messages.push(message),
getTerminalFont: () => font,
@@ -42,6 +43,7 @@ describe("Agent Manager terminal routing", () => {
createId: "side-1",
placement: "side",
worktreeId: "wt-1",
projectId: "prj-1",
})
router.handle({
@@ -83,6 +85,7 @@ describe("Agent Manager terminal routing", () => {
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
getProjectId: () => "prj-1",
log: () => undefined,
post: (message) => messages.push(message),
getTerminalFont: () => font,
@@ -132,6 +135,7 @@ describe("Agent Manager terminal routing", () => {
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
getProjectId: () => "prj-1",
log: () => undefined,
post: (message) => messages.push(message),
getTerminalFont: () => font,
@@ -178,6 +182,7 @@ describe("Agent Manager terminal routing", () => {
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
getProjectId: () => "prj-1",
log: () => undefined,
post: (message) => messages.push(message),
getTerminalFont: () => font,
@@ -217,6 +222,7 @@ describe("Agent Manager terminal routing", () => {
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
getProjectId: () => "prj-1",
log: () => undefined,
post: () => undefined,
getTerminalFont: () => font,
@@ -262,6 +268,7 @@ describe("Agent Manager terminal routing", () => {
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
getProjectId: () => "prj-1",
log: () => undefined,
post: (message) => messages.push(message),
getTerminalFont: () => font,
@@ -561,4 +561,102 @@ describe("Agent Manager terminal state", () => {
dispose()
})
})
// Multi-project regression: AgentManagerApp feeds createTerminalState a
// project-namespaced selection accessor (`${projectId}:${sel}`) so ids
// from different projects never collide. The wire protocol still speaks
// plain worktree ids, and `terminal.created` carries the owning
// `projectId` so the answer lands back in the namespaced context.
function nsScene(initial: string | null = LOCAL, pid = "prj-1") {
const [selection, setSelection] = createSignal<string | null>(initial)
const ns = (sel: string) => `${pid}:${sel}`
const state = createTerminalState(() => {
const sel = selection()
return sel === null ? null : ns(sel)
})
const posted: Array<Record<string, unknown>> = []
const events = { activated: [] as string[], selected: [] as string[], created: [] as string[] }
const handlers = createTerminalHandlers({
state,
tabIds: () => state.current().map((term) => term.id),
selectReview: () => undefined,
selectSessionTab: () => undefined,
clearSession: () => undefined,
resetOthers: () => undefined,
isPendingId: () => false,
findTab: () => undefined,
postMessage: (message) => posted.push(message as Record<string, unknown>),
onShowSide: () => undefined,
getSelection: selection,
LOCAL,
REVIEW_TAB_ID: "review",
})
const dispatch = createTerminalMessageHandler({
state,
activate: (id) => events.activated.push(id),
saveTabMemory: () => undefined,
setSelection: (value) => events.selected.push(value),
showError: () => undefined,
postMessage: (message) => posted.push(message as Record<string, unknown>),
onCreated: (contextKey) => events.created.push(contextKey),
})
return { state, posted, events, handlers, dispatch, ns }
}
it("keeps the namespaced state key out of side creates and buckets project-stamped answers", () => {
createRoot((dispose) => {
const item = nsScene(LOCAL)
item.handlers.requestSide()
expect(item.posted).toHaveLength(1)
const request = item.posted[0]!
expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null })
expect(item.dispatch({ ...createdSide(String(request.createId), "terminal:side"), projectId: "prj-1" })).toBe(
true,
)
expect(item.state.sideKey()).toBe("prj-1:local")
expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"])
expect(item.state.sideActiveFor("prj-1:local")).toBe("terminal:side")
// A worktree context sends its plain worktree id, not "prj-1:wt-1".
const wt = nsScene("wt-1")
wt.handlers.addSide()
expect(wt.posted[0]).toMatchObject({
type: "agentManager.terminal.create",
placement: "side",
worktreeId: "wt-1",
})
dispose()
})
})
it("buckets project-stamped tab terminals under the namespaced context", () => {
createRoot((dispose) => {
const item = nsScene(LOCAL)
item.handlers.requestNew()
expect(item.posted[0]).toMatchObject({
type: "agentManager.terminal.create",
placement: "tab",
worktreeId: null,
})
const created: ExtensionMessage = {
type: "agentManager.terminal.created",
createId: String(item.posted[0]!.createId),
placement: "tab",
worktreeId: null,
projectId: "prj-1",
terminalId: "terminal:tab",
title: "Terminal 1",
wsUrl: "ws://tab",
font,
}
expect(item.dispatch(created)).toBe(true)
expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"])
// Selection and tab order stay on the plain protocol id.
expect(item.events.selected).toEqual([LOCAL])
expect(item.events.created).toEqual([LOCAL])
expect(item.events.activated).toEqual(["terminal:tab"])
dispose()
})
})
})
@@ -890,6 +890,7 @@ const AgentManagerContent: Component = () => {
post: (msg: unknown) => vscode.postMessage(msg as never),
tabMemory,
terms,
nsKey,
activateTerminal: (id: string) => termHandlers.activate(id),
setActivePendingId,
selectSession: session.selectSession,
@@ -2066,9 +2067,11 @@ const AgentManagerContent: Component = () => {
setLocalSessionIDs(sessionSubset)
}
// Mirror the order into the terminal state so `terms.current()`
// (the source for renderTerminalLayer's slot order) matches.
// (the source for renderTerminalLayer's slot order) matches. The
// terminal state is keyed by namespaced context, not the plain
// tab-order key.
const terminalSubset = reordered.filter(isTerminalTabId)
if (terminalSubset.length > 0) terms.reorder(key, terminalSubset)
if (terminalSubset.length > 0) terms.reorder(nsKey(key), terminalSubset)
}
const handleDragEnd = () => {
@@ -24,6 +24,9 @@ export interface SelectionActionDeps<T extends SessionLike> {
post: (msg: unknown) => void
tabMemory: () => Record<string, string>
terms: TermState
/** Terminal state is keyed by project-namespaced context; map a plain
* selection ("local" or a worktree id) to its terminal-state key. */
nsKey: (sel: string) => string
activateTerminal: (id: string) => void
setActivePendingId: (id: string | undefined) => void
selectSession: (id: string) => void
@@ -40,7 +43,7 @@ export function selectLocalAction<T extends SessionLike>(deps: SelectionActionDe
deps.setSelection(LOCAL)
deps.post({ type: "agentManager.requestRepoInfo" })
const remembered = deps.tabMemory()[LOCAL]
if (deps.terms.hasRemembered(LOCAL, remembered)) return deps.activateTerminal(remembered!)
if (deps.terms.hasRemembered(deps.nsKey(LOCAL), remembered)) return deps.activateTerminal(remembered!)
deps.terms.setActiveId(undefined)
const target = remembered ? locals.find((s) => s.id === remembered) : undefined
const fallback = target ?? locals[0]
@@ -64,7 +67,7 @@ export function selectWorktreeAction<T extends SessionLike>(
deps.saveTabMemory()
deps.setSelection(worktreeId)
const remembered = deps.tabMemory()[worktreeId]
if (deps.terms.hasRemembered(worktreeId, remembered)) return deps.activateTerminal(remembered!)
if (deps.terms.hasRemembered(deps.nsKey(worktreeId), remembered)) return deps.activateTerminal(remembered!)
deps.terms.setActiveId(undefined)
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
const fallback = target ?? sessions[0]
@@ -639,13 +639,16 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
const createSide = () => {
const key = deps.state.sideKey()
// The wire protocol speaks plain worktree ids; `sideKey` is the
// project-namespaced state key and must not leak into the message.
const sel = deps.getSelection()
const id = newId()
deps.state.beginSide(key, id)
deps.postMessage({
type: "agentManager.terminal.create",
createId: id,
placement: "side",
worktreeId: key === deps.LOCAL ? null : key,
worktreeId: sel === null || sel === deps.LOCAL ? null : sel,
})
}
@@ -838,7 +841,10 @@ type CreatedMessage = Extract<ExtensionMessage, { type: "agentManager.terminal.c
type ScriptTerminalsMessage = Extract<ExtensionMessage, { type: "agentManager.scriptTerminals" }>
function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) {
const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId
// `target` is the plain protocol id (selection/tab-order keys); `key` is
// the project-namespaced terminal-state key (same shape as syncScripts).
const target = msg.worktreeId === null ? LOCAL : msg.worktreeId
const key = msg.projectId ? `${msg.projectId}:${target}` : target
const term = {
id: msg.terminalId,
title: msg.title,
@@ -852,20 +858,20 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) {
// reloaded (or the context is gone) — close the PTY again instead
// of leaking it.
const request = deps.state.completeSide(msg.createId)
if (!request || request.contextKey !== contextKey) {
if (!request || request.contextKey !== key) {
deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId })
return
}
deps.state.add(msg.worktreeId, term)
deps.state.add(key === LOCAL ? null : key, term)
// The newest terminal becomes the visible one in its panel.
deps.state.setSideActive(contextKey, msg.terminalId)
deps.onSideCreated?.(contextKey, msg.terminalId)
deps.state.setSideActive(key, msg.terminalId)
deps.onSideCreated?.(key, msg.terminalId)
return
}
deps.state.add(msg.worktreeId, term)
deps.onCreated?.(contextKey, msg.terminalId)
deps.state.add(key === LOCAL ? null : key, term)
deps.onCreated?.(target, msg.terminalId)
deps.saveTabMemory()
deps.setSelection(contextKey)
deps.setSelection(target)
deps.activate(msg.terminalId)
}
@@ -785,6 +785,9 @@ export interface AgentManagerTerminalCreatedMessage {
placement: TerminalPlacement
/** null for LOCAL, worktree id otherwise */
worktreeId: string | null
/** Project that owns the create; the webview namespaces its per-project
* terminal state with it (mirrors `ScriptTerminalView.projectId`). */
projectId?: string
terminalId: string
title: string
wsUrl: string