Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -356,6 +357,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
terminalZshP10k: false,
terminalZdotdir: true,
terminalShellIntegrationDisabled: true,
maxTerminalPoolSize: 5,

diagnosticsEnabled: true,

Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export type ExtensionState = Pick<
| "terminalZshOhMy"
| "terminalZshP10k"
| "terminalZdotdir"
| "maxTerminalPoolSize"
| "execaShellPath"
| "diagnosticsEnabled"
| "language"
Expand Down
6 changes: 6 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -852,6 +853,7 @@ export class ClineProvider
terminalZshP10k = false,
terminalPowershellCounter = false,
terminalZdotdir = false,
maxTerminalPoolSize = TerminalRegistry.DEFAULT_MAX_TERMINAL_POOL_SIZE,
ttsEnabled,
ttsSpeed,
}) => {
Expand All @@ -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)
},
Expand Down Expand Up @@ -2162,6 +2165,7 @@ export class ClineProvider
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
maxTerminalPoolSize,
mcpEnabled,
currentApiConfigName,
listApiConfigMeta,
Expand Down Expand Up @@ -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 ?? [],
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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") {
Expand Down
80 changes: 79 additions & 1 deletion src/integrations/terminal/TerminalRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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)
}
}
109 changes: 109 additions & 0 deletions src/integrations/terminal/__tests__/TerminalRegistry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
3 changes: 3 additions & 0 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
maxTerminalPoolSize,
writeDelayMs,
showRooIgnoredFiles,
enableSubfolderRules,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -862,6 +864,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalZshOhMy={terminalZshOhMy}
terminalZshP10k={terminalZshP10k}
terminalZdotdir={terminalZdotdir}
maxTerminalPoolSize={maxTerminalPoolSize}
setCachedStateField={setCachedStateField}
/>
)}
Expand Down
25 changes: 25 additions & 0 deletions webview-ui/src/components/settings/TerminalSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
terminalZshOhMy?: boolean
terminalZshP10k?: boolean
terminalZdotdir?: boolean
maxTerminalPoolSize?: number
setCachedStateField: SetCachedStateField<
| "terminalOutputPreviewSize"
| "terminalShellIntegrationTimeout"
Expand All @@ -36,6 +37,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "terminalZshOhMy"
| "terminalZshP10k"
| "terminalZdotdir"
| "maxTerminalPoolSize"
>
}

Expand All @@ -49,6 +51,7 @@ export const TerminalSettings = ({
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
maxTerminalPoolSize,
setCachedStateField,
className,
...props
Expand Down Expand Up @@ -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>

Expand Down
Loading
Loading