feat(agent-manager): add session terminal with focus toggle keybindings (#491)

* feat(agent-manager): add session terminal with focus toggle keybindings

* fix: dispose managed terminals on cleanup
This commit is contained in:
Marius
2026-02-19 19:23:37 +00:00
committed by GitHub
parent 0486317f29
commit 0e990a147d
7 changed files with 229 additions and 1 deletions
+24
View File
@@ -101,6 +101,18 @@
"title": "Agent Manager: Next Tab",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.showTerminal",
"title": "Agent Manager: Focus Terminal",
"category": "Kilo Code",
"icon": "$(terminal)"
},
{
"command": "kilo-code.new.agentManager.focusPanel",
"title": "Agent Manager: Focus Panel",
"category": "Kilo Code",
"icon": "$(circuit-board)"
},
{
"command": "kilo-code.new.agentManager.newTab",
"title": "Agent Manager: New Tab",
@@ -309,6 +321,18 @@
"mac": "cmd+right",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.showTerminal",
"key": "ctrl+/",
"mac": "cmd+/",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.focusPanel",
"key": "ctrl+.",
"mac": "cmd+.",
"when": "terminalFocus && kilo-code.agentTerminalFocus"
},
{
"command": "kilo-code.new.agentManager.newTab",
"key": "ctrl+t",
@@ -4,6 +4,7 @@ import { KiloProvider } from "../KiloProvider"
import { buildWebviewHtml } from "../utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { WorktreeStateManager } from "./WorktreeStateManager"
import { SessionTerminalManager } from "./SessionTerminalManager"
/**
* AgentManagerProvider opens the Agent Manager panel.
@@ -21,12 +22,16 @@ export class AgentManagerProvider implements vscode.Disposable {
private outputChannel: vscode.OutputChannel
private worktrees: WorktreeManager | undefined
private state: WorktreeStateManager | undefined
private terminalManager: SessionTerminalManager
constructor(
private readonly extensionUri: vscode.Uri,
private readonly connectionService: KiloConnectionService,
) {
this.outputChannel = vscode.window.createOutputChannel("Kilo Agent Manager")
this.terminalManager = new SessionTerminalManager((msg) =>
this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
)
}
private log(...args: unknown[]) {
@@ -124,11 +129,20 @@ export class AgentManagerProvider implements vscode.Disposable {
return this.onAddSessionToWorktree(msg.worktreeId)
if (type === "agentManager.closeSession" && typeof msg.sessionId === "string")
return this.onCloseSession(msg.sessionId)
if (type === "agentManager.showTerminal" && typeof msg.sessionId === "string") {
this.terminalManager.showTerminal(msg.sessionId, this.state)
return null
}
if (type === "agentManager.requestRepoInfo") {
void this.sendRepoInfo()
return null
}
// When switching sessions, show existing terminal if one is open
if (type === "loadMessages" && typeof msg.sessionID === "string") {
this.terminalManager.showExisting(msg.sessionID)
}
// After clearSession, re-register worktree sessions so SSE events keep flowing
if (type === "clearSession") {
void Promise.resolve().then(() => {
@@ -443,11 +457,29 @@ export class AgentManagerProvider implements vscode.Disposable {
})
}
/**
* Show terminal for the currently active session (triggered by keyboard shortcut).
* Posts an action to the webview which will respond with the session ID.
*/
public showTerminalForCurrentSession(): void {
this.postToWebview({ type: "action", action: "showTerminal" })
}
/**
* Reveal the Agent Manager panel and focus the prompt input.
* Used for the keyboard shortcut to switch back from terminal.
*/
public focusPanel(): void {
if (!this.panel) return
this.panel.reveal(vscode.ViewColumn.One, false)
}
public postMessage(message: unknown): void {
this.panel?.webview.postMessage(message)
}
public dispose(): void {
this.terminalManager.dispose()
this.provider?.dispose()
this.panel?.dispose()
this.outputChannel.dispose()
@@ -0,0 +1,135 @@
import * as vscode from "vscode"
import type { WorktreeStateManager } from "./WorktreeStateManager"
/**
* Manages VS Code terminals for agent manager sessions.
* Each session can have an associated terminal that opens in the session's worktree directory,
* or the main workspace folder for local sessions.
*/
export class SessionTerminalManager {
private terminals = new Map<string, { terminal: vscode.Terminal; cwd: string }>()
private disposables: vscode.Disposable[] = []
constructor(private log: (msg: string) => void) {
this.disposables.push(
vscode.window.onDidCloseTerminal((terminal) => {
for (const [sessionId, entry] of this.terminals) {
if (entry.terminal !== terminal) continue
this.terminals.delete(sessionId)
this.log(`Removed terminal mapping for session ${sessionId} (terminal closed)`)
break
}
this.updateContextKey()
}),
vscode.window.onDidChangeActiveTerminal((terminal) => {
const managed = terminal ? this.isManaged(terminal) : false
vscode.commands.executeCommand("setContext", "kilo-code.agentTerminalFocus", managed)
}),
)
}
/**
* Show (or create) a terminal for the given session.
* Resolves CWD from the worktree state, falling back to workspace root.
*/
showTerminal(sessionId: string, state: WorktreeStateManager | undefined): void {
// If terminal already exists, just focus it
if (this.showExisting(sessionId, false)) return
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
const worktreePath = state?.directoryFor(sessionId)
const cwd = worktreePath ?? workspacePath
if (!cwd) {
this.log(`showTerminal: no cwd resolved for session ${sessionId}`)
vscode.window.showWarningMessage("No workspace folder open")
return
}
const session = state?.getSession(sessionId)
const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined
const name = worktree ? `Agent: ${worktree.branch}` : "Agent: local"
this.showOrCreate(sessionId, cwd, name)
}
/**
* Show the terminal for a session if it already exists (used when switching sessions).
* Returns true if the terminal was shown, false if no terminal exists for the session.
* Pass preserveFocus=true to keep focus on the current editor (default for session switching).
*/
showExisting(sessionId: string, preserveFocus = true): boolean {
const entry = this.terminals.get(sessionId)
if (!entry) return false
if (entry.terminal.exitStatus !== undefined) {
this.terminals.delete(sessionId)
this.log(`showExisting: terminal exited for session ${sessionId}, clearing`)
return false
}
entry.terminal.show(preserveFocus)
this.log(`showExisting: revealed terminal for session ${sessionId}`)
return true
}
/**
* Check if a session has an active terminal.
*/
hasTerminal(sessionId: string): boolean {
const entry = this.terminals.get(sessionId)
return entry !== undefined && entry.terminal.exitStatus === undefined
}
dispose(): void {
vscode.commands.executeCommand("setContext", "kilo-code.agentTerminalFocus", false)
for (const entry of this.terminals.values()) entry.terminal.dispose()
this.terminals.clear()
for (const d of this.disposables) d.dispose()
}
private isManaged(terminal: vscode.Terminal): boolean {
for (const entry of this.terminals.values()) {
if (entry.terminal === terminal) return true
}
return false
}
private updateContextKey(): void {
const active = vscode.window.activeTerminal
const managed = active ? this.isManaged(active) : false
vscode.commands.executeCommand("setContext", "kilo-code.agentTerminalFocus", managed)
}
private showOrCreate(sessionId: string, cwd: string, name: string): void {
let entry = this.terminals.get(sessionId)
// Clean up exited terminals
if (entry && entry.terminal.exitStatus !== undefined) {
this.terminals.delete(sessionId)
entry = undefined
}
// Recreate if CWD changed
if (entry && entry.cwd !== cwd) {
entry.terminal.dispose()
this.terminals.delete(sessionId)
entry = undefined
this.log(`showTerminal: cwd changed for session ${sessionId}, recreating`)
}
if (!entry) {
const terminal = vscode.window.createTerminal({
cwd,
name,
iconPath: new vscode.ThemeIcon("terminal"),
})
entry = { terminal, cwd }
this.terminals.set(sessionId, entry)
this.log(`showTerminal: created terminal for session ${sessionId} (cwd=${cwd})`)
}
entry.terminal.show(false)
this.updateContextKey()
}
}
+6
View File
@@ -82,6 +82,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.showTerminal", () => {
agentManagerProvider.showTerminalForCurrentSession()
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.focusPanel", () => {
agentManagerProvider.focusPanel()
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.newTab", () => {
agentManagerProvider.postMessage({ type: "action", action: "newTab" })
}),
@@ -258,7 +258,10 @@ 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 === "showTerminal") {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
else if (msg.action === "closeTab") closeActiveTab()
else if (msg.action === "newWorktree") handleNewWorktreeOrPromote()
else if (msg.action === "closeWorktree") closeSelectedWorktree()
@@ -648,6 +651,20 @@ const AgentManagerContent: Component = () => {
class="am-tab-add"
onClick={handleAddSession}
/>
<div class="am-tab-terminal">
<Tooltip value="Open Terminal" placement="bottom">
<IconButton
icon="console"
size="small"
variant="ghost"
label="Open Terminal"
onClick={() => {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
}}
/>
</Tooltip>
</div>
</div>
</Show>
@@ -361,6 +361,13 @@
align-self: center;
}
.am-tab-terminal {
flex-shrink: 0;
align-self: center;
margin-left: auto;
padding-right: 4px;
}
/* Empty worktree state */
.am-empty-state {
@@ -833,6 +833,12 @@ export interface RequestRepoInfoMessage {
type: "agentManager.requestRepoInfo"
}
// Show terminal for a session
export interface ShowTerminalRequest {
type: "agentManager.showTerminal"
sessionId: string
}
export type WebviewMessage =
| SendMessageRequest
| AbortRequest
@@ -876,6 +882,7 @@ export type WebviewMessage =
| CloseSessionRequest
| TelemetryRequest
| RequestRepoInfoMessage
| ShowTerminalRequest
// ============================================
// VS Code API type