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
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { isNumber } from '../../../../../../base/common/types.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import type { ICommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js';
import { ITerminalLogService, type ITerminalLaunchError } from '../../../../../../platform/terminal/common/terminal.js';
import { trackIdleOnPrompt, waitForIdle, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js';
import { trackIdleOnPrompt, waitForContinuationPrompt, waitForIdle, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js';
import type { IMarker as IXtermMarker } from '@xterm/xterm';
import { ITerminalInstance } from '../../../../terminal/browser/terminal.js';
import { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } from './strategyHelpers.js';
Expand Down Expand Up @@ -168,6 +168,12 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute
trackIdleOnPrompt(this._instance, idlePollInterval * 3, store, idlePollInterval, this._logService).then(() => {
this._log('onDone long idle prompt');
}),
// Detect shell continuation prompts (e.g. dquote>, quote>) that
// indicate unmatched quotes or brackets.
waitForContinuationPrompt(this._instance.onData, this._instance, idlePollInterval, store, this._logService).then(prompt => {
this._log(`onDone via continuation prompt: ${prompt}`);
return { type: 'continuationPrompt', prompt } as const;
}),
]);

// Ensure xterm is available
Expand Down Expand Up @@ -238,6 +244,16 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute
didEnterAltBuffer: true
};
}
if (onDoneResult && onDoneResult.type === 'continuationPrompt') {
this._log(`Aborting command due to continuation prompt: ${onDoneResult.prompt}`);
await this._instance.sendText('\x03', false);
await waitForIdle(this._instance.onData, 200);
return {
output: undefined,
exitCode: undefined,
error: `The command has a syntax error — the shell showed a "${onDoneResult.prompt}" continuation prompt, indicating unmatched quotes or brackets. Fix the quoting and retry.`,
};
}
const finishedCommand = onDoneResult && onDoneResult.type === 'success' ? onDoneResult.command : undefined;
if (finishedCommand) {
this._log(`Finished command id=${finishedCommand.id ?? 'none'} for requested=${commandId ?? 'none'}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,88 @@ export interface IPromptDetectionResult {
reason?: string;
}

/**
* Known shell continuation prompt patterns that indicate a command has
* unmatched quotes, brackets, or other syntax issues. These prompts appear
* when the shell is waiting for additional input to complete a construct.
*/
const continuationPromptPatterns: RegExp[] = [
/^dquote>\s*$/, // zsh: unmatched double quote
/^quote>\s*$/, // zsh: unmatched single quote
/^bquote>\s*$/, // zsh: unmatched backtick
/^pipe>\s*$/, // zsh: pipe continuation
/^cmdsubst>\s*$/, // zsh: command substitution
/^heredoc>\s*$/, // zsh: heredoc
];

/**
* Detects if the given text looks like a shell continuation prompt, indicating
* that a command has unmatched quotes or other syntax issues.
*/
export function isContinuationPrompt(cursorLine: string): boolean {
const trimmed = cursorLine.trim();
return continuationPromptPatterns.some(p => p.test(trimmed));
}

/**
* Monitors the terminal for shell continuation prompts (e.g. `dquote>`, `quote>`)
* which indicate that a command has unmatched quotes or brackets. Returns a promise
* that resolves with the prompt text when a continuation prompt is detected.
*
* Uses a longer idle window (2x the normal interval) and verifies the cursor is
* positioned right after the prompt text to reduce false positives from commands
* that happen to print continuation-prompt-like text in their output.
*/
export function waitForContinuationPrompt(
onData: Event<unknown>,
instance: ITerminalInstance,
idleDurationMs: number,
store: DisposableStore,
logService?: ITerminalLogService,
): Promise<string> {
const deferred = new DeferredPromise<string>();
// Require a longer idle period than normal prompt detection to reduce
// false positives from commands that briefly print prompt-like text.
const conservativeIdleMs = idleDurationMs * 2;

const checkForContinuationPrompt = async () => {
try {
const xterm = await instance.xtermReadyPromise;
if (!xterm) {
return;
}
const buffer = xterm.raw.buffer.active;
const cursorY = buffer.baseY + buffer.cursorY;
const line = buffer.getLine?.(cursorY);
if (line) {
const content = line.translateToString(true);
if (isContinuationPrompt(content)) {
// Verify the cursor is positioned at the end of the prompt
// (i.e. the shell is awaiting input), not mid-line in command output.
const trimmedLen = content.trimEnd().length;
if (buffer.cursorX <= trimmedLen) {
logService?.debug(`waitForContinuationPrompt: Detected continuation prompt "${content.trim()}"`);
deferred.complete(content.trim());
} else {
logService?.debug(`waitForContinuationPrompt: Cursor at column ${buffer.cursorX} beyond prompt end ${trimmedLen}, ignoring`);
}
}
}
} catch {
// Ignore errors reading terminal content
}
};

const scheduler = store.add(new RunOnceScheduler(() => {
void checkForContinuationPrompt();
}, conservativeIdleMs));

store.add(onData(() => scheduler.schedule()));
scheduler.schedule();

return deferred.p;
}
Comment thread
meganrogge marked this conversation as resolved.

/**
* Detects if the given text content appears to end with a common prompt pattern.
*/
Expand All @@ -66,6 +148,12 @@ export function detectsCommonPromptPattern(cursorLine: string): IPromptDetection
return { detected: false, reason: 'Content is empty or contains only whitespace' };
}

// Exclude shell continuation prompts (e.g. dquote>, quote>) — these
// indicate unmatched quotes, not a ready prompt.
if (isContinuationPrompt(cursorLine)) {
return { detected: false, reason: `Shell continuation prompt detected (not a ready prompt): "${cursorLine}"` };
}

// PowerShell prompt: PS C:\> or similar patterns
if (/PS\s+[A-Z]:\\.*>\s*$/.test(cursorLine)) {
return { detected: true, reason: `PowerShell prompt pattern detected: "${cursorLine}"` };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js';
import { waitForIdle, waitForIdleWithPromptHeuristics, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js';
import { waitForContinuationPrompt, waitForIdle, waitForIdleWithPromptHeuristics, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js';
import type { IMarker as IXtermMarker } from '@xterm/xterm';
import { ITerminalInstance } from '../../../../terminal/browser/terminal.js';
import { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } from './strategyHelpers.js';
Expand Down Expand Up @@ -128,11 +128,18 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS

// Assume the command is done when it's idle
this._log('Waiting for idle with prompt heuristics');
const promptResultOrAltBuffer = await Promise.race([
waitForIdleWithPromptHeuristics(this._instance.onData, this._instance, idlePollInterval, idlePollInterval * 10),
alternateBufferPromise.then(() => 'alternateBuffer' as const)
const continuationPromptPromise = waitForContinuationPrompt(this._instance.onData, this._instance, idlePollInterval, store, this._logService);
const promptResultOrAltBufferOrContinuation = await Promise.race([
waitForIdleWithPromptHeuristics(this._instance.onData, this._instance, idlePollInterval, idlePollInterval * 10).then(result => ({ type: 'prompt' as const, result })),
alternateBufferPromise.then(() => ({ type: 'alternateBuffer' as const })),
// Detect shell continuation prompts (e.g. dquote>, quote>) that
// indicate unmatched quotes or brackets.
continuationPromptPromise.then(prompt => {
this._log(`Detected continuation prompt: ${prompt}`);
return { type: 'continuationPrompt' as const, prompt };
}),
]);
if (promptResultOrAltBuffer === 'alternateBuffer') {
if (promptResultOrAltBufferOrContinuation.type === 'alternateBuffer') {
this._log('Detected alternate buffer entry, skipping output capture');
return {
output: undefined,
Expand All @@ -142,7 +149,17 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS
didEnterAltBuffer: true,
};
}
const promptResult = promptResultOrAltBuffer;
if (promptResultOrAltBufferOrContinuation.type === 'continuationPrompt') {
this._log(`Aborting command due to continuation prompt: ${promptResultOrAltBufferOrContinuation.prompt}`);
await this._instance.sendText('\x03', false);
await waitForIdle(this._instance.onData, 200);
return {
output: undefined,
exitCode: undefined,
error: `The command has a syntax error — the shell showed a "${promptResultOrAltBufferOrContinuation.prompt}" continuation prompt, indicating unmatched quotes or brackets. Fix the quoting and retry.`,
};
}
const promptResult = promptResultOrAltBufferOrContinuation.result;
this._log(`Prompt detection result: ${promptResult.detected ? 'detected' : 'not detected'} - ${promptResult.reason}`);

if (token.isCancellationRequested) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { isCI, isMacintosh } from '../../../../../../base/common/platform.js';
import type { ICommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js';
import { ITerminalLogService, type ITerminalLaunchError } from '../../../../../../platform/terminal/common/terminal.js';
import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js';
import { trackIdleOnPrompt, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js';
import { trackIdleOnPrompt, waitForContinuationPrompt, waitForIdle, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js';
import type { IMarker as IXtermMarker } from '@xterm/xterm';
import { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } from './strategyHelpers.js';
import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js';
Expand Down Expand Up @@ -154,6 +154,13 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS
this._log('onDone via idle prompt');
}),
] : []),
// Detect shell continuation prompts (e.g. dquote>, quote>) that
// indicate unmatched quotes or brackets. Without this, the terminal
// hangs indefinitely because no command-finished event fires.
waitForContinuationPrompt(this._instance.onData, this._instance, idlePollInterval, store, this._logService).then(prompt => {
this._log(`onDone via continuation prompt: ${prompt}`);
return { type: 'continuationPrompt', prompt } as const;
}),
]);

// Ensure xterm is available
Expand Down Expand Up @@ -193,6 +200,16 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS
didEnterAltBuffer: true
};
}
if (onDoneResult && onDoneResult.type === 'continuationPrompt') {
this._log(`Aborting command due to continuation prompt: ${onDoneResult.prompt}`);
await this._instance.sendText('\x03', false);
await waitForIdle(this._instance.onData, 200);
return {
output: undefined,
exitCode: undefined,
error: `The command has a syntax error — the shell showed a "${onDoneResult.prompt}" continuation prompt, indicating unmatched quotes or brackets. Fix the quoting and retry.`,
};
}
const finishedCommand = onDoneResult && onDoneResult.type === 'success' ? onDoneResult.command : undefined;

if (token.isCancellationRequested) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

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

suite('Execute Strategy - Prompt Detection', () => {
ensureNoDisposablesAreLeakedInTestSuite();
Expand Down Expand Up @@ -68,4 +68,52 @@ user@host:~$ `;
strictEqual(detectsCommonPromptPattern('\n\n$ \n\n').detected, true); // prompt with surrounding whitespace
strictEqual(detectsCommonPromptPattern('output\nPS C:\\> ').detected, true); // prompt at end after output
});

