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
This commit is contained in:
Marius
2026-02-19 20:01:54 +01:00
committed by GitHub
parent bc1d27d339
commit 0486317f29
7 changed files with 302 additions and 31 deletions
+44
View File
@@ -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": {
+12
View File
@@ -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
@@ -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])
})
})
})
@@ -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")
})
})
@@ -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<SetupState>({ active: false, message: "" })
const [worktrees, setWorktrees] = createSignal<WorktreeState[]>([])
const [managedSessions, setManagedSessions] = createSignal<ManagedSessionState[]>([])
const [selection, setSelection] = createSignal<SidebarSelection>("local")
const [selection, setSelection] = createSignal<SidebarSelection>(LOCAL)
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
// 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(() => (
<Dialog title="Delete Worktree" fit>
<div class="am-confirm">
<div class="am-confirm-message">
<Icon name="trash" size="small" />
<span>
Delete worktree <code class="am-confirm-branch">{wt.branch}</code>? This removes the worktree from disk
and dissociates all sessions.
</span>
</div>
<div class="am-confirm-actions">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" class="am-confirm-delete" onClick={doDelete} autofocus>
Delete
</Button>
</div>
</div>
</Dialog>
))
}
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 (
<div class="am-layout">
<div class="am-sidebar">
{/* Local workspace item */}
<button
class={`am-local-item ${selection() === "local" ? "am-local-item-active" : ""}`}
class={`am-local-item ${selection() === LOCAL ? "am-local-item-active" : ""}`}
data-sidebar-id="local"
onClick={() => selectLocal()}
>
@@ -538,7 +644,7 @@ const AgentManagerContent: Component = () => {
icon="plus"
size="small"
variant="ghost"
label="New session"
label={`New session (${modKey}T)`}
class="am-tab-add"
onClick={handleAddSession}
/>
@@ -554,6 +660,7 @@ const AgentManagerContent: Component = () => {
<div class="am-empty-state-text">No sessions open</div>
<Button variant="primary" size="small" onClick={handleAddSession}>
New session
<span class="am-shortcut-hint">{modKey}T</span>
</Button>
</div>
</Show>
@@ -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;
}
@@ -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)]!
}