Skip to content
Merged
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
38 changes: 34 additions & 4 deletions src/features/terminal/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import * as path from 'path';
import { Disposable, env, tasks, Terminal, TerminalOptions, Uri } from 'vscode';
import { PythonEnvironment, PythonProject, PythonProjectEnvironmentApi, PythonProjectGetterApi } from '../../api';
import { traceVerbose } from '../../common/logging';
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';
import { identifyTerminalShell } from '../common/shellDetector';
import { shellIntegrationSupportedShells } from './shells/common/shellUtils';

export const SHELL_INTEGRATION_TIMEOUT = 500; // 0.5 seconds

Expand All @@ -24,10 +27,26 @@ export function getShellIntegrationTimeout(): number {
}

/**
* 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
* Waits for shell integration to be ready on the given terminal, up to a timeout.
*
* Returns:
* - `true` if shell integration is (or becomes) available.
* - `false` if the timeout is hit, a common prompt pattern is detected, the terminal
* is undefined, or the shell is known not to support shell integration.
*
* Behavior:
* 1. Returns `true` immediately if `terminal.shellIntegration` is already set.
* 2. Returns `false` immediately when the shell type is identified and is NOT in
* {@link shellIntegrationSupportedShells} (e.g. `nu`, `cmd`, `csh`, `tcsh`,
* `ksh`, `xonsh`). VS Code does not provide shell integration for these
* shells, so waiting up to 5s for an event that will never fire only delays
* the fallback `terminal.sendText` activation.
* If shell detection throws or returns `'unknown'`, we fall through to the
* race below to preserve previous behavior.
* 3. Otherwise races three conditions:
* a. Timeout based on VS Code's `terminal.integrated.shellIntegration.timeout` setting.
* b. Shell integration becoming available (`window.onDidChangeTerminalShellIntegration`).
* c. Detection of common prompt patterns in terminal output.
*/
export async function waitForShellIntegration(terminal?: Terminal): Promise<boolean> {
if (!terminal) {
Expand All @@ -37,6 +56,17 @@ export async function waitForShellIntegration(terminal?: Terminal): Promise<bool
return true;
}

// Skip the wait for shells that VS Code does not provide shell integration for.
try {
const shellType = identifyTerminalShell(terminal);
if (shellType !== 'unknown' && !shellIntegrationSupportedShells.includes(shellType)) {
traceVerbose(`Shell '${shellType}' does not support shell integration; skipping wait.`);
return false;
}
} catch {
// Detection failed — preserve original behavior by falling through to the race.
}

const timeoutMs = getShellIntegrationTimeout();

const disposables: Disposable[] = [];
Expand Down
155 changes: 155 additions & 0 deletions src/test/features/terminal/utils.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import * as assert from 'assert';
import * as sinon from 'sinon';
import { Terminal } from 'vscode';
import * as windowApis from '../../../common/window.apis';
import * as workspaceApis from '../../../common/workspace.apis';
import * as shellDetector from '../../../features/common/shellDetector';
import {
ACT_TYPE_COMMAND,
ACT_TYPE_OFF,
ACT_TYPE_SHELL,
AutoActivationType,
getAutoActivationType,
shouldActivateInCurrentTerminal,
waitForShellIntegration,
} from '../../../features/terminal/utils';

interface MockWorkspaceConfig {
Expand Down Expand Up @@ -545,3 +549,154 @@ suite('Terminal Utils - shouldActivateInCurrentTerminal', () => {
);
});
});