test('detectsCommonPromptPattern should reject shell continuation prompts', () => {
strictEqual(detectsCommonPromptPattern('dquote>').detected, false);
strictEqual(detectsCommonPromptPattern('dquote> ').detected, false);
strictEqual(detectsCommonPromptPattern('quote>').detected, false);
strictEqual(detectsCommonPromptPattern('bquote>').detected, false);
strictEqual(detectsCommonPromptPattern('pipe>').detected, false);
strictEqual(detectsCommonPromptPattern('heredoc>').detected, false);
strictEqual(detectsCommonPromptPattern('cmdsubst>').detected, false);
});
});

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

test('isContinuationPrompt should detect zsh continuation prompts', () => {
strictEqual(isContinuationPrompt('dquote>'), true);
strictEqual(isContinuationPrompt('dquote> '), true);
strictEqual(isContinuationPrompt('quote>'), true);
strictEqual(isContinuationPrompt('bquote>'), true);
strictEqual(isContinuationPrompt('pipe>'), true);
strictEqual(isContinuationPrompt('heredoc>'), true);
strictEqual(isContinuationPrompt('cmdsubst>'), true);
});

test('isContinuationPrompt should handle whitespace', () => {
strictEqual(isContinuationPrompt(' dquote> '), true);
strictEqual(isContinuationPrompt('\tdquote>\t'), true);
});

test('isContinuationPrompt should reject normal prompts', () => {
strictEqual(isContinuationPrompt('$ '), false);
strictEqual(isContinuationPrompt('# '), false);
strictEqual(isContinuationPrompt('user@host:~$ '), false);
strictEqual(isContinuationPrompt('PS C:\\>'), false);
});

test('isContinuationPrompt should reject command output', () => {
strictEqual(isContinuationPrompt('some output'), false);
strictEqual(isContinuationPrompt('error: command not found'), false);
strictEqual(isContinuationPrompt(''), false);
strictEqual(isContinuationPrompt(' '), false);
});

test('isContinuationPrompt should reject partial matches', () => {
strictEqual(isContinuationPrompt('some dquote>'), false);
strictEqual(isContinuationPrompt('dquote> some text'), false);
});
});
Loading