mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add command timeout and auto-skipped commands settings
- Add commandMaxWaitTime setting (default 30 seconds) to allow Roo to continue with other tasks when commands exceed timeout - Add autoSkippedCommands setting with common dev server patterns that should run in background - Update TerminalProcess to implement timeout logic and background command detection - Add UI components in Terminal Settings for both new settings - Add comprehensive tests for timeout and background command functionality - Update ClineProvider and executeCommandTool to pass settings through Fixes #8459
This commit is contained in:
parent
13534cc8b7
commit
a3ccac03e6
12 changed files with 565 additions and 8 deletions
|
|
@ -71,6 +71,8 @@ export const globalSettingsSchema = z.object({
|
|||
deniedCommands: z.array(z.string()).optional(),
|
||||
commandExecutionTimeout: z.number().optional(),
|
||||
commandTimeoutAllowlist: z.array(z.string()).optional(),
|
||||
commandMaxWaitTime: z.number().optional(),
|
||||
autoSkippedCommands: z.array(z.string()).optional(),
|
||||
preventCompletionWithOpenTodos: z.boolean().optional(),
|
||||
allowedMaxRequests: z.number().nullish(),
|
||||
allowedMaxCost: z.number().nullish(),
|
||||
|
|
@ -271,6 +273,8 @@ export const EVALS_SETTINGS: RooCodeSettings = {
|
|||
allowedCommands: ["*"],
|
||||
commandExecutionTimeout: 20,
|
||||
commandTimeoutAllowlist: [],
|
||||
commandMaxWaitTime: 30,
|
||||
autoSkippedCommands: ["npm run dev", "npm start", "python -m http.server", "yarn dev", "yarn start"],
|
||||
preventCompletionWithOpenTodos: false,
|
||||
|
||||
browserToolEnabled: false,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ export async function executeCommandTool(
|
|||
terminalOutputLineLimit = 500,
|
||||
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
||||
terminalShellIntegrationDisabled = false,
|
||||
commandMaxWaitTime = 30,
|
||||
autoSkippedCommands = [],
|
||||
} = providerState ?? {}
|
||||
|
||||
// Get command execution timeout from VSCode configuration (in seconds)
|
||||
|
|
@ -94,6 +96,8 @@ export async function executeCommandTool(
|
|||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
commandExecutionTimeout,
|
||||
commandMaxWaitTime,
|
||||
autoSkippedCommands,
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -141,6 +145,8 @@ export type ExecuteCommandOptions = {
|
|||
terminalOutputLineLimit?: number
|
||||
terminalOutputCharacterLimit?: number
|
||||
commandExecutionTimeout?: number
|
||||
commandMaxWaitTime?: number
|
||||
autoSkippedCommands?: string[]
|
||||
}
|
||||
|
||||
export async function executeCommand(
|
||||
|
|
@ -153,6 +159,8 @@ export async function executeCommand(
|
|||
terminalOutputLineLimit = 500,
|
||||
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
||||
commandExecutionTimeout = 0,
|
||||
commandMaxWaitTime = 30,
|
||||
autoSkippedCommands = [],
|
||||
}: ExecuteCommandOptions,
|
||||
): Promise<[boolean, ToolResponse]> {
|
||||
// Convert milliseconds back to seconds for display purposes.
|
||||
|
|
@ -249,7 +257,7 @@ export async function executeCommand(
|
|||
workingDir = terminal.getCurrentWorkingDirectory()
|
||||
}
|
||||
|
||||
const process = terminal.runCommand(command, callbacks)
|
||||
const process = terminal.runCommand(command, callbacks, commandMaxWaitTime, autoSkippedCommands)
|
||||
task.terminalProcess = process
|
||||
|
||||
// Implement command execution timeout (skip if timeout is 0).
|
||||
|
|
|
|||
|
|
@ -1815,6 +1815,8 @@ export class ClineProvider
|
|||
openRouterImageGenerationSelectedModel,
|
||||
openRouterUseMiddleOutTransform,
|
||||
featureRoomoteControlEnabled,
|
||||
commandMaxWaitTime,
|
||||
autoSkippedCommands,
|
||||
} = await this.getState()
|
||||
|
||||
let cloudOrganizations: CloudOrganizationMembership[] = []
|
||||
|
|
@ -1964,6 +1966,8 @@ export class ClineProvider
|
|||
openRouterImageGenerationSelectedModel,
|
||||
openRouterUseMiddleOutTransform,
|
||||
featureRoomoteControlEnabled,
|
||||
commandMaxWaitTime: commandMaxWaitTime ?? 30,
|
||||
autoSkippedCommands: autoSkippedCommands ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2195,6 +2199,8 @@ export class ClineProvider
|
|||
return false
|
||||
}
|
||||
})(),
|
||||
commandMaxWaitTime: stateValues.commandMaxWaitTime ?? 30,
|
||||
autoSkippedCommands: stateValues.autoSkippedCommands ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@ export abstract class BaseTerminal implements RooTerminal {
|
|||
|
||||
abstract isClosed(): boolean
|
||||
|
||||
abstract runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise
|
||||
abstract runCommand(
|
||||
command: string,
|
||||
callbacks: RooTerminalCallbacks,
|
||||
commandMaxWaitTime?: number,
|
||||
autoSkippedCommands?: string[],
|
||||
): RooTerminalProcessResultPromise
|
||||
|
||||
/**
|
||||
* Sets the active stream for this terminal and notifies the process
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ export class ExecaTerminal extends BaseTerminal {
|
|||
return false
|
||||
}
|
||||
|
||||
public override runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise {
|
||||
public override runCommand(
|
||||
command: string,
|
||||
callbacks: RooTerminalCallbacks,
|
||||
commandMaxWaitTime?: number,
|
||||
autoSkippedCommands?: string[],
|
||||
): RooTerminalProcessResultPromise {
|
||||
this.busy = true
|
||||
|
||||
const process = new ExecaTerminalProcess(this)
|
||||
|
|
@ -30,6 +35,7 @@ export class ExecaTerminal extends BaseTerminal {
|
|||
const promise = new Promise<void>((resolve, reject) => {
|
||||
process.once("continue", () => resolve())
|
||||
process.once("error", (error) => reject(error))
|
||||
// Note: ExecaTerminalProcess doesn't support timeout yet, but we maintain the interface
|
||||
process.run(command)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,12 @@ export class Terminal extends BaseTerminal {
|
|||
return this.terminal.exitStatus !== undefined
|
||||
}
|
||||
|
||||
public override runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise {
|
||||
public override runCommand(
|
||||
command: string,
|
||||
callbacks: RooTerminalCallbacks,
|
||||
commandMaxWaitTime?: number,
|
||||
autoSkippedCommands?: string[],
|
||||
): RooTerminalProcessResultPromise {
|
||||
// We set busy before the command is running because the terminal may be
|
||||
// waiting on terminal integration, and we must prevent another instance
|
||||
// from selecting the terminal for use during that time.
|
||||
|
|
@ -59,6 +64,19 @@ export class Terminal extends BaseTerminal {
|
|||
process.once("shell_execution_complete", (details) => callbacks.onShellExecutionComplete(details, process))
|
||||
process.once("no_shell_integration", (msg) => callbacks.onNoShellIntegration?.(msg, process))
|
||||
|
||||
// Add handlers for timeout and background command events
|
||||
process.once("command_timeout", (cmd: string) => {
|
||||
console.log(`[Terminal] Command timeout for: ${cmd}`)
|
||||
callbacks.onLine?.(
|
||||
`\n[Command timeout reached - continuing with other tasks while this runs in background]\n`,
|
||||
process,
|
||||
)
|
||||
})
|
||||
process.once("background_command", (cmd: string) => {
|
||||
console.log(`[Terminal] Background command started: ${cmd}`)
|
||||
callbacks.onLine?.(`\n[Running in background - continuing with other tasks]\n`, process)
|
||||
})
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
// Set up event handlers
|
||||
process.once("continue", () => resolve())
|
||||
|
|
@ -75,8 +93,8 @@ export class Terminal extends BaseTerminal {
|
|||
// Clean up temporary directory if shell integration is available, zsh did its job:
|
||||
ShellIntegrationManager.zshCleanupTmpDir(this.id)
|
||||
|
||||
// Run the command in the terminal
|
||||
process.run(command)
|
||||
// Run the command in the terminal with timeout settings
|
||||
process.run(command, commandMaxWaitTime, autoSkippedCommands)
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import { Terminal } from "./Terminal"
|
|||
|
||||
export class TerminalProcess extends BaseTerminalProcess {
|
||||
private terminalRef: WeakRef<Terminal>
|
||||
private commandTimeout?: NodeJS.Timeout
|
||||
private commandMaxWaitTime: number = 30000 // Default 30 seconds
|
||||
private autoSkippedCommands: string[] = []
|
||||
private isBackgroundCommand: boolean = false
|
||||
|
||||
constructor(terminal: Terminal) {
|
||||
super()
|
||||
|
|
@ -44,9 +48,20 @@ export class TerminalProcess extends BaseTerminalProcess {
|
|||
return terminal
|
||||
}
|
||||
|
||||
public override async run(command: string) {
|
||||
public override async run(command: string, commandMaxWaitTime?: number, autoSkippedCommands?: string[]) {
|
||||
this.command = command
|
||||
|
||||
// Update settings if provided
|
||||
if (commandMaxWaitTime !== undefined) {
|
||||
this.commandMaxWaitTime = commandMaxWaitTime * 1000 // Convert to milliseconds
|
||||
}
|
||||
if (autoSkippedCommands !== undefined) {
|
||||
this.autoSkippedCommands = autoSkippedCommands
|
||||
}
|
||||
|
||||
// Check if this command should run in background
|
||||
this.isBackgroundCommand = this.shouldRunInBackground(command)
|
||||
|
||||
const terminal = this.terminal.terminal
|
||||
|
||||
const isShellIntegrationAvailable = terminal.shellIntegration && terminal.shellIntegration.executeCommand
|
||||
|
|
@ -134,6 +149,30 @@ export class TerminalProcess extends BaseTerminalProcess {
|
|||
|
||||
this.isHot = true
|
||||
|
||||
// Set up timeout for long-running commands if not a background command
|
||||
if (!this.isBackgroundCommand && this.commandMaxWaitTime > 0) {
|
||||
this.commandTimeout = setTimeout(() => {
|
||||
console.log(
|
||||
`[TerminalProcess] Command timeout reached after ${this.commandMaxWaitTime / 1000} seconds for: ${command}`,
|
||||
)
|
||||
|
||||
// Emit event to allow Roo to continue with other tasks
|
||||
this.emit("command_timeout", command)
|
||||
|
||||
// Don't abort the command, just allow Roo to continue
|
||||
// The command will continue running in the background
|
||||
this.isBackgroundCommand = true
|
||||
}, this.commandMaxWaitTime)
|
||||
}
|
||||
|
||||
// If it's a background command, emit immediately to allow Roo to continue
|
||||
if (this.isBackgroundCommand) {
|
||||
console.log(`[TerminalProcess] Running command in background: ${command}`)
|
||||
setTimeout(() => {
|
||||
this.emit("background_command", command)
|
||||
}, 100) // Small delay to ensure command starts
|
||||
}
|
||||
|
||||
// Wait for stream to be available
|
||||
let stream: AsyncIterable<string>
|
||||
|
||||
|
|
@ -208,6 +247,12 @@ export class TerminalProcess extends BaseTerminalProcess {
|
|||
// Set streamClosed immediately after stream ends.
|
||||
this.terminal.setActiveStream(undefined)
|
||||
|
||||
// Clear timeout if command completed before timeout
|
||||
if (this.commandTimeout) {
|
||||
clearTimeout(this.commandTimeout)
|
||||
this.commandTimeout = undefined
|
||||
}
|
||||
|
||||
// Wait for shell execution to complete.
|
||||
await shellExecutionComplete
|
||||
|
||||
|
|
@ -464,4 +509,24 @@ export class TerminalProcess extends BaseTerminalProcess {
|
|||
|
||||
return match133 !== undefined ? match133 : match633
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command should run in the background based on patterns
|
||||
*/
|
||||
private shouldRunInBackground(command: string): boolean {
|
||||
if (!this.autoSkippedCommands || this.autoSkippedCommands.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerCommand = command.toLowerCase()
|
||||
return this.autoSkippedCommands.some((pattern) => {
|
||||
const lowerPattern = pattern.toLowerCase()
|
||||
// Support wildcards in patterns
|
||||
if (lowerPattern.includes("*")) {
|
||||
const regex = new RegExp(lowerPattern.replace(/\*/g, ".*"))
|
||||
return regex.test(lowerCommand)
|
||||
}
|
||||
return lowerCommand.includes(lowerPattern)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
// npx vitest run src/integrations/terminal/__tests__/TerminalProcessTimeout.spec.ts
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||
import { EventEmitter } from "events"
|
||||
import { TerminalProcess } from "../TerminalProcess"
|
||||
import { Terminal } from "../Terminal"
|
||||
|
||||
// Mock vscode module
|
||||
vi.mock("vscode", () => ({
|
||||
workspace: {
|
||||
getConfiguration: vi.fn(() => ({
|
||||
get: vi.fn(() => null),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("TerminalProcess Timeout Functionality", () => {
|
||||
let terminalProcess: TerminalProcess
|
||||
let mockTerminal: Terminal
|
||||
let mockVscodeTerminal: any
|
||||
let mockShellExecution: any
|
||||
let mockShellIntegration: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock shell execution
|
||||
mockShellExecution = new EventEmitter()
|
||||
|
||||
// Create mock shell integration
|
||||
mockShellIntegration = {
|
||||
executeCommand: vi.fn().mockReturnValue(mockShellExecution),
|
||||
}
|
||||
|
||||
// Create mock VSCode terminal
|
||||
mockVscodeTerminal = {
|
||||
shellIntegration: mockShellIntegration,
|
||||
sendText: vi.fn(),
|
||||
show: vi.fn(),
|
||||
}
|
||||
|
||||
// Create mock Terminal instance
|
||||
mockTerminal = {
|
||||
terminal: mockVscodeTerminal,
|
||||
busy: false,
|
||||
isStreamClosed: false,
|
||||
cmdCounter: 0,
|
||||
setActiveStream: vi.fn(),
|
||||
} as any
|
||||
|
||||
// Clear all timers
|
||||
vi.clearAllTimers()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("Command Timeout", () => {
|
||||
it("should timeout after specified duration", async () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
|
||||
// Start the process with a 5 second timeout
|
||||
const runPromise = terminalProcess.run("sleep 10", 5)
|
||||
|
||||
// Emit stream available
|
||||
terminalProcess.emit(
|
||||
"stream_available",
|
||||
(async function* () {
|
||||
yield "\x1b]633;C\x07" // Command start marker
|
||||
yield "Running..."
|
||||
})(),
|
||||
)
|
||||
|
||||
// Advance time by 6 seconds (past the timeout)
|
||||
vi.advanceTimersByTime(6000)
|
||||
|
||||
// Verify timeout event was emitted
|
||||
expect(emitSpy).toHaveBeenCalledWith("command_timeout", "sleep 10")
|
||||
})
|
||||
|
||||
it("should not timeout if command completes before timeout", async () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
|
||||
// Start the process with a 10 second timeout
|
||||
const runPromise = terminalProcess.run("echo test", 10)
|
||||
|
||||
// Emit stream available with command output
|
||||
const stream = (async function* () {
|
||||
yield "\x1b]633;C\x07" // Command start marker
|
||||
yield "test\n"
|
||||
yield "\x1b]633;D;0\x07" // Command end marker with exit code 0
|
||||
})()
|
||||
|
||||
terminalProcess.emit("stream_available", stream)
|
||||
|
||||
// Advance time by 2 seconds
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
// Emit shell execution complete
|
||||
terminalProcess.emit("shell_execution_complete", { exitCode: 0 })
|
||||
|
||||
// Process the stream
|
||||
for await (const _ of stream) {
|
||||
// Stream consumed
|
||||
}
|
||||
|
||||
// Wait for the promise to resolve
|
||||
await runPromise
|
||||
|
||||
// Verify timeout event was NOT emitted
|
||||
expect(emitSpy).not.toHaveBeenCalledWith("command_timeout", expect.any(String))
|
||||
})
|
||||
|
||||
it("should handle timeout value of 0 (no timeout)", async () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
|
||||
// Start the process with no timeout (0)
|
||||
const runPromise = terminalProcess.run("long-running-command", 0)
|
||||
|
||||
// Emit stream available
|
||||
terminalProcess.emit(
|
||||
"stream_available",
|
||||
(async function* () {
|
||||
yield "\x1b]633;C\x07"
|
||||
yield "Running..."
|
||||
})(),
|
||||
)
|
||||
|
||||
// Advance time by a long duration
|
||||
vi.advanceTimersByTime(60000) // 60 seconds
|
||||
|
||||
// Verify timeout event was NOT emitted
|
||||
expect(emitSpy).not.toHaveBeenCalledWith("command_timeout", expect.any(String))
|
||||
})
|
||||
})
|
||||
|
||||
describe("Background Commands", () => {
|
||||
it("should detect background command by exact match", async () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
|
||||
const autoSkippedCommands = ["npm run dev", "yarn start"]
|
||||
|
||||
// Start the process with auto-skipped commands (don't await)
|
||||
terminalProcess.run("npm run dev", 30, autoSkippedCommands)
|
||||
|
||||
// Use fake timers to advance time
|
||||
vi.advanceTimersByTime(150)
|
||||
|
||||
// Should emit background command event
|
||||
expect(emitSpy).toHaveBeenCalledWith("background_command", "npm run dev")
|
||||
})
|
||||
|
||||
it("should detect background command by wildcard pattern", async () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
|
||||
const autoSkippedCommands = ["npm run *", "python -m http.server*"]
|
||||
|
||||
// Test wildcard matching (don't await)
|
||||
terminalProcess.run("npm run test:watch", 30, autoSkippedCommands)
|
||||
|
||||
// Use fake timers to advance time
|
||||
vi.advanceTimersByTime(150)
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith("background_command", "npm run test:watch")
|
||||
})
|
||||
|
||||
it("should not treat non-matching commands as background", async () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
|
||||
const autoSkippedCommands = ["npm run dev", "yarn start"]
|
||||
|
||||
// Start the process with a non-matching command (don't await)
|
||||
terminalProcess.run("ls -la", 5, autoSkippedCommands)
|
||||
|
||||
// Use fake timers to advance time
|
||||
vi.advanceTimersByTime(150)
|
||||
|
||||
// Should not emit background command event
|
||||
expect(emitSpy).not.toHaveBeenCalledWith("background_command", expect.any(String))
|
||||
|
||||
// Emit stream available to prevent timeout
|
||||
terminalProcess.emit(
|
||||
"stream_available",
|
||||
(async function* () {
|
||||
yield "\x1b]633;C\x07"
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle empty auto-skipped commands list", async () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
|
||||
// Start the process with empty auto-skipped commands (don't await)
|
||||
terminalProcess.run("npm run dev", 5, [])
|
||||
|
||||
// Use fake timers to advance time
|
||||
vi.advanceTimersByTime(150)
|
||||
|
||||
// Should not be treated as background
|
||||
expect(emitSpy).not.toHaveBeenCalledWith("background_command", expect.any(String))
|
||||
|
||||
// Emit stream available to prevent timeout
|
||||
terminalProcess.emit(
|
||||
"stream_available",
|
||||
(async function* () {
|
||||
yield "\x1b]633;C\x07"
|
||||
})(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldRunInBackground helper", () => {
|
||||
// Since shouldRunInBackground is private, we test it indirectly through the run method
|
||||
|
||||
it("should correctly match exact patterns", () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
|
||||
const patterns = ["npm run dev", "yarn start", "python manage.py runserver"]
|
||||
|
||||
// Test exact match (don't await)
|
||||
terminalProcess.run("npm run dev", 30, patterns)
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(emitSpy).toHaveBeenCalledWith("background_command", "npm run dev")
|
||||
|
||||
// Reset spy and create new instance
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const emitSpy2 = vi.spyOn(terminalProcess, "emit")
|
||||
|
||||
// Test non-match (don't await)
|
||||
terminalProcess.run("npm run build", 30, patterns)
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(emitSpy2).not.toHaveBeenCalledWith("background_command", expect.any(String))
|
||||
})
|
||||
|
||||
it("should correctly match wildcard patterns", () => {
|
||||
const patterns = ["npm run *", "python -m *", "docker compose*"]
|
||||
|
||||
// Test wildcard match for npm
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
let emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
terminalProcess.run("npm run dev", 30, patterns)
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(emitSpy).toHaveBeenCalledWith("background_command", "npm run dev")
|
||||
|
||||
// Test wildcard match for python
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
terminalProcess.run("python -m http.server", 30, patterns)
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(emitSpy).toHaveBeenCalledWith("background_command", "python -m http.server")
|
||||
|
||||
// Test wildcard match for docker
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
terminalProcess.run("docker compose up", 30, patterns)
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(emitSpy).toHaveBeenCalledWith("background_command", "docker compose up")
|
||||
|
||||
// Test non-match
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
terminalProcess.run("docker ps", 30, patterns)
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(emitSpy).not.toHaveBeenCalledWith("background_command", expect.any(String))
|
||||
})
|
||||
|
||||
it("should handle case sensitivity correctly", () => {
|
||||
const patterns = ["NPM RUN DEV", "Yarn Start"]
|
||||
|
||||
// Should be case-insensitive for npm
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
let emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
terminalProcess.run("npm run dev", 30, patterns)
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(emitSpy).toHaveBeenCalledWith("background_command", "npm run dev")
|
||||
|
||||
// Should be case-insensitive for yarn
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
emitSpy = vi.spyOn(terminalProcess, "emit")
|
||||
terminalProcess.run("yarn start", 30, patterns)
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(emitSpy).toHaveBeenCalledWith("background_command", "yarn start")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Timeout cleanup", () => {
|
||||
it("should clear timeout when command completes", async () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
|
||||
|
||||
// Start the process with a timeout
|
||||
const runPromise = terminalProcess.run("echo test", 10)
|
||||
|
||||
// Emit stream available with complete output
|
||||
const stream = (async function* () {
|
||||
yield "\x1b]633;C\x07"
|
||||
yield "test\n"
|
||||
yield "\x1b]633;D;0\x07"
|
||||
})()
|
||||
|
||||
terminalProcess.emit("stream_available", stream)
|
||||
terminalProcess.emit("shell_execution_complete", { exitCode: 0 })
|
||||
|
||||
// Process the stream
|
||||
for await (const _ of stream) {
|
||||
// Stream consumed
|
||||
}
|
||||
|
||||
await runPromise
|
||||
|
||||
// Verify timeout was cleared (if it was set)
|
||||
// Note: clearTimeout is called even if no timeout was set
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle abort correctly", () => {
|
||||
terminalProcess = new TerminalProcess(mockTerminal)
|
||||
|
||||
// Start a process to set up listening state
|
||||
terminalProcess.run("test command", 10)
|
||||
|
||||
// Add a line listener to make it listening
|
||||
terminalProcess.on("line", () => {})
|
||||
|
||||
// Abort the process
|
||||
terminalProcess.abort()
|
||||
|
||||
// Verify SIGINT was sent
|
||||
expect(mockVscodeTerminal.sendText).toHaveBeenCalledWith("\x03")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -11,7 +11,12 @@ export interface RooTerminal {
|
|||
process?: RooTerminalProcess
|
||||
getCurrentWorkingDirectory(): string
|
||||
isClosed: () => boolean
|
||||
runCommand: (command: string, callbacks: RooTerminalCallbacks) => RooTerminalProcessResultPromise
|
||||
runCommand: (
|
||||
command: string,
|
||||
callbacks: RooTerminalCallbacks,
|
||||
commandMaxWaitTime?: number,
|
||||
autoSkippedCommands?: string[],
|
||||
) => RooTerminalProcessResultPromise
|
||||
setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number): void
|
||||
shellExecutionComplete(exitDetails: ExitCodeDetails): void
|
||||
getProcessesWithOutput(): RooTerminalProcess[]
|
||||
|
|
@ -49,6 +54,8 @@ export interface RooTerminalProcessEvents {
|
|||
shell_execution_complete: [exitDetails: ExitCodeDetails]
|
||||
error: [error: Error]
|
||||
no_shell_integration: [message: string]
|
||||
command_timeout: [command: string]
|
||||
background_command: [command: string]
|
||||
}
|
||||
|
||||
export interface ExitCodeDetails {
|
||||
|
|
|
|||
|
|
@ -235,6 +235,8 @@ export type ExtensionState = Pick<
|
|||
| "followupAutoApproveTimeoutMs"
|
||||
| "allowedCommands"
|
||||
| "deniedCommands"
|
||||
| "commandMaxWaitTime"
|
||||
| "autoSkippedCommands"
|
||||
| "allowedMaxRequests"
|
||||
| "allowedMaxCost"
|
||||
| "browserToolEnabled"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
|||
terminalZshP10k?: boolean
|
||||
terminalZdotdir?: boolean
|
||||
terminalCompressProgressBar?: boolean
|
||||
commandMaxWaitTime?: number
|
||||
autoSkippedCommands?: string[]
|
||||
setCachedStateField: SetCachedStateField<
|
||||
| "terminalOutputLineLimit"
|
||||
| "terminalOutputCharacterLimit"
|
||||
|
|
@ -40,6 +42,8 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
|||
| "terminalZshP10k"
|
||||
| "terminalZdotdir"
|
||||
| "terminalCompressProgressBar"
|
||||
| "commandMaxWaitTime"
|
||||
| "autoSkippedCommands"
|
||||
>
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +59,8 @@ export const TerminalSettings = ({
|
|||
terminalZshP10k,
|
||||
terminalZdotdir,
|
||||
terminalCompressProgressBar,
|
||||
commandMaxWaitTime,
|
||||
autoSkippedCommands,
|
||||
setCachedStateField,
|
||||
className,
|
||||
...props
|
||||
|
|
@ -184,6 +190,85 @@ export const TerminalSettings = ({
|
|||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium mb-1">
|
||||
{t("settings:terminal.commandMaxWaitTime.label")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
min={0}
|
||||
max={300}
|
||||
step={5}
|
||||
value={[commandMaxWaitTime ?? 30]}
|
||||
onValueChange={([value]) => setCachedStateField("commandMaxWaitTime", value)}
|
||||
data-testid="terminal-command-max-wait-time-slider"
|
||||
/>
|
||||
<span className="w-10">{commandMaxWaitTime ?? 30}s</span>
|
||||
</div>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1">
|
||||
<Trans i18nKey="settings:terminal.commandMaxWaitTime.description">
|
||||
<VSCodeLink
|
||||
href={buildDocLink(
|
||||
"features/shell-integration#command-max-wait-time",
|
||||
"settings_terminal_command_max_wait_time",
|
||||
)}
|
||||
style={{ display: "inline" }}>
|
||||
{" "}
|
||||
</VSCodeLink>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium mb-1">
|
||||
{t("settings:terminal.autoSkippedCommands.label")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(autoSkippedCommands ?? []).map((command, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
const newCommands = [...(autoSkippedCommands ?? [])]
|
||||
newCommands[index] = e.target.value
|
||||
setCachedStateField("autoSkippedCommands", newCommands)
|
||||
}}
|
||||
className="flex-1 px-2 py-1 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
|
||||
placeholder={t("settings:terminal.autoSkippedCommands.placeholder")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newCommands = (autoSkippedCommands ?? []).filter(
|
||||
(_, i) => i !== index,
|
||||
)
|
||||
setCachedStateField("autoSkippedCommands", newCommands)
|
||||
}}
|
||||
className="px-2 py-1 bg-vscode-button-background text-vscode-button-foreground rounded hover:bg-vscode-button-hoverBackground">
|
||||
{t("common:remove")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
setCachedStateField("autoSkippedCommands", [...(autoSkippedCommands ?? []), ""])
|
||||
}}
|
||||
className="px-2 py-1 bg-vscode-button-background text-vscode-button-foreground rounded hover:bg-vscode-button-hoverBackground self-start">
|
||||
{t("settings:terminal.autoSkippedCommands.addButton")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1">
|
||||
<Trans i18nKey="settings:terminal.autoSkippedCommands.description">
|
||||
<VSCodeLink
|
||||
href={buildDocLink(
|
||||
"features/shell-integration#auto-skipped-commands",
|
||||
"settings_terminal_auto_skipped_commands",
|
||||
)}
|
||||
style={{ display: "inline" }}>
|
||||
{" "}
|
||||
</VSCodeLink>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -668,6 +668,16 @@
|
|||
"inheritEnv": {
|
||||
"label": "Inherit environment variables",
|
||||
"description": "When enabled, the terminal will inherit environment variables from VSCode's parent process, such as user-profile-defined shell integration settings. This directly toggles VSCode global setting `terminal.integrated.inheritEnv`. <0>Learn more</0>"
|
||||
},
|
||||
"commandMaxWaitTime": {
|
||||
"label": "Command max wait time",
|
||||
"description": "Maximum time in seconds to wait for a command to complete before allowing Roo to continue with other tasks. The command will continue running in the background. Set to 0 to disable timeout. <0>Learn more</0>"
|
||||
},
|
||||
"autoSkippedCommands": {
|
||||
"label": "Auto-skipped commands",
|
||||
"description": "List of command patterns that should automatically run in the background, allowing Roo to continue with other tasks immediately. Supports wildcards (*). <0>Learn more</0>",
|
||||
"placeholder": "e.g., npm run dev, yarn start, python -m http.server",
|
||||
"addButton": "Add Command Pattern"
|
||||
}
|
||||
},
|
||||
"advancedSettings": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue