diff --git a/.changeset/agent-manager-side-terminal.md b/.changeset/agent-manager-side-terminal.md new file mode 100644 index 00000000000..533da3233cb --- /dev/null +++ b/.changeset/agent-manager-side-terminal.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/side-terminal-panel-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/side-terminal-panel-empty-chromium-linux.png new file mode 100644 index 00000000000..7515cef4a3f --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/side-terminal-panel-empty-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31c246e1baf581b431033ee2b2ce1c5080cca02d034b42d4dbce20f9acb5f33f +size 9538 diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 403c574dcaf..45d6fbeb525 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1016,6 +1016,20 @@ "scope": "application", "description": "Prefix for automatically named Agent Manager branches, for example 'marius/' or 'feature/'. Explicit branch names are unchanged." }, + "kilo-code.new.agentManager.terminalButtonDestination": { + "type": "string", + "scope": "application", + "default": "vscode", + "enum": [ + "vscode", + "agentManager" + ], + "enumDescriptions": [ + "Open or focus the VS Code integrated terminal.", + "Open or focus an embedded terminal in the Agent Manager side panel." + ], + "description": "Choose where the Agent Manager terminal button and Focus Terminal keyboard shortcut open a terminal." + }, "kilo-code.new.indexing.showButtonWhenDisabled": { "type": "boolean", "default": true, diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 94298ca320a..1bab4b9b8ea 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -46,6 +46,7 @@ import { pruneSubagents } from "./prune-subagents" import { startSession } from "./mcp-warmup" import { readTerminalFont, watchTerminalFont } from "./terminal-font" +import { readTerminalDestination, watchTerminalDestination } from "./terminal-destination" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" import { ensureSandbox } from "./sandbox-bootstrap" @@ -78,6 +79,7 @@ export class AgentManagerProvider implements Disposable { private unsubTool: (() => void) | undefined private unsubStatus: (() => void) | undefined private unsubFont: (() => void) | undefined + private unsubDestination: (() => void) | undefined private closing: Promise | undefined private onVisibilityChange: ((visible: boolean) => void) | undefined // Tracks sessions owned by this panel until they are explicitly closed. @@ -111,6 +113,9 @@ export class AgentManagerProvider implements Disposable { this.unsubFont = watchTerminalFont((font) => { this.postToWebview({ type: "agentManager.terminal.fontChanged", font }) }) + this.unsubDestination = watchTerminalDestination((destination) => { + this.postToWebview({ type: "agentManager.terminal.destinationChanged", destination }) + }) this.run = new RunController({ root: () => this.getRoot(), state: () => this.getStateManager(), @@ -304,6 +309,7 @@ export class AgentManagerProvider implements Disposable { this.activeSessionId = undefined this.visiblePresence.clear() this.panel = undefined + void this.terminalRouter.dispose() this.onVisibilityChange?.(false) } ctx.sessions.dispose() @@ -922,6 +928,7 @@ export class AgentManagerProvider implements Disposable { case "agentManager.toggleSectionCollapsed": case "agentManager.moveToSection": case "agentManager.moveSection": + case "agentManager.terminal.create": return true default: return false @@ -1624,6 +1631,7 @@ export class AgentManagerProvider implements Disposable { sidebarCollapsed: state.getSidebarCollapsed(), reviewDiffStyle: state.getReviewDiffStyle(), reviewMarkdownRender: getDiffMarkdownRender(), + terminalDestination: readTerminalDestination(), isGitRepo: true, defaultBaseBranch: state.getDefaultBaseBranch(), ...run, @@ -1646,6 +1654,7 @@ export class AgentManagerProvider implements Disposable { staleWorktreeIds: [], reviewDiffStyle: "unified", reviewMarkdownRender: getDiffMarkdownRender(), + terminalDestination: readTerminalDestination(), isGitRepo: false, runStatuses: [], runScriptConfigured: false, @@ -1923,6 +1932,7 @@ export class AgentManagerProvider implements Disposable { this.unsubTool?.() this.unsubStatus?.() this.unsubFont?.() + this.unsubDestination?.() this.orchestration.dispose() this.visiblePresence.clear() this.diffs.stop() diff --git a/packages/kilo-vscode/src/agent-manager/terminal-destination.ts b/packages/kilo-vscode/src/agent-manager/terminal-destination.ts new file mode 100644 index 00000000000..f6fbceb5911 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/terminal-destination.ts @@ -0,0 +1,37 @@ +/** + * Read and watch the user's Agent Manager terminal destination setting. + * + * The terminal button and `Cmd/Ctrl+/` either open the VS Code integrated + * terminal (default, backwards compatible) or an embedded xterm in the + * Agent Manager side panel. Kept next to `terminal-font.ts`; a separate + * module so the font helpers stay untouched. + */ + +import * as vscode from "vscode" + +export type TerminalDestination = "vscode" | "agentManager" + +const KEY = "kilo-code.new.agentManager.terminalButtonDestination" + +/** Unknown values fall back to the VS Code terminal so a stale or + * hand-edited setting never strands the user without a terminal. */ +export function resolveTerminalDestination(value: unknown): TerminalDestination { + return value === "agentManager" ? value : "vscode" +} + +export function readTerminalDestination(): TerminalDestination { + const config = vscode.workspace.getConfiguration("kilo-code.new.agentManager") + return resolveTerminalDestination(config.get("terminalButtonDestination")) +} + +export function affectsTerminalDestination(e: vscode.ConfigurationChangeEvent): boolean { + return e.affectsConfiguration(KEY) +} + +/** Subscribe to destination changes. Returns a cleanup function. */ +export function watchTerminalDestination(callback: (destination: TerminalDestination) => void): () => void { + const sub = vscode.workspace.onDidChangeConfiguration((e) => { + if (affectsTerminalDestination(e)) callback(readTerminalDestination()) + }) + return () => sub.dispose() +} diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 7c8c7e46cc2..566952f9b21 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -7,7 +7,7 @@ * Owns: * - the `TerminalManager` lifecycle (create / close / resize / dispose) * - the per-context "Terminal N" ordinal counter - * - cwd resolution (worktree path → workspace root fallback) + * - cwd resolution (selected worktree or workspace root) * - WebSocket URL construction with loopback `auth_token` auth * * Vscode-free: all VS Code access is funnelled through the `deps` @@ -15,7 +15,7 @@ */ import type { KiloClient } from "@kilocode/sdk/v2/client" -import type { AgentManagerInMessage, AgentManagerOutMessage, TerminalFont } from "./types" +import type { AgentManagerInMessage, AgentManagerOutMessage, TerminalFont, TerminalPlacement } from "./types" import { TerminalManager } from "./terminal-manager" interface ServerConfig { @@ -52,14 +52,19 @@ function isTerminalMessage( } export class TerminalRouter { - private readonly manager: TerminalManager + private manager: TerminalManager private readonly ordinals = new Map() + private generation = 0 constructor(private readonly deps: TerminalRoutingDeps) { - this.manager = new TerminalManager({ - getClient: () => deps.getClient(), + this.manager = this.createManager() + } + + private createManager(): TerminalManager { + return new TerminalManager({ + getClient: () => this.deps.getClient(), buildWsUrl: (ptyID, cwd) => this.buildWsUrl(ptyID, cwd), - log: deps.log, + log: this.deps.log, }) } @@ -71,7 +76,7 @@ export class TerminalRouter { handle(m: AgentManagerInMessage): boolean { if (!isTerminalMessage(m)) return false if (m.type === "agentManager.terminal.create") { - void this.handleCreate(m.worktreeId) + void this.handleCreate(m.createId, m.placement, m.worktreeId) return true } if (m.type === "agentManager.terminal.close") { @@ -85,25 +90,44 @@ export class TerminalRouter { return true } - /** Tear down every live PTY. Forwards to `TerminalManager.dispose`. */ + /** + * Tear down every live PTY and invalidate in-flight create requests. + * The router stays usable afterwards: a create landing from before the + * disposal is closed immediately instead of leaking a PTY the webview + * no longer tracks. + */ dispose(): Promise { - return this.manager.dispose() + this.generation++ + const manager = this.manager + this.manager = this.createManager() + return manager.dispose() } - private async handleCreate(worktreeId: string | null): Promise { + private async handleCreate(createId: string, placement: TerminalPlacement, worktreeId: string | null): Promise { + const generation = this.generation + const manager = this.manager const cwd = this.resolveCwd(worktreeId) if (!cwd) { this.deps.post({ type: "agentManager.terminal.error", - message: "Open a folder before creating a terminal", + createId, + message: worktreeId + ? "The selected worktree is no longer available" + : "Open a folder before creating a terminal", }) return } const title = `Terminal ${this.nextOrdinal(worktreeId)}` try { - const created = await this.manager.create({ worktreeId, cwd, title }) + const created = await manager.create({ worktreeId, cwd, title }) + if (generation !== this.generation) { + await manager.close(created.terminalId) + return + } this.deps.post({ type: "agentManager.terminal.created", + createId, + placement, worktreeId: created.worktreeId, terminalId: created.terminalId, title: created.title, @@ -111,22 +135,25 @@ export class TerminalRouter { font: this.deps.getTerminalFont(), }) } catch (err) { + if (generation !== this.generation) return const message = err instanceof Error ? err.message : String(err) this.deps.log(`Terminal create failed: ${message}`) - this.deps.post({ type: "agentManager.terminal.error", message }) + this.deps.post({ type: "agentManager.terminal.error", createId, message }) } } /** * Resolve the cwd for a terminal in the given context. * - * LOCAL (null) falls back to the workspace root; a worktree id - * resolves to its on-disk path. Returns undefined when no folder is - * open — the caller surfaces this as a user-facing error. + * LOCAL (null) uses the workspace root; a worktree id resolves strictly + * to its on-disk path — silently falling back to the workspace root + * would run the shell in the wrong directory. Returns undefined when + * no folder is open or the worktree is gone; the caller surfaces this + * as a user-facing error. */ private resolveCwd(worktreeId: string | null): string | undefined { if (worktreeId === null) return this.deps.getRoot() - return this.deps.getWorktreePath(worktreeId) ?? this.deps.getRoot() + return this.deps.getWorktreePath(worktreeId) } /** Per-context counter so default titles are "Terminal 1", "Terminal 2"… diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 63ade03fc94..45e963eed70 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -16,9 +16,13 @@ import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import" import type { ExternalWorktreeItem } from "./WorktreeManager" import type { RunStatus } from "./run/manager" import type { TerminalFont } from "./terminal-font" +import type { TerminalDestination } from "./terminal-destination" export type { TerminalFont } +/** Where a terminal lives: main tab strip or right-side inspector panel. */ +export type TerminalPlacement = "tab" | "side" + // --------------------------------------------------------------------------- // Shared payload types // --------------------------------------------------------------------------- @@ -140,6 +144,7 @@ interface StateMessage { runStatuses?: RunStatus[] runScriptConfigured?: boolean runScriptPath?: string + terminalDestination?: TerminalDestination } // --------------------------------------------------------------------------- @@ -148,6 +153,11 @@ interface StateMessage { interface TerminalCreatedMessage { type: "agentManager.terminal.created" + /** Correlates with the create request; lets the webview spot stale + * creates. Deliberately not named `requestId`: that field name is the + * generic webview request/response correlation channel. */ + createId: string + placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ worktreeId: string | null terminalId: string @@ -164,9 +174,16 @@ interface TerminalClosedMessage { interface TerminalErrorMessage { type: "agentManager.terminal.error" terminalId?: string + /** Set when the error answers a specific create request. */ + createId?: string message: string } +interface TerminalDestinationChangedMessage { + type: "agentManager.terminal.destinationChanged" + destination: TerminalDestination +} + interface TerminalFontChangedMessage { type: "agentManager.terminal.fontChanged" font: TerminalFont @@ -332,6 +349,7 @@ export type AgentManagerOutMessage = | TerminalCreatedMessage | TerminalClosedMessage | TerminalErrorMessage + | TerminalDestinationChangedMessage | TerminalFontChangedMessage // --------------------------------------------------------------------------- @@ -758,6 +776,9 @@ interface MoveSectionIn { interface TerminalCreateIn { type: "agentManager.terminal.create" + /** Webview-generated correlation id, echoed back in created/error. */ + createId: string + placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ worktreeId: string | null } diff --git a/packages/kilo-vscode/tests/accessibility.spec.ts b/packages/kilo-vscode/tests/accessibility.spec.ts index 151c71efb06..4df3806db55 100644 --- a/packages/kilo-vscode/tests/accessibility.spec.ts +++ b/packages/kilo-vscode/tests/accessibility.spec.ts @@ -13,6 +13,7 @@ const STORIES = [ { id: "settings--providers-configure", name: "Settings / providers empty state" }, { id: "marketplace--empty-list", name: "Marketplace / empty state" }, { id: "agentmanager--sidebar-search-open", name: "Agent Manager / sidebar search" }, + { id: "agentmanager--side-terminal-panel-empty", name: "Agent Manager / side terminal" }, { id: "session-tabs--switcher-open", name: "Session tabs / switcher" }, ] 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 bee29f59a9e..7160511132f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -42,6 +42,8 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"), path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalTab.tsx"), + path.join(ROOT, "webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), + path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/SortableTerminalTab.tsx"), path.join(ROOT, "webview-ui/agent-manager/terminal/render.tsx"), path.join(ROOT, "webview-ui/diff-virtual/DiffVirtualApp.tsx"), @@ -801,6 +803,10 @@ const VSCODE_ALLOWED: Record = { "terminal-font.ts": { note: "vscode config reader for integrated terminal font settings", }, + // Reads + watches the terminal button destination setting + "terminal-destination.ts": { + note: "vscode config reader for the terminal destination setting", + }, } /** diff --git a/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts index 691b8a7ab67..8142d2c20c0 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts @@ -26,6 +26,8 @@ const ROOT = path.resolve(import.meta.dir, "../..") const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), + path.join(ROOT, "webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"), + path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx"), ] /** 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 new file mode 100644 index 00000000000..40330aa29c4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-destination.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import { affectsTerminalDestination, resolveTerminalDestination } from "../../src/agent-manager/terminal-destination" + +function event(key: string) { + return { + affectsConfiguration: (target: string) => target === key, + } as Parameters[0] +} + +describe("Agent Manager terminal destination", () => { + it("defaults unknown settings to the VS Code terminal", () => { + expect(resolveTerminalDestination(undefined)).toBe("vscode") + expect(resolveTerminalDestination("invalid")).toBe("vscode") + expect(resolveTerminalDestination("vscode")).toBe("vscode") + expect(resolveTerminalDestination("agentManager")).toBe("agentManager") + }) + + it("watches only the terminal button destination setting", () => { + expect(affectsTerminalDestination(event("kilo-code.new.agentManager.terminalButtonDestination"))).toBe(true) + expect(affectsTerminalDestination(event("terminal.integrated.fontFamily"))).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts index 9bf257a8c3b..64504d2600b 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-font.test.ts @@ -61,7 +61,14 @@ describe("Agent Manager terminal font", () => { getTerminalFont: () => font, }) - expect(router.handle({ type: "agentManager.terminal.create", worktreeId: null })).toBe(true) + expect( + router.handle({ + type: "agentManager.terminal.create", + createId: "font-1", + placement: "tab", + worktreeId: null, + }), + ).toBe(true) }) const created = await message @@ -82,9 +89,12 @@ describe("Agent Manager terminal font", () => { saveTabMemory: () => undefined, setSelection: () => undefined, showError: () => undefined, + postMessage: () => undefined, }) const message = { type: "agentManager.terminal.created", + createId: "font-2", + placement: "tab", worktreeId: null, terminalId: "terminal-1", title: "Terminal 1", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts new file mode 100644 index 00000000000..bfa243d1fc8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { TerminalRouter } from "../../src/agent-manager/terminal-routing" +import type { AgentManagerOutMessage } from "../../src/agent-manager/types" + +const font = { fontFamily: "Menlo", fontSize: 12 } + +function wait() { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe("Agent Manager terminal routing", () => { + it("round-trips side placement and rejects missing worktrees", async () => { + const messages: AgentManagerOutMessage[] = [] + const client = { + pty: { + create: async () => ({ data: { id: "pty-1", title: "Terminal 1" } }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: (id) => (id === "wt-1" ? "/workspace/wt-1" : undefined), + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + router.handle({ + type: "agentManager.terminal.create", + createId: "side-1", + placement: "side", + worktreeId: "wt-1", + }) + await wait() + expect(messages[0]).toMatchObject({ + type: "agentManager.terminal.created", + createId: "side-1", + placement: "side", + worktreeId: "wt-1", + }) + + router.handle({ + type: "agentManager.terminal.create", + createId: "side-missing", + placement: "side", + worktreeId: "missing", + }) + expect(messages[1]).toMatchObject({ + type: "agentManager.terminal.error", + createId: "side-missing", + }) + await router.dispose() + }) + + it("isolates a reopened panel from an in-flight disposal", async () => { + const messages: AgentManagerOutMessage[] = [] + const removed: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + let creates = 0 + const client = { + pty: { + create: () => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + creates++ + if (creates === 1) resolvers.push(resolve) + else resolve({ data: { id: "pty-new", title: "Terminal 1" } }) + }), + remove: async ({ ptyID }: { ptyID: string }) => { + removed.push(ptyID) + return { data: true } + }, + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + router.handle({ + type: "agentManager.terminal.create", + createId: "old", + placement: "side", + worktreeId: null, + }) + await router.dispose() + router.handle({ + type: "agentManager.terminal.create", + createId: "new", + placement: "side", + worktreeId: null, + }) + resolvers[0]?.({ data: { id: "pty-old", title: "Terminal 1" } }) + await wait() + + expect(messages).toHaveLength(1) + expect(messages[0]).toMatchObject({ type: "agentManager.terminal.created", createId: "new" }) + expect(removed).toContain("pty-old") + await router.dispose() + expect(removed).toContain("pty-new") + }) +}) 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 new file mode 100644 index 00000000000..199d3adca2b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "bun:test" +import { createSideTerminal } from "../../webview-ui/agent-manager/terminal/side" + +function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolean; focused?: boolean } = {}) { + const calls = { + requestSide: 0, + closeSide: 0, + hide: 0, + refocus: 0, + openVscode: 0, + posted: [] as Array>, + tracked: [] as string[], + } + let visible = opts.visible ?? false + let focused = opts.focused ?? false + const ctl = createSideTerminal({ + handlers: { + requestSide: () => { + calls.requestSide++ + visible = true + }, + closeSide: () => { + calls.closeSide++ + visible = false + return true + }, + }, + visible: () => visible, + focused: () => focused, + hide: () => { + calls.hide++ + visible = false + }, + refocus: () => calls.refocus++, + postMessage: (msg) => calls.posted.push(msg as Record), + track: (button) => calls.tracked.push(button), + openVscode: () => calls.openVscode++, + }) + if (opts.destination) ctl.setDestination(opts.destination) + return { ctl, calls } +} + +describe("Agent Manager side terminal controller", () => { + it("toggles the panel and hands focus to the chat only when the terminal had it", () => { + const focused = scene({ destination: "agentManager", visible: true, focused: true }) + focused.ctl.toggle() + expect(focused.calls.hide).toBe(1) + expect(focused.calls.refocus).toBe(1) + + const elsewhere = scene({ destination: "agentManager", visible: true, focused: false }) + elsewhere.ctl.toggle() + expect(elsewhere.calls.hide).toBe(1) + expect(elsewhere.calls.refocus).toBe(0) + + const hidden = scene({ destination: "agentManager", visible: false }) + hidden.ctl.toggle() + expect(hidden.calls.requestSide).toBe(1) + expect(hidden.calls.hide).toBe(0) + }) + + it("refocuses the chat after killing a focused terminal, not otherwise", () => { + const focused = scene({ focused: true }) + expect(focused.ctl.close()).toBe(true) + expect(focused.calls.closeSide).toBe(1) + expect(focused.calls.refocus).toBe(1) + + const elsewhere = scene({ focused: false }) + expect(elsewhere.ctl.close()).toBe(true) + expect(elsewhere.calls.refocus).toBe(0) + }) + + it("routes the primary action by destination", () => { + const vscodeFirst = scene({ destination: "vscode" }) + vscodeFirst.ctl.openPreferred("tab_toolbar") + expect(vscodeFirst.calls.openVscode).toBe(1) + expect(vscodeFirst.calls.requestSide).toBe(0) + + const panelFirst = scene({ destination: "agentManager" }) + panelFirst.ctl.openPreferred("keyboard_shortcut") + expect(panelFirst.calls.requestSide).toBe(1) + expect(panelFirst.calls.openVscode).toBe(0) + }) + + it("persists the picked destination with a section-relative settings key", () => { + const item = scene() + item.ctl.choose("agentManager") + expect(item.ctl.destination()).toBe("agentManager") + expect(item.calls.posted).toEqual([ + { type: "updateSetting", key: "agentManager.terminalButtonDestination", value: "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 new file mode 100644 index 00000000000..432eed1fb6b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { LOCAL } from "../../webview-ui/agent-manager/navigate" +import { + createTerminalHandlers, + createTerminalMessageHandler, + createTerminalState, +} from "../../webview-ui/agent-manager/terminal/state" +import type { ExtensionMessage } from "../../webview-ui/src/types/messages/extension-messages" + +const font = { fontFamily: "Menlo", fontSize: 12 } + +function scene(initial: string | null = LOCAL) { + const [selection, setSelection] = createSignal(initial) + const state = createTerminalState(selection) + const posted: Array> = [] + const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], hidden: 0 } + const tabs = () => state.current().map((term) => term.id) + const handlers = createTerminalHandlers({ + state, + tabIds: tabs, + selectReview: () => undefined, + selectSessionTab: () => undefined, + clearSession: () => undefined, + resetOthers: () => undefined, + isPendingId: () => false, + findTab: () => undefined, + postMessage: (message) => posted.push(message as Record), + onShowSide: (key) => events.shown.push(key), + onHideSide: () => events.hidden++, + getSelection: selection, + LOCAL, + REVIEW_TAB_ID: "review", + }) + const dispatch = createTerminalMessageHandler({ + state, + activate: (id) => events.activated.push(id), + saveTabMemory: () => events.saved++, + setSelection: (value) => { + events.selected.push(value) + setSelection(value) + }, + showError: () => undefined, + postMessage: (message) => posted.push(message as Record), + }) + return { state, selection, setSelection, posted, events, handlers, dispatch } +} + +describe("Agent Manager terminal state", () => { + it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { + id: "terminal:tab", + title: "Terminal 1", + wsUrl: "ws://tab", + font, + placement: "tab", + }) + item.state.add(null, { + id: "terminal:side", + title: "Terminal 2", + wsUrl: "ws://side", + font, + placement: "side", + }) + + expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"]) + expect(item.state.all().map((term) => term.id)).toEqual(["terminal:tab"]) + expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"]) + expect(item.state.side()?.id).toBe("terminal:side") + + item.setSelection(null) + expect(item.state.current()).toEqual([]) + expect(item.state.sideKey()).toBe(LOCAL) + expect(item.state.side()?.id).toBe("terminal:side") + dispose() + }) + }) + + it("deduplicates side creation and reuses the terminal without tab side effects", () => { + createRoot((dispose) => { + const item = scene() + item.handlers.requestSide() + item.handlers.requestSide() + + expect(item.posted).toHaveLength(1) + const request = item.posted[0]! + expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null }) + const createId = String(request.createId) + const created = { + type: "agentManager.terminal.created", + createId, + placement: "side", + worktreeId: null, + terminalId: "terminal:side", + title: "Terminal 1", + wsUrl: "ws://side", + font, + } satisfies ExtensionMessage + expect(item.dispatch(created)).toBe(true) + expect(item.state.side()?.id).toBe("terminal:side") + expect(item.events.activated).toEqual([]) + expect(item.events.selected).toEqual([]) + expect(item.events.saved).toBe(0) + + item.handlers.requestSide() + expect(item.posted).toHaveLength(1) + expect(item.state.focusRequest()?.id).toBe("terminal:side") + dispose() + }) + }) + + it("creates explicit terminal tabs independently of the side destination", () => { + createRoot((dispose) => { + const item = scene("wt-1") + item.handlers.requestNew() + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", + placement: "tab", + worktreeId: "wt-1", + }) + dispose() + }) + }) + + it("cancels a side terminal that is still starting", () => { + createRoot((dispose) => { + const item = scene() + item.handlers.requestSide() + const request = item.posted[0]! + expect(item.handlers.closeSide()).toBe(true) + expect(item.state.pendingSide(LOCAL)).toBeUndefined() + + item.dispatch({ + type: "agentManager.terminal.created", + createId: String(request.createId), + placement: "side", + worktreeId: null, + terminalId: "terminal:late", + title: "Terminal 1", + wsUrl: "ws://late", + font, + }) + expect(item.state.side()).toBeUndefined() + expect(item.posted.at(-1)).toEqual({ type: "agentManager.terminal.close", terminalId: "terminal:late" }) + dispose() + }) + }) + + it("closes a side terminal without changing the active chat tab", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { + id: "terminal:side", + title: "Terminal 1", + wsUrl: "ws://side", + font, + placement: "side", + }) + expect(item.handlers.closeSide()).toBe(true) + expect(item.state.side()).toBeUndefined() + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:side" }]) + expect(item.events.hidden).toBe(1) + dispose() + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 1485db0e783..3e8e4d30677 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -117,6 +117,36 @@ describe("Extension — package.json command sync", () => { }) }) + it("keeps Agent Manager terminal shortcuts distinct", () => { + const terminal = pkg.contributes?.keybindings?.find( + (item: { command: string }) => item.command === "kilo-code.new.agentManager.showTerminal", + ) + const create = pkg.contributes?.keybindings?.find( + (item: { command: string }) => item.command === "kilo-code.new.agentManager.newTerminal", + ) + expect(terminal).toMatchObject({ + key: "ctrl+/", + mac: "cmd+/", + when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", + }) + expect(create).toMatchObject({ + key: "ctrl+shift+t", + mac: "cmd+shift+t", + when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", + }) + }) + + it("declares the Agent Manager terminal destination setting", () => { + const setting = pkg.contributes?.configuration?.properties?.["kilo-code.new.agentManager.terminalButtonDestination"] + expect(setting).toMatchObject({ + type: "string", + scope: "application", + default: "vscode", + enum: ["vscode", "agentManager"], + }) + expect(setting.enumDescriptions).toHaveLength(setting.enum.length) + }) + it("scopes the open PR shortcut to Agent Manager", () => { const binding = pkg.contributes?.keybindings?.find( (item: { command: string }) => item.command === "kilo-code.new.agentManager.openPR", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 92da93f7721..9d355c7006b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -120,7 +120,15 @@ import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order" import { createTabOrderSync } from "./tab-order-sync" import { reportRemoteSessions, reportVisibleSession, visible } from "./remote-sessions" import { ConstrainDragYAxis } from "../src/components/chat/TabDnd" -import { isTerminalTabId, createTerminalState, createTerminalHandlers, createTerminalMessageHandler } from "./terminal" +import { + SideTerminalPanel, + TerminalDestinationButton, + isTerminalTabId, + createTerminalState, + createTerminalHandlers, + createTerminalMessageHandler, + createSideTerminal, +} from "./terminal" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" @@ -185,7 +193,7 @@ interface ApplyState { } /** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */ type SidebarSelection = typeof LOCAL | string | null -type SidePanel = "diff" | "pr" | null +type SidePanel = "diff" | "pr" | "terminal" | null const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) // Fallback keybindings before extension sends resolved ones const MAX_JUMP_INDEX = 9 @@ -270,8 +278,8 @@ const AgentManagerContent: Component = () => { // rAF coalescing for resize handlers — at most one signal write per frame let sidebarRaf: number | undefined let pendingSidebarWidth: number | undefined - let diffRaf: number | undefined - let pendingDiffWidth: number | undefined + let sideRaf: number | undefined + let pendingSideWidth: number | undefined const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) @@ -279,7 +287,33 @@ const AgentManagerContent: Component = () => { const [diffDatas, setDiffDatas] = createSignal>({}) const [diffLoading, setDiffLoading] = createSignal(false) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + // The diff and terminal panels each remember their own width: a diff + // benefits from half the window, a terminal only needs about a third. + const TERMINAL_MIN_WIDTH = 360 + const TERMINAL_MAX_WIDTH = 640 const [diffWidth, setDiffWidth] = createSignal(Math.round(window.innerWidth * 0.5)) + const [terminalWidth, setTerminalWidth] = createSignal( + Math.min(TERMINAL_MAX_WIDTH, Math.max(TERMINAL_MIN_WIDTH, Math.round(window.innerWidth / 3))), + ) + // The hidden-but-mounted host still fits the terminal, so pick the + // terminal's width whenever one is alive and no other mode is showing. + const widthMode = () => sidePanel() ?? (terms.sides().length > 0 ? "terminal" : null) + const hostWidth = () => (widthMode() === "terminal" ? terminalWidth() : diffWidth()) + const sideMin = () => (widthMode() === "terminal" ? TERMINAL_MIN_WIDTH : 200) + const resizeSide = (width: number) => { + pendingSideWidth = Math.max(sideMin(), Math.min(width, window.innerWidth * 0.8)) + if (sideRaf !== undefined) return + sideRaf = requestAnimationFrame(() => { + sideRaf = undefined + if (widthMode() === "terminal") setTerminalWidth(pendingSideWidth!) + else setDiffWidth(pendingSideWidth!) + }) + } + const showSideTerminal = () => { + setHistory(false) + setReviewActive(false) + setSidePanel("terminal") + } const [reviewOpenByContext, setReviewOpenByContext] = createSignal>({}) const [reviewCommentsByContext, setReviewCommentsByContext] = createSignal>({}) @@ -1104,12 +1138,7 @@ const AgentManagerContent: Component = () => { requestAnimationFrame(() => sidebarSearchMenu?.open()) } } else if (msg.action === "showTerminal") { - // Cmd+/ opens the legacy VS Code integrated terminal for the - // active session (or local). The new xterm tab affordance has - // its own keybind (Cmd+Shift+T) so both coexist. - const id = session.currentSessionID() - if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) - else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" }) + sideCtl.openPreferred("keyboard_shortcut") } else if (msg.action === "toggleDiff") { if (reviewActive()) { closeReviewTab() @@ -1249,7 +1278,14 @@ const AgentManagerContent: Component = () => { setSelection, showError: (message) => showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }), + postMessage: (message) => vscode.postMessage(message as never), onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId), + onSideCreated: (contextKey, terminalId) => { + // Focus only when the user is still looking at this panel — + // a slow create landing after a mode switch must not steal it. + if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId) + }, + onDestinationChanged: (destination) => sideCtl.setDestination(destination), }) const unsubTerminals = vscode.onMessage((msg) => { terminalDispatch(msg) @@ -2057,11 +2093,30 @@ const AgentManagerContent: Component = () => { findTab: (id) => tabLookup().get(id), postMessage: (msg) => vscode.postMessage(msg as never), onRemove: freezeTabs, + onShowSide: showSideTerminal, + onHideSide: () => { + if (sidePanel() === "terminal") setSidePanel(null) + }, getSelection: selection, LOCAL, REVIEW_TAB_ID, }) + const sideCtl = createSideTerminal({ + handlers: termHandlers, + visible: () => sidePanel() === "terminal", + focused: () => terms.focusedId() !== undefined && terms.focusedId() === terms.side()?.id, + hide: () => setSidePanel(null), + refocus: () => window.dispatchEvent(new Event("focusPrompt")), + postMessage: (msg) => vscode.postMessage(msg as never), + track: (button, surface, properties) => metrics.track(button, surface, properties), + openVscode: () => { + const id = session.currentSessionID() + if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) + else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" }) + }, + }) + const handleReviewTabMouseDown = (e: MouseEvent) => { if (e.button !== 1) return e.preventDefault() @@ -2160,6 +2215,12 @@ const AgentManagerContent: Component = () => { // Close the currently active tab via keyboard shortcut. // If no tabs remain, fall through to close the selected worktree. const closeActiveTab = () => { + // A focused side terminal owns Cmd+W while its panel is visible — + // closing a chat tab out from under the user's cursor would be + // surprising. + if (sidePanel() === "terminal" && terms.focusedId() && terms.focusedId() === terms.side()?.id) { + if (sideCtl.close()) return + } if (termHandlers.closeActive()) { tabFocus.restore() return @@ -2815,28 +2876,17 @@ const AgentManagerContent: Component = () => { /> - {/* Legacy VS Code integrated terminal shortcut. Coexists - with the xterm terminal tabs (accessed via the `+` - split-button or Cmd+Shift+T): Cmd+/ still opens the - integrated terminal for the active session. */} - - { - metrics.track("vscode_terminal", "tab_toolbar") - const id = session.currentSessionID() - if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) - else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" }) - }} - /> - + {/* Terminal destination split button: the primary action + follows the user's setting (VS Code integrated terminal + or the embedded side panel), the dropdown picks which. + Cmd+Shift+T still creates an xterm tab via the `+` menu. */} + sidePanel() === "terminal"} + keybind={() => kb().showTerminal ?? ""} + onOpen={() => sideCtl.openPreferred("tab_toolbar")} + onChoose={sideCtl.choose} + /> @@ -2986,24 +3036,26 @@ const AgentManagerContent: Component = () => { - -
- { - pendingDiffWidth = Math.max(200, Math.min(w, window.innerWidth * 0.8)) - if (diffRaf === undefined) { - diffRaf = requestAnimationFrame(() => { - diffRaf = undefined - setDiffWidth(pendingDiffWidth!) - }) - } - }} - /> + {/* One inspector host for all right-side modes. It stays + mounted while a side terminal is alive — hidden via + .am-side-host-hidden (absolute + opacity), never + unmounted, so xterm render loops keep streaming. */} + 0}> +
+ + +
{ activeTerminalId={terms.activeId()} /> + sidePanel() === "terminal"} + onClose={() => sideCtl.close()} + onStart={() => termHandlers.requestSide()} + />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 97113821690..6cfde9cf3bb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -2515,6 +2515,13 @@ body.am-wt-dragging-active * { min-width: 250px; } +/* Fixed-width slot so menu items with and without a check mark align. */ +.am-menu-check { + display: flex; + flex-shrink: 0; + width: 16px; +} + .am-split-menu [data-slot="dropdown-menu-item"] { padding: 6px 10px; } @@ -4535,6 +4542,75 @@ body.vscode-high-contrast-light { z-index: 1; } +/* Side terminal panel — lives inside .am-diff-panel-wrapper next to the + diff and PR panels. Header reuses .am-diff-header metrics so the + chrome does not shift when switching inspector modes. Visibility is + opacity-only: the xterm render loop dies if the subtree leaves the + paint tree. */ +.am-side-terminal { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + opacity: 0; + pointer-events: none; + z-index: 1; + background: var(--vscode-terminal-background, #1e1e1e); + /* Force a dedicated compositor layer so opacity flips do not + re-lay-out the xterm canvases underneath. */ + will-change: opacity; +} + +.am-side-terminal-visible { + opacity: 1; + pointer-events: auto; +} + +.am-side-terminal-layer { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + opacity: 0; + pointer-events: none; +} + +.am-side-terminal-layer-active { + opacity: 1; + pointer-events: auto; +} + +.am-side-terminal-state { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-weak); + font-size: var(--font-size-small); + background: var(--vscode-terminal-background, #1e1e1e); +} + +.am-side-terminal-empty { + color: var(--text-weak); +} + +/* Hidden-but-alive side panel host: taken out of the flow so the chat + reclaims the width, but kept painted so hidden side terminals keep + streaming. Anchored to .am-detail-stack (position: relative). */ +.am-side-host-hidden { + position: absolute; + top: 0; + right: 0; + bottom: 0; + opacity: 0; + pointer-events: none; +} + .am-terminal-host { flex: 1; min-height: 0; 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 c930131f420..fa5d7e2191e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -61,6 +61,12 @@ export const dict = { "agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية", "agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء", "agentManager.terminal.connectionError": "خطأ في اتصال المحطة الطرفية", + "agentManager.terminal.kill": "إنهاء المحطة الطرفية", + "agentManager.terminal.empty": "لا توجد محطة طرفية هنا بعد", + "agentManager.terminal.start": "بدء المحطة الطرفية", + "agentManager.terminal.destination": "اختر ما الذي يفتحه زر المحطة الطرفية", + "agentManager.terminal.openInVscode": "المحطة الطرفية في VS Code", + "agentManager.terminal.openInPanel": "لوحة Agent Manager", "agentManager.terminal.errorTitle": "خطأ في المحطة الطرفية", "agentManager.setup.failed": "فشل إعداد مساحة العمل", "agentManager.setup.settingUp": "جارٍ إعداد مساحة العمل", 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 03efd3495c9..5e06e15c153 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -62,6 +62,12 @@ export const dict = { "agentManager.terminal.new": "Nova aba de terminal", "agentManager.terminal.ended": "terminal encerrado — feche a aba para dispensar", "agentManager.terminal.connectionError": "erro de conexão do terminal", + "agentManager.terminal.kill": "Encerrar terminal", + "agentManager.terminal.empty": "Ainda não há terminal aqui", + "agentManager.terminal.start": "Iniciar terminal", + "agentManager.terminal.destination": "Escolha o que o botão do terminal abre", + "agentManager.terminal.openInVscode": "Terminal do VS Code", + "agentManager.terminal.openInPanel": "Painel do Agent Manager", "agentManager.terminal.errorTitle": "Erro no terminal", "agentManager.setup.failed": "Falha na configuração do worktree", "agentManager.setup.settingUp": "Configurando worktree", 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 09f813f7d04..12b8b7b85aa 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -61,6 +61,12 @@ export const dict = { "agentManager.terminal.new": "Nova kartica terminala", "agentManager.terminal.ended": "terminal je završen — zatvorite karticu da biste odbacili", "agentManager.terminal.connectionError": "greška u vezi terminala", + "agentManager.terminal.kill": "Prekini terminal", + "agentManager.terminal.empty": "Ovdje još nema terminala", + "agentManager.terminal.start": "Pokreni terminal", + "agentManager.terminal.destination": "Odaberite šta otvara dugme terminala", + "agentManager.terminal.openInVscode": "VS Code terminal", + "agentManager.terminal.openInPanel": "Panel Agent Managera", "agentManager.terminal.errorTitle": "Greška terminala", "agentManager.setup.failed": "Postavljanje radnog prostora neuspješno", "agentManager.setup.settingUp": "Postavljanje radnog prostora", 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 f7dfe7697d9..95f4ea277e3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -63,6 +63,12 @@ export const dict = { "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal afsluttet — luk fanen for at fjerne", "agentManager.terminal.connectionError": "forbindelsesfejl til terminal", + "agentManager.terminal.kill": "Afslut terminal", + "agentManager.terminal.empty": "Ingen terminal her endnu", + "agentManager.terminal.start": "Start terminal", + "agentManager.terminal.destination": "Vælg, hvad terminalknappen åbner", + "agentManager.terminal.openInVscode": "VS Code-terminal", + "agentManager.terminal.openInPanel": "Agent Manager-panel", "agentManager.terminal.errorTitle": "Terminalfejl", "agentManager.setup.failed": "Opsætning af worktree mislykkedes", "agentManager.setup.settingUp": "Opsætter worktree", 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 e116ebd4f81..735e4b8a3ac 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -62,6 +62,12 @@ export const dict = { "agentManager.terminal.new": "Neuer Terminal-Tab", "agentManager.terminal.ended": "Terminal beendet — Tab schließen zum Verwerfen", "agentManager.terminal.connectionError": "Verbindungsfehler im Terminal", + "agentManager.terminal.kill": "Terminal beenden", + "agentManager.terminal.empty": "Hier ist noch kein Terminal", + "agentManager.terminal.start": "Terminal starten", + "agentManager.terminal.destination": "Auswählen, was die Terminal-Schaltfläche öffnet", + "agentManager.terminal.openInVscode": "VS Code-Terminal", + "agentManager.terminal.openInPanel": "Agent Manager-Panel", "agentManager.terminal.errorTitle": "Terminal-Fehler", "agentManager.setup.failed": "Einrichtung des Arbeitsbereichs fehlgeschlagen", "agentManager.setup.settingUp": "Arbeitsbereich wird eingerichtet", 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 02bc4333cc1..b5fa71f8def 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -65,6 +65,12 @@ export const dict = { "agentManager.terminal.new": "New Terminal Tab", "agentManager.terminal.ended": "terminal ended — close tab to dismiss", "agentManager.terminal.connectionError": "terminal connection error", + "agentManager.terminal.kill": "Kill terminal", + "agentManager.terminal.empty": "No terminal here yet", + "agentManager.terminal.start": "Start terminal", + "agentManager.terminal.destination": "Choose what the terminal button opens", + "agentManager.terminal.openInVscode": "VS Code terminal", + "agentManager.terminal.openInPanel": "Agent Manager panel", "agentManager.terminal.errorTitle": "Terminal error", "agentManager.setup.failed": "Worktree setup failed", 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 b3019f0253d..fee5328aed1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -62,6 +62,12 @@ export const dict = { "agentManager.terminal.new": "Nueva pestaña de terminal", "agentManager.terminal.ended": "terminal finalizado — cierra la pestaña para descartar", "agentManager.terminal.connectionError": "error de conexión del terminal", + "agentManager.terminal.kill": "Terminar terminal", + "agentManager.terminal.empty": "Aún no hay ningún terminal aquí", + "agentManager.terminal.start": "Iniciar terminal", + "agentManager.terminal.destination": "Elegir qué abre el botón del terminal", + "agentManager.terminal.openInVscode": "Terminal de VS Code", + "agentManager.terminal.openInPanel": "Panel de Agent Manager", "agentManager.terminal.errorTitle": "Error de terminal", "agentManager.setup.failed": "Error en la configuración del worktree", "agentManager.setup.settingUp": "Configurando worktree", 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 8618b9a955b..08b253dbea1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -62,6 +62,12 @@ export const dict = { "agentManager.terminal.new": "Nouvel onglet de terminal", "agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer", "agentManager.terminal.connectionError": "erreur de connexion du terminal", + "agentManager.terminal.kill": "Tuer le terminal", + "agentManager.terminal.empty": "Aucun terminal ici pour l'instant", + "agentManager.terminal.start": "Démarrer le terminal", + "agentManager.terminal.destination": "Choisir ce que le bouton Terminal ouvre", + "agentManager.terminal.openInVscode": "Terminal VS Code", + "agentManager.terminal.openInPanel": "Panneau Agent Manager", "agentManager.terminal.errorTitle": "Erreur du terminal", "agentManager.setup.failed": "Échec de la configuration de l'espace de travail", "agentManager.setup.settingUp": "Configuration de l'espace de travail", 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 10c36ee11b5..d1056ca2d59 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -66,6 +66,12 @@ export const dict = { "agentManager.terminal.new": "Nuova scheda terminale", "agentManager.terminal.ended": "terminale terminato - chiudi la scheda per nasconderlo", "agentManager.terminal.connectionError": "errore di connessione del terminale", + "agentManager.terminal.kill": "Termina terminale", + "agentManager.terminal.empty": "Qui non c'è ancora un terminale", + "agentManager.terminal.start": "Avvia terminale", + "agentManager.terminal.destination": "Scegli cosa apre il pulsante del terminale", + "agentManager.terminal.openInVscode": "Terminale di VS Code", + "agentManager.terminal.openInPanel": "Pannello Agent Manager", "agentManager.terminal.errorTitle": "Errore terminale", "agentManager.setup.failed": "Setup del worktree non riuscito", 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 9e44ca3cfaf..2a87b1b0070 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -62,6 +62,12 @@ export const dict = { "agentManager.terminal.new": "新しいターミナルタブ", "agentManager.terminal.ended": "ターミナルが終了しました — タブを閉じて破棄", "agentManager.terminal.connectionError": "ターミナル接続エラー", + "agentManager.terminal.kill": "ターミナルを終了", + "agentManager.terminal.empty": "ここにはまだターミナルがありません", + "agentManager.terminal.start": "ターミナルを開始", + "agentManager.terminal.destination": "ターミナルボタンで開く場所を選択", + "agentManager.terminal.openInVscode": "VS Codeのターミナル", + "agentManager.terminal.openInPanel": "Agent Managerパネル", "agentManager.terminal.errorTitle": "ターミナルエラー", "agentManager.setup.failed": "ワークスペースのセットアップに失敗しました", "agentManager.setup.settingUp": "ワークスペースをセットアップ中", 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 8d67919701f..6c8989e7769 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -61,6 +61,12 @@ export const dict = { "agentManager.terminal.new": "새 터미널 탭", "agentManager.terminal.ended": "터미널 종료됨 — 탭을 닫아 해제", "agentManager.terminal.connectionError": "터미널 연결 오류", + "agentManager.terminal.kill": "터미널 종료", + "agentManager.terminal.empty": "아직 여기에 터미널이 없습니다", + "agentManager.terminal.start": "터미널 시작", + "agentManager.terminal.destination": "터미널 버튼으로 열 위치 선택", + "agentManager.terminal.openInVscode": "VS Code 터미널", + "agentManager.terminal.openInPanel": "Agent Manager 패널", "agentManager.terminal.errorTitle": "터미널 오류", "agentManager.setup.failed": "워크스페이스 설정 실패", "agentManager.setup.settingUp": "워크스페이스 설정 중", 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 16f0c69fee0..9d4272149b9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -65,6 +65,12 @@ export const dict = { "agentManager.terminal.new": "Nieuw terminaltabblad", "agentManager.terminal.ended": "terminal beëindigd — sluit tabblad om te negeren", "agentManager.terminal.connectionError": "terminalverbindingsfout", + "agentManager.terminal.kill": "Terminal beëindigen", + "agentManager.terminal.empty": "Hier is nog geen terminal", + "agentManager.terminal.start": "Terminal starten", + "agentManager.terminal.destination": "Kies wat de terminalknop opent", + "agentManager.terminal.openInVscode": "VS Code-terminal", + "agentManager.terminal.openInPanel": "Agent Manager-paneel", "agentManager.terminal.errorTitle": "Terminalfout", "agentManager.setup.failed": "Worktree setup mislukt", 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 b72e1842892..1602ae29146 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -61,6 +61,12 @@ export const dict = { "agentManager.terminal.new": "Ny terminalfane", "agentManager.terminal.ended": "terminal avsluttet — lukk fanen for å avvise", "agentManager.terminal.connectionError": "tilkoblingsfeil for terminal", + "agentManager.terminal.kill": "Avslutt terminal", + "agentManager.terminal.empty": "Ingen terminal her ennå", + "agentManager.terminal.start": "Start terminal", + "agentManager.terminal.destination": "Velg hva terminalknappen åpner", + "agentManager.terminal.openInVscode": "VS Code-terminal", + "agentManager.terminal.openInPanel": "Agent Manager-panel", "agentManager.terminal.errorTitle": "Terminalfeil", "agentManager.setup.failed": "Oppsett av arbeidsområde mislyktes", "agentManager.setup.settingUp": "Setter opp arbeidsområde", 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 3a6e0fda56d..508c4e5f480 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -62,6 +62,12 @@ export const dict = { "agentManager.terminal.new": "Nowa karta terminala", "agentManager.terminal.ended": "terminal zakończony — zamknij kartę, aby zamknąć", "agentManager.terminal.connectionError": "błąd połączenia terminala", + "agentManager.terminal.kill": "Zakończ terminal", + "agentManager.terminal.empty": "Nie ma tu jeszcze terminala", + "agentManager.terminal.start": "Uruchom terminal", + "agentManager.terminal.destination": "Wybierz, co otwiera przycisk terminala", + "agentManager.terminal.openInVscode": "Terminal VS Code", + "agentManager.terminal.openInPanel": "Panel Agent Manager", "agentManager.terminal.errorTitle": "Błąd terminala", "agentManager.setup.failed": "Konfiguracja worktree nie powiodła się", "agentManager.setup.settingUp": "Konfigurowanie worktree", 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 d2f230a1cd7..ff2b99364e9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -62,6 +62,12 @@ export const dict = { "agentManager.terminal.new": "Новая вкладка терминала", "agentManager.terminal.ended": "терминал завершен — закройте вкладку, чтобы скрыть", "agentManager.terminal.connectionError": "ошибка подключения к терминалу", + "agentManager.terminal.kill": "Завершить терминал", + "agentManager.terminal.empty": "Здесь пока нет терминала", + "agentManager.terminal.start": "Запустить терминал", + "agentManager.terminal.destination": "Выберите, где будет открываться терминал", + "agentManager.terminal.openInVscode": "Терминал VS Code", + "agentManager.terminal.openInPanel": "Панель Agent Manager", "agentManager.terminal.errorTitle": "Ошибка терминала", "agentManager.setup.failed": "Не удалось настроить рабочее пространство", "agentManager.setup.settingUp": "Настройка рабочего пространства", 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 af8b8e11d5c..6748345b3b4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -61,6 +61,12 @@ export const dict = { "agentManager.terminal.new": "แท็บเทอร์มินัลใหม่", "agentManager.terminal.ended": "เทอร์มินัลสิ้นสุด — ปิดแท็บเพื่อยกเลิก", "agentManager.terminal.connectionError": "ข้อผิดพลาดการเชื่อมต่อเทอร์มินัล", + "agentManager.terminal.kill": "หยุดเทอร์มินัล", + "agentManager.terminal.empty": "ยังไม่มีเทอร์มินัลที่นี่", + "agentManager.terminal.start": "เริ่มเทอร์มินัล", + "agentManager.terminal.destination": "เลือกว่าปุ่มเทอร์มินัลจะเปิดอะไร", + "agentManager.terminal.openInVscode": "เทอร์มินัล VS Code", + "agentManager.terminal.openInPanel": "แผง Agent Manager", "agentManager.terminal.errorTitle": "ข้อผิดพลาดเทอร์มินัล", "agentManager.setup.failed": "ตั้งค่า Worktree ล้มเหลว", "agentManager.setup.settingUp": "กำลังตั้งค่า Worktree", 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 ba9711f61db..d7bfe7974c6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -66,6 +66,12 @@ export const dict = { "agentManager.terminal.new": "Yeni Terminal Sekmesi", "agentManager.terminal.ended": "terminal sona erdi — kapatmak için sekmeyi kapatın", "agentManager.terminal.connectionError": "terminal bağlantı hatası", + "agentManager.terminal.kill": "Terminali sonlandır", + "agentManager.terminal.empty": "Burada henüz terminal yok", + "agentManager.terminal.start": "Terminali başlat", + "agentManager.terminal.destination": "Terminal düğmesinin ne açacağını seçin", + "agentManager.terminal.openInVscode": "VS Code terminali", + "agentManager.terminal.openInPanel": "Agent Manager paneli", "agentManager.terminal.errorTitle": "Terminal hatası", "agentManager.setup.failed": "Worktree kurulumu başarısız", 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 2ce90dccfff..13f8b23a4df 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -66,6 +66,12 @@ export const dict = { "agentManager.terminal.new": "Нова вкладка термінала", "agentManager.terminal.ended": "термінал завершено — закрийте вкладку, щоб відхилити", "agentManager.terminal.connectionError": "помилка з'єднання термінала", + "agentManager.terminal.kill": "Завершити термінал", + "agentManager.terminal.empty": "Тут ще немає термінала", + "agentManager.terminal.start": "Запустити термінал", + "agentManager.terminal.destination": "Виберіть, що відкриватиме кнопка термінала", + "agentManager.terminal.openInVscode": "Термінал VS Code", + "agentManager.terminal.openInPanel": "Панель Agent Manager", "agentManager.terminal.errorTitle": "Помилка термінала", "agentManager.setup.failed": "Налаштування робочого дерева не вдалося", 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 fb46b8f06aa..94c78a94da2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -61,6 +61,12 @@ export const dict = { "agentManager.terminal.new": "新建终端标签页", "agentManager.terminal.ended": "终端已结束 — 关闭标签页以消除", "agentManager.terminal.connectionError": "终端连接错误", + "agentManager.terminal.kill": "终止终端", + "agentManager.terminal.empty": "此处尚无终端", + "agentManager.terminal.start": "启动终端", + "agentManager.terminal.destination": "选择终端按钮的打开目标", + "agentManager.terminal.openInVscode": "VS Code 终端", + "agentManager.terminal.openInPanel": "Agent Manager 面板", "agentManager.terminal.errorTitle": "终端错误", "agentManager.setup.failed": "工作区设置失败", "agentManager.setup.settingUp": "正在设置工作区", 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 b1655b23ee1..f9850760b34 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -61,6 +61,12 @@ export const dict = { "agentManager.terminal.new": "新增終端分頁", "agentManager.terminal.ended": "終端已結束 — 關閉分頁以消除", "agentManager.terminal.connectionError": "終端連線錯誤", + "agentManager.terminal.kill": "終止終端機", + "agentManager.terminal.empty": "此處尚無終端機", + "agentManager.terminal.start": "啟動終端機", + "agentManager.terminal.destination": "選擇終端機按鈕的開啟目標", + "agentManager.terminal.openInVscode": "VS Code 終端機", + "agentManager.terminal.openInPanel": "Agent Manager 面板", "agentManager.terminal.errorTitle": "終端錯誤", "agentManager.setup.failed": "工作區設定失敗", "agentManager.setup.settingUp": "正在設定工作區", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx new file mode 100644 index 00000000000..bf42185b831 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -0,0 +1,85 @@ +/** + * Right-side terminal panel for the Agent Manager inspector. + * + * Lives inside the shared `.am-diff-panel-wrapper` host next to the diff + * and PR panels, so all three inspector modes share one resize handle + * and one width. The header intentionally reuses the `.am-diff-header` + * structure and metrics so switching modes does not shift the chrome. + * + * Visibility is opacity-based, never unmount: the xterm render loop + * dies when its subtree leaves the paint tree (see `render.tsx`). + */ + +import type { Accessor, Component } from "solid-js" +import { Show, createEffect } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Button } from "@kilocode/kilo-ui/button" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { useLanguage } from "../../src/context/language" +import { renderSideTerminalLayer } from "./render" +import type { TerminalStateControls } from "./state" + +interface Props { + state: TerminalStateControls + /** Context the panel currently shows (`state.sideKey`). */ + contextKey: Accessor + /** True while the inspector is in terminal mode. */ + visible: Accessor + /** Kill the terminal (or cancel its create) and hide. */ + onClose: () => void + /** Empty-state action: create a side terminal for this context. */ + onStart: () => void +} + +export const SideTerminalPanel: Component = (props) => { + const { t } = useLanguage() + let panel!: HTMLElement + createEffect(() => { + panel.inert = !props.visible() + }) + const side = () => props.state.side() + const pending = () => props.state.pendingSide(props.contextKey()) !== undefined + return ( +
+
+
+ + {side()?.title ?? t("agentManager.tab.terminal")} +
+
+ + + +
+
+ {renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, visible: props.visible })} + +
+ + {t("common.loading")} +
+
+ +
+ {t("agentManager.terminal.empty")} + +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx new file mode 100644 index 00000000000..a0ae02bee2d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx @@ -0,0 +1,65 @@ +/** + * Terminal destination split button for the Agent Manager tab toolbar. + * + * The primary action opens whatever the user picked (VS Code integrated + * terminal or the embedded side panel); the dropdown switches between + * them. Markup mirrors the `+` new-tab split button in + * `tab-rendering.tsx` so both share the same split-button styling. + */ + +import type { Accessor, Component } from "solid-js" +import { Show } from "solid-js" +import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { useLanguage } from "../../src/context/language" +import type { TerminalDestination } from "../../src/types/messages/agent-manager" + +interface Props { + destination: Accessor + /** True while the embedded terminal panel is showing. */ + active: Accessor + keybind: Accessor + onOpen: () => void + onChoose: (destination: TerminalDestination) => void +} + +export const TerminalDestinationButton: Component = (props) => { + const { t } = useLanguage() + const item = (destination: TerminalDestination, label: string) => ( + props.onChoose(destination)}> + + + + + + {label} + + ) + return ( +
+ + + + + + + + + + {item("vscode", t("agentManager.terminal.openInVscode"))} + {item("agentManager", t("agentManager.terminal.openInPanel"))} + + + +
+ ) +} 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 82669255354..792d2385866 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -38,6 +38,14 @@ interface Props { * an xterm re-paint when the slot transitions back to visible after * sitting behind an occluding layer. */ active: boolean + /** Serial of the latest explicit focus request for this terminal + * (`state.focusRequest()`), consumed so re-requesting focus on an + * already-visible terminal still re-focuses it. */ + focusSerial?: number + /** Reports DOM focus entering or leaving the xterm host. The state + * layer tracks this as `focusedId` so `Cmd+W` can target the + * terminal that actually has the cursor. */ + onFocusChange?: (focused: boolean) => void } /** How long the ResizeObserver waits after the last size change before @@ -188,6 +196,17 @@ export const TerminalTab: Component = (props) => { // ⌘T / ⌘W / ⌘⌥← etc. still work while the terminal is focused. term.attachCustomKeyEventHandler((event) => !isAgentManagerShortcut(event)) + // Track DOM focus so the state layer knows which terminal holds the + // cursor (drives Cmd+W targeting). focusout is ignored when focus + // moves within the same host (xterm shuffles inner nodes). + const onFocusIn = () => props.onFocusChange?.(true) + const onFocusOut = (event: FocusEvent) => { + if (event.relatedTarget instanceof Node && host.contains(event.relatedTarget)) return + props.onFocusChange?.(false) + } + host.addEventListener("focusin", onFocusIn) + host.addEventListener("focusout", onFocusOut) + const ws = new WebSocket(props.wsUrl) ws.binaryType = "arraybuffer" let closed = false @@ -260,7 +279,12 @@ export const TerminalTab: Component = (props) => { // — historically "press Enter to wake it up". Forcing a // `fit + refresh(0, rows-1)` once per activation reclaims the paint // priority; from then on the browser keeps the canvas live. + // + // Focus is opt-in per repaint (`shouldFocus`): repaints triggered by + // resizes or font changes must not yank the cursor out of the chat + // input, only explicit activation / focus requests may. let pendingFrame: number | null = null + let shouldFocus = false const isRenderable = () => { if (!host.isConnected) return false const rect = host.getBoundingClientRect() @@ -278,9 +302,11 @@ export const TerminalTab: Component = (props) => { log("repaint fit() threw", err) } term.refresh(0, Math.max(0, term.rows - 1)) - if (document.hasFocus()) term.focus() + if (shouldFocus && document.hasFocus()) term.focus() + shouldFocus = false } - const scheduleRepaint = () => { + const scheduleRepaint = (focus = false) => { + shouldFocus ||= focus if (pendingFrame !== null) return pendingFrame = requestAnimationFrame(runRepaint) } @@ -309,26 +335,37 @@ export const TerminalTab: Component = (props) => { scheduleRepaint() }) - let wasActive = props.active + // Activation and explicit focus requests focus the terminal; + // deactivation blurs it so keystrokes never land in a hidden xterm. + // `wasActive` starts false so a terminal mounted already-active (the + // create-and-activate flow) still gets its initial focus repaint. + let wasActive = false + let focusSerial = -1 createEffect(() => { const now = props.active - if (now && !wasActive) scheduleRepaint() + const serial = props.focusSerial ?? 0 + if (now && (!wasActive || serial !== focusSerial)) scheduleRepaint(true) + if (!now && wasActive) term.blur() wasActive = now + focusSerial = serial }) // Also recover when the user returns from an external window or the // OS-level window manager (alt-tab, browser → VS Code, etc.) — the // browser often suspends canvas paint while the window is in the // background, and the Solid `active` prop alone doesn't see that. - // Gated on `props.active` so inactive tabs don't do needless work. + // Gated on `props.active` so inactive tabs don't do needless work, + // and on the terminal already owning focus so returning to the + // window never steals the cursor back from the chat input. + const ownsFocus = () => host.contains(document.activeElement) const onVisibilityChange = () => { if (document.hidden) return - if (!props.active) return - scheduleRepaint() + if (!props.active || !ownsFocus()) return + scheduleRepaint(true) } const onWindowFocus = () => { - if (!props.active) return - scheduleRepaint() + if (!props.active || !ownsFocus()) return + scheduleRepaint(true) } document.addEventListener("visibilitychange", onVisibilityChange) window.addEventListener("focus", onWindowFocus) @@ -350,6 +387,8 @@ export const TerminalTab: Component = (props) => { if (pendingFrame !== null) cancelAnimationFrame(pendingFrame) document.removeEventListener("visibilitychange", onVisibilityChange) window.removeEventListener("focus", onWindowFocus) + host.removeEventListener("focusin", onFocusIn) + host.removeEventListener("focusout", onFocusOut) fontSub() themeObserver.disconnect() clearTimeout(resizeTimer) 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 22dd8038a15..6bac7cab9bb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts @@ -20,6 +20,9 @@ export { createTerminalMessageHandler, } from "./state" export type { TerminalTabState, TerminalStateControls, TerminalHandlerDeps } from "./state" -export { renderTerminalTab, renderTerminalLayer } from "./render" +export { renderTerminalTab, renderTerminalLayer, renderSideTerminalLayer } from "./render" +export { SideTerminalPanel } from "./SideTerminalPanel" +export { TerminalDestinationButton } from "./TerminalDestinationButton" +export { createSideTerminal } from "./side" export { TerminalTab } from "./TerminalTab" export { SortableTerminalTab } from "./SortableTerminalTab" 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 ad6fced8aa6..df304f59627 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx @@ -7,11 +7,18 @@ */ import { For, Show } from "solid-js" -import type { JSX } from "solid-js" +import type { Accessor, JSX } from "solid-js" import { SortableTerminalTab } from "./SortableTerminalTab" import { TerminalTab } from "./TerminalTab" import type { TerminalStateControls } from "./state" +/** Serial of the latest focus request addressed to `id`, or 0. Read + * inside JSX so the effect re-runs when a request lands. */ +function focusSerial(state: TerminalStateControls, id: string): number { + const request = state.focusRequest() + return request?.id === id ? request.serial : 0 +} + export interface TerminalTabRenderDeps { id: string terms: TerminalStateControls @@ -94,8 +101,15 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS {(term) => { const visible = () => slotVisible(term.id, term.contextKey) return ( -
- +
+ props.state.setFocusedId(focused ? term.id : undefined)} + />
) }} @@ -104,3 +118,40 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS ) } + +/** + * Render the side-panel terminal layer inside the right-hand inspector. + * + * Same paint-tree invariant as `renderTerminalLayer`: every side + * terminal stays mounted, visibility is toggled via `opacity` / + * `pointer-events` / `inert` only. The layer is scoped to + * `contextKey` — side terminals from other contexts stay composed in + * the background and never refit. + */ +export function renderSideTerminalLayer(props: { + state: TerminalStateControls + contextKey: Accessor + visible: Accessor +}): JSX.Element { + return ( +
+ + {(term) => { + const active = () => props.visible() && term.contextKey === props.contextKey() + return ( +
+ props.state.setFocusedId(focused ? term.id : undefined)} + /> +
+ ) + }} +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts new file mode 100644 index 00000000000..3c7fe391450 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts @@ -0,0 +1,94 @@ +/** + * Right-side terminal wiring for the Agent Manager webview. + * + * Extracted from AgentManagerApp.tsx to keep that file under the + * `max-lines` lint cap. Owns the destination preference plus the toggle + * semantics of the toolbar button / `Cmd/Ctrl+/` shortcut, so the + * embedded terminal behaves like the diff panel: press once to reveal, + * press again to hide. Hiding never kills the terminal — only the + * explicit close action (or `Cmd+W` while it holds focus) does. + */ + +import { createSignal } from "solid-js" +import type { Accessor } from "solid-js" +import type { TerminalDestination } from "../../src/types/messages/agent-manager" + +interface Handlers { + requestSide(): void + closeSide(): boolean +} + +export interface SideTerminalDeps { + handlers: Handlers + /** True while the right-side inspector shows the terminal. */ + visible: Accessor + /** True while the side terminal itself holds DOM focus. */ + focused: Accessor + /** Leave terminal mode; the terminal stays alive in the background. */ + hide: () => void + /** Move focus back to the chat composer. */ + refocus: () => void + postMessage: (msg: unknown) => void + track: (button: string, surface: string, properties: Record) => void + /** Open or focus the VS Code integrated terminal for the active context. */ + openVscode: () => void +} + +export function createSideTerminal(deps: SideTerminalDeps) { + const [destination, setDestination] = createSignal("vscode") + + /** + * Hiding while the terminal holds focus would strand the cursor on + * , so hand it to the chat composer — the common flow is + * type → Cmd+/ → run command → Cmd+/ → keep typing. When the user + * was anywhere else (chat, diff, another tab), focus stays put. + */ + const handoff = (wasFocused: boolean) => { + if (wasFocused) deps.refocus() + } + + const toggle = () => { + if (deps.visible()) { + const was = deps.focused() + deps.hide() + handoff(was) + return + } + deps.handlers.requestSide() + } + + /** Kill the current context's side terminal (or cancel its in-flight + * create) and hide the panel. */ + const close = (): boolean => { + const was = deps.focused() + const done = deps.handlers.closeSide() + if (done) handoff(was) + return done + } + + /** Toolbar button and `Cmd/Ctrl+/`: follow the user's destination. */ + const openPreferred = (trigger: "keyboard_shortcut" | "tab_toolbar") => { + const target = destination() + deps.track("terminal", trigger, { destination: target }) + if (target === "agentManager") { + toggle() + return + } + deps.openVscode() + } + + /** + * Dropdown pick. Applied locally right away so the button reacts + * without a round trip, then persisted as a VS Code setting; the + * extension echoes it back via `terminal.destinationChanged`. + * The key is relative to the `kilo-code.new` section, matching every + * other `updateSetting` sender. + */ + const choose = (target: TerminalDestination) => { + deps.track("terminal_destination", "tab_toolbar", { destination: target }) + setDestination(target) + deps.postMessage({ type: "updateSetting", key: "agentManager.terminalButtonDestination", value: target }) + } + + return { destination, setDestination, toggle, close, openPreferred, choose } +} 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 f472623b287..3e9fba7af14 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -5,13 +5,18 @@ * `max-lines` lint cap. Owns the per-context terminal list, the * `activeTerminalId` focus signal, and a small set of imperative * helpers the main component composes with its existing tab logic. + * + * Main terminal tabs and right-side terminals share the same PTY + * transport, but their UI state is intentionally separate: tab + * activation replaces the chat, while a side terminal lives in the + * right-hand inspector and keeps the current session visible. */ -import { createMemo, createSignal } from "solid-js" +import { createSignal } from "solid-js" import type { Accessor } from "solid-js" import { LOCAL } from "../navigate" import type { ExtensionMessage } from "../../src/types/messages/extension-messages" -import type { TerminalFont } from "../../src/types/messages/agent-manager" +import type { TerminalDestination, TerminalFont, TerminalPlacement } from "../../src/types/messages/agent-manager" export type { TerminalFont } @@ -26,6 +31,7 @@ export interface TerminalTabState { title: string wsUrl: string font: TerminalFont + placement: TerminalPlacement } /** Terminal row enriched with the sidebar context it belongs to. Used by @@ -35,26 +41,59 @@ export interface TerminalTabStateWithContext extends TerminalTabState { contextKey: string } +/** Explicit focus demand, consumed by `TerminalTab` via the render layer. + * The serial lets repeated requests for the same terminal retrigger the + * focus effect. */ +export interface TerminalFocusRequest { + id: string + serial: number +} + +/** A create request for a side terminal that has not been answered yet. + * `cancelled` is set when the user closes the panel while the PTY is + * still starting; the late `created` answer is then closed again. */ +interface SideRequest { + contextKey: string + cancelled: boolean +} + export interface TerminalStateControls { /** Record received from `terminal.created`. */ add(worktreeId: string | null, term: TerminalTabState): void - /** Drop a terminal from its context (location resolved automatically). */ - remove(terminalId: string): string | undefined + /** Drop a terminal from its context (location resolved automatically). + * Returns the removed record so callers can react to placement. */ + remove(terminalId: string): TerminalTabStateWithContext | undefined /** Resolve the context key a terminal lives in, if any. */ contextFor(terminalId: string): string | undefined - /** All terminals for the given sidebar selection. */ + /** All tab terminals for the given sidebar selection. */ forSelection(selection: string | null): TerminalTabStateWithContext[] /** Map of { id -> tab state } for O(1) lookup. */ lookup: Accessor> - /** All terminals for the currently selected context. */ + /** All tab terminals for the currently selected context. */ current: Accessor - /** Every terminal across every context (for the persistent render layer). */ + /** Every tab terminal across every context (for the persistent render layer). */ all: Accessor + /** Every side terminal across every context (for the side-panel layer). */ + sides: Accessor + /** The side terminal of the current context, if any. */ + side: Accessor + /** The side terminal of an arbitrary context, if any. */ + sideForContext(contextKey: string): TerminalTabStateWithContext | undefined /** Context key for the current sidebar selection, or `undefined` when nothing is selected. */ currentKey: Accessor + /** Context key for the side panel: like `currentKey` but unassigned + * sessions (null selection) share the LOCAL workspace-root terminal. */ + sideKey: Accessor /** Active terminal id signal + setter. */ activeId: Accessor setActiveId: (id: string | undefined) => void + /** The terminal that currently holds DOM focus, if any. Set by + * `TerminalTab` focus listeners; drives `Cmd+W` targeting. */ + focusedId: Accessor + setFocusedId: (id: string | undefined) => void + /** Latest explicit focus demand. */ + focusRequest: Accessor + requestFocus(id: string): void /** True when the given remembered tab id points to a live terminal for the given selection. */ hasRemembered(selection: string | null, remembered: string | undefined): boolean /** @@ -70,49 +109,96 @@ export interface TerminalStateControls { * was applied, false otherwise so the caller can fall through. */ reorderDrag(from: string, to: string): boolean + /** Request id of the in-flight side-terminal create for a context. */ + pendingSide(contextKey: string): string | undefined + /** Mark a side-terminal create as in flight for a context. */ + beginSide(contextKey: string, createId: string): void + /** Cancel the in-flight create; returns true when one was pending. */ + cancelSide(contextKey: string): boolean + /** Settle a create request; returns it so the caller can validate. */ + completeSide(createId: string): SideRequest | undefined } /** Wire up reactive state for terminal tabs. The caller passes the current - * `selection()` accessor so memos can key by the right context. + * `selection()` accessor so the accessors below key by the right context. * * ## Reference stability * * Terminals are stored as `TerminalTabStateWithContext` (contextKey - * baked in) so the reactive accessors below can return them *by - * reference* without ever allocating a new object per terminal. That - * matters because Solid's `` uses element reference equality to - * decide whether a child is "the same" across renders. If `all()` - * created `{...t, contextKey}` each time (the original bug), adding - * a new terminal to context A would rewrite every object in every - * context — `` would then unmount + remount every live xterm - * across the whole app, destroying instances and losing canvas state. + * baked in) so the accessors below can return them *by reference* + * without ever allocating a new object per terminal. That matters + * because Solid's `` uses element reference equality to decide + * whether a child is "the same" across renders. If `all()` created + * `{...t, contextKey}` each time (the original bug), adding a new + * terminal to context A would rewrite every object in every context — + * `` would then unmount + remount every live xterm across the + * whole app, destroying instances and losing canvas state. + * + * ## Plain accessors, not memos + * + * The derived accessors are plain functions rather than `createMemo`. + * Signal reads inside them are still tracked by whatever computation + * calls them, and `` identity comes from the stored records above + * (not from the array), so behavior is identical — while the module + * stays unit-testable: bun resolves `solid-js` to its server build, + * where a memo never recomputes after a signal write. Re-filtering a + * handful of terminals per read is far cheaper than an xterm frame. */ export function createTerminalState(selection: Accessor): TerminalStateControls { const [terminalsByContext, setTerminalsByContext] = createSignal>({}) const [activeId, setActiveId] = createSignal() + const [focusedId, setFocusedId] = createSignal() + const [focusRequest, setFocusRequest] = createSignal() + let focusSerial = 0 + // In-flight side-terminal creates, keyed both ways: per context (what + // the panel shows) and per request id (what the answer carries). + const [pending, setPending] = createSignal>({}) + const requests = new Map() - const currentKey = createMemo((): string | undefined => { + const currentKey = (): string | undefined => { const sel = selection() if (sel === null) return undefined return sel === LOCAL ? LOCAL : sel - }) + } + + const sideKey = (): string => { + const sel = selection() + if (sel === null || sel === LOCAL) return LOCAL + return sel + } - const current = createMemo((): TerminalTabStateWithContext[] => { + const current = (): TerminalTabStateWithContext[] => { const key = currentKey() if (!key) return [] - return terminalsByContext()[key] ?? [] - }) + return (terminalsByContext()[key] ?? []).filter((t) => t.placement === "tab") + } - const all = createMemo((): TerminalTabStateWithContext[] => { + const all = (): TerminalTabStateWithContext[] => { const map = terminalsByContext() // Concat existing per-context arrays without spreading their // elements, so the same record references flow through to . const out: TerminalTabStateWithContext[] = [] - for (const list of Object.values(map)) out.push(...list) + for (const list of Object.values(map)) { + for (const t of list) if (t.placement === "tab") out.push(t) + } + return out + } + + const sides = (): TerminalTabStateWithContext[] => { + const map = terminalsByContext() + // Same reference-stability rule as `all` — the side render layer is + // a over live xterm instances too. + const out: TerminalTabStateWithContext[] = [] + for (const list of Object.values(map)) { + for (const t of list) if (t.placement === "side") out.push(t) + } return out - }) + } - const lookup = createMemo(() => new Map(current().map((t) => [t.id, t]))) + const sideForContext = (key: string) => terminalsByContext()[key]?.find((t) => t.placement === "side") + const side = () => sideForContext(sideKey()) + + const lookup = () => new Map(current().map((t) => [t.id, t])) const contextFor = (terminalId: string): string | undefined => { for (const [key, terms] of Object.entries(terminalsByContext())) { @@ -124,7 +210,7 @@ export function createTerminalState(selection: Accessor): Termina const forSelection = (sel: string | null): TerminalTabStateWithContext[] => { if (sel === null) return [] const key = sel === LOCAL ? LOCAL : sel - return terminalsByContext()[key] ?? [] + return (terminalsByContext()[key] ?? []).filter((t) => t.placement === "tab") } const add = (worktreeId: string | null, term: TerminalTabState) => { @@ -132,14 +218,18 @@ export function createTerminalState(selection: Accessor): Termina setTerminalsByContext((prev) => { const list = prev[key] ?? [] if (list.some((t) => t.id === term.id)) return prev + // One side terminal per context; the message handler dedupes via + // pending requests, this guard covers stale double answers. + if (term.placement === "side" && list.some((t) => t.placement === "side")) return prev const enriched: TerminalTabStateWithContext = { ...term, contextKey: key } return { ...prev, [key]: [...list, enriched] } }) } - const remove = (terminalId: string): string | undefined => { + const remove = (terminalId: string): TerminalTabStateWithContext | undefined => { const key = contextFor(terminalId) if (!key) return undefined + const removed = terminalsByContext()[key]?.find((t) => t.id === terminalId) setTerminalsByContext((prev) => { const list = (prev[key] ?? []).filter((t) => t.id !== terminalId) const next = { ...prev } @@ -147,7 +237,13 @@ export function createTerminalState(selection: Accessor): Termina else next[key] = list return next }) - return key + if (focusedId() === terminalId) setFocusedId(undefined) + return removed + } + + const requestFocus = (id: string) => { + focusSerial++ + setFocusRequest({ id, serial: focusSerial }) } const hasRemembered = (sel: string | null, remembered: string | undefined): boolean => { @@ -159,7 +255,11 @@ export function createTerminalState(selection: Accessor): Termina setTerminalsByContext((prev) => { const list = prev[key] if (!list || list.length === 0) return prev - const byId = new Map(list.map((t) => [t.id, t])) + // Tab order only covers tab terminals; side terminals never join + // the tab strip and keep their position at the end of the list. + const tabs = list.filter((t) => t.placement === "tab") + const side = list.filter((t) => t.placement === "side") + const byId = new Map(tabs.map((t) => [t.id, t])) const next: TerminalTabStateWithContext[] = [] for (const id of orderedIds) { const t = byId.get(id) @@ -172,9 +272,10 @@ export function createTerminalState(selection: Accessor): Termina // appeared between drag start and commit) at their original tail // position — simpler than merging and matches the existing // `applyTabOrder` semantics used elsewhere in the app. - for (const t of list) if (byId.has(t.id)) next.push(t) - if (next.length === list.length && next.every((t, i) => t.id === list[i]!.id)) return prev - return { ...prev, [key]: next } + for (const t of tabs) if (byId.has(t.id)) next.push(t) + const ordered = [...next, ...side] + if (ordered.length === list.length && ordered.every((t, i) => t.id === list[i]!.id)) return prev + return { ...prev, [key]: ordered } }) } @@ -187,7 +288,7 @@ export function createTerminalState(selection: Accessor): Termina const reorderDrag = (from: string, to: string): boolean => { const key = currentKey() if (!key) return false - const order = (terminalsByContext()[key] ?? []).map((t) => t.id) + const order = current().map((t) => t.id) const fi = order.indexOf(from) const ti = order.indexOf(to) if (fi === -1 || ti === -1 || fi === ti) return false @@ -198,6 +299,39 @@ export function createTerminalState(selection: Accessor): Termina return true } + const pendingSide = (key: string) => pending()[key] + + const beginSide = (key: string, createId: string) => { + requests.set(createId, { contextKey: key, cancelled: false }) + setPending((prev) => ({ ...prev, [key]: createId })) + } + + const cancelSide = (key: string): boolean => { + const id = pending()[key] + if (!id) return false + const request = requests.get(id) + if (request) request.cancelled = true + setPending((prev) => { + const next = { ...prev } + delete next[key] + return next + }) + return true + } + + const completeSide = (createId: string): SideRequest | undefined => { + const request = requests.get(createId) + if (!request) return undefined + requests.delete(createId) + setPending((prev) => { + if (prev[request.contextKey] !== createId) return prev + const next = { ...prev } + delete next[request.contextKey] + return next + }) + return request + } + return { add, remove, @@ -206,12 +340,24 @@ export function createTerminalState(selection: Accessor): Termina lookup, current, all, + sides, + side, + sideForContext, currentKey, + sideKey, activeId, setActiveId, + focusedId, + setFocusedId, + focusRequest, + requestFocus, hasRemembered, reorder, reorderDrag, + pendingSide, + beginSide, + cancelSide, + completeSide, } } @@ -228,6 +374,10 @@ export interface TerminalHandlerDeps { findTab: (id: string) => { id: string } | undefined postMessage: (msg: unknown) => void onRemove?: () => void + /** Reveal the right-side inspector in terminal mode. */ + onShowSide: (contextKey: string) => void + /** Leave terminal mode without killing the terminal. */ + onHideSide: () => void /** Resolve the current sidebar selection for the new-terminal helper. */ getSelection: () => string | null /** Sentinel value for the LOCAL sidebar selection. */ @@ -235,6 +385,12 @@ export interface TerminalHandlerDeps { REVIEW_TAB_ID: string } +/** Correlation ids for terminal create requests. */ +function newId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID() + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + /** * Build the close-terminal handler the main component wires to the * close button. Picks the next visible tab before dropping the entry @@ -253,7 +409,38 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { const requestNew = () => { const sel = deps.getSelection() if (sel === null) return - deps.postMessage({ type: "agentManager.terminal.create", worktreeId: sel === deps.LOCAL ? null : sel }) + deps.postMessage({ + type: "agentManager.terminal.create", + createId: newId(), + placement: "tab", + worktreeId: sel === deps.LOCAL ? null : sel, + }) + } + + /** + * Reveal the side panel and create-or-focus the context's side + * terminal. Reuses the existing terminal when one is alive, dedupes + * against an in-flight create, and never touches the tab strip or + * the chat session. + */ + const requestSide = () => { + const key = deps.state.sideKey() + deps.onShowSide(key) + const existing = deps.state.sideForContext(key) + if (existing) { + deps.state.requestFocus(existing.id) + return + } + if (deps.state.pendingSide(key)) return + const id = newId() + deps.state.beginSide(key, id) + const sel = deps.getSelection() + deps.postMessage({ + type: "agentManager.terminal.create", + createId: id, + placement: "side", + worktreeId: sel === null || sel === deps.LOCAL ? null : sel, + }) } const closeTerminal = (terminalId: string) => { @@ -289,6 +476,20 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { deps.postMessage({ type: "agentManager.terminal.close", terminalId }) } + /** + * Kill the current context's side terminal and hide the panel. With + * a create still in flight, cancels it instead — the late answer is + * closed by the message handler. + */ + const closeSide = () => { + const term = deps.state.side() + deps.onHideSide() + if (!term) return deps.state.cancelSide(deps.state.sideKey()) + deps.state.remove(term.id) + deps.postMessage({ type: "agentManager.terminal.close", terminalId: term.id }) + return true + } + const middleClick = (terminalId: string, e: MouseEvent) => { if (e.button !== 1) return e.preventDefault() @@ -303,7 +504,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { return true } - return { closeTerminal, middleClick, activate, deactivate, requestNew, closeActive } + return { closeTerminal, closeSide, middleClick, activate, deactivate, requestNew, requestSide, closeActive } } export interface TerminalMessageHandlerDeps { @@ -312,6 +513,7 @@ export interface TerminalMessageHandlerDeps { saveTabMemory: () => void setSelection: (sel: string | typeof LOCAL) => void showError: (message: string) => void + postMessage: (message: unknown) => void /** * Called with the context key ("local" or worktree id) and the new * terminal id once a `terminal.created` message lands. The main @@ -320,39 +522,85 @@ export interface TerminalMessageHandlerDeps { * than wherever `tabIds()`'s base composition happens to put it. */ onCreated?: (contextKey: string, terminalId: string) => void + /** Side terminal for a context finished creating. */ + onSideCreated?: (contextKey: string, terminalId: string) => void + /** Side terminal create failed for a context. */ + onSideError?: (contextKey: string) => void + /** Side terminal was closed (locally or by the extension). */ + onSideClosed?: (contextKey: string) => void + /** The destination setting changed (live settings sync). */ + onDestinationChanged?: (destination: TerminalDestination) => void +} + +type CreatedMessage = Extract + +function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { + const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId + const term = { + id: msg.terminalId, + title: msg.title, + wsUrl: msg.wsUrl, + font: msg.font, + placement: msg.placement, + } + if (msg.placement === "side") { + // Side terminals are answered to a specific pending request. A + // missing, cancelled, or context-mismatched request means the user + // already moved on — close the PTY again instead of leaking it. + const request = deps.state.completeSide(msg.createId) + if (!request || request.cancelled || request.contextKey !== contextKey) { + deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) + return + } + deps.state.add(msg.worktreeId, term) + deps.onSideCreated?.(contextKey, msg.terminalId) + return + } + deps.state.add(msg.worktreeId, term) + deps.onCreated?.(contextKey, msg.terminalId) + deps.saveTabMemory() + deps.setSelection(contextKey) + deps.activate(msg.terminalId) } /** - * Wire handlers for the three inbound terminal messages. Returns a - * dispatcher that accepts each message type and returns true if it - * handled the payload. Keeps all the terminal-specific routing logic - * out of the main webview component. + * Wire handlers for the inbound terminal messages. Returns a dispatcher + * that accepts each message type and returns true if it handled the + * payload. Keeps all the terminal-specific routing logic out of the + * main webview component. */ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { return (msg: ExtensionMessage): boolean => { if (msg.type === "agentManager.terminal.created") { - const contextKey = msg.worktreeId === null ? LOCAL : msg.worktreeId - deps.state.add(msg.worktreeId, { - id: msg.terminalId, - title: msg.title, - wsUrl: msg.wsUrl, - font: msg.font, - }) - deps.onCreated?.(contextKey, msg.terminalId) - deps.saveTabMemory() - deps.setSelection(contextKey) - deps.activate(msg.terminalId) + handleCreated(deps, msg) return true } if (msg.type === "agentManager.terminal.closed") { - deps.state.remove(msg.terminalId) + const removed = deps.state.remove(msg.terminalId) if (deps.state.activeId() === msg.terminalId) deps.state.setActiveId(undefined) + if (removed?.placement === "side") deps.onSideClosed?.(removed.contextKey) return true } if (msg.type === "agentManager.terminal.error") { + const request = msg.createId ? deps.state.completeSide(msg.createId) : undefined + // Errors for requests the user already cancelled are noise. + if (request?.cancelled) return true + if (request) deps.onSideError?.(request.contextKey) deps.showError(msg.message) return true } + if (msg.type === "agentManager.terminal.destinationChanged") { + deps.onDestinationChanged?.(msg.destination) + return true + } + // The initial destination rides along on the state message. Claimed + // here so the main webview handler stays free of terminal settings, + // but reported as unhandled because the rest of that payload belongs + // to the other subscribers. + if (msg.type === "agentManager.state" && msg.terminalDestination) { + deps.onDestinationChanged?.(msg.terminalDestination) + return false + } return false } } 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 4f2aef6e2dd..009b72bb174 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 @@ -17,6 +17,8 @@ import { ServerContext } from "../context/server" import { WorktreeModeProvider } from "../context/worktree-mode" import { SidebarSearchMenu } from "../../agent-manager/SidebarSearchMenu" import { SidebarToggleButton } from "../../agent-manager/SidebarToggleButton" +import { SideTerminalPanel, createTerminalState } from "../../agent-manager/terminal" +import { LOCAL } from "../../agent-manager/navigate" import type { SidebarSearchItem } from "../../agent-manager/sidebar-search" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" @@ -930,6 +932,39 @@ export const TabBarSingleTab: Story = { ), } +// Side terminal panel inside the real inspector host chain, empty state — +// no live PTY, so the start affordance renders. The header reuses the +// .am-diff-header metrics so the a11y/screenshot baseline also guards the +// alignment against the diff panel chrome. +export const SideTerminalPanelEmpty: Story = { + name: "Side terminal panel — empty", + render: () => { + const state = createTerminalState(() => LOCAL) + return ( + +
+
+
+ Agent session stays visible beside the terminal. +
+
+
+ LOCAL} + visible={() => true} + onClose={() => undefined} + onStart={() => undefined} + /> +
+
+
+
+
+ ) + }, +} + // --------------------------------------------------------------------------- // NewWorktreeDialog — inline selector popovers must escape the dialog scroll // containers. Regression: the reasoning-variant and mode pickers were clipped diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts index 298d9eba88b..ac6df6daeed 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts @@ -5,6 +5,12 @@ export interface TerminalFont { fontSize: number } +/** Where the terminal button / Focus Terminal shortcut opens a terminal. */ +export type TerminalDestination = "vscode" | "agentManager" + +/** Where a terminal lives: main tab strip or right-side inspector panel. */ +export type TerminalPlacement = "tab" | "side" + // Agent Manager worktree state types (mirrored from WorktreeStateManager) export interface WorktreeState { id: string 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 0faed279b9f..a7bdd73a7da 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 @@ -34,7 +34,9 @@ import type { ReviewComment, RunStatus, SectionState, + TerminalDestination, TerminalFont, + TerminalPlacement, WorktreeErrorCode, WorktreeFileDiff, WorktreeGitStats, @@ -698,6 +700,7 @@ export interface AgentManagerStateMessage { runStatuses?: RunStatus[] runScriptConfigured?: boolean runScriptPath?: string + terminalDestination?: TerminalDestination } // --------------------------------------------------------------------------- @@ -706,6 +709,11 @@ export interface AgentManagerStateMessage { export interface AgentManagerTerminalCreatedMessage { type: "agentManager.terminal.created" + /** Correlates with the create request; lets the webview spot stale + * creates. Deliberately not named `requestId`: that field name is the + * generic webview request/response correlation channel. */ + createId: string + placement: TerminalPlacement /** null for LOCAL, worktree id otherwise */ worktreeId: string | null terminalId: string @@ -727,9 +735,16 @@ export interface AgentManagerTerminalClosedMessage { export interface AgentManagerTerminalErrorMessage { type: "agentManager.terminal.error" terminalId?: string + /** Set when the error answers a specific create request. */ + createId?: string message: string } +export interface AgentManagerTerminalDestinationChangedMessage { + type: "agentManager.terminal.destinationChanged" + destination: TerminalDestination +} + export interface AgentManagerRunStatusMessage extends RunStatus { type: "agentManager.runStatus" } @@ -1241,6 +1256,7 @@ export type ExtensionMessage = | AgentManagerTerminalFontChangedMessage | AgentManagerTerminalClosedMessage | AgentManagerTerminalErrorMessage + | AgentManagerTerminalDestinationChangedMessage // legacy-migration start | MigrationStateMessage | MigrationDataMessage 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 2852e699e67..677c5ace736 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 @@ -4,7 +4,7 @@ import type { MessageLoadMode } from "./sessions" import type { PermissionFileDiff } from "./permissions" import type { ModelSelection, ProviderConfig } from "./providers" import type { Config } from "./config" -import type { ModelAllocation, ReviewComment } from "./agent-manager" +import type { ModelAllocation, ReviewComment, TerminalPlacement } from "./agent-manager" import type { ReviewMessageData } from "../../../../src/shared/review-comments" import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets" import type { AnacondaDesktopWebviewMessage } from "../../../../src/shared/anaconda-desktop-messages" @@ -712,9 +712,12 @@ export interface ShowExistingLocalTerminalRequest { type: "agentManager.showExistingLocalTerminal" } -// Create a new xterm terminal tab in the given worktree context (null = local) +// Create a new xterm terminal in the given worktree context (null = workspace root) export interface AgentManagerTerminalCreateRequest { type: "agentManager.terminal.create" + /** Webview-generated correlation id, echoed back in created/error. */ + createId: string + placement: TerminalPlacement worktreeId: string | null }