From 0486317f29990918b7fcdebc62a41d0ccb302cd0 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 19 Feb 2026 20:01:54 +0100 Subject: [PATCH] feat: keyboard shortcuts for agent manager tab/worktree management (#495) * feat: add keyboard shortcuts for agent manager tab/worktree management * fix: use mock.module in git-context tests to avoid cross-file mock leakage * feat: shared worktree delete confirmation dialog with Enter/Esc, Cmd+W fallthrough, Cmd+T race fix * fix: Enter on delete dialog, VS Code theme colors, Cmd+N auto-switch, shortcut hints, smooth neighbor selection on worktree delete * refactor: move confirm dialog styles to kilo-ui, replace 'local' magic string with LOCAL constant * revert: restore upstream commit-message tests, drop our git.ts/GitRunner changes * revert: remove out-of-scope kilo-ui/SessionList/chat.css changes, keep confirm styles in agent-manager.css * fix: remove stale /P reference from comment * fix: move keybinding entries from submenus to keybindings array --- packages/kilo-vscode/package.json | 44 +++++ packages/kilo-vscode/src/extension.ts | 12 ++ .../kilo-vscode/tests/unit/navigate.test.ts | 14 +- .../unit/next-selection-after-delete.test.ts | 36 +++++ .../agent-manager/AgentManagerApp.tsx | 151 +++++++++++++++--- .../agent-manager/agent-manager.css | 56 +++++++ .../webview-ui/agent-manager/navigate.ts | 20 ++- 7 files changed, 302 insertions(+), 31 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/next-selection-after-delete.test.ts diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index cd119e1dd2..6904269f9c 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -101,6 +101,26 @@ "title": "Agent Manager: Next Tab", "category": "Kilo Code" }, + { + "command": "kilo-code.new.agentManager.newTab", + "title": "Agent Manager: New Tab", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.agentManager.closeTab", + "title": "Agent Manager: Close Tab", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.agentManager.newWorktree", + "title": "Agent Manager: New Worktree", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.agentManager.closeWorktree", + "title": "Agent Manager: Close Worktree", + "category": "Kilo Code" + }, { "command": "kilo-code.new.generateCommitMessage", "title": "Generate Commit Message", @@ -288,6 +308,30 @@ "key": "ctrl+right", "mac": "cmd+right", "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.newTab", + "key": "ctrl+t", + "mac": "cmd+t", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.closeTab", + "key": "ctrl+w", + "mac": "cmd+w", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.newWorktree", + "key": "ctrl+n", + "mac": "cmd+n", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.closeWorktree", + "key": "ctrl+shift+w", + "mac": "cmd+shift+w", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" } ], "configuration": { diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index be001db535..baba343445 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -82,6 +82,18 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => { agentManagerProvider.postMessage({ type: "action", action: "tabNext" }) }), + vscode.commands.registerCommand("kilo-code.new.agentManager.newTab", () => { + agentManagerProvider.postMessage({ type: "action", action: "newTab" }) + }), + vscode.commands.registerCommand("kilo-code.new.agentManager.closeTab", () => { + agentManagerProvider.postMessage({ type: "action", action: "closeTab" }) + }), + vscode.commands.registerCommand("kilo-code.new.agentManager.newWorktree", () => { + agentManagerProvider.postMessage({ type: "action", action: "newWorktree" }) + }), + vscode.commands.registerCommand("kilo-code.new.agentManager.closeWorktree", () => { + agentManagerProvider.postMessage({ type: "action", action: "closeWorktree" }) + }), ) // Register autocomplete provider diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index b63bb7b888..d46314a387 100644 --- a/packages/kilo-vscode/tests/unit/navigate.test.ts +++ b/packages/kilo-vscode/tests/unit/navigate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { resolveNavigation, validateLocalSession } from "../../webview-ui/agent-manager/navigate" +import { resolveNavigation, validateLocalSession, LOCAL } from "../../webview-ui/agent-manager/navigate" const ids = ["a", "b", "c", "d"] @@ -24,7 +24,7 @@ describe("resolveNavigation", () => { describe("from first session", () => { it("up → local", () => { - expect(resolveNavigation("up", "a", ids)).toEqual({ action: "local" }) + expect(resolveNavigation("up", "a", ids)).toEqual({ action: LOCAL }) }) it("down → selects second session", () => { @@ -68,7 +68,7 @@ describe("resolveNavigation", () => { }) it("up from only session → local", () => { - expect(resolveNavigation("up", "x", ["x"])).toEqual({ action: "local" }) + expect(resolveNavigation("up", "x", ["x"])).toEqual({ action: LOCAL }) }) it("down from only session → none", () => { @@ -95,20 +95,20 @@ describe("resolveNavigation", () => { expect(trail).toEqual(["s1", "s2", "s3"]) // Navigate back up through all sessions to local - const upTrail: (string | "local")[] = [] + const upTrail: (string | typeof LOCAL)[] = [] for (let i = 0; i < 4; i++) { const result = resolveNavigation("up", current, sessions) if (result.action === "select") { current = result.id upTrail.push(current) - } else if (result.action === "local") { + } else if (result.action === LOCAL) { current = undefined - upTrail.push("local") + upTrail.push(LOCAL) } else { break } } - expect(upTrail).toEqual(["s2", "s1", "local"]) + expect(upTrail).toEqual(["s2", "s1", LOCAL]) }) }) }) diff --git a/packages/kilo-vscode/tests/unit/next-selection-after-delete.test.ts b/packages/kilo-vscode/tests/unit/next-selection-after-delete.test.ts new file mode 100644 index 0000000000..19b0368640 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-selection-after-delete.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "bun:test" +import { nextSelectionAfterDelete, LOCAL } from "../../webview-ui/agent-manager/navigate" + +describe("nextSelectionAfterDelete", () => { + it("selects the worktree below when deleting from the middle", () => { + expect(nextSelectionAfterDelete("b", ["a", "b", "c"])).toBe("c") + }) + + it("selects the worktree above when deleting the last item", () => { + expect(nextSelectionAfterDelete("c", ["a", "b", "c"])).toBe("b") + }) + + it("selects the worktree below when deleting the first item", () => { + expect(nextSelectionAfterDelete("a", ["a", "b", "c"])).toBe("b") + }) + + it("falls back to LOCAL when deleting the only worktree", () => { + expect(nextSelectionAfterDelete("a", ["a"])).toBe(LOCAL) + }) + + it("falls back to LOCAL when ID is not found", () => { + expect(nextSelectionAfterDelete("x", ["a", "b"])).toBe(LOCAL) + }) + + it("falls back to LOCAL when list is empty", () => { + expect(nextSelectionAfterDelete("a", [])).toBe(LOCAL) + }) + + it("handles two-item list deleting first", () => { + expect(nextSelectionAfterDelete("a", ["a", "b"])).toBe("b") + }) + + it("handles two-item list deleting second", () => { + expect(nextSelectionAfterDelete("b", ["a", "b"])).toBe("a") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index fc852c599c..a03266996a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -11,7 +11,8 @@ import type { SessionInfo, } from "../src/types/messages" import { ThemeProvider } from "@kilocode/kilo-ui/theme" -import { DialogProvider } from "@kilocode/kilo-ui/context/dialog" +import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog" +import { Dialog } from "@kilocode/kilo-ui/dialog" import { MarkedProvider } from "@kilocode/kilo-ui/context/marked" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" @@ -32,7 +33,7 @@ import { WorktreeModeProvider } from "../src/context/worktree-mode" import { ChatView } from "../src/components/chat" import { LanguageBridge, DataBridge } from "../src/App" import { formatRelativeDate } from "../src/utils/date" -import { validateLocalSession } from "./navigate" +import { validateLocalSession, nextSelectionAfterDelete, LOCAL } from "./navigate" import "./agent-manager.css" interface SetupState { @@ -42,17 +43,21 @@ interface SetupState { error?: boolean } -/** Sidebar selection: "local" for workspace, worktree ID for a worktree, or null for an unassigned session. */ -type SidebarSelection = "local" | string | null +/** Sidebar selection: LOCAL for workspace, worktree ID for a worktree, or null for an unassigned session. */ +type SidebarSelection = typeof LOCAL | string | null + +const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) +const modKey = isMac ? "\u2318" : "Ctrl+" const AgentManagerContent: Component = () => { const session = useSession() const vscode = useVSCode() + const dialog = useDialog() const [setup, setSetup] = createSignal({ active: false, message: "" }) const [worktrees, setWorktrees] = createSignal([]) const [managedSessions, setManagedSessions] = createSignal([]) - const [selection, setSelection] = createSignal("local") + const [selection, setSelection] = createSignal(LOCAL) const [repoBranch, setRepoBranch] = createSignal() // Recover persisted local session IDs from webview state @@ -129,7 +134,7 @@ const AgentManagerContent: Component = () => { // Sessions for the currently selected worktree (tab bar), sorted by creation date const activeWorktreeSessions = createMemo((): SessionInfo[] => { const sel = selection() - if (!sel || sel === "local") return [] + if (!sel || sel === LOCAL) return [] const managed = managedSessions().filter((ms) => ms.worktreeId === sel) const ids = new Set(managed.map((ms) => ms.id)) return session @@ -141,7 +146,7 @@ const AgentManagerContent: Component = () => { // Active tab sessions: local sessions when on "local", worktree sessions otherwise const activeTabs = createMemo((): SessionInfo[] => { const sel = selection() - if (sel === "local") return localSessions() + if (sel === LOCAL) return localSessions() if (sel) return activeWorktreeSessions() return [] }) @@ -149,7 +154,7 @@ const AgentManagerContent: Component = () => { // Whether the selected context has zero sessions const contextEmpty = createMemo(() => { const sel = selection() - if (sel === "local") return localSessionIDs().length === 0 + if (sel === LOCAL) return localSessionIDs().length === 0 if (sel) return activeWorktreeSessions().length === 0 return false }) @@ -171,8 +176,8 @@ const AgentManagerContent: Component = () => { // Navigate sidebar items with arrow keys const navigate = (direction: "up" | "down") => { - const flat: { type: "local" | "wt" | "session"; id: string }[] = [ - { type: "local", id: "local" }, + const flat: { type: typeof LOCAL | "wt" | "session"; id: string }[] = [ + { type: LOCAL, id: LOCAL }, ...worktrees().map((wt) => ({ type: "wt" as const, id: wt.id })), ...unassignedSessions().map((s) => ({ type: "session" as const, id: s.id })), ] @@ -184,7 +189,7 @@ const AgentManagerContent: Component = () => { if (next < 0 || next >= flat.length) return const item = flat[next]! - if (item.type === "local") { + if (item.type === LOCAL) { selectLocal() } else if (item.type === "wt") { selectWorktree(item.id) @@ -217,7 +222,7 @@ const AgentManagerContent: Component = () => { } const selectLocal = () => { - setSelection("local") + setSelection(LOCAL) vscode.postMessage({ type: "agentManager.requestRepoInfo" }) const locals = localSessions() const first = locals[0] @@ -253,17 +258,29 @@ const AgentManagerContent: Component = () => { else if (msg.action === "sessionNext") navigate("down") else if (msg.action === "tabPrevious") navigateTab("left") else if (msg.action === "tabNext") navigateTab("right") + else if (msg.action === "newTab") handleNewTabForCurrentSelection() + else if (msg.action === "closeTab") closeActiveTab() + else if (msg.action === "newWorktree") handleNewWorktreeOrPromote() + else if (msg.action === "closeWorktree") closeSelectedWorktree() } window.addEventListener("message", handler) - // Prevent Cmd+Up/Down/Left/Right from triggering native scroll - const preventScroll = (e: KeyboardEvent) => { + // Prevent Cmd+Arrow/T/W/N from triggering native browser actions + const preventDefaults = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey)) return if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { e.preventDefault() } + // Prevent browser defaults for our shortcuts (new tab, close tab, new window) + if (["t", "w", "n"].includes(e.key.toLowerCase()) && !e.shiftKey) { + e.preventDefault() + } + // Prevent defaults for shift variants (close worktree) + if (e.key.toLowerCase() === "w" && e.shiftKey) { + e.preventDefault() + } } - window.addEventListener("keydown", preventScroll) + window.addEventListener("keydown", preventDefaults) // When the panel regains focus (e.g. returning from terminal), focus the prompt const onWindowFocus = () => window.dispatchEvent(new Event("focusPrompt")) @@ -272,7 +289,7 @@ const AgentManagerContent: Component = () => { // When a session is created while on local, replace the current pending tab with the real session. // Guard against duplicate sessionCreated events (HTTP response + SSE can both fire). const unsubCreate = vscode.onMessage((msg) => { - if (msg.type === "sessionCreated" && selection() === "local") { + if (msg.type === "sessionCreated" && selection() === LOCAL) { const created = msg as { type: string; session: { id: string } } if (localSessionIDs().includes(created.session.id)) return const pending = activePendingId() @@ -299,6 +316,9 @@ const AgentManagerContent: Component = () => { globalThis.setTimeout(() => setSetup({ active: false, message: "" }), error ? 3000 : 500) if (!error && ev.sessionId) { session.selectSession(ev.sessionId) + // Auto-switch sidebar to the worktree containing this session + const ms = managedSessions().find((s) => s.id === ev.sessionId) + if (ms?.worktreeId) setSelection(ms.worktreeId) } } else { setSetup({ active: true, message: ev.message, branch: ev.branch }) @@ -324,7 +344,7 @@ const AgentManagerContent: Component = () => { onCleanup(() => { window.removeEventListener("message", handler) - window.removeEventListener("keydown", preventScroll) + window.removeEventListener("keydown", preventDefaults) window.removeEventListener("focus", onWindowFocus) unsubCreate() unsub() @@ -344,10 +364,47 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.createWorktree" }) } + const confirmDeleteWorktree = (worktreeId: string) => { + const wt = worktrees().find((w) => w.id === worktreeId) + if (!wt) return + const doDelete = () => { + vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId: wt.id }) + if (selection() === wt.id) { + const next = nextSelectionAfterDelete( + wt.id, + worktrees().map((w) => w.id), + ) + if (next === LOCAL) selectLocal() + else selectWorktree(next) + } + dialog.close() + } + dialog.show(() => ( + +
+
+ + + Delete worktree {wt.branch}? This removes the worktree from disk + and dissociates all sessions. + +
+
+ + +
+
+
+ )) + } + const handleDeleteWorktree = (worktreeId: string, e: MouseEvent) => { e.stopPropagation() - vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId }) - if (selection() === worktreeId) selectLocal() + confirmDeleteWorktree(worktreeId) } const handlePromote = (sessionId: string, e: MouseEvent) => { @@ -357,7 +414,7 @@ const AgentManagerContent: Component = () => { const handleAddSession = () => { const sel = selection() - if (sel === "local") { + if (sel === LOCAL) { addPendingTab() } else if (sel) { vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel }) @@ -399,12 +456,61 @@ const AgentManagerContent: Component = () => { } } + // Close the currently active tab via keyboard shortcut. + // If no tabs remain, fall through to close the selected worktree. + const closeActiveTab = () => { + const tabs = activeTabs() + if (tabs.length === 0) { + closeSelectedWorktree() + return + } + const current = session.currentSessionID() + const pending = activePendingId() + const target = current + ? tabs.find((s) => s.id === current) + : pending + ? tabs.find((s) => s.id === pending) + : undefined + if (!target) return + const synthetic = new MouseEvent("click") + handleCloseTab(target.id, synthetic) + } + + // Cmd+T: add a new tab strictly to the current selection (no side effects) + const handleNewTabForCurrentSelection = () => { + const sel = selection() + if (sel === LOCAL) { + addPendingTab() + } else if (sel) { + // Pass the captured worktree ID directly to avoid race conditions + vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel }) + } + } + + // Cmd+N: if an unassigned session is selected, promote it; otherwise create a new worktree + const handleNewWorktreeOrPromote = () => { + const sel = selection() + const sid = session.currentSessionID() + if (sel === null && sid && !worktreeSessionIds().has(sid)) { + vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid }) + return + } + handleCreateWorktree() + } + + // Close the currently selected worktree with a confirmation dialog + const closeSelectedWorktree = () => { + const sel = selection() + if (!sel || sel === LOCAL) return + confirmDeleteWorktree(sel) + } + return (
{/* Local workspace item */}
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 a0774c3336..b8de304c8e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -479,3 +479,59 @@ height: 16px; flex-shrink: 0; } + +/* Confirmation dialog */ + +.am-confirm { + display: flex; + flex-direction: column; + gap: 16px; + padding: 0 24px 16px 24px; +} + +.am-confirm-message { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: var(--font-size-base); + color: var(--text-base); + line-height: 1.5; +} + +.am-confirm-message > [data-component="icon"] { + flex-shrink: 0; + color: var(--icon-critical-base); + margin-top: 2px; +} + +.am-confirm-branch { + font-family: var(--font-family-mono, monospace); + font-size: var(--font-size-small); + padding: 1px 6px; + border-radius: var(--radius-sm); + background: var(--surface-inset-base); + white-space: nowrap; +} + +.am-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.am-confirm-delete { + background: var(--surface-critical-strong) !important; + color: var(--text-on-brand-base) !important; +} + +.am-confirm-delete:hover { + opacity: 0.9; +} + +/* Keyboard shortcut hint inside buttons */ + +.am-shortcut-hint { + font-size: var(--font-size-small); + opacity: 0.6; + margin-left: 6px; +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts index bbcab8d336..473c7908aa 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts @@ -7,7 +7,10 @@ * Returns the action to take: select a session by ID, go to local, or do nothing. */ -export type NavResult = { action: "select"; id: string } | { action: "local" } | { action: "none" } +/** Sentinel value for the local workspace selection. */ +export const LOCAL = "local" as const + +export type NavResult = { action: "select"; id: string } | { action: typeof LOCAL } | { action: "none" } export function resolveNavigation(direction: "up" | "down", current: string | undefined, ids: string[]): NavResult { // Determine current position: -1 = local, 0..N-1 = session index @@ -25,7 +28,7 @@ export function resolveNavigation(direction: "up" | "down", current: string | un const next = direction === "up" ? idx - 1 : idx + 1 // Moving up past the first session → go to local - if (next === -1) return { action: "local" } + if (next === -1) return { action: LOCAL } // At the bottom boundary if (next >= ids.length) return { action: "none" } @@ -42,3 +45,16 @@ export function validateLocalSession(persisted: string | undefined, ids: string[ if (ids.indexOf(persisted) === -1) return undefined return persisted } + +/** + * After removing a worktree, pick the nearest remaining sidebar neighbor. + * Order: the worktree just below → the one above → LOCAL. + */ +export function nextSelectionAfterDelete(deletedId: string, worktreeIds: string[]): typeof LOCAL | string { + const idx = worktreeIds.indexOf(deletedId) + if (idx === -1) return LOCAL + const remaining = worktreeIds.filter((id) => id !== deletedId) + if (remaining.length === 0) return LOCAL + // Prefer the item that was below (same index in the shortened list), else the one above + return remaining[Math.min(idx, remaining.length - 1)]! +}