| 
1 | 1 | import * as path from 'path';  | 
2 |  | -import { Terminal, TerminalOptions, Uri } from 'vscode';  | 
 | 2 | +import { Disposable, env, Terminal, TerminalOptions, Uri } from 'vscode';  | 
3 | 3 | import { PythonEnvironment, PythonProject, PythonProjectEnvironmentApi, PythonProjectGetterApi } from '../../api';  | 
4 |  | -import { sleep } from '../../common/utils/asyncUtils';  | 
 | 4 | +import { timeout } from '../../common/utils/asyncUtils';  | 
 | 5 | +import { createSimpleDebounce } from '../../common/utils/debounce';  | 
 | 6 | +import { onDidChangeTerminalShellIntegration, onDidWriteTerminalData } from '../../common/window.apis';  | 
5 | 7 | import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.apis';  | 
6 | 8 | 
 
  | 
7 | 9 | export const SHELL_INTEGRATION_TIMEOUT = 500; // 0.5 seconds  | 
8 | 10 | export const SHELL_INTEGRATION_POLL_INTERVAL = 20; // 0.02 seconds  | 
9 | 11 | 
 
  | 
 | 12 | +/**  | 
 | 13 | + * Three conditions in a Promise.race:  | 
 | 14 | + * 1. Timeout based on VS Code's terminal.integrated.shellIntegration.timeout setting  | 
 | 15 | + * 2. Shell integration becoming available (window.onDidChangeTerminalShellIntegration event)  | 
 | 16 | + * 3. Detection of common prompt patterns in terminal output  | 
 | 17 | + */  | 
