Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/integrations/terminal/BaseTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export abstract class BaseTerminal implements RooTerminal {
protected streamClosed: boolean

public taskId?: string
public requestedCwd?: string
public process?: RooTerminalProcess
public completedProcesses: RooTerminalProcess[] = []

Expand Down
12 changes: 10 additions & 2 deletions src/integrations/terminal/Terminal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from "vscode"
import pWaitFor from "p-wait-for"

import { arePathsEqual } from "../../utils/path"
import type { RooTerminalCallbacks, RooTerminalProcessResultPromise } from "./types"
import { BaseTerminal } from "./BaseTerminal"
import { TerminalProcess } from "./TerminalProcess"
Expand Down Expand Up @@ -47,7 +48,14 @@ export class Terminal extends BaseTerminal {
this.busy = true

const process = new TerminalProcess(this)
process.command = command
const currentCwd = this.getCurrentWorkingDirectory()
let commandToRun = command

if (this.requestedCwd && !arePathsEqual(this.requestedCwd, currentCwd)) {
// Wrap path in quotes to handle spaces
commandToRun = `cd "${this.requestedCwd}" && ${command}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When constructing the command (e.g., cd "${this.requestedCwd}" && ...), consider sanitizing 'requestedCwd' further to escape embedded quotes or other special characters.

Suggested change
commandToRun = `cd "${this.requestedCwd}" && ${command}`
commandToRun = `cd "${this.requestedCwd.replace(/\"/g, '\\"')}" && ${command}`

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Path escaping and shell portability: requestedCwd is interpolated into a shell command without escaping. If it contains a double quote, this will break command parsing. Also, using '&&' assumes a POSIX-like shell; Windows PowerShell 5 doesn't support '&&'. Escape quotes and consider shell-aware composition.

Suggested change
commandToRun = `cd "${this.requestedCwd}" && ${command}`
commandToRun = `cd "${this.requestedCwd.replace(/"/g, '\\"')}" && ${command}`

}
process.command = commandToRun
this.process = process

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

// Run the command in the terminal
process.run(command)
process.run(commandToRun)
})
.catch(() => {
console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`)
Expand Down
10 changes: 10 additions & 0 deletions src/integrations/terminal/TerminalRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,22 @@ export class TerminalRegistry {
})
}

// Third priority: find any idle terminal with the same provider,
// preferably one with the same task ID.
if (!terminal) {
const idleTerminals = terminals.filter((t) => !t.busy && t.provider === provider)
if (idleTerminals.length > 0) {
terminal = idleTerminals.find((t) => t.taskId === taskId) ?? idleTerminals[0]
}
}

// If no suitable terminal found, create a new one.
if (!terminal) {
terminal = this.createTerminal(cwd, provider)
}

terminal.taskId = taskId
terminal.requestedCwd = cwd
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Normalize CWD for consistency: requestedCwd is assigned directly from input. Other comparisons normalize to fsPath; assign the normalized path here to avoid mismatches across platforms.

Suggested change
terminal.requestedCwd = cwd
terminal.requestedCwd = vscode.Uri.file(cwd).fsPath


return terminal
}
Expand Down
103 changes: 103 additions & 0 deletions src/integrations/terminal/__tests__/Terminal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// npx vitest run src/integrations/terminal/__tests__/Terminal.spec.ts

import * as vscode from "vscode"
import { Terminal } from "../Terminal"
import { TerminalProcess } from "../TerminalProcess"
import { vi } from "vitest"

// Mock dependencies
vi.mock("vscode", () => ({
window: {
createTerminal: vi.fn().mockReturnValue({
shellIntegration: {
cwd: { fsPath: "/initial/cwd" },
},
sendText: vi.fn(),
exitStatus: undefined,
}),
},
ThemeIcon: vi.fn(),
}))

vi.mock("p-wait-for", () => ({
default: vi.fn().mockResolvedValue(true),
}))

vi.mock("../TerminalProcess")
vi.mock("../../../utils/path", () => ({
arePathsEqual: vi.fn((a, b) => a === b),
}))

describe("Terminal", () => {
describe("runCommand", () => {
let terminal: Terminal
let mockRun: any
let mockProcess: any

beforeEach(() => {
terminal = new Terminal(1, undefined, "/initial/cwd")
mockRun = vi.fn()
mockProcess = {
run: mockRun,
on: vi.fn(),
once: vi.fn(),
emit: vi.fn(),
}
vi.mocked(TerminalProcess).mockImplementation((): any => {
return mockProcess
})
})

afterEach(() => {
vi.clearAllMocks()
})

it("should prepend cd command if requestedCwd differs from currentCwd", () => {
terminal.requestedCwd = "/requested/cwd"
vi.spyOn(terminal, "getCurrentWorkingDirectory").mockReturnValue("/initial/cwd")

const callbacks = {
onLine: vi.fn(),
onCompleted: vi.fn(),
onShellExecutionStarted: vi.fn(),
onShellExecutionComplete: vi.fn(),
}

terminal.runCommand("ls", callbacks)

expect(mockProcess.command).toBe('cd "/requested/cwd" && ls')
expect(mockRun).toHaveBeenCalledWith('cd "/requested/cwd" && ls')
})

it("should not prepend cd command if requestedCwd is the same as currentCwd", () => {
terminal.requestedCwd = "/initial/cwd"
vi.spyOn(terminal, "getCurrentWorkingDirectory").mockReturnValue("/initial/cwd")
const callbacks = {
onLine: vi.fn(),
onCompleted: vi.fn(),
onShellExecutionStarted: vi.fn(),
onShellExecutionComplete: vi.fn(),
}

terminal.runCommand("ls", callbacks)

expect(mockProcess.command).toBe("ls")
expect(mockRun).toHaveBeenCalledWith("ls")
})

it("should not prepend cd command if requestedCwd is not set", () => {
vi.spyOn(terminal, "getCurrentWorkingDirectory").mockReturnValue("/initial/cwd")
const callbacks = {
onLine: vi.fn(),
onCompleted: vi.fn(),
onShellExecutionStarted: vi.fn(),
onShellExecutionComplete: vi.fn(),
}

terminal.runCommand("ls", callbacks)

expect(mockProcess.command).toBe("ls")
expect(mockRun).toHaveBeenCalledWith("ls")
})
})
})
79 changes: 79 additions & 0 deletions src/integrations/terminal/__tests__/TerminalRegistry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
import * as vscode from "vscode"
import { Terminal } from "../Terminal"
import { TerminalRegistry } from "../TerminalRegistry"
import { arePathsEqual } from "../../../utils/path"

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

vi.mock("../../../utils/path", () => ({
arePathsEqual: vi.fn((a, b) => a === b),
}))

vi.mock("execa", () => ({
execa: vi.fn(),
}))
Expand Down Expand Up @@ -119,4 +124,78 @@ describe("TerminalRegistry", () => {
}
})
})

describe("getOrCreateTerminal", () => {
let createTerminalSpy: any

beforeEach(() => {
// Reset terminals before each test
;(TerminalRegistry as any).terminals = []
createTerminalSpy = vi.spyOn(TerminalRegistry, "createTerminal")
})

afterEach(() => {
createTerminalSpy.mockRestore()
})

it("should create a new terminal if none exist", async () => {
await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
expect(createTerminalSpy).toHaveBeenCalledWith("/test/path", "vscode")
})

it("should reuse a terminal with the same task ID and cwd", async () => {
const existingTerminal = new Terminal(1, undefined, "/test/path")
existingTerminal.taskId = "task1"
;(TerminalRegistry as any).terminals.push(existingTerminal)

const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
expect(terminal).toBe(existingTerminal)
expect(createTerminalSpy).not.toHaveBeenCalled()
})

it("should reuse an idle terminal with the same cwd", async () => {
const existingTerminal = new Terminal(1, undefined, "/test/path")
;(TerminalRegistry as any).terminals.push(existingTerminal)

const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task2")
expect(terminal).toBe(existingTerminal)
expect(createTerminalSpy).not.toHaveBeenCalled()
})

it("should reuse an idle terminal with a different cwd if no better option is available", async () => {
const existingTerminal = new Terminal(1, undefined, "/other/path")
;(TerminalRegistry as any).terminals.push(existingTerminal)

const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
expect(terminal).toBe(existingTerminal)
expect(createTerminalSpy).not.toHaveBeenCalled()
expect(terminal.requestedCwd).toBe("/test/path")
})

it("should prioritize reusing a terminal with the same task ID when cwd has drifted", async () => {
const terminal1 = new Terminal(1, undefined, "/other/path1")
terminal1.taskId = "task1"
const terminal2 = new Terminal(2, undefined, "/other/path2")
terminal2.taskId = "task2"
;(TerminalRegistry as any).terminals.push(terminal1, terminal2)

const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
expect(terminal).toBe(terminal1)
expect(createTerminalSpy).not.toHaveBeenCalled()
})

it("should create a new terminal if all existing terminals are busy", async () => {
const existingTerminal = new Terminal(1, undefined, "/test/path")
existingTerminal.busy = true
;(TerminalRegistry as any).terminals.push(existingTerminal)

await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
expect(createTerminalSpy).toHaveBeenCalledWith("/test/path", "vscode")
})

it("should set the requestedCwd on the returned terminal", async () => {
const terminal = await TerminalRegistry.getOrCreateTerminal("/test/path", "task1")
expect(terminal.requestedCwd).toBe("/test/path")
})
})
})
1 change: 1 addition & 0 deletions src/integrations/terminal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface RooTerminal {
running: boolean
taskId?: string
process?: RooTerminalProcess
requestedCwd?: string
getCurrentWorkingDirectory(): string
isClosed: () => boolean
runCommand: (command: string, callbacks: RooTerminalCallbacks) => RooTerminalProcessResultPromise
Expand Down
Loading