diff --git a/package.json b/package.json index 0ae1c9a7..46a8e66d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "Other" ], "enabledApiProposals": [ - "terminalShellEnv" + "terminalShellEnv", + "terminalDataWriteEvent" ], "capabilities": { "untrustedWorkspaces": { diff --git a/src/common/utils/asyncUtils.ts b/src/common/utils/asyncUtils.ts index 4bb79f84..02bb265c 100644 --- a/src/common/utils/asyncUtils.ts +++ b/src/common/utils/asyncUtils.ts @@ -1,3 +1,3 @@ -export async function sleep(milliseconds: number) { +export async function timeout(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/src/common/utils/debounce.ts b/src/common/utils/debounce.ts index 1b197063..3885e00f 100644 --- a/src/common/utils/debounce.ts +++ b/src/common/utils/debounce.ts @@ -1,13 +1,14 @@ -export interface SimpleDebounce { +import { Disposable } from 'vscode'; +export interface SimpleDebounce extends Disposable { trigger(): void; } -class SimpleDebounceImpl { +class SimpleDebounceImpl implements SimpleDebounce { private timeout: NodeJS.Timeout | undefined; constructor(private readonly ms: number, private readonly callback: () => void) {} - public trigger() { + public trigger(): void { if (this.timeout) { clearTimeout(this.timeout); } @@ -15,6 +16,12 @@ class SimpleDebounceImpl { this.callback(); }, this.ms); } + + public dispose(): void { + if (this.timeout) { + clearTimeout(this.timeout); + } + } } export function createSimpleDebounce(ms: number, callback: () => void): SimpleDebounce { diff --git a/src/common/window.apis.ts b/src/common/window.apis.ts index 51a20320..26af79d5 100644 --- a/src/common/window.apis.ts +++ b/src/common/window.apis.ts @@ -49,6 +49,14 @@ export function onDidChangeTerminalShellIntegration( return window.onDidChangeTerminalShellIntegration(listener, thisArgs, disposables); } +export function onDidWriteTerminalData( + listener: (e: { readonly terminal: Terminal; readonly data: string }) => any, + thisArgs?: any, + disposables?: Disposable[], +): Disposable { + return window.onDidWriteTerminalData(listener, thisArgs, disposables); +} + export function showOpenDialog(options?: OpenDialogOptions): Thenable { return window.showOpenDialog(options); } diff --git a/src/features/terminal/shells/common/shellUtils.ts b/src/features/terminal/shells/common/shellUtils.ts index 381be880..0d74e31e 100644 --- a/src/features/terminal/shells/common/shellUtils.ts +++ b/src/features/terminal/shells/common/shellUtils.ts @@ -1,7 +1,7 @@ import { PythonCommandRunConfiguration, PythonEnvironment } from '../../../../api'; import { traceInfo } from '../../../../common/logging'; import { getGlobalPersistentState } from '../../../../common/persistentState'; -import { sleep } from '../../../../common/utils/asyncUtils'; +import { timeout } from '../../../../common/utils/asyncUtils'; import { isWindows } from '../../../../common/utils/platformUtils'; import { activeTerminalShellIntegration } from '../../../../common/window.apis'; import { getConfiguration } from '../../../../common/workspace.apis'; @@ -106,11 +106,11 @@ export function extractProfilePath(content: string): string | undefined { export async function shellIntegrationForActiveTerminal(name: string, profile?: string): Promise { let hasShellIntegration = activeTerminalShellIntegration(); - let timeout = 0; + let timeOutstamp = 0; - while (!hasShellIntegration && timeout < SHELL_INTEGRATION_TIMEOUT) { - await sleep(SHELL_INTEGRATION_POLL_INTERVAL); - timeout += SHELL_INTEGRATION_POLL_INTERVAL; + while (!hasShellIntegration && timeOutstamp < SHELL_INTEGRATION_TIMEOUT) { + await timeout(SHELL_INTEGRATION_POLL_INTERVAL); + timeOutstamp += SHELL_INTEGRATION_POLL_INTERVAL; hasShellIntegration = activeTerminalShellIntegration(); } diff --git a/src/features/terminal/utils.ts b/src/features/terminal/utils.ts index c8f6443d..bca60e08 100644 --- a/src/features/terminal/utils.ts +++ b/src/features/terminal/utils.ts @@ -1,19 +1,123 @@ import * as path from 'path'; -import { Terminal, TerminalOptions, Uri } from 'vscode'; +import { Disposable, env, Terminal, TerminalOptions, Uri } from 'vscode'; import { PythonEnvironment, PythonProject, PythonProjectEnvironmentApi, PythonProjectGetterApi } from '../../api'; -import { sleep } from '../../common/utils/asyncUtils'; +import { timeout } from '../../common/utils/asyncUtils'; +import { createSimpleDebounce } from '../../common/utils/debounce'; +import { onDidChangeTerminalShellIntegration, onDidWriteTerminalData } from '../../common/window.apis'; import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.apis'; export const SHELL_INTEGRATION_TIMEOUT = 500; // 0.5 seconds export const SHELL_INTEGRATION_POLL_INTERVAL = 20; // 0.02 seconds +/** + * Three conditions in a Promise.race: + * 1. Timeout based on VS Code's terminal.integrated.shellIntegration.timeout setting + * 2. Shell integration becoming available (window.onDidChangeTerminalShellIntegration event) + * 3. Detection of common prompt patterns in terminal output + */ export async function waitForShellIntegration(terminal: Terminal): Promise { - let timeout = 0; - while (!terminal.shellIntegration && timeout < SHELL_INTEGRATION_TIMEOUT) { - await sleep(SHELL_INTEGRATION_POLL_INTERVAL); - timeout += SHELL_INTEGRATION_POLL_INTERVAL; + if (terminal.shellIntegration) { + return true; + } + + const config = getConfiguration('terminal.integrated'); + const shellIntegrationEnabled = config.get('shellIntegration.enabled', true); + const timeoutValue = config.get('shellIntegration.timeout'); + const isRemote = env.remoteName !== undefined; + let timeoutMs: number; + if (typeof timeoutValue !== 'number' || timeoutValue < 0) { + timeoutMs = shellIntegrationEnabled ? 5000 : isRemote ? 3000 : 2000; + } else { + timeoutMs = Math.max(timeoutValue, 500); + } + + const disposables: Disposable[] = []; + + try { + const result = await Promise.race([ + // Condition 1: Shell integration timeout setting + timeout(timeoutMs).then(() => false), + + // Condition 2: Shell integration becomes available + new Promise((resolve) => { + disposables.push( + onDidChangeTerminalShellIntegration((e) => { + if (e.terminal === terminal) { + resolve(true); + } + }), + ); + }), + + // Condition 3: Detect prompt patterns in terminal output + new Promise((resolve) => { + const dataEvents: string[] = []; + const debounced = createSimpleDebounce(50, () => { + if (dataEvents && detectsCommonPromptPattern(dataEvents.join(''))) { + resolve(false); + } + }); + disposables.push(debounced); + disposables.push( + onDidWriteTerminalData((e) => { + if (e.terminal === terminal) { + dataEvents.push(e.data); + debounced.trigger(); + } + }), + ); + }), + ]); + + return result; + } finally { + disposables.forEach((d) => d.dispose()); } - return terminal.shellIntegration !== undefined; +} + +// Detects if the given text content appears to end with a common prompt pattern. +function detectsCommonPromptPattern(terminalData: string): boolean { + if (terminalData.trim().length === 0) { + return false; + } + + const sanitizedTerminalData = removeAnsiEscapeCodes(terminalData); + // PowerShell prompt: PS C:\> or similar patterns + if (/PS\s+[A-Z]:\\.*>\s*$/.test(sanitizedTerminalData)) { + return true; + } + + // Command Prompt: C:\path> + if (/^[A-Z]:\\.*>\s*$/.test(sanitizedTerminalData)) { + return true; + } + + // Bash-style prompts ending with $ + if (/\$\s*$/.test(sanitizedTerminalData)) { + return true; + } + + // Root prompts ending with # + if (/#\s*$/.test(sanitizedTerminalData)) { + return true; + } + + // Python REPL prompt + if (/^>>>\s*$/.test(sanitizedTerminalData)) { + return true; + } + + // Custom prompts ending with the starship character (\u276f) + if (/\u276f\s*$/.test(sanitizedTerminalData)) { + return true; + } + + // Generic prompts ending with common prompt characters + if (/[>%]\s*$/.test(sanitizedTerminalData)) { + return true; + } + + return false; } export function isTaskTerminal(terminal: Terminal): boolean { @@ -171,3 +275,28 @@ export async function getAllDistinctProjectEnvironments( return envs.length > 0 ? envs : undefined; } + +// Defacto standard: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html +const CSI_SEQUENCE = /(?:\x1b\[|\x9b)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~]/; +const OSC_SEQUENCE = /(?:\x1b\]|\x9d).*?(?:\x1b\\|\x07|\x9c)/; +const ESC_SEQUENCE = /\x1b(?:[ #%\(\)\*\+\-\.\/]?[a-zA-Z0-9\|}~@])/; +const CONTROL_SEQUENCES = new RegExp( + '(?:' + [CSI_SEQUENCE.source, OSC_SEQUENCE.source, ESC_SEQUENCE.source].join('|') + ')', + 'g', +); + +/** + * Strips ANSI escape sequences from a string. + * @param str The dastringa stringo strip the ANSI escape sequences from. + * + * @example + * removeAnsiEscapeCodes('\u001b[31mHello, World!\u001b[0m'); + * // 'Hello, World!' + */ +export function removeAnsiEscapeCodes(str: string): string { + if (str) { + str = str.replace(CONTROL_SEQUENCES, ''); + } + + return str; +} diff --git a/src/managers/common/utils.ts b/src/managers/common/utils.ts index bffa42af..8d2e9494 100644 --- a/src/managers/common/utils.ts +++ b/src/managers/common/utils.ts @@ -13,6 +13,14 @@ export function noop() { // do nothing } +/** + * In **contrast** to just checking `typeof` this will return `false` for `NaN`. + * @returns whether the provided parameter is a JavaScript Number or not. + */ +export function isNumber(obj: unknown): obj is number { + return typeof obj === 'number' && !isNaN(obj); +} + export function shortVersion(version: string): string { const pattern = /(\d)\.(\d+)(?:\.(\d+)?)?/gm; const match = pattern.exec(version); diff --git a/src/test/features/creators/autoFindProjects.unit.test.ts b/src/test/features/creators/autoFindProjects.unit.test.ts index 269aaa99..b1ad82a7 100644 --- a/src/test/features/creators/autoFindProjects.unit.test.ts +++ b/src/test/features/creators/autoFindProjects.unit.test.ts @@ -1,15 +1,15 @@ +import assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; import * as typmoq from 'typemoq'; -import * as wapi from '../../../common/workspace.apis'; -import * as winapi from '../../../common/window.apis'; -import { PythonProjectManager } from '../../../internal.api'; -import { createDeferred } from '../../../common/utils/deferred'; -import { AutoFindProjects } from '../../../features/creators/autoFindProjects'; -import assert from 'assert'; import { Uri } from 'vscode'; import { PythonProject } from '../../../api'; -import { sleep } from '../../../common/utils/asyncUtils'; +import { timeout } from '../../../common/utils/asyncUtils'; +import { createDeferred } from '../../../common/utils/deferred'; +import * as winapi from '../../../common/window.apis'; +import * as wapi from '../../../common/workspace.apis'; +import { AutoFindProjects } from '../../../features/creators/autoFindProjects'; +import { PythonProjectManager } from '../../../internal.api'; suite('Auto Find Project tests', () => { let findFilesStub: sinon.SinonStub; @@ -45,7 +45,7 @@ suite('Auto Find Project tests', () => { const result = await autoFindProjects.create(); assert.equal(result, undefined, 'Result should be undefined'); - await Promise.race([deferred.promise, sleep(100)]); + await Promise.race([deferred.promise, timeout(100)]); assert.ok(errorShown, 'Error message should have been shown'); }); @@ -64,7 +64,7 @@ suite('Auto Find Project tests', () => { const result = await autoFindProjects.create(); assert.equal(result, undefined, 'Result should be undefined'); - await Promise.race([deferred.promise, sleep(100)]); + await Promise.race([deferred.promise, timeout(100)]); assert.ok(errorShown, 'Error message should have been shown'); }); diff --git a/src/vscode.proposed.terminalDataWriteEvent.d.ts b/src/vscode.proposed.terminalDataWriteEvent.d.ts new file mode 100644 index 00000000..6913b862 --- /dev/null +++ b/src/vscode.proposed.terminalDataWriteEvent.d.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/78502 + // + // This API is still proposed but we don't intent on promoting it to stable due to problems + // around performance. See #145234 for a more likely API to get stabilized. + + export interface TerminalDataWriteEvent { + /** + * The {@link Terminal} for which the data was written. + */ + readonly terminal: Terminal; + /** + * The data being written. + */ + readonly data: string; + } + + namespace window { + /** + * An event which fires when the terminal's child pseudo-device is written to (the shell). + * In other words, this provides access to the raw data stream from the process running + * within the terminal, including VT sequences. + */ + export const onDidWriteTerminalData: Event; + } +}