mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-19 00:01:19 +00:00
- Add maxTerminalPoolSize setting (1-20, default 5) to global settings schema - Enforce pool size limit in TerminalRegistry.createTerminal() by disposing oldest idle terminals when at capacity - Update releaseTerminalsForTask() to dispose idle terminals when a task ends - Wire setting through ClineProvider, webviewMessageHandler, and ExtensionStateContext - Add slider UI in Terminal Settings (Basic section) - Add English i18n translation for the new setting - Add tests for pool size enforcement and task release cleanup Addresses #12153
406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
import * as vscode from "vscode"
|
|
|
|
import { arePathsEqual } from "../../utils/path"
|
|
|
|
import { RooTerminal, RooTerminalProvider } from "./types"
|
|
import { TerminalProcess } from "./TerminalProcess"
|
|
import { Terminal } from "./Terminal"
|
|
import { ExecaTerminal } from "./ExecaTerminal"
|
|
import { ShellIntegrationManager } from "./ShellIntegrationManager"
|
|
|
|
// Although vscode.window.terminals provides a list of all open terminals,
|
|
// there's no way to know whether they're busy or not (exitStatus does not
|
|
// provide useful information for most commands). In order to prevent creating
|
|
// too many terminals, we need to keep track of terminals through the life of
|
|
// the extension, as well as session specific terminals for the life of a task
|
|
// (to get latest unretrieved output).
|
|
// Since we have promises keeping track of terminal processes, we get the added
|
|
// benefit of keep track of busy terminals even after a task is closed.
|
|
|
|
export class TerminalRegistry {
|
|
private static terminals: RooTerminal[] = []
|
|
private static nextTerminalId = 1
|
|
private static disposables: vscode.Disposable[] = []
|
|
private static isInitialized = false
|
|
|
|
public static readonly DEFAULT_MAX_TERMINAL_POOL_SIZE = 5
|
|
private static maxTerminalPoolSize: number = TerminalRegistry.DEFAULT_MAX_TERMINAL_POOL_SIZE
|
|
|
|
/**
|
|
* Sets the maximum terminal pool size.
|
|
* @param size The maximum number of terminals to keep in the pool (1-20)
|
|
*/
|
|
public static setMaxTerminalPoolSize(size: number): void {
|
|
this.maxTerminalPoolSize = Math.max(1, Math.min(20, size))
|
|
}
|
|
|
|
/**
|
|
* Gets the maximum terminal pool size.
|
|
* @returns The maximum number of terminals allowed in the pool
|
|
*/
|
|
public static getMaxTerminalPoolSize(): number {
|
|
return this.maxTerminalPoolSize
|
|
}
|
|
|
|
public static initialize() {
|
|
if (this.isInitialized) {
|
|
throw new Error("TerminalRegistry.initialize() should only be called once")
|
|
}
|
|
|
|
this.isInitialized = true
|
|
|
|
// TODO: This initialization code is VSCode specific, and therefore
|
|
// should probably live elsewhere.
|
|
|
|
// Register handler for terminal close events to clean up temporary
|
|
// directories.
|
|
const closeDisposable = vscode.window.onDidCloseTerminal((vsceTerminal) => {
|
|
const terminal = this.getTerminalByVSCETerminal(vsceTerminal)
|
|
|
|
if (terminal) {
|
|
ShellIntegrationManager.zshCleanupTmpDir(terminal.id)
|
|
}
|
|
})
|
|
|
|
this.disposables.push(closeDisposable)
|
|
|
|
try {
|
|
const startDisposable = vscode.window.onDidStartTerminalShellExecution?.(
|
|
async (e: vscode.TerminalShellExecutionStartEvent) => {
|
|
// Get a handle to the stream as early as possible:
|
|
const stream = e.execution.read()
|
|
const terminal = this.getTerminalByVSCETerminal(e.terminal)
|
|
|
|
console.info("[onDidStartTerminalShellExecution]", {
|
|
command: e.execution?.commandLine?.value,
|
|
terminalId: terminal?.id,
|
|
})
|
|
|
|
if (terminal) {
|
|
terminal.setActiveStream(stream)
|
|
terminal.busy = true // Mark terminal as busy when shell execution starts
|
|
} else {
|
|
console.error(
|
|
"[onDidStartTerminalShellExecution] Shell execution started, but not from a Roo-registered terminal:",
|
|
e,
|
|
)
|
|
}
|
|
},
|
|
)
|
|
|
|
if (startDisposable) {
|
|
this.disposables.push(startDisposable)
|
|
}
|
|
|
|
const endDisposable = vscode.window.onDidEndTerminalShellExecution?.(
|
|
async (e: vscode.TerminalShellExecutionEndEvent) => {
|
|
const terminal = this.getTerminalByVSCETerminal(e.terminal)
|
|
const process = terminal?.process
|
|
const exitDetails = TerminalProcess.interpretExitCode(e.exitCode)
|
|
|
|
console.info("[onDidEndTerminalShellExecution]", {
|
|
command: e.execution?.commandLine?.value,
|
|
terminalId: terminal?.id,
|
|
...exitDetails,
|
|
})
|
|
|
|
if (!terminal) {
|
|
console.error(
|
|
"[onDidEndTerminalShellExecution] Shell execution ended, but not from a Roo-registered terminal:",
|
|
e,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if (!terminal.running) {
|
|
console.error(
|
|
"[TerminalRegistry] Shell execution end event received, but process is not running for terminal:",
|
|
{ terminalId: terminal?.id, command: process?.command, exitCode: e.exitCode },
|
|
)
|
|
|
|
terminal.busy = false
|
|
return
|
|
}
|
|
|
|
if (!process) {
|
|
console.error(
|
|
"[TerminalRegistry] Shell execution end event received on running terminal, but process is undefined:",
|
|
{ terminalId: terminal.id, exitCode: e.exitCode },
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Signal completion to any waiting processes.
|
|
terminal.shellExecutionComplete(exitDetails)
|
|
terminal.busy = false // Mark terminal as not busy when shell execution ends
|
|
},
|
|
)
|
|
|
|
if (endDisposable) {
|
|
this.disposables.push(endDisposable)
|
|
}
|
|
} catch (error) {
|
|
console.error("[TerminalRegistry] Error setting up shell execution handlers:", error)
|
|
}
|
|
}
|
|
|
|
public static createTerminal(cwd: string, provider: RooTerminalProvider): RooTerminal {
|
|
// Enforce pool size limit before creating a new terminal.
|
|
this.enforcePoolSizeLimit()
|
|
|
|
let newTerminal
|
|
|
|
if (provider === "vscode") {
|
|
newTerminal = new Terminal(this.nextTerminalId++, undefined, cwd)
|
|
} else {
|
|
newTerminal = new ExecaTerminal(this.nextTerminalId++, cwd)
|
|
}
|
|
|
|
this.terminals.push(newTerminal)
|
|
|
|
return newTerminal
|
|
}
|
|
|
|
/**
|
|
* Gets an existing terminal or creates a new one for the given working
|
|
* directory.
|
|
*
|
|
* @param cwd The working directory path
|
|
* @param taskId Optional task ID to associate with the terminal
|
|
* @returns A Terminal instance
|
|
*/
|
|
public static async getOrCreateTerminal(
|
|
cwd: string,
|
|
taskId?: string,
|
|
provider: RooTerminalProvider = "vscode",
|
|
): Promise<RooTerminal> {
|
|
const terminals = this.getAllTerminals()
|
|
let terminal: RooTerminal | undefined
|
|
|
|
// First priority: Find a terminal already assigned to this task with
|
|
// matching directory.
|
|
if (taskId) {
|
|
terminal = terminals.find((t) => {
|
|
if (t.busy || t.taskId !== taskId || t.provider !== provider) {
|
|
return false
|
|
}
|
|
|
|
const terminalCwd = t.getCurrentWorkingDirectory()
|
|
|
|
if (!terminalCwd) {
|
|
return false
|
|
}
|
|
|
|
return arePathsEqual(vscode.Uri.file(cwd).fsPath, terminalCwd)
|
|
})
|
|
}
|
|
|
|
// Second priority: Find any available terminal with matching directory.
|
|
if (!terminal) {
|
|
terminal = terminals.find((t) => {
|
|
if (t.busy || t.provider !== provider) {
|
|
return false
|
|
}
|
|
|
|
const terminalCwd = t.getCurrentWorkingDirectory()
|
|
|
|
if (!terminalCwd) {
|
|
return false
|
|
}
|
|
|
|
return arePathsEqual(vscode.Uri.file(cwd).fsPath, terminalCwd)
|
|
})
|
|
}
|
|
|
|
// If no suitable terminal found, create a new one.
|
|
if (!terminal) {
|
|
terminal = this.createTerminal(cwd, provider)
|
|
}
|
|
|
|
terminal.taskId = taskId
|
|
|
|
return terminal
|
|
}
|
|
|
|
/**
|
|
* Gets unretrieved output from a terminal process.
|
|
*
|
|
* @param id The terminal ID
|
|
* @returns The unretrieved output as a string, or empty string if terminal not found
|
|
*/
|
|
public static getUnretrievedOutput(id: number): string {
|
|
return this.getTerminalById(id)?.getUnretrievedOutput() ?? ""
|
|
}
|
|
|
|
/**
|
|
* Checks if a terminal process is "hot" (recently active).
|
|
*
|
|
* @param id The terminal ID
|
|
* @returns True if the process is hot, false otherwise
|
|
*/
|
|
public static isProcessHot(id: number): boolean {
|
|
return this.getTerminalById(id)?.process?.isHot ?? false
|
|
}
|
|
|
|
/**
|
|
* Gets terminals filtered by busy state and optionally by task id.
|
|
*
|
|
* @param busy Whether to get busy or non-busy terminals
|
|
* @param taskId Optional task ID to filter terminals by
|
|
* @returns Array of Terminal objects
|
|
*/
|
|
public static getTerminals(busy: boolean, taskId?: string): RooTerminal[] {
|
|
return this.getAllTerminals().filter((t) => {
|
|
// Filter by busy state.
|
|
if (t.busy !== busy) {
|
|
return false
|
|
}
|
|
|
|
// If taskId is provided, also filter by taskId.
|
|
if (taskId !== undefined && t.taskId !== taskId) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Gets background terminals (taskId undefined) that have unretrieved output
|
|
* or are still running.
|
|
*
|
|
* @param busy Whether to get busy or non-busy terminals
|
|
* @returns Array of Terminal objects
|
|
*/
|
|
public static getBackgroundTerminals(busy?: boolean): RooTerminal[] {
|
|
return this.getAllTerminals().filter((t) => {
|
|
// Only get background terminals (taskId undefined).
|
|
if (t.taskId !== undefined) {
|
|
return false
|
|
}
|
|
|
|
// If busy is undefined, return all background terminals.
|
|
if (busy === undefined) {
|
|
return t.getProcessesWithOutput().length > 0 || t.process?.hasUnretrievedOutput()
|
|
}
|
|
|
|
// Filter by busy state.
|
|
return t.busy === busy
|
|
})
|
|
}
|
|
|
|
public static cleanup() {
|
|
// Clean up all temporary directories.
|
|
ShellIntegrationManager.clear()
|
|
this.disposables.forEach((disposable) => disposable.dispose())
|
|
this.disposables = []
|
|
}
|
|
|
|
/**
|
|
* Releases all terminals associated with a task. Idle terminals that
|
|
* are not busy and have no unretrieved output are disposed (closed).
|
|
* Busy terminals are simply unassigned from the task.
|
|
*
|
|
* @param taskId The task ID
|
|
*/
|
|
public static releaseTerminalsForTask(taskId: string): void {
|
|
const terminalsToDispose: RooTerminal[] = []
|
|
|
|
this.terminals.forEach((terminal) => {
|
|
if (terminal.taskId === taskId) {
|
|
terminal.taskId = undefined
|
|
|
|
// Dispose idle terminals that have no pending output.
|
|
if (!terminal.busy && !terminal.running && !terminal.process?.hasUnretrievedOutput()) {
|
|
terminalsToDispose.push(terminal)
|
|
}
|
|
}
|
|
})
|
|
|
|
for (const terminal of terminalsToDispose) {
|
|
this.disposeTerminal(terminal)
|
|
}
|
|
}
|
|
|
|
private static getAllTerminals(): RooTerminal[] {
|
|
this.terminals = this.terminals.filter((t) => !t.isClosed())
|
|
return this.terminals
|
|
}
|
|
|
|
private static getTerminalById(id: number): RooTerminal | undefined {
|
|
const terminal = this.terminals.find((t) => t.id === id)
|
|
|
|
if (terminal?.isClosed()) {
|
|
this.removeTerminal(id)
|
|
return undefined
|
|
}
|
|
|
|
return terminal
|
|
}
|
|
|
|
/**
|
|
* Gets a terminal by its VSCode terminal instance
|
|
* @param terminal The VSCode terminal instance
|
|
* @returns The Terminal object, or undefined if not found
|
|
*/
|
|
private static getTerminalByVSCETerminal(vsceTerminal: vscode.Terminal): RooTerminal | undefined {
|
|
const found = this.terminals.find((t) => t instanceof Terminal && t.terminal === vsceTerminal)
|
|
|
|
if (found?.isClosed()) {
|
|
this.removeTerminal(found.id)
|
|
return undefined
|
|
}
|
|
|
|
return found
|
|
}
|
|
|
|
private static removeTerminal(id: number) {
|
|
ShellIntegrationManager.zshCleanupTmpDir(id)
|
|
this.terminals = this.terminals.filter((t) => t.id !== id)
|
|
}
|
|
|
|
/**
|
|
* Enforces the terminal pool size limit by disposing the oldest idle
|
|
* terminals when the pool is at or above the maximum size.
|
|
*/
|
|
private static enforcePoolSizeLimit(): void {
|
|
const allTerminals = this.getAllTerminals()
|
|
|
|
if (allTerminals.length < this.maxTerminalPoolSize) {
|
|
return
|
|
}
|
|
|
|
// Find idle terminals (not busy, not running, no task assigned).
|
|
const idleTerminals = allTerminals.filter(
|
|
(t) => !t.busy && !t.running && !t.taskId && !t.process?.hasUnretrievedOutput(),
|
|
)
|
|
|
|
// Dispose oldest idle terminals until we're under the limit.
|
|
// Terminals are ordered by creation (oldest first).
|
|
let toRemove = allTerminals.length - this.maxTerminalPoolSize + 1 // +1 to make room for the new one
|
|
|
|
for (const terminal of idleTerminals) {
|
|
if (toRemove <= 0) {
|
|
break
|
|
}
|
|
|
|
this.disposeTerminal(terminal)
|
|
toRemove--
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disposes a terminal by closing the underlying VSCode terminal
|
|
* and removing it from the registry.
|
|
*/
|
|
private static disposeTerminal(terminal: RooTerminal): void {
|
|
// For VSCode terminals, dispose the underlying terminal.
|
|
if (terminal instanceof Terminal) {
|
|
terminal.terminal.dispose()
|
|
}
|
|
|
|
this.removeTerminal(terminal.id)
|
|
}
|
|
}
|