mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
cb83656718
commit
3068bdca4b
10 changed files with 238 additions and 1 deletions
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -282,6 +282,7 @@ export type ExtensionState = Pick<
|
|||
| "terminalZshOhMy"
|
||||
| "terminalZshP10k"
|
||||
| "terminalZdotdir"
|
||||
| "maxTerminalPoolSize"
|
||||
| "execaShellPath"
|
||||
| "diagnosticsEnabled"
|
||||
| "language"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue