Skip to content

Commit 46f4a28

Browse files
Merge branch 'main' into issue/368
2 parents c936852 + 2c987fc commit 46f4a28

4 files changed

Lines changed: 500 additions & 19 deletions

File tree

src/core/tools/ExecuteCommandTool.ts

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ export function canRetryShellIntegrationError(error: unknown): error is ShellInt
3535
return error instanceof ShellIntegrationError && !error.commandSubmitted
3636
}
3737

38+
/**
39+
* Grace period before a foreground command may trigger a `command_output` ask.
40+
* Short commands that emit output and exit within this window never prompt the
41+
* user; the ask only fires when the command is still running once the delay
42+
* elapses, so users can still interrupt or provide feedback on long-running
43+
* commands.
44+
*/
45+
export const COMMAND_OUTPUT_ASK_DELAY_MS = 5_000
46+
3847
export function getTerminalProviderForExecution(terminalShellIntegrationDisabled: boolean): {
3948
terminalProvider: RooTerminalProvider
4049
isCmdExeFallback: boolean
@@ -340,6 +349,58 @@ export async function executeCommandInTerminal(
340349
resolveOnCompleted = resolve
341350
})
342351

352+
// Delay the `command_output` ask so short foreground commands that emit
353+
// output and exit normally never prompt the user. The ask only fires if the
354+
// command is still running once COMMAND_OUTPUT_ASK_DELAY_MS has elapsed
355+
// since execution started, preserving the interrupt/feedback path for
356+
// long-running commands. The anchor is re-based to onShellExecutionStarted
357+
// (falling back to the pre-runCommand timestamp when that event never
358+
// fires) so shell-integration startup on cold terminals does not consume
359+
// the grace period.
360+
let commandStartedAt = 0
361+
let commandOutputAskTimer: NodeJS.Timeout | undefined
362+
363+
const askForCommandOutput = async (process: RooTerminalProcess): Promise<void> => {
364+
if (runInBackground || hasAskedForCommandOutput || completed) {
365+
return
366+
}
367+
368+
// Mark that we've asked to prevent multiple concurrent asks
369+
hasAskedForCommandOutput = true
370+
371+
try {
372+
const { response, text, images } = await task.ask("command_output", "")
373+
runInBackground = true
374+
375+
if (response === "messageResponse") {
376+
message = { text, images }
377+
}
378+
379+
// Any answer means the command should keep running in the background;
380+
// continue the process so the tool resolves now instead of blocking
381+
// until the command actually completes.
382+
process.continue()
383+
} catch (_error) {
384+
// Silently handle ask errors (e.g., "Current ask promise was ignored")
385+
}
386+
}
387+
388+
const scheduleCommandOutputAsk = (process: RooTerminalProcess): void => {
389+
if (runInBackground || hasAskedForCommandOutput || completed || commandOutputAskTimer) {
390+
return
391+
}
392+
393+
const remainingDelay = COMMAND_OUTPUT_ASK_DELAY_MS - (Date.now() - commandStartedAt)
394+
395+
commandOutputAskTimer = setTimeout(
396+
() => {
397+
commandOutputAskTimer = undefined
398+
void askForCommandOutput(process)
399+
},
400+
Math.max(remainingDelay, 0),
401+
)
402+
}
403+
343404
const callbacks: RooTerminalCallbacks = {
344405
onLine: async (lines: string, process: RooTerminalProcess) => {
345406
accumulatedOutput += lines
@@ -359,26 +420,19 @@ export async function executeCommandInTerminal(
359420
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
360421
schedulePartialCommandOutputUpdate()
361422

362-
if (runInBackground || hasAskedForCommandOutput) {
363-
return
364-
}
365-
366-
// Mark that we've asked to prevent multiple concurrent asks
367-
hasAskedForCommandOutput = true
368-
369-
try {
370-
const { response, text, images } = await task.ask("command_output", "")
371-
runInBackground = true
372-
373-
if (response === "messageResponse") {
374-
message = { text, images }
375-
process.continue()
376-
}
377-
} catch (_error) {
378-
// Silently handle ask errors (e.g., "Current ask promise was ignored")
379-
}
423+
scheduleCommandOutputAsk(process)
380424
},
381425
onCompleted: async (output: string | undefined) => {
426+
clearTimeout(commandOutputAskTimer)
427+
commandOutputAskTimer = undefined
428+
429+
// If an interactive command_output ask is still pending, supersede it
430+
// so it resolves immediately instead of lingering until the next
431+
// interactive message bumps lastMessageTs.
432+
if (hasAskedForCommandOutput && !runInBackground) {
433+
task.supersedePendingAsk()
434+
}
435+
382436
clearTimeout(pendingCommandOutputEmitTimer)
383437
pendingCommandOutputEmitTimer = undefined
384438

@@ -412,9 +466,21 @@ export async function executeCommandInTerminal(
412466
console.error("[ExecuteCommandTool] Failed to flush final command_output:", error)
413467
})
414468
},
415-
onShellExecutionStarted: (pid: number | undefined) => {
469+
onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => {
416470
const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
417471
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
472+
473+
// Re-anchor the ask delay to actual execution start so the shell
474+
// integration startup wait does not count against the grace period.
475+
commandStartedAt = Date.now()
476+
477+
// Output should not precede this event, but if it did, reschedule
478+
// the pending ask against the corrected anchor.
479+
if (commandOutputAskTimer) {
480+
clearTimeout(commandOutputAskTimer)
481+
commandOutputAskTimer = undefined
482+
scheduleCommandOutputAsk(process)
483+
}
418484
},
419485
onShellExecutionComplete: (details: ExitCodeDetails) => {
420486
const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode }
@@ -441,6 +507,8 @@ export async function executeCommandInTerminal(
441507
workingDir = terminal.getCurrentWorkingDirectory()
442508
}
443509

510+
// Fallback anchor for providers that never fire onShellExecutionStarted.
511+
commandStartedAt = Date.now()
444512
const process = terminal.runCommand(command, callbacks)
445513
task.terminalProcess = process
446514

@@ -462,6 +530,8 @@ export async function executeCommandInTerminal(
462530
new Promise<void>((resolve) => {
463531
agentTimeoutId = setTimeout(() => {
464532
runInBackground = true
533+
clearTimeout(commandOutputAskTimer)
534+
commandOutputAskTimer = undefined
465535
process.continue()
466536
task.supersedePendingAsk()
467537
resolve()
@@ -501,6 +571,7 @@ export async function executeCommandInTerminal(
501571
} finally {
502572
clearTimeout(agentTimeoutId)
503573
clearTimeout(userTimeoutId)
574+
clearTimeout(commandOutputAskTimer)
504575
clearTimeout(pendingCommandOutputEmitTimer)
505576
task.terminalProcess = undefined
506577
}

0 commit comments

Comments
 (0)