feat: add configurable terminal pool size with cleanup on task release

- 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
This commit is contained in:
Roo Code 2026-04-20 07:26:31 +00:00
parent cb83656718
commit 3068bdca4b
10 changed files with 238 additions and 1 deletions

View file

@ -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,

View file

@ -282,6 +282,7 @@ export type ExtensionState = Pick<
| "terminalZshOhMy"
| "terminalZshP10k"
| "terminalZdotdir"
| "maxTerminalPoolSize"
| "execaShellPath"
| "diagnosticsEnabled"
| "language"

View file

@ -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,

View file

@ -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") {

View file

@ -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)
}
}

View file

@ -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()
})
})
})

View file

@ -183,6 +183,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
maxTerminalPoolSize,
writeDelayMs,
showRooIgnoredFiles,
enableSubfolderRules,
@ -396,6 +397,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalZshOhMy={terminalZshOhMy}
terminalZshP10k={terminalZshP10k}
terminalZdotdir={terminalZdotdir}
maxTerminalPoolSize={maxTerminalPoolSize}
setCachedStateField={setCachedStateField}
/>
)}

View file

@ -26,6 +26,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
terminalZshOhMy?: boolean
terminalZshP10k?: boolean
terminalZdotdir?: boolean
maxTerminalPoolSize?: number
setCachedStateField: SetCachedStateField<
| "terminalOutputPreviewSize"
| "terminalShellIntegrationTimeout"
@ -36,6 +37,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "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")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="terminal-max-pool-size"
section="terminal"
label={t("settings:terminal.maxPoolSize.label")}>
<label className="block font-medium mb-1">{t("settings:terminal.maxPoolSize.label")}</label>
<div className="flex items-center gap-2">
<Slider
min={1}
max={20}
step={1}
value={[maxTerminalPoolSize ?? 5]}
onValueChange={([value]) =>
setCachedStateField("maxTerminalPoolSize", Math.min(20, Math.max(1, value)))
}
/>
<span className="w-10">{maxTerminalPoolSize ?? 5}</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:terminal.maxPoolSize.description")}
</div>
</SearchableSetting>
</div>
</div>

View file

@ -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 })),

View file

@ -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</0>"
},
"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.",