Skip to content

Commit d16766f

Browse files
authored
Merge branch 'main' into copilot/add-toggle-for-uv-venvs
2 parents ebaa3b4 + 7d52871 commit d16766f

File tree

10 files changed

+211
-37
lines changed

10 files changed

+211
-37
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"Other"
1313
],
1414
"enabledApiProposals": [
15-
"terminalShellEnv"
15+
"terminalShellEnv",
16+
"terminalDataWriteEvent"
1617
],
1718
"capabilities": {
1819
"untrustedWorkspaces": {

src/common/utils/asyncUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export async function sleep(milliseconds: number) {
1+
export async function timeout(milliseconds: number): Promise<void> {
22
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
33
}

src/common/utils/debounce.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
1-
export interface SimpleDebounce {
1+
import { Disposable } from 'vscode';
2+
export interface SimpleDebounce extends Disposable {
23
trigger(): void;
34
}
45

5-
class SimpleDebounceImpl {
6+
class SimpleDebounceImpl implements SimpleDebounce {
67
private timeout: NodeJS.Timeout | undefined;
78

89
constructor(private readonly ms: number, private readonly callback: () => void) {}
910

10-
public trigger() {
11+
public trigger(): void {
1112
if (this.timeout) {
1213
clearTimeout(this.timeout);
1314
}
1415
this.timeout = setTimeout(() => {
1516
this.callback();
1617
}, this.ms);
1718
}
19+
20+
public dispose(): void {
21+
if (this.timeout) {
22+
clearTimeout(this.timeout);
23+
}
24+
}
1825
}
1926

2027
export function createSimpleDebounce(ms: number, callback: () => void): SimpleDebounce {

src/common/window.apis.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export function onDidChangeTerminalShellIntegration(
4949
return window.onDidChangeTerminalShellIntegration(listener, thisArgs, disposables);
5050
}
5151

52+
export function onDidWriteTerminalData(
53+
listener: (e: { readonly terminal: Terminal; readonly data: string }) => any,
54+
thisArgs?: any,
55+
disposables?: Disposable[],
56+
): Disposable {
57+
return window.onDidWriteTerminalData(listener, thisArgs, disposables);
58+
}
59+
5260
export function showOpenDialog(options?: OpenDialogOptions): Thenable<Uri[] | undefined> {
5361
return window.showOpenDialog(options);
5462
}

src/features/terminal/shells/common/shellUtils.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { PythonCommandRunConfiguration, PythonEnvironment } from '../../../../api';
22
import { traceInfo } from '../../../../common/logging';
33
import { getGlobalPersistentState } from '../../../../common/persistentState';
4-
import { sleep } from '../../../../common/utils/asyncUtils';
4+
import { timeout } from '../../../../common/utils/asyncUtils';
55
import { isWindows } from '../../../../common/utils/platformUtils';
66
import { activeTerminalShellIntegration } from '../../../../common/window.apis';
77
import { getConfiguration } from '../../../../common/workspace.apis';
@@ -106,11 +106,11 @@ export function extractProfilePath(content: string): string | undefined {
106106

107107
export async function shellIntegrationForActiveTerminal(name: string, profile?: string): Promise<boolean> {
108108
let hasShellIntegration = activeTerminalShellIntegration();
109-
let timeout = 0;
109+
let timeOutstamp = 0;
110110

111-
while (!hasShellIntegration && timeout < SHELL_INTEGRATION_TIMEOUT) {
112-
await sleep(SHELL_INTEGRATION_POLL_INTERVAL);
113-
timeout += SHELL_INTEGRATION_POLL_INTERVAL;
111+
while (!hasShellIntegration && timeOutstamp < SHELL_INTEGRATION_TIMEOUT) {
112+
await timeout(SHELL_INTEGRATION_POLL_INTERVAL);
113+
timeOutstamp += SHELL_INTEGRATION_POLL_INTERVAL;
114114
hasShellIntegration = activeTerminalShellIntegration();
115115
}
116116

src/features/terminal/terminalManager.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -309,18 +309,8 @@ export class TerminalManagerImpl implements TerminalManager {
309309
// https://github.com/microsoft/vscode-python-environments/issues/172
310310
// const name = options.name ?? `Python: ${environment.displayName}`;
311311
const newTerminal = createTerminal({
312-
name: options.name,
313-
shellPath: options.shellPath,
314-
shellArgs: options.shellArgs,
315-
cwd: options.cwd,
312+
...options,
316313
env: envVars,
317-
strictEnv: options.strictEnv,
318-
message: options.message,
319-
iconPath: options.iconPath,
320-
hideFromUser: options.hideFromUser,
321-
color: options.color,
322-
location: options.location,
323-
isTransient: options.isTransient,
324314
});
325315

326316
if (autoActType === ACT_TYPE_COMMAND) {

src/features/terminal/utils.ts

Lines changed: 136 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,123 @@
11
import * as path from 'path';
2-
import { Terminal, TerminalOptions, Uri } from 'vscode';
2+
import { Disposable, env, Terminal, TerminalOptions, Uri } from 'vscode';
33
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';
57
import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.apis';
68

79
export const SHELL_INTEGRATION_TIMEOUT = 500; // 0.5 seconds
810
export const SHELL_INTEGRATION_POLL_INTERVAL = 20; // 0.02 seconds
911

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+
*/
1018
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());
1575
}
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;
17121
}
18122

19123
export function isTaskTerminal(terminal: Terminal): boolean {
@@ -171,3 +275,28 @@ export async function getAllDistinctProjectEnvironments(
171275

172276
return envs.length > 0 ? envs : undefined;
173277
}
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+
}

src/managers/common/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ export function noop() {
1313
// do nothing
1414
}
1515

16+
/**
17+
* In **contrast** to just checking `typeof` this will return `false` for `NaN`.
18+
* @returns whether the provided parameter is a JavaScript Number or not.
19+
*/
20+
export function isNumber(obj: unknown): obj is number {
21+
return typeof obj === 'number' && !isNaN(obj);
22+
}
23+
1624
export function shortVersion(version: string): string {
1725
const pattern = /(\d)\.(\d+)(?:\.(\d+)?)?/gm;
1826
const match = pattern.exec(version);

src/test/features/creators/autoFindProjects.unit.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1+
import assert from 'assert';
12
import * as path from 'path';
23
import * as sinon from 'sinon';
34
import * as typmoq from 'typemoq';
4-
import * as wapi from '../../../common/workspace.apis';
5-
import * as winapi from '../../../common/window.apis';
6-
import { PythonProjectManager } from '../../../internal.api';
7-
import { createDeferred } from '../../../common/utils/deferred';
8-
import { AutoFindProjects } from '../../../features/creators/autoFindProjects';
9-
import assert from 'assert';
105
import { Uri } from 'vscode';
116
import { PythonProject } from '../../../api';
12-
import { sleep } from '../../../common/utils/asyncUtils';
7+
import { timeout } from '../../../common/utils/asyncUtils';
8+
import { createDeferred } from '../../../common/utils/deferred';
9+
import * as winapi from '../../../common/window.apis';
10+
import * as wapi from '../../../common/workspace.apis';
11+
import { AutoFindProjects } from '../../../features/creators/autoFindProjects';
12+
import { PythonProjectManager } from '../../../internal.api';
1313

1414
suite('Auto Find Project tests', () => {
1515
let findFilesStub: sinon.SinonStub;
@@ -45,7 +45,7 @@ suite('Auto Find Project tests', () => {
4545
const result = await autoFindProjects.create();
4646
assert.equal(result, undefined, 'Result should be undefined');
4747

48-
await Promise.race([deferred.promise, sleep(100)]);
48+
await Promise.race([deferred.promise, timeout(100)]);
4949
assert.ok(errorShown, 'Error message should have been shown');
5050
});
5151

@@ -64,7 +64,7 @@ suite('Auto Find Project tests', () => {
6464
const result = await autoFindProjects.create();
6565
assert.equal(result, undefined, 'Result should be undefined');
6666

67-
await Promise.race([deferred.promise, sleep(100)]);
67+
await Promise.race([deferred.promise, timeout(100)]);
6868
assert.ok(errorShown, 'Error message should have been shown');
6969
});
7070

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
declare module 'vscode' {
7+
// https://github.com/microsoft/vscode/issues/78502
8+
//
9+
// This API is still proposed but we don't intent on promoting it to stable due to problems
10+
// around performance. See #145234 for a more likely API to get stabilized.
11+
12+
export interface TerminalDataWriteEvent {
13+
/**
14+
* The {@link Terminal} for which the data was written.
15+
*/
16+
readonly terminal: Terminal;
17+
/**
18+
* The data being written.
19+
*/
20+
readonly data: string;
21+
}
22+
23+
namespace window {
24+
/**
25+
* An event which fires when the terminal's child pseudo-device is written to (the shell).
26+
* In other words, this provides access to the raw data stream from the process running
27+
* within the terminal, including VT sequences.
28+
*/
29+
export const onDidWriteTerminalData: Event<TerminalDataWriteEvent>;
30+
}
31+
}

0 commit comments

Comments
 (0)