Skip to content

Add prompt detection heuristics for no shell integration command execution timeout #259567

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 5, 2025
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,115 @@ export async function waitForIdle(onData: Event<unknown>, idleDurationMs: number
return deferred.p.finally(() => store.dispose());
}

export interface IPromptDetectionResult {
/**
* Whether a prompt was detected.
*/
detected: boolean;
/**
* The reason for logging.
*/
reason?: string;
}

/**
* Detects if the given text content appears to end with a common prompt pattern.
*/
export function detectsCommonPromptPattern(cursorLine: string): IPromptDetectionResult {
if (cursorLine.trim().length === 0) {
return { detected: false, reason: 'Content is empty or contains only whitespace' };
}

// PowerShell prompt: PS C:\> or similar patterns
if (/PS\s+[A-Z]:\\.*>\s*$/.test(cursorLine)) {
return { detected: true, reason: `PowerShell prompt pattern detected: "${cursorLine}"` };
}

// Command Prompt: C:\path>
if (/^[A-Z]:\\.*>\s*$/.test(cursorLine)) {
return { detected: true, reason: `Command Prompt pattern detected: "${cursorLine}"` };
}

// Bash-style prompts ending with $
if (/\$\s*$/.test(cursorLine)) {
return { detected: true, reason: `Bash-style prompt pattern detected: "${cursorLine}"` };
}

// Root prompts ending with #
if (/#\s*$/.test(cursorLine)) {
return { detected: true, reason: `Root prompt pattern detected: "${cursorLine}"` };
}

// Python REPL prompt
if (/^>>>\s*$/.test(cursorLine)) {
return { detected: true, reason: `Python REPL prompt pattern detected: "${cursorLine}"` };
}

// Custom prompts ending with the starship character (\u276f)
if (/\u276f\s*$/.test(cursorLine)) {
return { detected: true, reason: `Starship prompt pattern detected: "${cursorLine}"` };
}

// Generic prompts ending with common prompt characters
if (/[>%]\s*$/.test(cursorLine)) {
return { detected: true, reason: `Generic prompt pattern detected: "${cursorLine}"` };
}

return { detected: false, reason: `No common prompt pattern found in last line: "${cursorLine}"` };
}

/**
* Enhanced version of {@link waitForIdle} that uses prompt detection heuristics. After the terminal
* idles for the specified period, checks if the terminal's cursor line looks like a common prompt.
* If not, extends the timeout to give the command more time to complete.
*/
export async function waitForIdleWithPromptHeuristics(
onData: Event<unknown>,
instance: ITerminalInstance,
idlePollIntervalMs: number,
extendedTimeoutMs: number,
): Promise<IPromptDetectionResult> {
await waitForIdle(onData, idlePollIntervalMs);

const xterm = await instance.xtermReadyPromise;
if (!xterm) {
return { detected: false, reason: `Xterm not available, using ${idlePollIntervalMs}ms timeout` };
}
const startTime = Date.now();

// Attempt to detect a prompt pattern after idle
while (Date.now() - startTime < extendedTimeoutMs) {
try {
let content = '';
const buffer = xterm.raw.buffer.active;
const line = buffer.getLine(buffer.baseY + buffer.cursorY);
if (line) {
content = line.translateToString(true);
}
const promptResult = detectsCommonPromptPattern(content);
if (promptResult.detected) {
return promptResult;
}
} catch (error) {
// Continue polling even if there's an error reading terminal content
}
await waitForIdle(onData, Math.min(idlePollIntervalMs, extendedTimeoutMs - (Date.now() - startTime)));
}

// Extended timeout reached without detecting a prompt
try {
let content = '';
const buffer = xterm.raw.buffer.active;
const line = buffer.getLine(buffer.baseY + buffer.cursorY);
if (line) {
content = line.translateToString(true) + '\n';
}
return { detected: false, reason: `Extended timeout reached without prompt detection. Last line: "${content.trim()}"` };
} catch (error) {
return { detected: false, reason: `Extended timeout reached. Error reading terminal content: ${error}` };
}
}

