Skip to content

Commit 214b606

Browse files
feat: Improve terminal reuse logic to prevent proliferation
This change improves the terminal reuse logic to prevent the creation of unnecessary terminals. The previous implementation was too strict and would create new terminals even when idle ones were available. The new logic introduces a fallback mechanism to reuse idle terminals even if their current working directory has drifted. When a terminal with a drifted CWD is reused, a `cd` command is prepended to the command being executed to ensure it runs in the correct directory. This change also includes: - Updates to the `RooTerminal` interface and `BaseTerminal` class to support the new logic. - Comprehensive unit tests to validate the new terminal reuse behavior.
1 parent 85b0e8a commit 214b606

File tree

6 files changed

+204
-2
lines changed

6 files changed

+204
-2
lines changed

src/integrations/terminal/BaseTerminal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export abstract class BaseTerminal implements RooTerminal {
2020
protected streamClosed: boolean
2121

2222
public taskId?: string
23+
public requestedCwd?: string
2324
public process?: RooTerminalProcess
2425
public completedProcesses: RooTerminalProcess[] = []
2526

src/integrations/terminal/Terminal.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as vscode from "vscode"
22
import pWaitFor from "p-wait-for"
33

4+
import { arePathsEqual } from "../../utils/path"
45
import type { RooTerminalCallbacks, RooTerminalProcessResultPromise } from "./types"
56
import { BaseTerminal } from "./BaseTerminal"
67
import { TerminalProcess } from "./TerminalProcess"
@@ -47,7 +48,14 @@ export class Terminal extends BaseTerminal {
4748
this.busy = true
4849

4950
const process = new TerminalProcess(this)
50-
process.command = command
51+
const currentCwd = this.getCurrentWorkingDirectory()
52+
let commandToRun = command
53+
54+
if (this.requestedCwd && !arePathsEqual(this.requestedCwd, currentCwd)) {
55+
// Wrap path in quotes to handle spaces
56+
commandToRun = `cd "${this.requestedCwd}" && ${command}`
57+
}
58+
process.command = commandToRun
5159
this.process = process
5260

5361
// Set up event handlers from callbacks before starting process.
@@ -76,7 +84,7 @@ export class Terminal extends BaseTerminal {
7684
ShellIntegrationManager.zshCleanupTmpDir(this.id)
7785

7886
// Run the command in the terminal
79-
process.run(command)
87+
process.run(commandToRun)
8088
})
8189
.catch(() => {
8290
console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`)

src/integrations/terminal/TerminalRegistry.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,22 @@ export class TerminalRegistry {
192192
})
193193
}
194194

195+
// Third priority: find any idle terminal with the same provider,
196+
// preferably one with the same task ID.
197+
if (!terminal) {
198+
const idleTerminals = terminals.filter((t) => !t.busy && t.provider === provider)
199+
if (idleTerminals.length > 0) {
200+
terminal = idleTerminals.find((t) => t.taskId === taskId) ?? idleTerminals[0]
201+
}
202+
}
203+
195204
// If no suitable terminal found, create a new one.
196205
if (!terminal) {
197206
terminal = this.createTerminal(cwd, provider)
198207
}
199208

200209
terminal.taskId = taskId
210+
terminal.requestedCwd = cwd
201211

202212
return terminal
203213
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// npx vitest run src/integrations/terminal/__tests__/Terminal.spec.ts
2+
3+
import * as vscode from "vscode"
4+
import { Terminal } from "../Terminal"
5+
import { TerminalProcess } from "../TerminalProcess"
6+
import { vi } from "vitest"
7+
8+
// Mock dependencies
9+
vi.mock("vscode", () => ({
10+
window: {
11+
createTerminal: vi.fn().mockReturnValue({
12+
shellIntegration: {
13+
cwd: { fsPath: "/initial/cwd" },
14+
},
15+
sendText: vi.fn(),
16+
exitStatus: undefined,
17+
}),
18+
},
19+
ThemeIcon: vi.fn(),
20+
}))
21+
22+
vi.mock("p-wait-for", () => ({
23+
default: vi.fn().mockResolvedValue(true),
24+
}))
25+
26+
vi.mock("../TerminalProcess")
27+
vi.mock("../../../utils/path", () => ({
28+
arePathsEqual: vi.fn((a, b) => a === b),
29+
}))
30+
31+
describe("Terminal", () => {
32+
describe("runCommand", () => {
33+
let terminal: Terminal
34+
let mockRun: any
35+
let mockProcess: any
36+
37+
beforeEach(() => {
38+
terminal = new Terminal(1, undefined, "/initial/cwd")
39+
mockRun = vi.fn()
40+
mockProcess = {
41+
run: mockRun,
42+
on: vi.fn(),
43+
once: vi.fn(),
44+
emit: vi.fn(),
45+
}
46+
vi.mocked(TerminalProcess).mockImplementation((): any => {
47+
return mockProcess
48+
})
49+
})
50+
51+
afterEach(() => {
52+
vi.clearAllMocks()
53+
})
54+
55+
it("should prepend cd command if requestedCwd differs from currentCwd", () => {
56+
terminal.requestedCwd = "/requested/cwd"
57+
vi.spyOn(terminal, "getCurrentWorkingDirectory").mockReturnValue("/initial/cwd")
58+
59+
const callbacks = {
60+
onLine: vi.fn(),
61+
onCompleted: vi.fn(),
62+
onShellExecutionStarted: vi.fn(),
63+
onShellExecutionComplete: vi.fn(),
64+
}
65+
66+
terminal.runCommand("ls", callbacks)
67+
68+
expect(mockProcess.command).toBe('cd "/requested/cwd" && ls')
69+
expect(mockRun).toHaveBeenCalledWith('cd "/requested/cwd" && ls')
70+
})
71+
72+
it("should not prepend cd command if requestedCwd is the same as currentCwd", () => {
73+
terminal.requestedCwd = "/initial/cwd"
74+
vi.spyOn(terminal, "getCurrentWorkingDirectory").mockReturnValue("/initial/cwd")
75+
const callbacks = {
76+
onLine: vi.fn(),
77+
onCompleted: vi.fn(),
78+
onShellExecutionStarted: vi.fn(),
79+
onShellExecutionComplete: vi.fn(),
80+
}
81+
82+
terminal.runCommand("ls", callbacks)
83+
84+
expect(mockProcess.command).toBe("ls")
85+
expect(mockRun).toHaveBeenCalledWith("ls")
86+
})
87+
88+
it("should not prepend cd command if requestedCwd is not set", () => {
89+
vi.spyOn(terminal, "getCurrentWorkingDirectory").mockReturnValue("/initial/cwd")
90+
const callbacks = {
91+
onLine: vi.fn(),
92+
onCompleted: vi.fn(),
93+
onShellExecutionStarted: vi.fn(),
94+
onShellExecutionComplete: vi.fn(),
95+
}
96+
97+
terminal.runCommand("ls", callbacks)
98+
99+
expect(mockProcess.command).toBe("ls")
100+
expect(mockRun).toHaveBeenCalledWith("ls")
101+
})
102+
})
103+
})

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
import * as vscode from "vscode"
44
import { Terminal } from "../Terminal"
55
import { TerminalRegistry } from "../TerminalRegistry"
6+
import { arePathsEqual } from "../../../utils/path"
67

78
const PAGER = process.platform === "win32" ? "" : "cat"
89

10+
vi.mock("../../../utils/path", () => ({
11+
arePathsEqual: vi.fn((a, b) => a === b),
12+
}))
13+
914
vi.mock("execa", () => ({
1015
execa: vi.fn(),
1116
}))
@@ -119,4 +124,78 @@ describe("TerminalRegistry", () => {
119124
}
120125
})
121126
})
127+
128+
describe("getOrCreateTerminal", () => {
129+
let createTerminalSpy: any
130+
131+
beforeEach(() => {
132+
// Reset terminals before each test
133+
;(TerminalRegistry as any).terminals = []
134+
createTerminalSpy = vi.spyOn(TerminalRegistry, "createTerminal")
135+
})
136+
137+
afterEach(() => {
138+
createTerminalSpy.mockRestore()
139+
})
140+
141+
it("should create a new terminal if none exist", async () => {
142+
await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
143+
expect(createTerminalSpy).toHaveBeenCalledWith("/test/path", "vscode")
144+
})
145+
146+
it("should reuse a terminal with the same task ID and cwd", async () => {
147+
const existingTerminal = new Terminal(1, undefined, "/test/path")
148+
existingTerminal.taskId = "task1"
149+
;(TerminalRegistry as any).terminals.push(existingTerminal)
150+
151+
const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
152+
expect(terminal).toBe(existingTerminal)
153+
expect(createTerminalSpy).not.toHaveBeenCalled()
154+
})
155+
156+
it("should reuse an idle terminal with the same cwd", async () => {
157+
const existingTerminal = new Terminal(1, undefined, "/test/path")
158+
;(TerminalRegistry as any).terminals.push(existingTerminal)
159+
160+
const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task2")
161+
expect(terminal).toBe(existingTerminal)
162+
expect(createTerminalSpy).not.toHaveBeenCalled()
163+
})
164+
165+
it("should reuse an idle terminal with a different cwd if no better option is available", async () => {
166+
const existingTerminal = new Terminal(1, undefined, "/other/path")
167+
;(TerminalRegistry as any).terminals.push(existingTerminal)
168+
169+
const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
170+
expect(terminal).toBe(existingTerminal)
171+
expect(createTerminalSpy).not.toHaveBeenCalled()
172+
expect(terminal.requestedCwd).toBe("/test/path")
173+
})
174+
175+
it("should prioritize reusing a terminal with the same task ID when cwd has drifted", async () => {
176+
const terminal1 = new Terminal(1, undefined, "/other/path1")
177+
terminal1.taskId = "task1"
178+
const terminal2 = new Terminal(2, undefined, "/other/path2")
179+
terminal2.taskId = "task2"
180+
;(TerminalRegistry as any).terminals.push(terminal1, terminal2)
181+
182+
const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
183+
expect(terminal).toBe(terminal1)
184+
expect(createTerminalSpy).not.toHaveBeenCalled()
185+
})
186+
187+
it("should create a new terminal if all existing terminals are busy", async () => {
188+
const existingTerminal = new Terminal(1, undefined, "/test/path")
189+
existingTerminal.busy = true
190+
;(TerminalRegistry as any).terminals.push(existingTerminal)
191+
192+
await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
193+
expect(createTerminalSpy).toHaveBeenCalledWith("/test/path", "vscode")
194+
})
195+
196+
it("should set the requestedCwd on the returned terminal", async () => {
197+
const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
198+
expect(terminal.requestedCwd).toBe("/test/path")
199+
})
200+
})
122201
})

src/integrations/terminal/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface RooTerminal {
99
running: boolean
1010
taskId?: string
1111
process?: RooTerminalProcess
12+
requestedCwd?: string
1213
getCurrentWorkingDirectory(): string
1314
isClosed: () => boolean
1415
runCommand: (command: string, callbacks: RooTerminalCallbacks) => RooTerminalProcessResultPromise

0 commit comments

Comments
 (0)