From a3ccac03e644bded0831889725fd5a6d7450a413 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 2 Oct 2025 11:35:40 +0000 Subject: [PATCH] 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 --- packages/types/src/global-settings.ts | 4 + src/core/tools/executeCommandTool.ts | 10 +- src/core/webview/ClineProvider.ts | 6 + src/integrations/terminal/BaseTerminal.ts | 7 +- src/integrations/terminal/ExecaTerminal.ts | 8 +- src/integrations/terminal/Terminal.ts | 24 +- src/integrations/terminal/TerminalProcess.ts | 67 +++- .../__tests__/TerminalProcessTimeout.spec.ts | 341 ++++++++++++++++++ src/integrations/terminal/types.ts | 9 +- src/shared/ExtensionMessage.ts | 2 + .../components/settings/TerminalSettings.tsx | 85 +++++ webview-ui/src/i18n/locales/en/settings.json | 10 + 12 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 src/integrations/terminal/__tests__/TerminalProcessTimeout.spec.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index a56a00fc35..70328ecb69 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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, diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index 2c7ce0d023..9b5f5f2994 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -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). diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2c20d0939c..42b4d4043c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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 ?? [], } } diff --git a/src/integrations/terminal/BaseTerminal.ts b/src/integrations/terminal/BaseTerminal.ts index a79d417b07..a8e0a0aace 100644 --- a/src/integrations/terminal/BaseTerminal.ts +++ b/src/integrations/terminal/BaseTerminal.ts @@ -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 diff --git a/src/integrations/terminal/ExecaTerminal.ts b/src/integrations/terminal/ExecaTerminal.ts index 652f3ca39e..08b99856f4 100644 --- a/src/integrations/terminal/ExecaTerminal.ts +++ b/src/integrations/terminal/ExecaTerminal.ts @@ -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((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) }) diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 8bf2072f3d..94193cfc9b 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -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((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.`) diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index eb0424fe8d..0435e774a5 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -16,6 +16,10 @@ import { Terminal } from "./Terminal" export class TerminalProcess extends BaseTerminalProcess { private terminalRef: WeakRef + 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 @@ -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) + }) + } } diff --git a/src/integrations/terminal/__tests__/TerminalProcessTimeout.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessTimeout.spec.ts new file mode 100644 index 0000000000..5838a67e30 --- /dev/null +++ b/src/integrations/terminal/__tests__/TerminalProcessTimeout.spec.ts @@ -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") + }) + }) +}) diff --git a/src/integrations/terminal/types.ts b/src/integrations/terminal/types.ts index 65d521ba6e..7ecf972242 100644 --- a/src/integrations/terminal/types.ts +++ b/src/integrations/terminal/types.ts @@ -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 | 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 { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 66f389f81c..5986ce15b8 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -235,6 +235,8 @@ export type ExtensionState = Pick< | "followupAutoApproveTimeoutMs" | "allowedCommands" | "deniedCommands" + | "commandMaxWaitTime" + | "autoSkippedCommands" | "allowedMaxRequests" | "allowedMaxCost" | "browserToolEnabled" diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index 833fe93dde..8eee7d93ea 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -28,6 +28,8 @@ type TerminalSettingsProps = HTMLAttributes & { terminalZshP10k?: boolean terminalZdotdir?: boolean terminalCompressProgressBar?: boolean + commandMaxWaitTime?: number + autoSkippedCommands?: string[] setCachedStateField: SetCachedStateField< | "terminalOutputLineLimit" | "terminalOutputCharacterLimit" @@ -40,6 +42,8 @@ type TerminalSettingsProps = HTMLAttributes & { | "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 = ({ +
+ +
+ setCachedStateField("commandMaxWaitTime", value)} + data-testid="terminal-command-max-wait-time-slider" + /> + {commandMaxWaitTime ?? 30}s +
+
+ + + {" "} + + +
+
+
+ +
+ {(autoSkippedCommands ?? []).map((command, index) => ( +
+ { + 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")} + /> + +
+ ))} + +
+
+ + + {" "} + + +
+
diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index aa3199e8e8..0fbe356134 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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" + }, + "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" + }, + "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", + "placeholder": "e.g., npm run dev, yarn start, python -m http.server", + "addButton": "Add Command Pattern" } }, "advancedSettings": {