Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 3068bdc

Browse files
committed
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
1 parent cb83656 commit 3068bdc

10 files changed

Lines changed: 238 additions & 1 deletion

File tree

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export const globalSettingsSchema = z.object({
176176
terminalZshOhMy: z.boolean().optional(),
177177
terminalZshP10k: z.boolean().optional(),
178178
terminalZdotdir: z.boolean().optional(),
179+
maxTerminalPoolSize: z.number().int().min(1).max(20).optional(),
179180
execaShellPath: z.string().optional(),
180181

181182
diagnosticsEnabled: z.boolean().optional(),
@@ -356,6 +357,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
356357
terminalZshP10k: false,
357358
terminalZdotdir: true,
358359
terminalShellIntegrationDisabled: true,
360+
maxTerminalPoolSize: 5,
359361

360362
diagnosticsEnabled: true,
361363

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ export type ExtensionState = Pick<
282282
| "terminalZshOhMy"
283283
| "terminalZshP10k"
284284
| "terminalZdotdir"
285+
| "maxTerminalPoolSize"
285286
| "execaShellPath"
286287
| "diagnosticsEnabled"
287288
| "language"

src/core/webview/ClineProvider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
6363
import { ProfileValidator } from "../../shared/ProfileValidator"
6464

6565
import { Terminal } from "../../integrations/terminal/Terminal"
66+
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
6667
import { downloadTask, getTaskFileName } from "../../integrations/misc/export-markdown"
6768
import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export"
6869
import { getTheme } from "../../integrations/theme/getTheme"
@@ -852,6 +853,7 @@ export class ClineProvider
852853
terminalZshP10k = false,
853854
terminalPowershellCounter = false,
854855
terminalZdotdir = false,
856+
maxTerminalPoolSize = TerminalRegistry.DEFAULT_MAX_TERMINAL_POOL_SIZE,
855857
ttsEnabled,
856858
ttsSpeed,
857859
}) => {
@@ -863,6 +865,7 @@ export class ClineProvider
863865
Terminal.setTerminalZshP10k(terminalZshP10k)
864866
Terminal.setPowershellCounter(terminalPowershellCounter)
865867
Terminal.setTerminalZdotdir(terminalZdotdir)
868+
TerminalRegistry.setMaxTerminalPoolSize(maxTerminalPoolSize)
866869
setTtsEnabled(ttsEnabled ?? false)
867870
setTtsSpeed(ttsSpeed ?? 1)
868871
},
@@ -2162,6 +2165,7 @@ export class ClineProvider
21622165
terminalZshOhMy,
21632166
terminalZshP10k,
21642167
terminalZdotdir,
2168+
maxTerminalPoolSize,
21652169
mcpEnabled,
21662170
currentApiConfigName,
21672171
listApiConfigMeta,
@@ -2282,6 +2286,7 @@ export class ClineProvider
22822286
terminalZshOhMy: terminalZshOhMy ?? false,
22832287
terminalZshP10k: terminalZshP10k ?? false,
22842288
terminalZdotdir: terminalZdotdir ?? false,
2289+
maxTerminalPoolSize: maxTerminalPoolSize ?? TerminalRegistry.DEFAULT_MAX_TERMINAL_POOL_SIZE,
22852290
mcpEnabled: mcpEnabled ?? true,
22862291
currentApiConfigName: currentApiConfigName ?? "default",
22872292
listApiConfigMeta: listApiConfigMeta ?? [],
@@ -2509,6 +2514,7 @@ export class ClineProvider
25092514
terminalZshOhMy: stateValues.terminalZshOhMy ?? false,
25102515
terminalZshP10k: stateValues.terminalZshP10k ?? false,
25112516
terminalZdotdir: stateValues.terminalZdotdir ?? false,
2517+
maxTerminalPoolSize: stateValues.maxTerminalPoolSize ?? TerminalRegistry.DEFAULT_MAX_TERMINAL_POOL_SIZE,
25122518
mode: stateValues.mode ?? defaultModeSlug,
25132519
language: stateValues.language ?? formatLanguage(vscode.env.language),
25142520
mcpEnabled: stateValues.mcpEnabled ?? true,

src/core/webview/webviewMessageHandler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { CodeIndexManager } from "../../services/code-index/manager"
4949
import { checkExistKey } from "../../shared/checkExistApiConfig"
5050
import { experimentDefault } from "../../shared/experiments"
5151
import { Terminal } from "../../integrations/terminal/Terminal"
52+
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
5253
import { openFile } from "../../integrations/misc/open-file"
5354
import { openImage, saveImage } from "../../integrations/misc/image-handler"
5455
import { selectImages } from "../../integrations/misc/process-images"
@@ -718,6 +719,10 @@ export const webviewMessageHandler = async (
718719
if (value !== undefined) {
719720
Terminal.setTerminalZdotdir(value as boolean)
720721
}
722+
} else if (key === "maxTerminalPoolSize") {
723+
if (value !== undefined) {
724+
TerminalRegistry.setMaxTerminalPoolSize(value as number)
725+
}
721726
} else if (key === "execaShellPath") {
722727
Terminal.setExecaShellPath(value as string | undefined)
723728
} else if (key === "mcpEnabled") {

src/integrations/terminal/TerminalRegistry.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ export class TerminalRegistry {
2323
private static disposables: vscode.Disposable[] = []
2424
private static isInitialized = false
2525

26+
public static readonly DEFAULT_MAX_TERMINAL_POOL_SIZE = 5
27+
private static maxTerminalPoolSize: number = TerminalRegistry.DEFAULT_MAX_TERMINAL_POOL_SIZE
28+
29+
/**
30+
* Sets the maximum terminal pool size.
31+
* @param size The maximum number of terminals to keep in the pool (1-20)
32+
*/
33+
public static setMaxTerminalPoolSize(size: number): void {
34+
this.maxTerminalPoolSize = Math.max(1, Math.min(20, size))
35+
}
36+
37+
/**
38+
* Gets the maximum terminal pool size.
39+
* @returns The maximum number of terminals allowed in the pool
40+
*/
41+
public static getMaxTerminalPoolSize(): number {
42+
return this.maxTerminalPoolSize
43+
}
44+
2645
public static initialize() {
2746
if (this.isInitialized) {
2847
throw new Error("TerminalRegistry.initialize() should only be called once")
@@ -128,6 +147,9 @@ export class TerminalRegistry {
128147
}
129148

130149
public static createTerminal(cwd: string, provider: RooTerminalProvider): RooTerminal {
150+
// Enforce pool size limit before creating a new terminal.
151+
this.enforcePoolSizeLimit()
152+
131153
let newTerminal
132154

133155
if (provider === "vscode") {
@@ -277,16 +299,29 @@ export class TerminalRegistry {
277299
}
278300

279301
/**
280-
* Releases all terminals associated with a task.
302+
* Releases all terminals associated with a task. Idle terminals that
303+
* are not busy and have no unretrieved output are disposed (closed).
304+
* Busy terminals are simply unassigned from the task.
281305
*
282306
* @param taskId The task ID
283307
*/
284308
public static releaseTerminalsForTask(taskId: string): void {
309+
const terminalsToDispose: RooTerminal[] = []
310+
285311
this.terminals.forEach((terminal) => {
286312
if (terminal.taskId === taskId) {
287313
terminal.taskId = undefined
314+
315+
// Dispose idle terminals that have no pending output.
316+
if (!terminal.busy && !terminal.running && !terminal.process?.hasUnretrievedOutput()) {
317+
terminalsToDispose.push(terminal)
318+
}
288319
}
289320
})
321+
322+
for (const terminal of terminalsToDispose) {
323+
this.disposeTerminal(terminal)
324+
}
290325
}
291326

292327
private static getAllTerminals(): RooTerminal[] {
@@ -325,4 +360,47 @@ export class TerminalRegistry {
325360
ShellIntegrationManager.zshCleanupTmpDir(id)
326361
this.terminals = this.terminals.filter((t) => t.id !== id)
327362
}
363+
364+
/**
365+
* Enforces the terminal pool size limit by disposing the oldest idle
366+
* terminals when the pool is at or above the maximum size.
367+
*/
368+
private static enforcePoolSizeLimit(): void {
369+
const allTerminals = this.getAllTerminals()
370+
371+
if (allTerminals.length < this.maxTerminalPoolSize) {
372+
return
373+
}
374+
375+
// Find idle terminals (not busy, not running, no task assigned).
376+
const idleTerminals = allTerminals.filter(
377+
(t) => !t.busy && !t.running && !t.taskId && !t.process?.hasUnretrievedOutput(),
378+
)
379+
380+
// Dispose oldest idle terminals until we're under the limit.
381+
// Terminals are ordered by creation (oldest first).
382+
let toRemove = allTerminals.length - this.maxTerminalPoolSize + 1 // +1 to make room for the new one
383+
384+
for (const terminal of idleTerminals) {
385+
if (toRemove <= 0) {
386+
break
387+
}
388+
389+
this.disposeTerminal(terminal)
390+
toRemove--
391+
}
392+
}
393+
394+
/**
395+
* Disposes a terminal by closing the underlying VSCode terminal
396+
* and removing it from the registry.
397+
*/
398+
private static disposeTerminal(terminal: RooTerminal): void {
399+
// For VSCode terminals, dispose the underlying terminal.
400+
if (terminal instanceof Terminal) {
401+
terminal.terminal.dispose()
402+
}
403+
404+
this.removeTerminal(terminal.id)
405+
}
328406
}

src/integrations/terminal/__tests__/TerminalRegistry.spec.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,113 @@ describe("TerminalRegistry", () => {
123123
}
124124
})
125125
})
126+
127+
describe("maxTerminalPoolSize", () => {
128+
it("has a default pool size of 5", () => {
129+
expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(5)
130+
})
131+
132+
it("allows setting pool size within bounds", () => {
133+
const original = TerminalRegistry.getMaxTerminalPoolSize()
134+
try {
135+
TerminalRegistry.setMaxTerminalPoolSize(10)
136+
expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(10)
137+
138+
TerminalRegistry.setMaxTerminalPoolSize(1)
139+
expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(1)
140+
141+
TerminalRegistry.setMaxTerminalPoolSize(20)
142+
expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(20)
143+
} finally {
144+
TerminalRegistry.setMaxTerminalPoolSize(original)
145+
}
146+
})
147+
148+
it("clamps pool size to minimum of 1", () => {
149+
const original = TerminalRegistry.getMaxTerminalPoolSize()
150+
try {
151+
TerminalRegistry.setMaxTerminalPoolSize(0)
152+
expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(1)
153+
154+
TerminalRegistry.setMaxTerminalPoolSize(-5)
155+
expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(1)
156+
} finally {
157+
TerminalRegistry.setMaxTerminalPoolSize(original)
158+
}
159+
})
160+
161+
it("clamps pool size to maximum of 20", () => {
162+
const original = TerminalRegistry.getMaxTerminalPoolSize()
163+
try {
164+
TerminalRegistry.setMaxTerminalPoolSize(25)
165+
expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(20)
166+
167+
TerminalRegistry.setMaxTerminalPoolSize(100)
168+
expect(TerminalRegistry.getMaxTerminalPoolSize()).toBe(20)
169+
} finally {
170+
TerminalRegistry.setMaxTerminalPoolSize(original)
171+
}
172+
})
173+
174+
it("disposes oldest idle terminal when pool is at capacity", () => {
175+
const original = TerminalRegistry.getMaxTerminalPoolSize()
176+
try {
177+
TerminalRegistry.setMaxTerminalPoolSize(2)
178+
179+
// Create 2 terminals to reach the limit
180+
const t1 = TerminalRegistry.createTerminal("/test/path1", "vscode")
181+
const t2 = TerminalRegistry.createTerminal("/test/path2", "vscode")
182+
183+
// The first terminal should have been disposed to make room for the second
184+
// since pool size is 2, creating the 2nd should be fine
185+
// but creating a 3rd should dispose the first idle one
186+
const t3 = TerminalRegistry.createTerminal("/test/path3", "vscode")
187+
188+
// t1's underlying vscode terminal should have been disposed
189+
expect((t1 as Terminal).terminal.dispose).toHaveBeenCalled()
190+
} finally {
191+
TerminalRegistry.setMaxTerminalPoolSize(original)
192+
}
193+
})
194+
})
195+
196+
describe("releaseTerminalsForTask", () => {
197+
it("disposes idle terminals when releasing a task", () => {
198+
const t1 = TerminalRegistry.createTerminal("/test/path", "vscode")
199+
t1.taskId = "task-1"
200+
201+
TerminalRegistry.releaseTerminalsForTask("task-1")
202+
203+
// The terminal should have been disposed since it was idle
204+
expect((t1 as Terminal).terminal.dispose).toHaveBeenCalled()
205+
})
206+
207+
it("does not dispose busy terminals when releasing a task", () => {
208+
const t1 = TerminalRegistry.createTerminal("/test/path", "vscode")
209+
t1.taskId = "task-2"
210+
t1.busy = true
211+
212+
TerminalRegistry.releaseTerminalsForTask("task-2")
213+
214+
// The terminal should NOT have been disposed since it was busy
215+
expect((t1 as Terminal).terminal.dispose).not.toHaveBeenCalled()
216+
// But its taskId should have been cleared
217+
expect(t1.taskId).toBeUndefined()
218+
})
219+
220+
it("does not dispose terminals belonging to other tasks", () => {
221+
const t1 = TerminalRegistry.createTerminal("/test/path", "vscode")
222+
t1.taskId = "task-3"
223+
224+
const t2 = TerminalRegistry.createTerminal("/test/path", "vscode")
225+
t2.taskId = "task-4"
226+
227+
TerminalRegistry.releaseTerminalsForTask("task-3")
228+
229+
// t1 should be disposed (idle, belongs to task-3)
230+
expect((t1 as Terminal).terminal.dispose).toHaveBeenCalled()
231+
// t2 should NOT be disposed (belongs to task-4)
232+
expect((t2 as Terminal).terminal.dispose).not.toHaveBeenCalled()
233+
})
234+
})
126235
})

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
183183
terminalZshOhMy,
184184
terminalZshP10k,
185185
terminalZdotdir,
186+
maxTerminalPoolSize,
186187
writeDelayMs,
187188
showRooIgnoredFiles,
188189
enableSubfolderRules,
@@ -396,6 +397,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
396397
terminalZshOhMy,
397398
terminalZshP10k,
398399
terminalZdotdir,
400+
maxTerminalPoolSize: maxTerminalPoolSize ?? 5,
399401
terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium",
400402
mcpEnabled,
401403
maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500),
@@ -862,6 +864,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
862864
terminalZshOhMy={terminalZshOhMy}
863865
terminalZshP10k={terminalZshP10k}
864866
terminalZdotdir={terminalZdotdir}
867+
maxTerminalPoolSize={maxTerminalPoolSize}
865868
setCachedStateField={setCachedStateField}
866869
/>
867870
)}

