diff --git a/.changeset/multi-side-terminals.md b/.changeset/multi-side-terminals.md new file mode 100644 index 0000000000..8b54a390d9 --- /dev/null +++ b/.changeset/multi-side-terminals.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support multiple Agent Manager side-panel terminals per context. The panel header is now a tab strip that reuses the main tab bar's terminal tabs: click to switch, drag to reorder, X to close a single terminal, and + to open another one. Terminal numbers fill gaps left by closed terminals, and tabs pick up the live title from the shell or running program (OSC escape codes), so a dev server or build names its own tab. diff --git a/bun.lock b/bun.lock index fd599605c4..d22f4822ab 100644 --- a/bun.lock +++ b/bun.lock @@ -305,7 +305,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.15", + "version": "7.4.16", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index c1662d92b3..9f3d030728 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -113,6 +113,16 @@ export class TerminalManager { } } + /** Titles of every live terminal in a context — used by the router to + * pick the lowest free "Terminal N" ordinal. */ + titles(worktreeId: string | null): string[] { + const out: string[] = [] + for (const entry of this.entries.values()) { + if (entry.worktreeId === worktreeId) out.push(entry.title) + } + return out + } + /** Kill a single terminal. Best-effort — we always drop our bookkeeping. * The SDK's `pty.remove` returns `{ data, error }` without throwing * on 4xx/5xx, so we have to check `error` ourselves; otherwise a diff --git a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts index 3fef7352ed..6ca6491c7a 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-routing.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-routing.ts @@ -55,7 +55,9 @@ function isTerminalMessage( export class TerminalRouter { private manager: TerminalManager - private readonly ordinals = new Map() + /** Ordinals reserved by in-flight creates, per context — prevents two + * concurrent creates from grabbing the same "Terminal N" title. */ + private readonly reserved = new Map>() private generation = 0 constructor(private readonly deps: TerminalRoutingDeps) { @@ -102,6 +104,7 @@ export class TerminalRouter { this.generation++ const manager = this.manager this.manager = this.createManager() + this.reserved.clear() return manager.dispose() } @@ -119,7 +122,8 @@ export class TerminalRouter { }) return } - const title = `Terminal ${this.nextOrdinal(worktreeId)}` + const ordinal = this.reserveOrdinal(worktreeId) + const title = `Terminal ${ordinal}` try { // Join the shared backend connection instead of racing its synchronous // client accessor when this is the first Kilo action in the window. @@ -144,6 +148,11 @@ export class TerminalRouter { const message = err instanceof Error ? err.message : String(err) this.deps.log(`Terminal create failed: ${message}`) this.deps.post({ type: "agentManager.terminal.error", createId, message }) + } finally { + // Only a current-generation create may release: dispose() already + // cleared this create's reservation, and releasing here would + // delete a *new* panel's reservation for the same number. + if (generation === this.generation) this.releaseOrdinal(worktreeId, ordinal) } } @@ -161,15 +170,40 @@ export class TerminalRouter { return this.deps.getWorktreePath(worktreeId) } - /** Per-context counter so default titles are "Terminal 1", "Terminal 2"… - * Not persisted; a webview reload resets counts. */ - private nextOrdinal(worktreeId: string | null): number { + /** + * Pick the lowest "Terminal N" ordinal not used by a live terminal or + * an in-flight create in this context, and reserve it until the + * create settles. Gap-filling keeps numbering consistent: closing + * "Terminal 1" of three frees 1 for the next terminal, instead of + * drifting to ever-higher numbers. Not persisted; a webview reload + * resets the live set. + */ + private reserveOrdinal(worktreeId: string | null): number { const key = worktreeId ?? "__local__" - const next = (this.ordinals.get(key) ?? 0) + 1 - this.ordinals.set(key, next) + const used = new Set() + for (const title of this.manager.titles(worktreeId)) { + const match = /^Terminal (\d+)$/.exec(title) + if (match) used.add(Number(match[1])) + } + const pending = this.reserved.get(key) + if (pending) for (const n of pending) used.add(n) + let next = 1 + while (used.has(next)) next++ + const set = pending ?? new Set() + set.add(next) + this.reserved.set(key, set) return next } + /** Return an in-flight create's reservation. */ + private releaseOrdinal(worktreeId: string | null, ordinal: number) { + const key = worktreeId ?? "__local__" + const set = this.reserved.get(key) + if (!set) return + set.delete(ordinal) + if (set.size === 0) this.reserved.delete(key) + } + /** * Build the WebSocket URL for a given PTY. * 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 index ffa58bd6db..ef9bfcd6c4 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -111,6 +111,134 @@ describe("Agent Manager terminal routing", () => { expect(removed).toContain("pty-new") }) + it("fills numbering gaps left by closed terminals", async () => { + const messages: AgentManagerOutMessage[] = [] + const titles: string[] = [] + let seq = 0 + const client = { + pty: { + create: async ({ title }: { title: string }) => { + titles.push(title) + seq++ + return { data: { id: `pty-${seq}`, title } } + }, + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + const create = (createId: string) => + router.handle({ type: "agentManager.terminal.create", createId, placement: "side", worktreeId: null }) + + create("one") + await wait() + create("two") + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2"]) + + // Close "Terminal 1"; the next create reuses the freed number. + const first = messages.find((m) => m.type === "agentManager.terminal.created" && m.createId === "one") + if (first?.type !== "agentManager.terminal.created") throw new Error("missing created message") + router.handle({ type: "agentManager.terminal.close", terminalId: first.terminalId }) + await wait() + + create("three") + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2", "Terminal 1"]) + await router.dispose() + }) + + it("hands out distinct numbers to concurrent creates", async () => { + const messages: AgentManagerOutMessage[] = [] + const titles: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + const client = { + pty: { + create: ({ title }: { title: string }) => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + titles.push(title) + resolvers.push(resolve) + }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: (message) => messages.push(message), + getTerminalFont: () => font, + }) + + // Two creates before either settles must not share an ordinal. The + // backend-connection await defers the PTY creates to a microtask. + router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null }) + router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 2"]) + resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } }) + resolvers[1]?.({ data: { id: "pty-b", title: titles[1]! } }) + await wait() + + // A failed create releases its reservation for the next attempt. + await router.dispose() + }) + + it("does not let a stale create release a new generation's reservation", async () => { + const titles: string[] = [] + const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = [] + const client = { + pty: { + create: ({ title }: { title: string }) => + new Promise<{ data: { id: string; title: string } }>((resolve) => { + titles.push(title) + resolvers.push(resolve) + }), + remove: async () => ({ data: true }), + update: async () => ({ data: true }), + }, + } as unknown as KiloClient + const router = new TerminalRouter({ + getClient: () => client, + getClientAsync: async () => client, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }), + getRoot: () => "/workspace", + getWorktreePath: () => undefined, + log: () => undefined, + post: () => undefined, + getTerminalFont: () => font, + }) + + // Create A starts before the panel is recreated; its reservation dies + // with dispose(). Create B of the new generation reserves the same + // free number. When A's late completion settles, its release must not + // wipe B's reservation — otherwise create C would duplicate B's title. + router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null }) + await router.dispose() + router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 1"]) + resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } }) + await wait() + router.handle({ type: "agentManager.terminal.create", createId: "c", placement: "side", worktreeId: null }) + await wait() + expect(titles).toEqual(["Terminal 1", "Terminal 1", "Terminal 2"]) + await router.dispose() + }) + it("awaits the shared backend connection before creating a terminal", async () => { let connected = false const client = { @@ -120,6 +248,7 @@ describe("Agent Manager terminal routing", () => { update: async () => ({ data: true }), }, } as unknown as KiloClient + const messages: AgentManagerOutMessage[] = [] const router = new TerminalRouter({ getClient: () => { if (!connected) throw new Error("Not connected") @@ -138,7 +267,6 @@ describe("Agent Manager terminal routing", () => { getTerminalFont: () => font, }) - const messages: AgentManagerOutMessage[] = [] router.handle({ type: "agentManager.terminal.create", createId: "real", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts index 831e2c187b..9a071e5c15 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts @@ -10,12 +10,12 @@ function scene( destination?: "vscode" | "agentManager" saved?: "vscode" | "agentManager" visible?: boolean - focused?: boolean + focusedId?: string } = {}, ) { const calls = { requestSide: 0, - closeSide: 0, + closed: [] as string[], hide: 0, refocus: 0, openVscode: 0, @@ -24,21 +24,21 @@ function scene( tracked: [] as string[], } let visible = opts.visible ?? false - let focused = opts.focused ?? false + let focusedId = opts.focusedId as string | undefined const ctl = createSideTerminal({ handlers: { requestSide: () => { calls.requestSide++ visible = true }, - closeSide: () => { - calls.closeSide++ - visible = false + closeSide: (terminalId) => { + calls.closed.push(terminalId) + focusedId = undefined return true }, }, visible: () => visible, - focused: () => focused, + focusedId: () => focusedId, hide: () => { calls.hide++ visible = false @@ -56,12 +56,12 @@ function scene( 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 }) + const focused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" }) focused.ctl.toggle() expect(focused.calls.hide).toBe(1) expect(focused.calls.refocus).toBe(1) - const elsewhere = scene({ destination: "agentManager", visible: true, focused: false }) + const elsewhere = scene({ destination: "agentManager", visible: true }) elsewhere.ctl.toggle() expect(elsewhere.calls.hide).toBe(1) expect(elsewhere.calls.refocus).toBe(0) @@ -72,15 +72,18 @@ describe("Agent Manager side terminal controller", () => { expect(hidden.calls.hide).toBe(0) }) - it("refocuses the chat after killing a focused terminal, not otherwise", () => { - const focused = scene({ focused: true }) + it("kills the focused terminal and refocuses the chat", () => { + const focused = scene({ focusedId: "terminal:two" }) expect(focused.ctl.close()).toBe(true) - expect(focused.calls.closeSide).toBe(1) + expect(focused.calls.closed).toEqual(["terminal:two"]) expect(focused.calls.refocus).toBe(1) + }) - const elsewhere = scene({ focused: false }) - expect(elsewhere.ctl.close()).toBe(true) - expect(elsewhere.calls.refocus).toBe(0) + it("does nothing on close without a focused terminal", () => { + const item = scene() + expect(item.ctl.close()).toBe(false) + expect(item.calls.closed).toEqual([]) + expect(item.calls.refocus).toBe(0) }) it("routes the primary action by destination", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts index 432eed1fb6..db0acb769f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -14,7 +14,7 @@ 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 events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], errors: 0 } const tabs = () => state.current().map((term) => term.id) const handlers = createTerminalHandlers({ state, @@ -27,7 +27,6 @@ function scene(initial: string | null = LOCAL) { 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", @@ -40,12 +39,25 @@ function scene(initial: string | null = LOCAL) { events.selected.push(value) setSelection(value) }, - showError: () => undefined, + showError: () => events.errors++, postMessage: (message) => posted.push(message as Record), }) return { state, selection, setSelection, posted, events, handlers, dispatch } } +function createdSide(createId: string, terminalId: string, title = "Terminal 1") { + return { + type: "agentManager.terminal.created", + createId, + placement: "side", + worktreeId: null, + terminalId, + title, + wsUrl: `ws://${terminalId}`, + font, + } satisfies ExtensionMessage +} + describe("Agent Manager terminal state", () => { it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => { createRoot((dispose) => { @@ -64,21 +76,22 @@ describe("Agent Manager terminal state", () => { font, placement: "side", }) + item.state.setSideActive(LOCAL, "terminal: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") + expect(item.state.sideActiveFor(LOCAL)).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") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") dispose() }) }) - it("deduplicates side creation and reuses the terminal without tab side effects", () => { + it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => { createRoot((dispose) => { const item = scene() item.handlers.requestSide() @@ -88,18 +101,8 @@ describe("Agent Manager terminal state", () => { 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.dispatch(createdSide(createId, "terminal:side"))).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side") expect(item.events.activated).toEqual([]) expect(item.events.selected).toEqual([]) expect(item.events.saved).toBe(0) @@ -111,6 +114,74 @@ describe("Agent Manager terminal state", () => { }) }) + it("supports several side terminals per context with newest active", () => { + createRoot((dispose) => { + const item = scene() + item.handlers.addSide() + item.handlers.addSide() + expect(item.posted).toHaveLength(2) + const first = String(item.posted[0]!.createId) + const second = String(item.posted[1]!.createId) + + item.dispatch(createdSide(first, "terminal:one", "Terminal 1")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one"]) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + + item.dispatch(createdSide(second, "terminal:two", "Terminal 2")) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one", "terminal:two"]) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two") + dispose() + }) + }) + + it("switches the active side terminal on select", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.setSideActive(LOCAL, "terminal:two") + + item.handlers.selectSide("terminal:one") + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + expect(item.state.focusRequest()?.id).toBe("terminal:one") + dispose() + }) + }) + + it("moves activation to the last remaining side terminal on close", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.setSideActive(LOCAL, "terminal:two") + + expect(item.handlers.closeSide("terminal:two")).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one") + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:two" }]) + + expect(item.handlers.closeSide("terminal:one")).toBe(true) + expect(item.state.sideActiveFor(LOCAL)).toBeUndefined() + expect(item.state.sidesForContext(LOCAL)).toEqual([]) + + // Closing an unknown or non-side id is a no-op. + expect(item.handlers.closeSide("terminal:gone")).toBe(false) + expect(item.posted).toHaveLength(2) + dispose() + }) + }) + + it("closes a stale side answer whose create request is unknown", () => { + createRoot((dispose) => { + const item = scene() + // A created message for a createId the webview never sent (e.g. it + // reloaded while the PTY was starting) must not leak the PTY. + item.dispatch(createdSide("stale-id", "terminal:stale")) + expect(item.state.sidesForContext(LOCAL)).toEqual([]) + expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:stale" }]) + dispose() + }) + }) + it("creates explicit terminal tabs independently of the side destination", () => { createRoot((dispose) => { const item = scene("wt-1") @@ -124,44 +195,91 @@ describe("Agent Manager terminal state", () => { }) }) - it("cancels a side terminal that is still starting", () => { + it("routes side creates of a worktree context to that worktree", () => { 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), + const item = scene("wt-1") + item.handlers.addSide() + expect(item.posted[0]).toMatchObject({ + type: "agentManager.terminal.create", placement: "side", - worktreeId: null, - terminalId: "terminal:late", - title: "Terminal 1", - wsUrl: "ws://late", - font, + worktreeId: "wt-1", }) - 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", () => { + it("tracks OSC titles per terminal without touching the terminal records", () => { 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) + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + const before = item.state.sidesForContext(LOCAL)[0]! + + item.state.setTitle("terminal:one", "npm run dev") + expect(item.state.title("terminal:one")).toBe("npm run dev") + // Reference stability: the stored record is untouched so does + // not remount the xterm instance on a title change. + expect(item.state.sidesForContext(LOCAL)[0]).toBe(before) + + // Empty titles are ignored; removal drops the override. + item.state.setTitle("terminal:one", " ") + expect(item.state.title("terminal:one")).toBe("npm run dev") + item.state.remove("terminal:one") + expect(item.state.title("terminal:one")).toBeUndefined() + dispose() + }) + }) + + it("reorders side terminals within their context via drag", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" }) + item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" }) + item.state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" }) + item.state.add(null, { id: "terminal:tab", title: "Terminal 4", wsUrl: "ws://tab", font, placement: "tab" }) + + // Drag the first side terminal onto the third position. + expect(item.state.reorderSideDrag(LOCAL, "terminal:one", "terminal:three")).toBe(true) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + // Tab terminals are untouched. + expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"]) + + // The order survives switching to another context and back. + item.setSelection("wt-1") + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + item.setSelection(LOCAL) + expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([ + "terminal:two", + "terminal:three", + "terminal:one", + ]) + + // Unknown ids, tab-placement ids, and foreign contexts are rejected. + expect(item.state.reorderSideDrag(LOCAL, "terminal:gone", "terminal:two")).toBe(false) + expect(item.state.reorderSideDrag(LOCAL, "terminal:tab", "terminal:two")).toBe(false) + expect(item.state.reorderSideDrag("wt-1", "terminal:two", "terminal:three")).toBe(false) + dispose() + }) + }) + + it("reports the focused side terminal only for the current context", () => { + createRoot((dispose) => { + const item = scene() + item.state.add(null, { id: "terminal:side", title: "Terminal 1", wsUrl: "ws://side", font, placement: "side" }) + item.state.add(null, { id: "terminal:tab", title: "Terminal 2", wsUrl: "ws://tab", font, placement: "tab" }) + + expect(item.state.sideFocusedId()).toBeUndefined() + item.state.setFocusedId("terminal:tab") + expect(item.state.sideFocusedId()).toBeUndefined() + item.state.setFocusedId("terminal:side") + expect(item.state.sideFocusedId()).toBe("terminal:side") dispose() }) }) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.mts b/packages/kilo-vscode/tests/visual-regression.spec.mts index 3f210bb7cb..437ac4bd85 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.mts +++ b/packages/kilo-vscode/tests/visual-regression.spec.mts @@ -75,9 +75,12 @@ async function settle(page: Page) { // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. // Sandboxing rows can settle at different scroll heights after settings context updates. +// Side terminal tabs mount live xterm instances whose websocket error text +// lands at indeterminate times. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", + "agentmanager--side-terminal-panel-tabs", "composite-webview--permission-dock-config-preloaded", "settings--sandboxing-allowlist", "settings--sandboxing-panel", diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index 3f210bb7cb..437ac4bd85 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -75,9 +75,12 @@ async function settle(page: Page) { // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. // Sandboxing rows can settle at different scroll heights after settings context updates. +// Side terminal tabs mount live xterm instances whose websocket error text +// lands at indeterminate times. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", + "agentmanager--side-terminal-panel-tabs", "composite-webview--permission-dock-config-preloaded", "settings--sandboxing-allowlist", "settings--sandboxing-panel", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 2274560f54..7f1773ba89 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1833,9 +1833,6 @@ const AgentManagerContent: Component = () => { postMessage: (msg) => vscode.postMessage(msg as never), onRemove: freezeTabs, onShowSide: showSideTerminal, - onHideSide: () => { - if (sidePanel() === "terminal") setSidePanel(null) - }, getSelection: selection, LOCAL, REVIEW_TAB_ID, @@ -1844,7 +1841,7 @@ const AgentManagerContent: Component = () => { const sideCtl = createSideTerminal({ handlers: termHandlers, visible: () => sidePanel() === "terminal", - focused: () => terms.focusedId() !== undefined && terms.focusedId() === terms.side()?.id, + focusedId: () => terms.sideFocusedId(), hide: () => setSidePanel(null), refocus: () => window.dispatchEvent(new Event("focusPrompt")), postMessage: (msg) => vscode.postMessage(msg as never), @@ -1933,8 +1930,8 @@ const AgentManagerContent: Component = () => { if (!id) return undefined if (id === REVIEW_TAB_ID) return { id, title: t("session.tab.review") } if (isTerminalTabId(id)) { - const term = terms.lookup().get(id) - return term ? { id, title: term.title } : undefined + const title = terms.title(id) + return title ? { id, title } : undefined } return activeTabs().find((s) => s.id === id) }) @@ -1962,8 +1959,8 @@ const AgentManagerContent: Component = () => { 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) { + // surprising. Only that terminal dies; the panel keeps the rest. + if (sidePanel() === "terminal" && terms.sideFocusedId()) { if (sideCtl.close()) return } if (termHandlers.closeActive()) { @@ -2835,8 +2832,9 @@ const AgentManagerContent: Component = () => { state={terms} contextKey={terms.sideKey} visible={() => sidePanel() === "terminal"} - onClose={() => sideCtl.close()} - onStart={() => termHandlers.requestSide()} + onSelect={(id) => termHandlers.selectSide(id)} + onClose={(id) => termHandlers.closeSide(id)} + onStart={() => termHandlers.addSide()} /> 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 59499350a7..0cf28616bf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -4618,6 +4618,59 @@ body.vscode-high-contrast-light { color: var(--text-weak); } +/* Side terminal tab strip — one row of tabs reusing the top bar's + .am-tab chrome, plus the "+" action. Height matches .am-diff-header + (32px: 4px padding + 24px content) so switching inspector modes does + not shift the panel chrome. The strip itself never scrolls; the tab + list does, so a narrow panel never pushes the "+" action out of view + (same split as .am-tab-list-wrap / .am-tab-add-wrap). */ +.am-side-terminal-tabs { + display: flex; + align-items: stretch; + height: 32px; + padding: 4px 4px 0; + gap: 2px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-weak-base); + background: var(--surface-base); + position: relative; + z-index: 20; +} + +.am-side-terminal-tablist { + display: flex; + align-items: stretch; + gap: 2px; + flex: 1; + min-width: 0; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.am-side-terminal-tablist::-webkit-scrollbar { + display: none; +} + +/* Each tab shares the available width and shrinks with ellipsis. + touch-action unlocks pointer-based drag reordering (same as + .am-tab-sortable). */ +.am-side-terminal-tab { + display: flex; + flex: 0 1 140px; + min-width: 64px; + height: 100%; + touch-action: none; +} + +.am-side-terminal-add { + display: flex; + align-items: center; + flex-shrink: 0; + padding: 0 2px; +} + /* 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). */ 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 79d4b74c51..a60114c78c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية", "agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء", "agentManager.terminal.connectionError": "خطأ في اتصال المحطة الطرفية", - "agentManager.terminal.kill": "إنهاء المحطة الطرفية", + "agentManager.terminal.add": "محطة طرفية جديدة", "agentManager.terminal.empty": "لا توجد محطة طرفية هنا بعد", "agentManager.terminal.start": "بدء المحطة الطرفية", "agentManager.terminal.destination": "اختر ما الذي يفتحه زر المحطة الطرفية", 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 67cdb9bcf3..7e86e434ce 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -59,7 +59,7 @@ 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.add": "Novo 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", 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 f6fddfd612..14950180d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -58,7 +58,7 @@ 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.add": "Novi terminal", "agentManager.terminal.empty": "Ovdje još nema terminala", "agentManager.terminal.start": "Pokreni terminal", "agentManager.terminal.destination": "Odaberite šta otvara dugme terminala", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index beb827b1c9..a3da1f713d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -60,7 +60,7 @@ 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.add": "Ny terminal", "agentManager.terminal.empty": "Ingen terminal her endnu", "agentManager.terminal.start": "Start terminal", "agentManager.terminal.destination": "Vælg, hvad terminalknappen åbner", 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 b9691ecbaf..517fc1c8ca 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -59,7 +59,7 @@ 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.add": "Neues Terminal", "agentManager.terminal.empty": "Hier ist noch kein Terminal", "agentManager.terminal.start": "Terminal starten", "agentManager.terminal.destination": "Auswählen, was die Terminal-Schaltfläche öffnet", 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 236f18423a..a2f4005760 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -60,9 +60,9 @@ export const dict = { "agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES", "agentManager.terminal.new": "New Terminal Tab", + "agentManager.terminal.add": "New terminal", "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", 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 a75de2ce0c..2ae86ad16c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -59,7 +59,7 @@ 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.add": "Nuevo 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", 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 d8579c9cfd..5a5ee70727 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -59,7 +59,7 @@ 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.add": "Nouveau 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", 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 1192807424..bf21da2ca1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -63,7 +63,7 @@ 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.add": "Nuovo 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", 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 d855af8e15..938862acbf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -59,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "新しいターミナルタブ", "agentManager.terminal.ended": "ターミナルが終了しました — タブを閉じて破棄", "agentManager.terminal.connectionError": "ターミナル接続エラー", - "agentManager.terminal.kill": "ターミナルを終了", + "agentManager.terminal.add": "新しいターミナル", "agentManager.terminal.empty": "ここにはまだターミナルがありません", "agentManager.terminal.start": "ターミナルを開始", "agentManager.terminal.destination": "ターミナルボタンで開く場所を選択", 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 ae87aaf993..50ff31564c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "새 터미널 탭", "agentManager.terminal.ended": "터미널 종료됨 — 탭을 닫아 해제", "agentManager.terminal.connectionError": "터미널 연결 오류", - "agentManager.terminal.kill": "터미널 종료", + "agentManager.terminal.add": "새 터미널", "agentManager.terminal.empty": "아직 여기에 터미널이 없습니다", "agentManager.terminal.start": "터미널 시작", "agentManager.terminal.destination": "터미널 버튼으로 열 위치 선택", 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 1965204a47..d226afcb3d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -62,7 +62,7 @@ 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.add": "Nieuwe terminal", "agentManager.terminal.empty": "Hier is nog geen terminal", "agentManager.terminal.start": "Terminal starten", "agentManager.terminal.destination": "Kies wat de terminalknop opent", 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 44efed03a5..c3541d8851 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -58,7 +58,7 @@ 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.add": "Ny terminal", "agentManager.terminal.empty": "Ingen terminal her ennå", "agentManager.terminal.start": "Start terminal", "agentManager.terminal.destination": "Velg hva terminalknappen åpner", 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 137bf34132..b7497bb817 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -59,7 +59,7 @@ 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.add": "Nowy terminal", "agentManager.terminal.empty": "Nie ma tu jeszcze terminala", "agentManager.terminal.start": "Uruchom terminal", "agentManager.terminal.destination": "Wybierz, co otwiera przycisk terminala", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index c0df2ab3c9..36b7e5de34 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -59,7 +59,7 @@ export const dict = { "agentManager.terminal.new": "Новая вкладка терминала", "agentManager.terminal.ended": "терминал завершен — закройте вкладку, чтобы скрыть", "agentManager.terminal.connectionError": "ошибка подключения к терминалу", - "agentManager.terminal.kill": "Завершить терминал", + "agentManager.terminal.add": "Новый терминал", "agentManager.terminal.empty": "Здесь пока нет терминала", "agentManager.terminal.start": "Запустить терминал", "agentManager.terminal.destination": "Выберите, где будет открываться терминал", 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 012b3776ba..73f78c3bd4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "แท็บเทอร์มินัลใหม่", "agentManager.terminal.ended": "เทอร์มินัลสิ้นสุด — ปิดแท็บเพื่อยกเลิก", "agentManager.terminal.connectionError": "ข้อผิดพลาดการเชื่อมต่อเทอร์มินัล", - "agentManager.terminal.kill": "หยุดเทอร์มินัล", + "agentManager.terminal.add": "เทอร์มินัลใหม่", "agentManager.terminal.empty": "ยังไม่มีเทอร์มินัลที่นี่", "agentManager.terminal.start": "เริ่มเทอร์มินัล", "agentManager.terminal.destination": "เลือกว่าปุ่มเทอร์มินัลจะเปิดอะไร", 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 e18f88694b..58b55601af 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -63,7 +63,7 @@ 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.add": "Yeni terminal", "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", 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 1c1c1cbf22..b039672b63 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -63,7 +63,7 @@ export const dict = { "agentManager.terminal.new": "Нова вкладка термінала", "agentManager.terminal.ended": "термінал завершено — закрийте вкладку, щоб відхилити", "agentManager.terminal.connectionError": "помилка з'єднання термінала", - "agentManager.terminal.kill": "Завершити термінал", + "agentManager.terminal.add": "Новий термінал", "agentManager.terminal.empty": "Тут ще немає термінала", "agentManager.terminal.start": "Запустити термінал", "agentManager.terminal.destination": "Виберіть, що відкриватиме кнопка термінала", 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 2e0c7f5568..5dcbff7b0c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "新建终端标签页", "agentManager.terminal.ended": "终端已结束 — 关闭标签页以消除", "agentManager.terminal.connectionError": "终端连接错误", - "agentManager.terminal.kill": "终止终端", + "agentManager.terminal.add": "新建终端", "agentManager.terminal.empty": "此处尚无终端", "agentManager.terminal.start": "启动终端", "agentManager.terminal.destination": "选择终端按钮的打开目标", 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 e4403c5aaf..cb350d7366 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -58,7 +58,7 @@ export const dict = { "agentManager.terminal.new": "新增終端分頁", "agentManager.terminal.ended": "終端已結束 — 關閉分頁以消除", "agentManager.terminal.connectionError": "終端連線錯誤", - "agentManager.terminal.kill": "終止終端機", + "agentManager.terminal.add": "新增終端機", "agentManager.terminal.empty": "此處尚無終端機", "agentManager.terminal.start": "啟動終端機", "agentManager.terminal.destination": "選擇終端機按鈕的開啟目標", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx index bf42185b83..517965d88d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -3,22 +3,33 @@ * * 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. + * and one width. + * + * A context can own several side terminals. The header is a tab strip + * that reuses the top tab bar's `TerminalTabChrome` (same `am-tab*` + * structure, same X close button) plus a `+` action to add terminals. + * Tabs are drag-sortable via the same `@thisbeyond/solid-dnd` stack as + * the top tab bar; the order lives in the terminal state, so it is + * preserved across sidebar context switches for the webview's lifetime. + * The strip stays visible even when empty so the `+` action is always + * reachable. * * 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 { For, Show, createEffect, createSignal } from "solid-js" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" 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 { ConstrainDragYAxis, SortableTabContainer } from "../../src/components/chat/TabDnd" import { renderSideTerminalLayer } from "./render" +import { TerminalTabChrome } from "./SortableTerminalTab" import type { TerminalStateControls } from "./state" interface Props { @@ -27,9 +38,11 @@ interface Props { 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. */ + /** Make a terminal the visible one in the strip. */ + onSelect: (terminalId: string) => void + /** Kill one terminal. */ + onClose: (terminalId: string) => void + /** Create a new side terminal for this context. */ onStart: () => void } @@ -39,8 +52,26 @@ export const SideTerminalPanel: Component = (props) => { createEffect(() => { panel.inert = !props.visible() }) - const side = () => props.state.side() - const pending = () => props.state.pendingSide(props.contextKey()) !== undefined + const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>() + const sides = () => props.state.sidesForContext(props.contextKey()) + const ids = () => sides().map((term) => term.id) + const pending = () => props.state.pendingSide(props.contextKey()) + const onDragStart = (event: DragEvent) => { + const id = event.draggable?.id + if (typeof id !== "string") return + // Pin the overlay to the tab's width: the overlay container uses + // min-width, so a long OSC title would otherwise overflow it and + // shift the visual center off the cursor (the "drag offset" bug). + const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width + setDragging({ id, width }) + } + const onDragEnd = () => setDragging(undefined) + const onDragOver = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id + if (typeof from !== "string" || typeof to !== "string") return + props.state.reorderSideDrag(props.contextKey(), from, to) + } return (
= (props) => { aria-label={t("agentManager.tab.terminal")} aria-hidden={!props.visible()} > -
-
- - {side()?.title ?? t("agentManager.tab.terminal")} -
-
- +
+ + + + {/* Scrollable tab list — mirrors the top bar's .am-tab-list split + so the "+" action never scrolls away. role="tablist" only + when tabs exist: axe aria-required-children rejects an empty + tablist (and non-tab children like the add button). */} +
0 ? "tablist" : undefined} + aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined} + > + + + {(term) => ( + + props.onSelect(term.id)} + onMiddleClick={(e: MouseEvent) => { + if (e.button !== 1) return + e.preventDefault() + e.stopPropagation() + props.onClose(term.id) + }} + onClose={(e: MouseEvent) => { + e.stopPropagation() + props.onClose(term.id) + }} + /> + + )} + + +
+ {/* Cursor-following clone of the dragged tab (same pattern as + the top tab bar). The overlay is what makes the in-list + original use solid-dnd's slot-compensated transform, so the + dragged tab tracks the cursor without a jump/offset. The + original stays dimmed in its slot via .am-tab-dragging. */} + + + {(tab) => ( +
+ {props.state.title(tab().id) ?? t("agentManager.tab.terminal")} +
+ )} +
+
+
+
+
{renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, visible: props.visible })} - +
{t("common.loading")}
- +
{t("agentManager.terminal.empty")}
) @@ -126,7 +129,8 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS * 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. + * the background and never refit — and within a context only the + * active strip tab's terminal is shown. */ export function renderSideTerminalLayer(props: { state: TerminalStateControls @@ -137,7 +141,10 @@ export function renderSideTerminalLayer(props: {
{(term) => { - const active = () => props.visible() && term.contextKey === props.contextKey() + const active = () => + props.visible() && + term.contextKey === props.contextKey() && + props.state.sideActiveFor(term.contextKey) === term.id return (
props.state.setFocusedId(focused ? term.id : undefined)} + onTitleChange={(title) => props.state.setTitle(term.id, title)} />
) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts index cf1c3587cf..d0420b9916 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts @@ -56,15 +56,15 @@ export function resolveVscodeTerminalRequest( interface Handlers { requestSide(): void - closeSide(): boolean + closeSide(terminalId: string): 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 + /** Id of the side terminal holding DOM focus, if any. */ + focusedId: Accessor /** Leave terminal mode; the terminal stays alive in the background. */ hide: () => void /** Move focus back to the chat composer. */ @@ -96,7 +96,7 @@ export function createSideTerminal(deps: SideTerminalDeps) { const toggle = () => { if (deps.visible()) { - const was = deps.focused() + const was = deps.focusedId() !== undefined deps.hide() handoff(was) return @@ -104,12 +104,14 @@ export function createSideTerminal(deps: SideTerminalDeps) { deps.handlers.requestSide() } - /** Kill the current context's side terminal (or cancel its in-flight - * create) and hide the panel. */ + /** Kill the focused side terminal (Cmd/Ctrl+W). The panel stays open + * on the remaining terminals, or on the empty state when this was + * the last one. */ const close = (): boolean => { - const was = deps.focused() - const done = deps.handlers.closeSide() - if (done) handoff(was) + const id = deps.focusedId() + if (!id) return false + const done = deps.handlers.closeSide(id) + if (done) handoff(true) return done } 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 3e9fba7af1..4dff179435 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -50,11 +50,9 @@ export interface TerminalFocusRequest { } /** 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. */ + * Multiple creates can be in flight for the same context at once. */ interface SideRequest { contextKey: string - cancelled: boolean } export interface TerminalStateControls { @@ -75,10 +73,14 @@ export interface TerminalStateControls { 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 + /** Every side terminal of the given context, in creation order. */ + sidesForContext(contextKey: string): TerminalTabStateWithContext[] + /** Id of the active side terminal for a context. */ + sideActiveFor(contextKey: string): string | undefined + /** Mark a side terminal as the visible one for its context. */ + setSideActive(contextKey: string, terminalId: string): void + /** Id of the side terminal holding DOM focus in the current context, if any. */ + sideFocusedId(): string | 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 @@ -96,6 +98,13 @@ export interface TerminalStateControls { 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 + /** Live display title for a terminal: the OSC-provided title when the + * shell/program set one, otherwise the create-time title. */ + title(terminalId: string): string | undefined + /** Record an OSC title change for a terminal. Kept outside the + * terminal records so `` reference stability (and therefore the + * mounted xterm instances) is preserved. */ + setTitle(terminalId: string, title: string): void /** * Persist a new order for a context's terminals (webview-memory only — * terminals are ephemeral and never round-trip through the extension @@ -109,12 +118,16 @@ 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 + /** + * Apply a drag-over reorder within a context's side terminals (the + * side-panel strip). Returns true when both ends are side terminals + * of that context. + */ + reorderSideDrag(contextKey: string, from: string, to: string): boolean + /** Request ids of the in-flight side-terminal creates for a context. */ + pendingSide(contextKey: string): boolean /** 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 } @@ -149,10 +162,17 @@ export function createTerminalState(selection: Accessor): Termina const [activeId, setActiveId] = createSignal() const [focusedId, setFocusedId] = createSignal() const [focusRequest, setFocusRequest] = createSignal() + // OSC-provided titles, keyed by terminal id. Separate from the terminal + // records on purpose: replacing a record would remount its xterm via + // reference inequality (see the module comment above). + const [titles, setTitles] = createSignal>({}) + // Active side terminal per context. + const [actives, setActives] = 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>({}) + // the panel shows) and per request id (what the answer carries). A + // context can have several creates in flight at once. + const [pending, setPending] = createSignal>({}) const requests = new Map() const currentKey = (): string | undefined => { @@ -195,8 +215,31 @@ export function createTerminalState(selection: Accessor): Termina return out } - const sideForContext = (key: string) => terminalsByContext()[key]?.find((t) => t.placement === "side") - const side = () => sideForContext(sideKey()) + const sidesForContext = (key: string) => (terminalsByContext()[key] ?? []).filter((t) => t.placement === "side") + const sideActiveFor = (key: string) => actives()[key] + const sideFocusedId = () => { + const id = focusedId() + if (!id) return undefined + return sidesForContext(sideKey()).some((t) => t.id === id) ? id : undefined + } + + const setSideActive = (key: string, terminalId: string) => { + setActives((prev) => (prev[key] === terminalId ? prev : { ...prev, [key]: terminalId })) + } + + const title = (terminalId: string): string | undefined => { + const live = titles()[terminalId] + if (live) return live + const key = contextFor(terminalId) + if (!key) return undefined + return terminalsByContext()[key]?.find((t) => t.id === terminalId)?.title + } + + const setTitle = (terminalId: string, next: string) => { + const trimmed = next.trim() + if (!trimmed) return + setTitles((prev) => (prev[terminalId] === trimmed ? prev : { ...prev, [terminalId]: trimmed })) + } const lookup = () => new Map(current().map((t) => [t.id, t])) @@ -218,9 +261,6 @@ 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] } }) @@ -238,6 +278,24 @@ export function createTerminalState(selection: Accessor): Termina return next }) if (focusedId() === terminalId) setFocusedId(undefined) + // A removed active side terminal hands activation to the last + // remaining one of its context, so the panel never shows a dead slot. + if (removed?.placement === "side" && actives()[key] === terminalId) { + const rest = sidesForContext(key) + setActives((prev) => { + const next = { ...prev } + if (rest.length === 0) delete next[key] + else next[key] = rest[rest.length - 1]!.id + return next + }) + } + if (titles()[terminalId] !== undefined) { + setTitles((prev) => { + const next = { ...prev } + delete next[terminalId] + return next + }) + } return removed } @@ -299,34 +357,58 @@ 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 + /** + * Reorder the side terminals of a context by moving `from` to `to`'s + * position (side-panel strip drag-and-drop). Tab terminals keep their + * leading positions; only the side subset is reshuffled. The order + * lives in the same `terminalsByContext` list, so it survives sidebar + * context switches for the lifetime of the webview. + */ + const reorderSideDrag = (key: string, from: string, to: string): boolean => { + const order = sidesForContext(key).map((t) => t.id) + const fi = order.indexOf(from) + const ti = order.indexOf(to) + if (fi === -1 || ti === -1 || fi === ti) return false + const next = [...order] + next.splice(fi, 1) + next.splice(ti, 0, from) + setTerminalsByContext((prev) => { + const list = prev[key] + if (!list || list.length === 0) return prev + const tabs = list.filter((t) => t.placement === "tab") + const sides = list.filter((t) => t.placement === "side") + const byId = new Map(sides.map((t) => [t.id, t])) + const moved: TerminalTabStateWithContext[] = [] + for (const id of next) { + const t = byId.get(id) + if (t) moved.push(t) + } + // Fresh terminals that appeared mid-drag keep their tail position. + for (const t of sides) if (!moved.includes(t)) moved.push(t) + const ordered = [...tabs, ...moved] + if (ordered.length === list.length && ordered.every((t, i) => t.id === list[i]!.id)) return prev + return { ...prev, [key]: ordered } }) return true } + const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0 + + const beginSide = (key: string, createId: string) => { + requests.set(createId, { contextKey: key }) + setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] })) + } + 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 list = (prev[request.contextKey] ?? []).filter((id) => id !== createId) + if (list.length === (prev[request.contextKey]?.length ?? 0)) return prev const next = { ...prev } - delete next[request.contextKey] + if (list.length === 0) delete next[request.contextKey] + else next[request.contextKey] = list return next }) return request @@ -341,8 +423,10 @@ export function createTerminalState(selection: Accessor): Termina current, all, sides, - side, - sideForContext, + sidesForContext, + sideActiveFor, + setSideActive, + sideFocusedId, currentKey, sideKey, activeId, @@ -352,11 +436,13 @@ export function createTerminalState(selection: Accessor): Termina focusRequest, requestFocus, hasRemembered, + title, + setTitle, reorder, reorderDrag, + reorderSideDrag, pendingSide, beginSide, - cancelSide, completeSide, } } @@ -376,8 +462,6 @@ export interface TerminalHandlerDeps { 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. */ @@ -418,31 +502,42 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { } /** - * 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. + * Always create a fresh side terminal for the current context (the + * panel's `+` action and empty state). Multiple creates may be in + * flight at once; each lands as its own tab in the panel strip. */ - const requestSide = () => { + const addSide = () => { 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, + worktreeId: key === deps.LOCAL ? null : key, }) } + /** + * Reveal the side panel and focus the context's active side terminal, + * creating one when the context has none. Never touches the tab strip + * or the chat session. + */ + const requestSide = () => { + const key = deps.state.sideKey() + deps.onShowSide(key) + const existing = deps.state.sidesForContext(key) + if (existing.length > 0) { + const active = deps.state.sideActiveFor(key) ?? existing[existing.length - 1]!.id + deps.state.setSideActive(key, active) + deps.state.requestFocus(active) + return + } + if (deps.state.pendingSide(key)) return + addSide() + } + const closeTerminal = (terminalId: string) => { deps.onRemove?.() const ids = deps.tabIds() @@ -477,19 +572,29 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { } /** - * 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. + * Kill one side terminal. The panel stays open on the remaining + * terminals (or the empty state when this was the last one) — hiding + * is the toggle's job, not the close button's. Active-tab fallback + * is handled by the state layer. */ - 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 }) + const closeSide = (terminalId: string): boolean => { + // Validate before mutating: dropping a non-side record here would + // unmount its xterm while the backend PTY leaks (no close sent). + const term = deps.state.sides().find((t) => t.id === terminalId) + if (!term) return false + deps.state.remove(terminalId) + deps.postMessage({ type: "agentManager.terminal.close", terminalId }) return true } + /** Make a side terminal the visible one in its panel and focus it. */ + const selectSide = (terminalId: string) => { + const key = deps.state.contextFor(terminalId) + if (!key) return + deps.state.setSideActive(key, terminalId) + deps.state.requestFocus(terminalId) + } + const middleClick = (terminalId: string, e: MouseEvent) => { if (e.button !== 1) return e.preventDefault() @@ -504,7 +609,18 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { return true } - return { closeTerminal, closeSide, middleClick, activate, deactivate, requestNew, requestSide, closeActive } + return { + closeTerminal, + closeSide, + selectSide, + middleClick, + activate, + deactivate, + requestNew, + requestSide, + addSide, + closeActive, + } } export interface TerminalMessageHandlerDeps { @@ -545,14 +661,17 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { } 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. + // missing or context-mismatched request means the webview was + // reloaded (or the context is gone) — close the PTY again instead + // of leaking it. const request = deps.state.completeSide(msg.createId) - if (!request || request.cancelled || request.contextKey !== contextKey) { + if (!request || request.contextKey !== contextKey) { deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId }) return } deps.state.add(msg.worktreeId, term) + // The newest terminal becomes the visible one in its panel. + deps.state.setSideActive(contextKey, msg.terminalId) deps.onSideCreated?.(contextKey, msg.terminalId) return } @@ -583,8 +702,6 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { } 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 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx index 37c650a228..bd86ada3d5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TabDnd.tsx @@ -27,13 +27,13 @@ export const ConstrainDragYAxis: Component = () => { return null } -export const SortableTabContainer: ParentComponent<{ id: string }> = (props) => { +export const SortableTabContainer: ParentComponent<{ id: string; class?: string }> = (props) => { const sortable = createSortable(props.id) void sortable return (
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 009b72bb17..7494a81d51 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 @@ -933,9 +933,9 @@ 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. +// no live PTY, so the start affordance renders. The tab strip header keeps +// the .am-diff-header height so the a11y/screenshot baseline also guards +// the alignment against the diff panel chrome. export const SideTerminalPanelEmpty: Story = { name: "Side terminal panel — empty", render: () => { @@ -953,6 +953,47 @@ export const SideTerminalPanelEmpty: Story = { state={state} contextKey={() => LOCAL} visible={() => true} + onSelect={() => undefined} + onClose={() => undefined} + onStart={() => undefined} + /> +
+
+
+
+ + ) + }, +} + +// Tab strip with several side terminals: the active one shows the X close +// button, the others reveal it on hover. Terminals point at a dead port — +// xterm renders its connection-error notice inside the panel, which keeps +// the story self-contained without a live PTY. +export const SideTerminalPanelTabs: Story = { + name: "Side terminal panel — tabs", + render: () => { + const state = createTerminalState(() => LOCAL) + const font = { fontFamily: "monospace", fontSize: 12 } + state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://127.0.0.1:1/a", font, placement: "side" }) + state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://127.0.0.1:1/b", font, placement: "side" }) + state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://127.0.0.1:1/c", font, placement: "side" }) + state.setSideActive(LOCAL, "terminal:two") + state.setTitle("terminal:two", "npm run dev") + return ( + +
+
+
+ Agent session stays visible beside the terminal. +
+
+
+ LOCAL} + visible={() => true} + onSelect={(id) => state.setSideActive(LOCAL, id)} onClose={() => undefined} onStart={() => undefined} />