diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 288f6c2118..dee22f8cd6 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -176,6 +176,7 @@ export const globalSettingsSchema = z.object({ terminalZshOhMy: z.boolean().optional(), terminalZshP10k: z.boolean().optional(), terminalZdotdir: z.boolean().optional(), + maxTerminalPoolSize: z.number().int().min(1).max(20).optional(), execaShellPath: z.string().optional(), diagnosticsEnabled: z.boolean().optional(), @@ -356,6 +357,7 @@ export const EVALS_SETTINGS: RooCodeSettings = { terminalZshP10k: false, terminalZdotdir: true, terminalShellIntegrationDisabled: true, + maxTerminalPoolSize: 5, diagnosticsEnabled: true, diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b20539afe4..403e94f05d 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -282,6 +282,7 @@ export type ExtensionState = Pick< | "terminalZshOhMy" | "terminalZshP10k" | "terminalZdotdir" + | "maxTerminalPoolSize" | "execaShellPath" | "diagnosticsEnabled" | "language" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2ffe421c09..afe832ad0f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -63,6 +63,7 @@ import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels" import { ProfileValidator } from "../../shared/ProfileValidator" import { Terminal } from "../../integrations/terminal/Terminal" +import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { downloadTask, getTaskFileName } from "../../integrations/misc/export-markdown" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getTheme } from "../../integrations/theme/getTheme" @@ -852,6 +853,7 @@ export class ClineProvider terminalZshP10k = false, terminalPowershellCounter = false, terminalZdotdir = false, + maxTerminalPoolSize = TerminalRegistry.DEFAULT_MAX_TERMINAL_POOL_SIZE, ttsEnabled, ttsSpeed, }) => { @@ -863,6 +865,7 @@ export class ClineProvider Terminal.setTerminalZshP10k(terminalZshP10k) Terminal.setPowershellCounter(terminalPowershellCounter) Terminal.setTerminalZdotdir(terminalZdotdir) + TerminalRegistry.setMaxTerminalPoolSize(maxTerminalPoolSize) setTtsEnabled(ttsEnabled ?? false) setTtsSpeed(ttsSpeed ?? 1) }, @@ -2162,6 +2165,7 @@ export class ClineProvider terminalZshOhMy, terminalZshP10k, terminalZdotdir, + maxTerminalPoolSize, mcpEnabled, currentApiConfigName, listApiConfigMeta, @@ -2282,6 +2286,7 @@ export class ClineProvider terminalZshOhMy: terminalZshOhMy ?? false, terminalZshP10k: terminalZshP10k ?? false, terminalZdotdir: terminalZdotdir ?? false, + maxTerminalPoolSize: maxTerminalPoolSize ?? TerminalRegistry.DEFAULT_MAX_TERMINAL_POOL_SIZE, mcpEnabled: mcpEnabled ?? true, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], @@ -2509,6 +2514,7 @@ export class ClineProvider terminalZshOhMy: stateValues.terminalZshOhMy ?? false, terminalZshP10k: stateValues.terminalZshP10k ?? false, terminalZdotdir: stateValues.terminalZdotdir ?? false, + maxTerminalPoolSize: stateValues.maxTerminalPoolSize ?? TerminalRegistry.DEFAULT_MAX_TERMINAL_POOL_SIZE, mode: stateValues.mode ?? defaultModeSlug, language: stateValues.language ?? formatLanguage(vscode.env.language), mcpEnabled: stateValues.mcpEnabled ?? true, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e3b8c1bea8..fef3e41799 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -49,6 +49,7 @@ import { CodeIndexManager } from "../../services/code-index/manager" import { checkExistKey } from "../../shared/checkExistApiConfig" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" +import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { openFile } from "../../integrations/misc/open-file" import { openImage, saveImage } from "../../integrations/misc/image-handler" import { selectImages } from "../../integrations/misc/process-images" @@ -718,6 +719,10 @@ export const webviewMessageHandler = async ( if (value !== undefined) { Terminal.setTerminalZdotdir(value as boolean) } + } else if (key === "maxTerminalPoolSize") { + if (value !== undefined) { + TerminalRegistry.setMaxTerminalPoolSize(value as number) + } } else if (key === "execaShellPath") { Terminal.setExecaShellPath(value as string | undefined) } else if (key === "mcpEnabled") { diff --git a/src/integrations/terminal/TerminalRegistry.ts b/src/integrations/terminal/TerminalRegistry.ts index 6e0531bebe..3599520887 100644 --- a/src/integrations/terminal/TerminalRegistry.ts +++ b/src/integrations/terminal/TerminalRegistry.ts @@ -23,6 +23,25 @@ export class TerminalRegistry { 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") @@ -128,6 +147,9 @@ export class TerminalRegistry { } public static createTerminal(cwd: string, provider: RooTerminalProvider): RooTerminal { + // Enforce pool size limit before creating a new terminal. + this.enforcePoolSizeLimit() + let newTerminal if (provider === "vscode") { @@ -277,16 +299,29 @@ export class TerminalRegistry { } /** - * Releases all terminals associated with a task. + * 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[] { @@ -325,4 +360,47 @@ export class TerminalRegistry { 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) + } } diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index f8d35635d9..19f2fcfaff 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -123,4 +123,113 @@ describe("TerminalRegistry", () => { } }) }) + + describe("maxTerminalPoolSize", () => { + it("has a default pool size of 5", () => { + expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(5) + }) + + it("allows setting pool size within bounds", () => { + const original = TerminalRegistry.getMaxTerminalPoolSize() + try { + TerminalRegistry.setMaxTerminalPoolSize(10) + expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(10) + + TerminalRegistry.setMaxTerminalPoolSize(1) + expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(1) + + TerminalRegistry.setMaxTerminalPoolSize(20) + expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(20) + } finally { + TerminalRegistry.setMaxTerminalPoolSize(original) + } + }) + + it("clamps pool size to minimum of 1", () => { + const original = TerminalRegistry.getMaxTerminalPoolSize() + try { + TerminalRegistry.setMaxTerminalPoolSize(0) + expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(1) + + TerminalRegistry.setMaxTerminalPoolSize(-5) + expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(1) + } finally { + TerminalRegistry.setMaxTerminalPoolSize(original) + } + }) + + it("clamps pool size to maximum of 20", () => { + const original = TerminalRegistry.getMaxTerminalPoolSize() + try { + TerminalRegistry.setMaxTerminalPoolSize(25) + expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(20) + + TerminalRegistry.setMaxTerminalPoolSize(100) + expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(20) + } finally { + TerminalRegistry.setMaxTerminalPoolSize(original) + } + }) + + it("disposes oldest idle terminal when pool is at capacity", () => { + const original = TerminalRegistry.getMaxTerminalPoolSize() + try { + TerminalRegistry.setMaxTerminalPoolSize(2) + + // Create 2 terminals to reach the limit + const t1 = TerminalRegistry.createTerminal("/test/path1", "vscode") + const t2 = TerminalRegistry.createTerminal("/test/path2", "vscode") + + // The first terminal should have been disposed to make room for the second + // since pool size is 2, creating the 2nd should be fine + // but creating a 3rd should dispose the first idle one + const t3 = TerminalRegistry.createTerminal("/test/path3", "vscode") + + // t1's underlying vscode terminal should have been disposed + expect((t1 as Terminal).terminal.dispose).toHaveBeenCalled() + } finally { + TerminalRegistry.setMaxTerminalPoolSize(original) + } + }) + }) + + describe("releaseTerminalsForTask", () => { + it("disposes idle terminals when releasing a task", () => { + const t1 = TerminalRegistry.createTerminal("/test/path", "vscode") + t1.taskId = "task-1" + + TerminalRegistry.releaseTerminalsForTask("task-1") + + // The terminal should have been disposed since it was idle + expect((t1 as Terminal).terminal.dispose).toHaveBeenCalled() + }) + + it("does not dispose busy terminals when releasing a task", () => { + const t1 = TerminalRegistry.createTerminal("/test/path", "vscode") + t1.taskId = "task-2" + t1.busy = true + + TerminalRegistry.releaseTerminalsForTask("task-2") + + // The terminal should NOT have been disposed since it was busy + expect((t1 as Terminal).terminal.dispose).not.toHaveBeenCalled() + // But its taskId should have been cleared + expect(t1.taskId).toBeUndefined() + }) + + it("does not dispose terminals belonging to other tasks", () => { + const t1 = TerminalRegistry.createTerminal("/test/path", "vscode") + t1.taskId = "task-3" + + const t2 = TerminalRegistry.createTerminal("/test/path", "vscode") + t2.taskId = "task-4" + + TerminalRegistry.releaseTerminalsForTask("task-3") + + // t1 should be disposed (idle, belongs to task-3) + expect((t1 as Terminal).terminal.dispose).toHaveBeenCalled() + // t2 should NOT be disposed (belongs to task-4) + expect((t2 as Terminal).terminal.dispose).not.toHaveBeenCalled() + }) + }) }) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 47e087615e..b311ed06fb 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -183,6 +183,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, + maxTerminalPoolSize, writeDelayMs, showRooIgnoredFiles, enableSubfolderRules, @@ -396,6 +397,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy, terminalZshP10k, terminalZdotdir, + maxTerminalPoolSize: maxTerminalPoolSize ?? 5, terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), @@ -862,6 +864,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshOhMy={terminalZshOhMy} terminalZshP10k={terminalZshP10k} terminalZdotdir={terminalZdotdir} + maxTerminalPoolSize={maxTerminalPoolSize} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index 07f062cc01..3aca0b5627 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -26,6 +26,7 @@ type TerminalSettingsProps = HTMLAttributes & { terminalZshOhMy?: boolean terminalZshP10k?: boolean terminalZdotdir?: boolean + maxTerminalPoolSize?: number setCachedStateField: SetCachedStateField< | "terminalOutputPreviewSize" | "terminalShellIntegrationTimeout" @@ -36,6 +37,7 @@ type TerminalSettingsProps = HTMLAttributes & { | "terminalZshOhMy" | "terminalZshP10k" | "terminalZdotdir" + | "maxTerminalPoolSize" > } @@ -49,6 +51,7 @@ export const TerminalSettings = ({ terminalZshOhMy, terminalZshP10k, terminalZdotdir, + maxTerminalPoolSize, setCachedStateField, className, ...props @@ -124,6 +127,28 @@ export const TerminalSettings = ({ {t("settings:terminal.outputPreviewSize.description")} + + + +
+ + setCachedStateField("maxTerminalPoolSize", Math.min(20, Math.max(1, value))) + } + /> + {maxTerminalPoolSize ?? 5} +
+
+ {t("settings:terminal.maxPoolSize.description")} +
+
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ce7a607d9a..c13300d01b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -92,6 +92,8 @@ export interface ExtensionStateContextType extends ExtensionState { setWriteDelayMs: (value: number) => void terminalOutputPreviewSize?: "small" | "medium" | "large" setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void + maxTerminalPoolSize?: number + setMaxTerminalPoolSize: (value: number) => void mcpEnabled: boolean setMcpEnabled: (value: boolean) => void taskSyncEnabled: boolean @@ -207,6 +209,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode language: "en", // Default language code writeDelayMs: 1000, terminalShellIntegrationTimeout: 4000, + maxTerminalPoolSize: 5, mcpEnabled: true, taskSyncEnabled: false, currentApiConfigName: "default", @@ -545,6 +548,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setTerminalShellIntegrationDisabled: (value) => setState((prevState) => ({ ...prevState, terminalShellIntegrationDisabled: value })), setTerminalZdotdir: (value) => setState((prevState) => ({ ...prevState, terminalZdotdir: value })), + setMaxTerminalPoolSize: (value) => setState((prevState) => ({ ...prevState, maxTerminalPoolSize: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setTaskSyncEnabled: (value) => setState((prevState) => ({ ...prevState, taskSyncEnabled: value }) as any), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 183cd663e3..670c4013dd 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -757,6 +757,10 @@ "label": "Terminal character limit", "description": "Overrides the line limit to prevent memory issues by enforcing a hard cap on output size. If exceeded, keeps the beginning and end and shows a placeholder to Roo where content is skipped. <0>Learn more" }, + "maxPoolSize": { + "label": "Maximum terminal pool size", + "description": "Maximum number of terminals Roo Code can keep open at the same time. When this limit is reached, the oldest idle terminal is closed before creating a new one. Lower values help keep your workspace tidy; higher values avoid repeatedly opening new terminals." + }, "outputPreviewSize": { "label": "Command output preview size", "description": "Controls how much command output Roo sees directly. Full output is always saved and accessible when needed.",