suite('Terminal Utils - waitForShellIntegration', () => {
let mockGetConfiguration: sinon.SinonStub;
let identifyTerminalShellStub: sinon.SinonStub;
let onDidChangeTerminalShellIntegrationStub: sinon.SinonStub;
let onDidWriteTerminalDataStub: sinon.SinonStub;

function setupLongTimeoutConfig() {
// Make the timeout effectively infinite so tests resolve via the listener,
// not the timer. Avoids flakiness while keeping the race code paths exercised.
const config = {
get: sinon.stub(),
inspect: sinon.stub(),
update: sinon.stub(),
};
config.get.withArgs('shellIntegration.timeout').returns(60_000);
config.get.withArgs('shellIntegration.enabled', true).returns(true);
mockGetConfiguration.withArgs('terminal.integrated').returns(config);
}

setup(() => {
mockGetConfiguration = sinon.stub(workspaceApis, 'getConfiguration');
identifyTerminalShellStub = sinon.stub(shellDetector, 'identifyTerminalShell');
onDidChangeTerminalShellIntegrationStub = sinon.stub(windowApis, 'onDidChangeTerminalShellIntegration');
onDidWriteTerminalDataStub = sinon.stub(windowApis, 'onDidWriteTerminalData');

// Default: dispose-only fake event registrations. Tests that need to fire
// events override these via .callsFake.
const fakeDisposable = { dispose: () => undefined };
onDidChangeTerminalShellIntegrationStub.returns(fakeDisposable);
onDidWriteTerminalDataStub.returns(fakeDisposable);
});

teardown(() => {
sinon.restore();
});

test('returns false immediately when terminal is undefined', async () => {
const result = await waitForShellIntegration(undefined);

assert.strictEqual(result, false);
sinon.assert.notCalled(identifyTerminalShellStub);
sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub);
});

test('returns true immediately when terminal.shellIntegration is already set', async () => {
const terminal = { shellIntegration: {} } as unknown as Terminal;

const result = await waitForShellIntegration(terminal);

assert.strictEqual(result, true);
sinon.assert.notCalled(identifyTerminalShellStub);
sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub);
});

test('returns false immediately for nu without registering event listeners', async () => {
const terminal = {} as Terminal;
identifyTerminalShellStub.returns('nu');

const result = await waitForShellIntegration(terminal);

assert.strictEqual(result, false);
sinon.assert.calledOnce(identifyTerminalShellStub);
sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub);
sinon.assert.notCalled(onDidWriteTerminalDataStub);
});

test('returns false immediately for cmd', async () => {
const terminal = {} as Terminal;
identifyTerminalShellStub.returns('cmd');

const result = await waitForShellIntegration(terminal);

assert.strictEqual(result, false);
sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub);
});

test('returns false immediately for csh / tcsh / ksh / xonsh', async () => {
const unsupported = ['csh', 'tcsh', 'ksh', 'xonsh'];
for (const shell of unsupported) {
identifyTerminalShellStub.resetHistory();
identifyTerminalShellStub.returns(shell);
onDidChangeTerminalShellIntegrationStub.resetHistory();

const result = await waitForShellIntegration({} as Terminal);

assert.strictEqual(result, false, `expected false for shell '${shell}'`);
sinon.assert.notCalled(onDidChangeTerminalShellIntegrationStub);
}
});

test('falls through to event race for bash (supported shell)', async () => {
setupLongTimeoutConfig();
const terminal = {} as Terminal;
identifyTerminalShellStub.returns('bash');

let listenerRef: ((e: { terminal: Terminal }) => void) | undefined;
onDidChangeTerminalShellIntegrationStub.callsFake((listener: (e: { terminal: Terminal }) => void) => {
listenerRef = listener;
return { dispose: () => undefined };
});

const racePromise = waitForShellIntegration(terminal);
// Yield once so the Promise.race body has a chance to register listeners.
await new Promise<void>((r) => setImmediate(r));
assert.ok(listenerRef, 'shell integration listener should be registered');
listenerRef!({ terminal });

const result = await racePromise;
assert.strictEqual(result, true);
sinon.assert.calledOnce(onDidChangeTerminalShellIntegrationStub);
});

test('falls through to event race when shell type is unknown', async () => {
setupLongTimeoutConfig();
const terminal = {} as Terminal;
identifyTerminalShellStub.returns('unknown');

let listenerRef: ((e: { terminal: Terminal }) => void) | undefined;
onDidChangeTerminalShellIntegrationStub.callsFake((listener: (e: { terminal: Terminal }) => void) => {
listenerRef = listener;
return { dispose: () => undefined };
});

const racePromise = waitForShellIntegration(terminal);
await new Promise<void>((r) => setImmediate(r));
listenerRef!({ terminal });

const result = await racePromise;
assert.strictEqual(result, true);
});

test('falls through to event race when identifyTerminalShell throws', async () => {
setupLongTimeoutConfig();
const terminal = {} as Terminal;
identifyTerminalShellStub.throws(new Error('detection failed'));

let listenerRef: ((e: { terminal: Terminal }) => void) | undefined;
onDidChangeTerminalShellIntegrationStub.callsFake((listener: (e: { terminal: Terminal }) => void) => {
listenerRef = listener;
return { dispose: () => undefined };
});

const racePromise = waitForShellIntegration(terminal);
await new Promise<void>((r) => setImmediate(r));
listenerRef!({ terminal });

const result = await racePromise;
assert.strictEqual(result, true, 'should not regress when detection throws');
});
});
Loading