Merge pull request #12945 from Kilo-Org/design-agent-manager-terminal-ux

feat(vscode): improve Agent Manager terminal navigation
This commit is contained in:
Marius
2026-08-06 16:49:03 +02:00
committed by GitHub
25 changed files with 410 additions and 83 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Improve Agent Manager terminal focus and keyboard navigation. `Cmd+/` now focuses a visible embedded terminal before hiding it, `Cmd+Shift+T` creates a side terminal only while that terminal area has focus, and `Cmd+Shift+[` / `]` switch terminal tabs. `Cmd+Shift+M` focuses the Agent Manager prompt instead of opening VS Code Problems. `Cmd+W` hides the last side terminal instead of stopping its shell.
@@ -232,7 +232,7 @@ See [Agent Manager Workflows](/docs/automate/agent-manager-workflows#merging-wor
## Terminals
Each session has a dedicated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session.
Each session has a dedicated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session. If the embedded terminal is already visible but the prompt has focus, the same shortcut focuses the terminal without hiding it. Press it again while the terminal has focus to hide the panel.
### Choosing the Terminal Destination
@@ -243,11 +243,18 @@ The toolbar's terminal button is a split button: click it to open a terminal, or
The dropdown choice is remembered per panel and becomes the default for new panels. You can also set the default directly with the `kilo-code.new.agentManager.terminalButtonDestination` setting (`vscode` or `agentManager`). The `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) shortcut follows the same destination.
With the **Agent Manager panel** destination, the terminal works like the diff panel: press `Cmd+/` to reveal it and press again to hide it. Hiding never stops the terminal — scrollback and running processes continue in the background, and focus returns to the chat input. A terminal stops only when you close its tab in the panel.
With the **Agent Manager panel** destination, the terminal works like the diff panel: press `Cmd+/` to reveal and focus it, press it while the panel is visible but another control has focus to move focus into the terminal, and press it again from the terminal to hide it. Hiding never stops the terminal — scrollback and running processes continue in the background, and focus returns to the chat input. A terminal stops only when you click its close button or type `exit` in the shell.
### Multiple Terminals
The side panel hosts multiple terminals per context (the local workspace or a worktree). The panel header is a tab strip: click a tab to switch, click **+** to open another terminal, and click **X** (or middle-click) to close a single terminal. Drag tabs to reorder them. Closing a terminal no longer hides the panel — closing the last one lands on the empty state. Pressing `Cmd+W` (macOS) / `Ctrl+W` (Windows/Linux) with a focused side terminal closes exactly that terminal.
Agent Manager has two separate terminal tab strips:
- **Main terminal tabs** appear alongside the agent session tabs. With the prompt or a main terminal focused, press `Cmd+Shift+T` / `Ctrl+Shift+T` to create another main terminal tab.
- **Side terminal tabs** appear in the terminal panel. Focus a side terminal, then press `Cmd+Shift+T` / `Ctrl+Shift+T` to create another side terminal. You can also click **+** in the side-terminal strip.
The shortcut follows terminal focus, not panel visibility. A visible side panel with the prompt focused still creates a main terminal tab. Press `Cmd+Shift+[` / `Ctrl+Shift+[` for the previous terminal or `Cmd+Shift+]` / `Ctrl+Shift+]` for the next terminal in the focused terminal strip. Drag tabs to reorder them. Pressing `Cmd+W` / `Ctrl+W` with a focused side terminal closes that terminal when other terminals remain. On the last side terminal, it hides the panel and keeps the shell alive; use its close button or type `exit` to stop it.
`Cmd+T` / `Ctrl+T` always creates a new agent session tab. It never creates a terminal.
New terminals are named "Terminal N" using the lowest free number, and tabs pick up the live title from the shell or running program, so a dev server or editor names its own tab.
@@ -256,7 +263,7 @@ New terminals are named "Terminal N" using the lowest free number, and tabs pick
A common workflow is letting the agent work, then switching to the terminal to run tests or inspect the worktree, then switching back to control the agent:
1. **Agent Manager → Terminal:** Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to open and focus the terminal for the current session. The terminal runs inside the session's worktree, so commands like `npm test` or `git status` operate on the agent's isolated branch.
2. **Terminal → Agent Manager:** Press `Cmd+Shift+M` (macOS) / `Ctrl+Shift+M` (Windows/Linux) to bring focus back to the Agent Manager panel and its prompt input. This works from anywhere in VS Code — the terminal, another editor tab, or the sidebar.
2. **Terminal → Agent Manager:** Press `Cmd+Shift+M` (macOS) / `Ctrl+Shift+M` (Windows/Linux) to bring focus back to the Agent Manager panel and its prompt input. This explicit shortcut always targets the prompt and works from anywhere in VS Code — the terminal, another editor tab, or the sidebar. Returning to the panel by clicking its editor tab or switching windows restores the last focused control instead.
## Setup Scripts
@@ -392,11 +399,13 @@ Closing a managed worktree removes it from Agent Manager, deletes its `.kilo/wor
| `Cmd+Shift+N` | `Ctrl+Shift+N` | Create a new worktree immediately |
| `Cmd+Shift+O` | `Ctrl+Shift+O` | Import/open worktree |
| `Cmd+Shift+W` | `Ctrl+Shift+W` | Close current worktree |
| `Cmd+T` | `Ctrl+T` | New tab (session) in worktree |
| `Cmd+W` | `Ctrl+W` | Close current tab |
| `Cmd+T` | `Ctrl+T` | New agent session tab in worktree |
| `Cmd+W` | `Ctrl+W` | Close the focused tab or terminal; the last side terminal hides instead of stopping |
| `Cmd+Alt+Up` / `Down` | `Ctrl+Alt+Up` / `Down` | Previous / next worktree |
| `Cmd+Alt+Left` / `Right` | `Ctrl+Alt+Left` / `Right` | Previous / next tab in worktree |
| `Cmd+/` | `Ctrl+/` | Focus terminal for current session |
| `Cmd+/` | `Ctrl+/` | Focus terminal, or hide it when it already has focus |
| `Cmd+Shift+T` | `Ctrl+Shift+T` | New side terminal when a side terminal is focused; otherwise new main terminal tab |
| `Cmd+Shift+[` / `]` | `Ctrl+Shift+[` / `]` | Previous / next terminal |
| `Cmd+D` | `Ctrl+D` | Toggle diff panel |
| `Cmd+E` | `Ctrl+E` | Run / stop run script |
| `Cmd+Shift+/` | `Ctrl+Shift+/` | Show keyboard shortcuts |
+27
View File
@@ -210,6 +210,16 @@
"title": "Agent Manager: Next Tab",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.previousTerminal",
"title": "Agent Manager: Previous Terminal",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.nextTerminal",
"title": "Agent Manager: Next Terminal",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.search",
"title": "Agent Manager: Search Worktrees and Sessions",
@@ -514,6 +524,11 @@
"key": "ctrl+shift+g",
"mac": "cmd+shift+g"
},
{
"command": "-workbench.actions.view.problems",
"key": "ctrl+shift+m",
"mac": "cmd+shift+m"
},
{
"command": "kilo-code.new.agentManagerOpen",
"key": "ctrl+shift+m",
@@ -549,6 +564,18 @@
"mac": "cmd+alt+right",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.previousTerminal",
"key": "ctrl+shift+[",
"mac": "cmd+shift+[",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.nextTerminal",
"key": "ctrl+shift+]",
"mac": "cmd+shift+]",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.search",
"key": "ctrl+f",
@@ -73,7 +73,7 @@ import { createProjectWiring } from "./project/wiring"
import { ProjectScope } from "./project/scope"
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
import { focusPanelPrompt } from "./focus-panel"
export class AgentManagerProvider implements Disposable {
public static readonly viewType = "kilo-code.new.AgentManagerPanel"
private panel: PanelContext | undefined
@@ -356,22 +356,22 @@ export class AgentManagerProvider implements Disposable {
if (this.panel) {
this.log("Panel already open, revealing")
this.panel.reveal(preserveFocus)
if (!preserveFocus) this.postToWebview({ type: "action", action: "focusInput" })
if (!preserveFocus)
focusPanelPrompt(this.panel, this.waitForPanelReady(this.panel), this.waitForPanelActive(this.panel))
return
}
this.log("Opening Agent Manager panel")
this.host.capture("Agent Manager Opened", { source: PLATFORM })
this.attachPanel(
this.host.openPanel({
onBeforeMessage: (msg) => this.onMessage(msg),
worktreeDirectories: () => this.getWorktreeDirectories(),
workspaceRoot: () => this.getRoot(),
projectId: () => this.contexts.active()?.id,
}),
)
const panel = this.host.openPanel({
onBeforeMessage: (msg) => this.onMessage(msg),
worktreeDirectories: () => this.getWorktreeDirectories(),
workspaceRoot: () => this.getRoot(),
projectId: () => this.contexts.active()?.id,
})
this.attachPanel(panel)
if (!preserveFocus) focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel))
}
public onPanelVisibilityChange(cb: (visible: boolean) => void): void {
this.onVisibilityChange = cb
}
@@ -1693,9 +1693,10 @@ export class AgentManagerProvider implements Disposable {
* Used for the keyboard shortcut to switch back from terminal.
*/
public focusPanel(): void {
if (!this.panel) return
this.panel.reveal(false)
this.postToWebview({ type: "action", action: "focusInput" })
const panel = this.panel
if (!panel) return
panel.reveal(false)
focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel))
}
public isActive(): boolean {
@@ -0,0 +1,8 @@
import type { PanelContext } from "./host"
export function focusPanelPrompt(panel: PanelContext, ready: Promise<boolean>, active: Promise<boolean>): void {
void Promise.all([ready, active]).then(([ready, active]) => {
if (!ready || !active) return
panel.postMessage({ type: "action", action: "focusInput" })
})
}
@@ -71,6 +71,9 @@ export function buildKeybindingMap(
if (!bindings.runScript) bindings.runScript = formatKeybinding(mac ? "cmd+e" : "ctrl+e", mac)
if (!bindings.toggleDiff) bindings.toggleDiff = formatKeybinding(mac ? "cmd+d" : "ctrl+d", mac)
if (!bindings.showShortcuts) bindings.showShortcuts = formatKeybinding(mac ? "cmd+shift+/" : "ctrl+shift+/", mac)
if (!bindings.previousTerminal)
bindings.previousTerminal = formatKeybinding(mac ? "cmd+shift+[" : "ctrl+shift+[", mac)
if (!bindings.nextTerminal) bindings.nextTerminal = formatKeybinding(mac ? "cmd+shift+]" : "ctrl+shift+]", mac)
return bindings
}
+12 -1
View File
@@ -140,7 +140,12 @@ export function activate(context: vscode.ExtensionContext) {
// The terminal intercepts all keystrokes unless the command is listed in
// terminal.integrated.commandsToSkipShell, which only contains built-in
// commands by default.
const skip = ["kilo-code.new.agentManagerOpen", "kilo-code.new.agentManager.showTerminal"]
const skip = [
"kilo-code.new.agentManagerOpen",
"kilo-code.new.agentManager.showTerminal",
"kilo-code.new.agentManager.previousTerminal",
"kilo-code.new.agentManager.nextTerminal",
]
if (process.platform === "darwin") skip.push("kilo-code.new.agentManager.runScript")
ensureCommandsSkipShell(skip)
@@ -446,6 +451,12 @@ 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.previousTerminal", () => {
agentManagerProvider.postMessage({ type: "action", action: "terminalPrevious" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTerminal", () => {
agentManagerProvider.postMessage({ type: "action", action: "terminalNext" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.search", () => {
agentManagerProvider.postMessage({ type: "action", action: "search" })
}),
@@ -379,6 +379,28 @@ describe("Agent Manager Worktree Actions", () => {
expect(source).toContain('quickWorktree: isMac ? "⌘⇧N" : "Ctrl+Shift+N"')
})
it("reserves Cmd+Shift+M for the Agent Manager instead of Problems", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")) as {
contributes: { keybindings: { command: string; key?: string; mac?: string }[] }
}
const removed = manifest.contributes.keybindings.find((item) => item.command === "-workbench.actions.view.problems")
const manager = manifest.contributes.keybindings.find((item) => item.command === "kilo-code.new.agentManagerOpen")
expect(removed).toMatchObject({ key: "ctrl+shift+m", mac: "cmd+shift+m" })
expect(manager).toMatchObject({ key: "ctrl+shift+m", mac: "cmd+shift+m" })
})
it("creates side terminals only while a side terminal owns focus", () => {
const source = fs.readFileSync(TSX_FILE, "utf-8")
const start = source.indexOf('else if (msg.action === "newTerminal")')
const end = source.indexOf('else if (msg.action === "cycleAgentMode"', start)
const action = source.slice(start, end)
expect(action).toContain("if (terms.sideFocusedId()) termHandlers.addSide()")
expect(action).not.toContain("terminalVisible()")
expect(action).toContain("else termHandlers.requestNew()")
})
it("forwards the quick-worktree command to immediate creation", () => {
const source = fs.readFileSync(path.join(ROOT, "src/extension.ts"), "utf-8")
const start = source.indexOf('vscode.commands.registerCommand("kilo-code.new.agentManager.quickWorktree"')
@@ -12,6 +12,8 @@ function scene(
saved?: "vscode" | "agentManager"
visible?: boolean
focusedId?: string
count?: number
script?: boolean
mac?: boolean
} = {},
) {
@@ -33,6 +35,7 @@ function scene(
requestSide: () => {
calls.requestSide++
visible = true
focusedId ??= "terminal:side"
},
ensureSide: () => calls.ensureSide++,
closeSide: (terminalId) => {
@@ -43,6 +46,8 @@ function scene(
},
visible: () => visible,
focusedId: () => focusedId,
count: () => opts.count ?? 2,
isScript: () => opts.script ?? false,
hide: () => {
calls.hide++
visible = false
@@ -60,7 +65,7 @@ function scene(
}
describe("Agent Manager side terminal controller", () => {
it("toggles the panel and hands focus to the chat only when the terminal had it", () => {
it("toggles the panel, focusing a visible terminal before hiding it", () => {
const focused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" })
focused.ctl.toggle()
expect(focused.calls.hide).toBe(1)
@@ -68,7 +73,8 @@ describe("Agent Manager side terminal controller", () => {
const elsewhere = scene({ destination: "agentManager", visible: true })
elsewhere.ctl.toggle()
expect(elsewhere.calls.hide).toBe(1)
expect(elsewhere.calls.requestSide).toBe(1)
expect(elsewhere.calls.hide).toBe(0)
expect(elsewhere.calls.refocus).toBe(0)
const hidden = scene({ destination: "agentManager", visible: false })
@@ -93,18 +99,32 @@ describe("Agent Manager side terminal controller", () => {
hidden.ctl.syncContext("wt-2", "wt-1")
expect(hidden.calls.ensureSide).toBe(0)
const closed = scene({ visible: true })
const closed = scene({ visible: true, focusedId: "terminal:side" })
closed.ctl.syncContext("wt-2", "wt-1")
closed.ctl.toggle()
await Promise.resolve()
expect(closed.calls.ensureSide).toBe(0)
})
it("kills the focused terminal and refocuses the chat", () => {
it("closes the focused terminal without stealing focus from its survivor", () => {
const focused = scene({ focusedId: "terminal:two" })
expect(focused.ctl.close()).toBe(true)
expect(focused.calls.closed).toEqual(["terminal:two"])
expect(focused.calls.refocus).toBe(1)
expect(focused.calls.refocus).toBe(0)
})
it("hides instead of killing the last or provider-owned terminal", () => {
const last = scene({ focusedId: "terminal:last", count: 1 })
expect(last.ctl.close()).toBe(true)
expect(last.calls.closed).toEqual([])
expect(last.calls.hide).toBe(1)
expect(last.calls.refocus).toBe(1)
const script = scene({ focusedId: "script:run", script: true, count: 2 })
expect(script.ctl.close()).toBe(true)
expect(script.calls.closed).toEqual([])
expect(script.calls.hide).toBe(1)
expect(script.calls.refocus).toBe(1)
})
it("does nothing on close without a focused terminal", () => {
@@ -18,6 +18,7 @@ function scene(initial: string | null = LOCAL) {
const events = {
activated: [] as string[],
selected: [] as string[],
cleared: 0,
saved: 0,
shown: [] as string[],
errors: 0,
@@ -29,7 +30,7 @@ function scene(initial: string | null = LOCAL) {
tabIds: tabs,
selectReview: () => undefined,
selectSessionTab: () => undefined,
clearSession: () => undefined,
clearSession: () => events.cleared++,
resetOthers: () => undefined,
isPendingId: () => false,
findTab: () => undefined,
@@ -401,6 +402,69 @@ describe("Agent Manager terminal state", () => {
})
})
it("cycles side terminals in both directions and wraps", () => {
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:one")
expect(item.handlers.cycle("next", "side")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
expect(item.state.focusRequest()?.id).toBe("terminal:two")
expect(item.handlers.cycle("next", "side")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
expect(item.handlers.cycle("previous", "side")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
dispose()
})
})
it("cycles main terminal tabs independently from side terminals", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "tab" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "tab" })
item.state.setActiveId("terminal:one")
expect(item.handlers.cycle("next", "tab")).toBe(true)
expect(item.state.activeId()).toBe("terminal:two")
expect(item.handlers.cycle("next", "tab")).toBe(true)
expect(item.state.activeId()).toBe("terminal:one")
dispose()
})
})
it("starts terminal cycling at the boundary when no terminal is active", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "tab" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "tab" })
item.state.setActiveId(undefined)
expect(item.handlers.cycle("next", "tab")).toBe(true)
expect(item.state.activeId()).toBe("terminal:one")
item.state.setActiveId(undefined)
expect(item.handlers.cycle("previous", "tab")).toBe(true)
expect(item.state.activeId()).toBe("terminal:two")
dispose()
})
})
it("keeps the session open when its last main terminal closes", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "tab" })
item.state.setActiveId("terminal:one")
item.state.setFocusedId("terminal:one")
expect(item.handlers.closeFocused()).toBe(true)
expect(item.state.current()).toEqual([])
expect(item.events.cleared).toBe(0)
dispose()
})
})
it("moves activation to the last remaining side terminal on close", () => {
createRoot((dispose) => {
const item = scene()
@@ -410,6 +474,7 @@ describe("Agent Manager terminal state", () => {
expect(item.handlers.closeSide("terminal:two")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
expect(item.state.focusRequest()?.id).toBe("terminal:one")
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:two" }])
expect(item.handlers.closeSide("terminal:one")).toBe(true)
@@ -39,6 +39,11 @@ describe("formatKeybinding", () => {
it("formats plain key", () => {
expect(formatKeybinding("cmd+/", true)).toBe("⌘/")
})
it("formats bracket keys", () => {
expect(formatKeybinding("cmd+shift+[", true)).toBe("⌘⇧[")
expect(formatKeybinding("cmd+shift+]", true)).toBe("⌘⇧]")
})
})
describe("windows/linux", () => {
@@ -77,4 +82,11 @@ describe("buildKeybindingMap", () => {
expect(buildKeybindingMap(bindings, true).search).toBe("⌘F")
expect(buildKeybindingMap(bindings, false).search).toBe("Ctrl+F")
})
it("provides terminal navigation fallbacks", () => {
expect(buildKeybindingMap([], true).previousTerminal).toBe("⌘⇧[")
expect(buildKeybindingMap([], true).nextTerminal).toBe("⌘⇧]")
expect(buildKeybindingMap([], false).previousTerminal).toBe("Ctrl+Shift+[")
expect(buildKeybindingMap([], false).nextTerminal).toBe("Ctrl+Shift+]")
})
})
@@ -208,6 +208,8 @@ const defaultBindings: Record<string, string> = {
nextSession: isMac ? "⌘⌥↓" : "Ctrl+Alt+↓",
previousTab: isMac ? "⌘⌥←" : "Ctrl+Alt+←",
nextTab: isMac ? "⌘⌥→" : "Ctrl+Alt+→",
previousTerminal: isMac ? "⌘⇧[" : "Ctrl+Shift+[",
nextTerminal: isMac ? "⌘⇧]" : "Ctrl+Shift+]",
search: isMac ? "⌘F" : "Ctrl+F",
showTerminal: isMac ? "⌘/" : "Ctrl+/",
newTerminal: isMac ? "⌘⇧T" : "Ctrl+Shift+T",
@@ -422,9 +424,16 @@ const AgentManagerContent: Component = () => {
{ defer: true },
),
)
type FocusOwner = "prompt" | { terminal: string }
const focusMemory = new Map<string, FocusOwner>()
let focusInputUntil = 0
const focusPrompt = () => {
focusInputUntil = Date.now() + 500
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
terms.setActiveId(undefined)
terms.setFocusedId(undefined)
requestChatFocus(true)
}
const focusKey = () => {
const context = terms.sideKey()
const sessionID = session.currentSessionID() ?? activePendingId() ?? "new"
@@ -456,6 +465,7 @@ const AgentManagerContent: Component = () => {
return terminalVisible() ? false : true
}
const restoreFocus = () => {
if (Date.now() < focusInputUntil) return
const key = focusKey()
const owner = focusMemory.get(key)
if (owner && owner !== "prompt") {
@@ -1192,6 +1202,8 @@ const AgentManagerContent: Component = () => {
else if (msg.action === "sessionNext") projectNav.step("down")
else if (msg.action === "tabPrevious") navigateTab("left")
else if (msg.action === "tabNext") navigateTab("right")
else if (msg.action === "terminalPrevious") cycleTerminal("previous")
else if (msg.action === "terminalNext") cycleTerminal("next")
else if (msg.action === "search") {
if (!sidebarCollapsed()) sidebarSearchMenu?.open()
else {
@@ -1215,11 +1227,13 @@ const AgentManagerContent: Component = () => {
else if (msg.action === "advancedWorktree") showNewWorktreeDialog()
else if (msg.action === "closeWorktree") closeSelectedWorktree()
else if (msg.action === "showShortcuts") handleShowKeyboardShortcuts()
else if (msg.action === "focusInput") requestChatFocus(true)
else if (msg.action === "focusInput") focusPrompt()
else if (msg.action === "focusSearch")
focusChatSearch({ history: setHistory, review: setReviewActive, terminal: () => terms.setActiveId(undefined) })
else if (msg.action === "newTerminal") termHandlers.requestNew()
else if (msg.action === "cycleAgentMode" && document.hasFocus()) {
else if (msg.action === "newTerminal") {
if (terms.sideFocusedId()) termHandlers.addSide()
else termHandlers.requestNew()
} else if (msg.action === "cycleAgentMode" && document.hasFocus()) {
if (!mode.dispatch(1)) cycleAgent(1)
} else if (msg.action === "cyclePreviousAgentMode" && document.hasFocus()) {
if (!mode.dispatch(-1)) cycleAgent(-1)
@@ -1230,7 +1244,6 @@ const AgentManagerContent: Component = () => {
}
}
window.addEventListener("message", handler)
// Prevent Cmd/Ctrl shortcuts from triggering native browser actions
const preventDefaults = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return
@@ -1244,8 +1257,9 @@ const AgentManagerContent: Component = () => {
if (["t", "w", "n", "d", "e", "f"].includes(e.key.toLowerCase()) && !e.shiftKey) {
e.preventDefault()
}
// Prevent defaults for shift variants (close worktree, advanced/new/open worktree, open PR)
if (["w", "n", "o", "r"].includes(e.key.toLowerCase()) && e.shiftKey) {
// Prevent browser defaults for shift variants (new terminal, close worktree,
// advanced/new/open worktree, open PR, terminal cycling)
if (["t", "m", "w", "n", "o", "r", "[", "]"].includes(e.key.toLowerCase()) && e.shiftKey) {
e.preventDefault()
}
// Prevent browser defaults for shortcuts help (Cmd/Ctrl+Shift+/)
@@ -2115,6 +2129,8 @@ const AgentManagerContent: Component = () => {
handlers: termHandlers,
visible: () => sidePanel() === "terminal" && !history() && !reviewActive(),
focusedId: () => terms.sideFocusedId(),
count: () => terms.sidesForContext(terms.sideKey()).length,
isScript: terms.isScript,
hide: () => {
cancelAmbientSetup()
setSidePanel(null)
@@ -2233,16 +2249,24 @@ const AgentManagerContent: Component = () => {
})
}
const tabFocus = createTabFocus({ ids: () => tabIds(), select: focusTab })
const cycleTerminal = (direction: "previous" | "next") => {
const focused = terms.focusedId()
const placement = terms.sideFocusedId() || (!focused && terminalVisible()) ? "side" : "tab"
return termHandlers.cycle(direction, placement)
}
// Close the currently active tab via keyboard shortcut.
// If no tabs remain, fall through to close the selected worktree.
const closeActiveTab = () => {
// A focused side terminal owns Cmd+W while its panel is visible
// closing a chat tab out from under the user's cursor would be
// surprising. Only that terminal dies; the panel keeps the rest.
// 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.sideFocusedId()) {
if (sideCtl.close()) return
}
if (termHandlers.closeFocused()) {
tabFocus.restore()
return
}
if (termHandlers.closeActive()) {
tabFocus.restore()
return
@@ -2493,6 +2517,9 @@ const AgentManagerContent: Component = () => {
onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)}
terminalDestination={sideCtl.destination}
terminalDestinationActive={() => sidePanel() === "terminal"}
terminalDestinationFocused={() =>
sideCtl.destination() === "agentManager" && terms.sideFocusedId() !== undefined
}
terminalKeybind={() => kb().showTerminal ?? ""}
onTerminalDestinationOpen={() => {
cancelAmbientSetup()
@@ -2561,7 +2588,7 @@ const AgentManagerContent: Component = () => {
>
<div class={`am-main-pane ${terms.activeId() ? "am-main-pane-terminal-active" : ""}`}>
{/* Keep terminal tabs mounted so output streams across worktree switches. */}
{renderTerminalLayer({ state: terms })}
{renderTerminalLayer({ state: terms, onFocusPrompt: focusPrompt })}
{/* Session-less context (e.g. a worktree mid-provisioning): the
empty state lives in the main pane so the side terminal
panel can render next to it. */}
@@ -2719,6 +2746,9 @@ const AgentManagerContent: Component = () => {
state={terms}
contextKey={terms.sideKey}
visible={() => sidePanel() === "terminal"}
nextKeybind={kb().nextTerminal ?? ""}
closeKeybind={kb().closeTab ?? ""}
onFocusPrompt={focusPrompt}
onSelect={(id) => termHandlers.selectSide(id)}
onClose={(id) => {
cancelAmbientSetup()
@@ -57,6 +57,7 @@ export interface TabBarProps {
onToggleReview: () => void
terminalDestination: () => TerminalDestination
terminalDestinationActive: () => boolean
terminalDestinationFocused: () => boolean
terminalKeybind: () => string
onTerminalDestinationOpen: () => void
onTerminalDestinationChoose: (destination: TerminalDestination) => void
@@ -253,12 +254,13 @@ export const TabBar: Component<TabBarProps> = (props) => (
</Tooltip>
</Show>
{/* Terminal destination split button: the primary action
follows the user's setting (VS Code integrated terminal
or the embedded side panel), the dropdown picks which.
Cmd+Shift+T still creates an xterm tab via the `+` menu. */}
follows the user's setting (VS Code integrated terminal
or the embedded side panel), the dropdown picks which.
Cmd+Shift+T creates a terminal in the active terminal container. */}
<TerminalDestinationButton
destination={props.terminalDestination}
active={props.terminalDestinationActive}
focused={props.terminalDestinationFocused}
keybind={props.terminalKeybind}
onOpen={props.onTerminalDestinationOpen}
onChoose={props.onTerminalDestinationChoose}
@@ -4754,6 +4754,10 @@ body.vscode-high-contrast-light {
outline: 1px solid var(--vscode-contrastActiveBorder, var(--vscode-focusBorder));
outline-offset: -1px;
}
.am-terminal-host:focus-within {
box-shadow: inset 0 0 0 1px var(--vscode-contrastActiveBorder, var(--vscode-focusBorder));
}
}
/* Context menu restyle to match dropdown-menu visuals.
@@ -4799,6 +4803,10 @@ body.vscode-high-contrast-light {
opacity: 0.7;
}
.am-tab-terminal-focused {
background: var(--surface-base-hover);
}
/*
* Stacking layout for xterm terminal tabs.
*
@@ -5001,6 +5009,10 @@ body.vscode-high-contrast-light {
background: var(--vscode-terminal-background, #1e1e1e);
}
.am-terminal-host:focus-within {
box-shadow: inset 0 0 0 1px var(--border-focus, var(--vscode-focusBorder));
}
/* Third-party xterm classes addressed by attribute selector so the
agent-manager "am-* prefix" architecture test does not flag them.
FitAddon subtracts padding from xterm itself, not its parent host. */
@@ -12,7 +12,7 @@ export function createChatFocus(deps: {
review: () => boolean
}) {
const focus = (force: boolean) => {
if ((!force && !document.hasFocus()) || deps.term() || deps.history() || deps.review()) return
if ((!force && (!document.hasFocus() || deps.term())) || deps.history() || deps.review()) return
if (preservesTextFocus(document.activeElement)) return
if (!force && document.activeElement?.matches('[role="tab"]')) return
if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return
@@ -105,7 +105,7 @@ export const dict = {
"agentManager.shortcuts.nextTab": "Next tab",
"agentManager.shortcuts.newTab": "New tab",
"agentManager.shortcuts.closeTab": "Close tab",
"agentManager.shortcuts.toggleTerminal": "Toggle terminal",
"agentManager.shortcuts.toggleTerminal": "Focus / hide terminal",
"agentManager.shortcuts.runScript": "Run script",
"agentManager.run.options": "Run options",
"agentManager.run.configure": "Configure run script",
@@ -12,15 +12,16 @@ export function buildShortcutCategories(
bindings: Record<string, string>,
t: (key: string, params?: Record<string, string | number>) => string,
): ShortcutCategory[] {
const bind = (key: string) => bindings[key] ?? ""
return [
{
title: t("agentManager.shortcuts.category.quickSwitch"),
shortcuts: [
{ label: t("agentManager.sidebarSearch.label"), binding: bindings.search ?? "" },
{ label: t("agentManager.sidebarSearch.label"), binding: bind("search") },
{
label: t("agentManager.shortcuts.jumpToItem"),
binding: (() => {
const first = bindings.jumpTo1 ?? ""
const first = bind("jumpTo1")
const prefix = first.replace(/\d+$/, "")
return prefix ? `${prefix}1-9` : ""
})(),
@@ -30,39 +31,48 @@ export function buildShortcutCategories(
{
title: t("agentManager.shortcuts.category.sidebar"),
shortcuts: [
{ label: t("agentManager.shortcuts.previousItem"), binding: bindings.previousSession ?? "" },
{ label: t("agentManager.shortcuts.nextItem"), binding: bindings.nextSession ?? "" },
{ label: t("agentManager.shortcuts.previousItem"), binding: bind("previousSession") },
{ label: t("agentManager.shortcuts.nextItem"), binding: bind("nextSession") },
{ label: t("agentManager.shortcuts.advancedWorktree"), binding: bindings.newWorktree ?? "" },
{ label: t("agentManager.shortcuts.newWorktree"), binding: bindings.quickWorktree ?? "" },
{ label: t("agentManager.shortcuts.deleteWorktree"), binding: bindings.closeWorktree ?? "" },
{ label: t("agentManager.shortcuts.openWorktree"), binding: bindings.openWorktree ?? "" },
{ label: t("agentManager.shortcuts.openPR"), binding: bindings.openPR ?? "" },
{ label: t("agentManager.shortcuts.deleteWorktree"), binding: bind("closeWorktree") },
{ label: t("agentManager.shortcuts.openWorktree"), binding: bind("openWorktree") },
{ label: t("agentManager.shortcuts.openPR"), binding: bind("openPR") },
],
},
{
title: t("agentManager.shortcuts.category.tabs"),
shortcuts: [
{ label: t("agentManager.shortcuts.previousTab"), binding: bindings.previousTab ?? "" },
{ label: t("agentManager.shortcuts.nextTab"), binding: bindings.nextTab ?? "" },
{ label: t("agentManager.shortcuts.newTab"), binding: bindings.newTab ?? "" },
{ label: t("agentManager.shortcuts.closeTab"), binding: bindings.closeTab ?? "" },
{ label: t("agentManager.shortcuts.previousTab"), binding: bind("previousTab") },
{ label: t("agentManager.shortcuts.nextTab"), binding: bind("nextTab") },
{ label: t("agentManager.shortcuts.newTab"), binding: bind("newTab") },
{ label: t("agentManager.shortcuts.closeTab"), binding: bind("closeTab") },
],
},
{
title: t("agentManager.shortcuts.category.terminal"),
shortcuts: [
{ label: t("agentManager.shortcuts.toggleTerminal"), binding: bindings.showTerminal ?? "" },
{ label: t("agentManager.shortcuts.runScript"), binding: bindings.runScript ?? "" },
{ label: t("agentManager.shortcuts.toggleDiff"), binding: bindings.toggleDiff ?? "" },
{ label: t("agentManager.shortcuts.toggleTerminal"), binding: bind("showTerminal") },
{ label: t("agentManager.terminal.add"), binding: bind("newTerminal") },
{
label: `${t("agentManager.shortcuts.previousTab")} (${t("agentManager.tab.terminal")})`,
binding: bind("previousTerminal"),
},
{
label: `${t("agentManager.shortcuts.nextTab")} (${t("agentManager.tab.terminal")})`,
binding: bind("nextTerminal"),
},
{ label: t("agentManager.shortcuts.runScript"), binding: bind("runScript") },
{ label: t("agentManager.shortcuts.toggleDiff"), binding: bind("toggleDiff") },
],
},
{
title: t("agentManager.shortcuts.category.global"),
shortcuts: [
{ label: t("agentManager.shortcuts.openAgentManager"), binding: bindings.agentManagerOpen ?? "" },
{ label: t("agentManager.shortcuts.cycleAgentMode"), binding: bindings.cycleAgentMode ?? "" },
{ label: t("agentManager.shortcuts.cyclePreviousAgentMode"), binding: bindings.cyclePreviousAgentMode ?? "" },
{ label: t("agentManager.shortcuts.showShortcuts"), binding: bindings.showShortcuts ?? "" },
{ label: t("agentManager.shortcuts.openAgentManager"), binding: bind("agentManagerOpen") },
{ label: t("agentManager.shortcuts.cycleAgentMode"), binding: bind("cycleAgentMode") },
{ label: t("agentManager.shortcuts.cyclePreviousAgentMode"), binding: bind("cyclePreviousAgentMode") },
{ label: t("agentManager.shortcuts.showShortcuts"), binding: bind("showShortcuts") },
].filter((s) => s.binding),
},
].filter((c) => c.shortcuts.length > 0)
@@ -30,7 +30,7 @@ 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 { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../../src/context/language"
import { ConstrainDragYAxis } from "../../src/components/chat/TabDnd"
import { useTabScroll } from "../../src/utils/tab-scroll"
@@ -57,8 +57,11 @@ interface Props {
onCloseOthers: (terminalId: string) => void
/** Create a new side terminal for this context. */
onStart: () => void
nextKeybind: string
closeKeybind: string
/** Deliberately stop a running script terminal. */
onStop: (terminalId: string) => void
onFocusPrompt: () => void
}
export const SideTerminalPanel: Component<Props> = (props) => {
@@ -170,7 +173,10 @@ export const SideTerminalPanel: Component<Props> = (props) => {
label={props.state.title(term.id) ?? term.title}
tooltip={props.state.title(term.id) ?? term.title}
status={props.state.scriptStatus(term.id)}
keybind={active() === term.id ? "" : props.nextKeybind}
closeKeybind={props.closeKeybind}
active={active() === term.id}
focused={props.state.sideFocusedId() === term.id}
role="tab"
selected={active() === term.id}
tabIndex={active() === term.id ? 0 : -1}
@@ -226,7 +232,12 @@ export const SideTerminalPanel: Component<Props> = (props) => {
</Tooltip>
</div>
</div>
{renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, visible: props.visible })}
{renderSideTerminalLayer({
state: props.state,
contextKey: props.contextKey,
visible: props.visible,
onFocusPrompt: props.onFocusPrompt,
})}
<Show when={props.visible() && sides().length === 0 && pending()}>
<div class="am-side-terminal-state" role="status">
<Spinner />
@@ -27,6 +27,7 @@ export const TerminalTabChrome: Component<{
status?: ScriptTerminalStatus
keybind?: string
closeKeybind?: string
focused?: boolean
active: boolean
role?: "tab"
selected?: boolean
@@ -46,7 +47,9 @@ export const TerminalTabChrome: Component<{
return "console"
}
return (
<div class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""}`}>
<div
class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""} ${props.focused ? "am-tab-terminal-focused" : ""}`}
>
<div
class="am-tab-target"
role={props.role}
@@ -126,6 +129,7 @@ export const SortableTerminalTab: Component<{
status?: ScriptTerminalStatus
keybind?: string
closeKeybind?: string
focused?: boolean
active: boolean
role?: "tab"
selected?: boolean
@@ -148,6 +152,7 @@ export const SortableTerminalTab: Component<{
status={props.status}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
focused={props.focused}
active={props.active}
role={props.role}
selected={props.selected}
@@ -20,6 +20,8 @@ interface Props {
destination: Accessor<TerminalDestination>
/** True while the embedded terminal panel is showing. */
active: Accessor<boolean>
/** True while the embedded terminal owns DOM focus. */
focused: Accessor<boolean>
keybind: Accessor<string>
onOpen: () => void
onChoose: (destination: TerminalDestination) => void
@@ -27,6 +29,10 @@ interface Props {
export const TerminalDestinationButton: Component<Props> = (props) => {
const { t } = useLanguage()
const title = () =>
props.destination() === "agentManager" && props.focused()
? t("agentManager.shortcuts.toggleTerminal")
: t("agentManager.tab.openTerminal")
const item = (destination: TerminalDestination, label: string) => (
<DropdownMenu.Item onSelect={() => props.onChoose(destination)}>
<span class="am-menu-check">
@@ -39,7 +45,7 @@ export const TerminalDestinationButton: Component<Props> = (props) => {
)
return (
<div class="am-split-button">
<TooltipKeybind title={t("agentManager.tab.terminal")} keybind={props.keybind()} placement="bottom">
<TooltipKeybind title={title()} keybind={props.keybind()} placement="bottom">
<IconButton
icon="console"
size="small"
@@ -50,6 +50,9 @@ interface Props {
* layer tracks this as `focusedId` so `Cmd+W` can target the
* terminal that actually has the cursor. */
onFocusChange?: (focused: boolean) => void
/** Handle the Agent Manager prompt shortcut locally because xterm's
* textarea does not reliably forward custom commands to the workbench. */
onFocusPrompt?: () => void
/** Reports OSC window-title escape codes (`ESC ] 0/1/2 ; title BEL`)
* sent by the shell or running programs fish sets it to the active
* command, oh-my-zsh to user@host:cwd, vim to the file name. The
@@ -131,7 +134,7 @@ function isAgentManagerShortcut(e: KeyboardEvent): boolean {
const key = e.key.toLowerCase()
if (e.altKey && ["arrowleft", "arrowright", "arrowup", "arrowdown"].includes(key)) return true
if (["t", "w", "n", "d", "e", "f"].includes(key)) return true
if (e.shiftKey && ["w", "n", "o", "r", "m", "/", "?"].includes(key)) return true
if (e.shiftKey && ["t", "w", "n", "o", "r", "m", "[", "]", "/", "?"].includes(key)) return true
if (/^[1-9]$/.test(key)) return true
if (key === "/") return true
return false
@@ -151,6 +154,7 @@ export const TerminalTab: Component<Props> = (props) => {
const term = new Terminal({
convertEol: true,
cursorBlink: true,
cursorInactiveStyle: "outline",
fontFamily: props.font.fontFamily,
fontSize: props.font.fontSize,
scrollback: 5000,
@@ -172,9 +176,17 @@ export const TerminalTab: Component<Props> = (props) => {
}
})
// Pass agent-manager hotkeys through to the parent key handler so
// ⌘T / ⌘W / ⌘⌥← etc. still work while the terminal is focused.
term.attachCustomKeyEventHandler((event) => !isAgentManagerShortcut(event))
// Pass Agent Manager hotkeys through to the parent key handler so
// ⌘T / ⌘⇧T / ⌘W / terminal cycling / ⌘⌥← still work while focused.
term.attachCustomKeyEventHandler((event) => {
const prompt =
(event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "m"
if (prompt) {
if (event.type === "keydown") props.onFocusPrompt?.()
return false
}
return !isAgentManagerShortcut(event)
})
// Track DOM focus so the state layer knows which terminal holds the
// cursor (drives Cmd+W targeting). focusout is ignored when focus
@@ -51,6 +51,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
status={deps.terms.scriptStatus(deps.id)}
keybind={isActive() ? "" : deps.keybind()}
closeKeybind={deps.closeKeybind()}
focused={deps.terms.focusedId() === deps.id}
active={isActive()}
role={deps.role}
selected={deps.selected}
@@ -93,7 +94,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
* exists; that boundary never flips under a live xterm, since removing
* the last terminal disposes its instance first.
*/
export function renderTerminalLayer(props: { state: TerminalStateControls }): JSX.Element {
export function renderTerminalLayer(props: { state: TerminalStateControls; onFocusPrompt: () => void }): JSX.Element {
const layerActive = () => props.state.activeId() !== undefined
const slotVisible = (termId: string, contextKey: string) =>
props.state.activeId() === termId && props.state.currentKey() === contextKey
@@ -113,6 +114,7 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS
focusSerial={focusSerial(props.state, term.id)}
font={term.font}
onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)}
onFocusPrompt={props.onFocusPrompt}
onTitleChange={(title) => props.state.setTitle(term.id, title)}
/>
</div>
@@ -138,6 +140,7 @@ export function renderSideTerminalLayer(props: {
state: TerminalStateControls
contextKey: Accessor<string>
visible: Accessor<boolean>
onFocusPrompt: () => void
}): JSX.Element {
return (
<div class={`am-side-terminal-layer ${props.visible() ? "am-side-terminal-layer-active" : ""}`}>
@@ -159,6 +162,7 @@ export function renderSideTerminalLayer(props: {
status={() => props.state.scriptStatus(term.id)}
restartable={term.kind === undefined}
onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)}
onFocusPrompt={props.onFocusPrompt}
onTitleChange={(title) => props.state.setTitle(term.id, title)}
/>
</div>
@@ -5,8 +5,9 @@
* `max-lines` lint cap. Owns the destination preference plus the toggle
* semantics of the toolbar button / `Cmd/Ctrl+/` shortcut, so the
* embedded terminal behaves like the diff panel: press once to reveal,
* press again to hide. Hiding never kills the terminal only the
* explicit close action (or `Cmd+W` while it holds focus) does.
* press again while focused to hide, and press while visible but unfocused
* to return focus to the shell. Hiding never kills the terminal only the
* explicit close action does.
*
* ## Destination state ownership
*
@@ -71,6 +72,10 @@ export interface SideTerminalDeps {
visible: Accessor<boolean>
/** Id of the side terminal holding DOM focus, if any. */
focusedId: Accessor<string | undefined>
/** Number of side terminals in the visible context. */
count: Accessor<number>
/** Whether the focused terminal is provider-owned Run/Setup output. */
isScript: (terminalId: string) => boolean
/** Leave terminal mode; the terminal stays alive in the background. */
hide: () => void
/** Move focus back to the chat composer. */
@@ -106,9 +111,12 @@ export function createSideTerminal(deps: SideTerminalDeps) {
const toggle = () => {
if (deps.visible()) {
const was = deps.focusedId() !== undefined
if (!deps.focusedId()) {
deps.handlers.requestSide()
return
}
deps.hide()
handoff(was)
handoff(true)
return
}
deps.handlers.requestSide()
@@ -122,15 +130,18 @@ export function createSideTerminal(deps: SideTerminalDeps) {
})
}
/** 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. */
/** Leave the focused side terminal without killing its shell when it is
* the last terminal or provider-owned script output. */
const close = (): boolean => {
const id = deps.focusedId()
if (!id) return false
const done = deps.handlers.closeSide(id)
if (done) handoff(true)
return done
const only = deps.count() === 1
if (only || deps.isScript(id)) {
deps.hide()
handoff(true)
return true
}
return deps.handlers.closeSide(id)
}
/** Toolbar button and `Cmd/Ctrl+/`: follow the user's destination. */
@@ -746,8 +746,6 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
const target = deps.findTab(nextId)
if (target) deps.selectSessionTab(target.id, deps.isPendingId(target.id))
}
} else {
deps.clearSession()
}
}
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
@@ -764,6 +762,14 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
// 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
const key = term.contextKey
const active = deps.state.sideActiveFor(key) === terminalId
const rest = active ? deps.state.sidesForContext(key).filter((item) => item.id !== terminalId) : []
const survivor = rest[rest.length - 1]
if (survivor) {
deps.state.setSideActive(key, survivor.id)
deps.state.requestFocus(survivor.id)
}
if (term.kind) {
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
return true
@@ -820,6 +826,33 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
return true
}
/** Close the main terminal that actually owns DOM focus, not just the active tab. */
const closeFocused = () => {
const id = deps.state.focusedId()
if (!id || !deps.state.current().some((term) => term.id === id)) return false
closeTerminal(id)
return true
}
/** Cycle terminals within one placement, wrapping at either end. */
const cycle = (direction: "previous" | "next", placement: "side" | "tab") => {
const key = deps.state.sideKey()
const list = placement === "side" ? deps.state.sidesForContext(key) : deps.state.current()
if (list.length === 0) return false
const current = placement === "side" ? deps.state.sideActiveFor(key) : deps.state.activeId()
const index = list.findIndex((term) => term.id === current)
const start = index === -1 ? (direction === "next" ? -1 : list.length) : index
const offset = direction === "next" ? 1 : -1
const next = list[(start + offset + list.length) % list.length]!
if (placement === "side") {
deps.state.setSideActive(key, next.id)
deps.state.requestFocus(next.id)
return true
}
activate(next.id)
return true
}
return {
closeTerminal,
closeSide,
@@ -834,6 +867,8 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
ensureSide,
addSide,
closeActive,
closeFocused,
cycle,
}
}
@@ -962,6 +962,9 @@ export const SideTerminalPanelEmpty: Story = {
state={state}
contextKey={() => LOCAL}
visible={() => true}
nextKeybind="⌘⇧]"
closeKeybind="⌘W"
onFocusPrompt={() => undefined}
onSelect={() => undefined}
onClose={() => undefined}
onCloseOthers={() => undefined}
@@ -1004,6 +1007,9 @@ export const SideTerminalPanelTabs: Story = {
state={state}
contextKey={() => LOCAL}
visible={() => true}
nextKeybind="⌘⇧]"
closeKeybind="⌘W"
onFocusPrompt={() => undefined}
onSelect={(id) => state.setSideActive(LOCAL, id)}
onClose={() => undefined}
onCloseOthers={() => undefined}