10 | 18 | export async function waitForShellIntegration(terminal: Terminal): Promise<boolean> {  | 
11 |  | -    let timeout = 0;  | 
12 |  | -    while (!terminal.shellIntegration && timeout < SHELL_INTEGRATION_TIMEOUT) {  | 
13 |  | -        await sleep(SHELL_INTEGRATION_POLL_INTERVAL);  | 
14 |  | -        timeout += SHELL_INTEGRATION_POLL_INTERVAL;  | 
 | 19 | +    if (terminal.shellIntegration) {  | 
 | 20 | +        return true;  | 
 | 21 | +    }  | 
 | 22 | + | 
 | 23 | +    const config = getConfiguration('terminal.integrated');  | 
 | 24 | +    const shellIntegrationEnabled = config.get<boolean>('shellIntegration.enabled', true);  | 
 | 25 | +    const timeoutValue = config.get<number | undefined>('shellIntegration.timeout');  | 
 | 26 | +    const isRemote = env.remoteName !== undefined;  | 
 | 27 | +    let timeoutMs: number;  | 
 | 28 | +    if (typeof timeoutValue !== 'number' || timeoutValue < 0) {  | 
 | 29 | +        timeoutMs = shellIntegrationEnabled ? 5000 : isRemote ? 3000 : 2000;  | 
 | 30 | +    } else {  | 
 | 31 | +        timeoutMs = Math.max(timeoutValue, 500);  | 
 | 32 | +    }  | 
 | 33 | + | 
 | 34 | +    const disposables: Disposable[] = [];  | 
 | 35 | + | 
 | 36 | +    try {  | 
 | 37 | +        const result = await Promise.race([  | 
 | 38 | +            // Condition 1: Shell integration timeout setting  | 
 | 39 | +            timeout(timeoutMs).then(() => false),  | 
 | 40 | + | 
 | 41 | +            // Condition 2: Shell integration becomes available  | 
 | 42 | +            new Promise<boolean>((resolve) => {  | 
 | 43 | +                disposables.push(  | 
 | 44 | +                    onDidChangeTerminalShellIntegration((e) => {  | 
 | 45 | +                        if (e.terminal === terminal) {  | 
 | 46 | +                            resolve(true);  | 
 | 47 | +                        }  | 
 | 48 | +                    }),  | 
 | 49 | +                );  | 
 | 50 | +            }),  | 
 | 51 | + | 
 | 52 | +            // Condition 3: Detect prompt patterns in terminal output  | 
 | 53 | +            new Promise<boolean>((resolve) => {  | 
 | 54 | +                const dataEvents: string[] = [];  | 
 | 55 | +                const debounced = createSimpleDebounce(50, () => {  | 
 | 56 | +                    if (dataEvents && detectsCommonPromptPattern(dataEvents.join(''))) {  | 
 | 57 | +                        resolve(false);  | 
 | 58 | +                    }  | 
 | 59 | +                });  | 
 | 60 | +                disposables.push(debounced);  | 
 | 61 | +                disposables.push(  | 
 | 62 | +                    onDidWriteTerminalData((e) => {  | 
 | 63 | +                        if (e.terminal === terminal) {  | 
 | 64 | +                            dataEvents.push(e.data);  | 
 | 65 | +                            debounced.trigger();  | 
 | 66 | +                        }  | 
 | 67 | +                    }),  | 
 | 68 | +                );  | 
 | 69 | +            }),  | 
 | 70 | +        ]);  | 
 | 71 | + | 
 | 72 | +        return result;  | 
 | 73 | +    } finally {  | 
 | 74 | +        disposables.forEach((d) => d.dispose());  | 
15 | 75 |     }  | 
16 |  | -    return terminal.shellIntegration !== undefined;  | 
 | 76 | +}  | 
 | 77 | + | 
 | 78 | +// Detects if the given text content appears to end with a common prompt pattern.  | 
 | 79 | +function detectsCommonPromptPattern(terminalData: string): boolean {  | 
 | 80 | +    if (terminalData.trim().length === 0) {  | 
 | 81 | +        return false;  | 
 | 82 | +    }  | 
 | 83 | + | 
 | 84 | +    const sanitizedTerminalData = removeAnsiEscapeCodes(terminalData);  | 
 | 85 | +    // PowerShell prompt: PS C:\> or similar patterns  | 
 | 86 | +    if (/PS\s+[A-Z]:\\.*>\s*$/.test(sanitizedTerminalData)) {  | 
 | 87 | +        return true;  | 
 | 88 | +    }  | 
 | 89 | + | 
 | 90 | +    // Command Prompt: C:\path>  | 
 | 91 | +    if (/^[A-Z]:\\.*>\s*$/.test(sanitizedTerminalData)) {  | 
 | 92 | +        return true;  | 
 | 93 | +    }  | 
 | 94 | + | 
 | 95 | +    // Bash-style prompts ending with $  | 
 | 96 | +    if (/\$\s*$/.test(sanitizedTerminalData)) {  | 
 | 97 | +        return true;  | 
 | 98 | +    }  | 
 | 99 | + | 
 | 100 | +    // Root prompts ending with #  | 
 | 101 | +    if (/#\s*$/.test(sanitizedTerminalData)) {  | 
 | 102 | +        return true;  | 
 | 103 | +    }  | 
 | 104 | + | 
 | 105 | +    // Python REPL prompt  | 
 | 106 | +    if (/^>>>\s*$/.test(sanitizedTerminalData)) {  | 
 | 107 | +        return true;  | 
 | 108 | +    }  | 
 | 109 | + | 
 | 110 | +    // Custom prompts ending with the starship character (\u276f)  | 
 | 111 | +    if (/\u276f\s*$/.test(sanitizedTerminalData)) {  | 
 | 112 | +        return true;  | 
 | 113 | +    }  | 
 | 114 | + | 
 | 115 | +    // Generic prompts ending with common prompt characters  | 
 | 116 | +    if (/[>%]\s*$/.test(sanitizedTerminalData)) {  | 
 | 117 | +        return true;  | 
 | 118 | +    }  | 
 | 119 | + | 
 | 120 | +    return false;  | 
17 | 121 | }  | 
18 | 122 | 
 
  | 
19 | 123 | export function isTaskTerminal(terminal: Terminal): boolean {  | 
@@ -171,3 +275,28 @@ export async function getAllDistinctProjectEnvironments(  | 
171 | 275 | 
 
  | 
172 | 276 |     return envs.length > 0 ? envs : undefined;  | 
173 | 277 | }  | 
 | 278 | + | 
 | 279 | +// Defacto standard: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html  | 
 | 280 | +const CSI_SEQUENCE = /(?:\x1b\[|\x9b)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~]/;  | 
 | 281 | +const OSC_SEQUENCE = /(?:\x1b\]|\x9d).*?(?:\x1b\\|\x07|\x9c)/;  | 
 | 282 | +const ESC_SEQUENCE = /\x1b(?:[ #%\(\)\*\+\-\.\/]?[a-zA-Z0-9\|}~@])/;  | 
 | 283 | +const CONTROL_SEQUENCES = new RegExp(  | 
 | 284 | +    '(?:' + [CSI_SEQUENCE.source, OSC_SEQUENCE.source, ESC_SEQUENCE.source].join('|') + ')',  | 
 | 285 | +    'g',  | 
 | 286 | +);  | 
 | 287 | + | 
 | 288 | +/**  | 
 | 289 | + * Strips ANSI escape sequences from a string.  | 
 | 290 | + * @param str The dastringa stringo strip the ANSI escape sequences from.  | 
 | 291 | + *  | 
 | 292 | + * @example  | 
 | 293 | + * removeAnsiEscapeCodes('\u001b[31mHello, World!\u001b[0m');  | 
 | 294 | + * // 'Hello, World!'  | 
 | 295 | + */  | 
 | 296 | +export function removeAnsiEscapeCodes(str: string): string {  | 
 | 297 | +    if (str) {  | 
 | 298 | +        str = str.replace(CONTROL_SEQUENCES, '');  | 
 | 299 | +    }  | 
 | 300 | + | 
 | 301 | +    return str;  | 
 | 302 | +}  | 
0 commit comments