webview-ui/src/components/settings/TerminalSettings.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
2626
terminalZshOhMy?: boolean
2727
terminalZshP10k?: boolean
2828
terminalZdotdir?: boolean
29+
maxTerminalPoolSize?: number
2930
setCachedStateField: SetCachedStateField<
3031
| "terminalOutputPreviewSize"
3132
| "terminalShellIntegrationTimeout"
@@ -36,6 +37,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
3637
| "terminalZshOhMy"
3738
| "terminalZshP10k"
3839
| "terminalZdotdir"
40+
| "maxTerminalPoolSize"
3941
>
4042
}
4143

@@ -49,6 +51,7 @@ export const TerminalSettings = ({
4951
terminalZshOhMy,
5052
terminalZshP10k,
5153
terminalZdotdir,
54+
maxTerminalPoolSize,
5255
setCachedStateField,
5356
className,
5457
...props
@@ -124,6 +127,28 @@ export const TerminalSettings = ({
124127
{t("settings:terminal.outputPreviewSize.description")}
125128
</div>
126129
</SearchableSetting>
130+
131+
<SearchableSetting
132+
settingId="terminal-max-pool-size"
133+
section="terminal"
134+
label={t("settings:terminal.maxPoolSize.label")}>
135+
<label className="block font-medium mb-1">{t("settings:terminal.maxPoolSize.label")}</label>
136+
<div className="flex items-center gap-2">
137+
<Slider
138+
min={1}
139+
max={20}
140+
step={1}
141+
value={[maxTerminalPoolSize ?? 5]}
142+
onValueChange={([value]) =>
143+
setCachedStateField("maxTerminalPoolSize", Math.min(20, Math.max(1, value)))
144+
}
145+
/>
146+
<span className="w-10">{maxTerminalPoolSize ?? 5}</span>
147+
</div>
148+
<div className="text-vscode-descriptionForeground text-sm mt-1">
149+
{t("settings:terminal.maxPoolSize.description")}
150+
</div>
151+
</SearchableSetting>
127152
</div>
128153
</div>
129154

0 commit comments

Comments
 (0)