/**
* Tracks the terminal for being idle on a prompt input. This must be called before `executeCommand`
* is called.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { CancellationError } from '../../../../../../base/common/errors.js';
import { DisposableStore } from '../../../../../../base/common/lifecycle.js';
import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js';
import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js';
import { waitForIdle, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js';
import { waitForIdle, waitForIdleWithPromptHeuristics, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js';

/**
* This strategy is used when no shell integration is available. There are very few extension APIs
Expand Down Expand Up @@ -63,8 +63,9 @@ export class NoneExecuteStrategy implements ITerminalExecuteStrategy {
this._instance.sendText(commandLine, true);

// Assume the command is done when it's idle
this._log('Waiting for idle');
await waitForIdle(this._instance.onData, 1000);
this._log('Waiting for idle with prompt heuristics');
const promptResult = await waitForIdleWithPromptHeuristics(this._instance.onData, this._instance, 1000, 10000);
this._log(`Prompt detection result: ${promptResult.detected ? 'detected' : 'not detected'} - ${promptResult.reason}`);
if (token.isCancellationRequested) {
throw new CancellationError();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { strictEqual } from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { detectsCommonPromptPattern } from '../../browser/executeStrategy/executeStrategy.js';

suite('Execute Strategy - Prompt Detection', () => {
ensureNoDisposablesAreLeakedInTestSuite();

test('detectsCommonPromptPattern should detect PowerShell prompts', () => {
strictEqual(detectsCommonPromptPattern('PS C:\\>').detected, true);
strictEqual(detectsCommonPromptPattern('PS C:\\Windows\\System32>').detected, true);
strictEqual(detectsCommonPromptPattern('PS C:\\Users\\test> ').detected, true);
});

test('detectsCommonPromptPattern should detect Command Prompt', () => {
strictEqual(detectsCommonPromptPattern('C:\\>').detected, true);
strictEqual(detectsCommonPromptPattern('C:\\Windows\\System32>').detected, true);
strictEqual(detectsCommonPromptPattern('D:\\test> ').detected, true);
});

test('detectsCommonPromptPattern should detect Bash prompts', () => {
strictEqual(detectsCommonPromptPattern('user@host:~$ ').detected, true);
strictEqual(detectsCommonPromptPattern('$ ').detected, true);
strictEqual(detectsCommonPromptPattern('[user@host ~]$ ').detected, true);
});

test('detectsCommonPromptPattern should detect root prompts', () => {
strictEqual(detectsCommonPromptPattern('root@host:~# ').detected, true);
strictEqual(detectsCommonPromptPattern('# ').detected, true);
strictEqual(detectsCommonPromptPattern('[root@host ~]# ').detected, true);
});

test('detectsCommonPromptPattern should detect Python REPL', () => {
strictEqual(detectsCommonPromptPattern('>>> ').detected, true);
strictEqual(detectsCommonPromptPattern('>>>').detected, true);
});

test('detectsCommonPromptPattern should detect starship prompts', () => {
strictEqual(detectsCommonPromptPattern('~ \u276f ').detected, true);
strictEqual(detectsCommonPromptPattern('/path/to/project \u276f').detected, true);
});

test('detectsCommonPromptPattern should detect generic prompts', () => {
strictEqual(detectsCommonPromptPattern('test> ').detected, true);
strictEqual(detectsCommonPromptPattern('someprompt% ').detected, true);
});

test('detectsCommonPromptPattern should handle multiline content', () => {
const multilineContent = `command output line 1
command output line 2
user@host:~$ `;
strictEqual(detectsCommonPromptPattern(multilineContent).detected, true);
});

test('detectsCommonPromptPattern should reject non-prompt content', () => {
strictEqual(detectsCommonPromptPattern('just some output').detected, false);
strictEqual(detectsCommonPromptPattern('error: command not found').detected, false);
strictEqual(detectsCommonPromptPattern('').detected, false);
strictEqual(detectsCommonPromptPattern(' ').detected, false);
});

test('detectsCommonPromptPattern should handle edge cases', () => {
strictEqual(detectsCommonPromptPattern('output\n\n\n').detected, false);
strictEqual(detectsCommonPromptPattern('\n\n$ \n\n').detected, true); // prompt with surrounding whitespace
strictEqual(detectsCommonPromptPattern('output\nPS C:\\> ').detected, true); // prompt at end after output
});
});
Loading