Skip to content

Commit 112d032

Browse files
Copilotanthonykim1
andcommitted
Implement terminal reuse functionality with configuration option
Co-authored-by: anthonykim1 <[email protected]>
1 parent cb5207d commit 112d032

File tree

6 files changed

+222
-2
lines changed

6 files changed

+222
-2
lines changed

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,12 @@
633633
"preview"
634634
]
635635
},
636+
"python.terminal.reuseActiveTerminal": {
637+
"default": true,
638+
"description": "%python.terminal.reuseActiveTerminal.description%",
639+
"scope": "resource",
640+
"type": "boolean"
641+
},
636642
"python.REPL.enableREPLSmartSend": {
637643
"default": true,
638644
"description": "%python.EnableREPLSmartSend.description%",

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"python.terminal.executeInFileDir.description": "When executing a file in the terminal, whether to use execute in the file's directory, instead of the current open folder.",
7878
"python.terminal.focusAfterLaunch.description": "When launching a python terminal, whether to focus the cursor on the terminal.",
7979
"python.terminal.launchArgs.description": "Python launch arguments to use when executing a file in the terminal.",
80+
"python.terminal.reuseActiveTerminal.description": "When running code selections or lines, try to reuse an existing active Python terminal instead of creating a new one.",
8081
"python.testing.autoTestDiscoverOnSaveEnabled.description": "Enable auto run test discovery when saving a test file.",
8182
"python.testing.autoTestDiscoverOnSavePattern.description": "Glob pattern used to determine which files are used by autoTestDiscoverOnSaveEnabled.",
8283
"python.testing.cwd.description": "Optional working directory for tests.",

src/client/common/configSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ export class PythonSettings implements IPythonSettings {
378378
activateEnvironment: true,
379379
activateEnvInCurrentTerminal: false,
380380
enableShellIntegration: false,
381+
reuseActiveTerminal: true,
381382
};
382383

383384
this.REPL = pythonSettings.get<IREPLSettings>('REPL')!;

src/client/common/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export interface ITerminalSettings {
189189
readonly activateEnvironment: boolean;
190190
readonly activateEnvInCurrentTerminal: boolean;
191191
readonly enableShellIntegration: boolean;
192+
readonly reuseActiveTerminal: boolean;
192193
}
193194

194195
export interface IREPLSettings {

src/client/terminals/codeExecution/terminalCodeExecution.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { inject, injectable } from 'inversify';
77
import * as path from 'path';
8-
import { Disposable, Uri } from 'vscode';
8+
import { Disposable, Uri, window, Terminal } from 'vscode';
99
import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../common/application/types';
1010
import '../../common/extensions';
1111
import { IPlatformService } from '../../common/platform/types';
@@ -25,6 +25,7 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService {
2525
private hasRanOutsideCurrentDrive = false;
2626
protected terminalTitle!: string;
2727
private replActive?: Promise<boolean>;
28+
private existingReplTerminal?: Terminal;
2829

2930
constructor(
3031
@inject(ITerminalServiceFactory) protected readonly terminalServiceFactory: ITerminalServiceFactory,
@@ -59,11 +60,39 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService {
5960
this.configurationService.updateSetting('REPL.enableREPLSmartSend', false, resource);
6061
}
6162
} else {
62-
await this.getTerminalService(resource).executeCommand(code, true);
63+
// If we're using an existing terminal, send code directly to it
64+
if (this.existingReplTerminal) {
65+
this.existingReplTerminal.sendText(code);
66+
} else {
67+
await this.getTerminalService(resource).executeCommand(code, true);
68+
}
6369
}
6470
}
6571

6672
public async initializeRepl(resource: Resource) {
73+
// First, try to find and reuse an existing Python terminal
74+
const existingTerminal = await this.findExistingPythonTerminal(resource);
75+
if (existingTerminal) {
76+
// Store the existing terminal reference and show it
77+
this.existingReplTerminal = existingTerminal;
78+
existingTerminal.show();
79+
this.replActive = Promise.resolve(true);
80+
81+
// Listen for terminal close events to clear our reference
82+
const terminalCloseListener = window.onDidCloseTerminal((closedTerminal) => {
83+
if (closedTerminal === this.existingReplTerminal) {
84+
this.existingReplTerminal = undefined;
85+
this.replActive = undefined;
86+
}
87+
});
88+
this.disposables.push(terminalCloseListener);
89+
90+
return;
91+
}
92+
93+
// Clear any existing terminal reference since we're creating a new one
94+
this.existingReplTerminal = undefined;
95+
6796
const terminalService = this.getTerminalService(resource);
6897
if (this.replActive && (await this.replActive)) {
6998
await terminalService.show();
@@ -124,6 +153,41 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService {
124153
public async getExecuteFileArgs(resource?: Uri, executeArgs: string[] = []): Promise<PythonExecInfo> {
125154
return this.getExecutableInfo(resource, executeArgs);
126155
}
156+
157+
/**
158+
* Find an existing terminal that has a Python REPL running
159+
*/
160+
private async findExistingPythonTerminal(resource?: Uri): Promise<Terminal | undefined> {
161+
const pythonSettings = this.configurationService.getSettings(resource);
162+
163+
// Check if the feature is enabled
164+
if (!pythonSettings.terminal.reuseActiveTerminal) {
165+
return undefined;
166+
}
167+
168+
// Look through all existing terminals
169+
for (const terminal of window.terminals) {
170+
// Skip terminals that are closed or hidden
171+
if (terminal.exitStatus) {
172+
continue;
173+
}
174+
175+
// Check if this looks like a Python terminal based on name
176+
const terminalName = terminal.name.toLowerCase();
177+
if (terminalName.includes('python') || terminalName.includes('repl')) {
178+
// For now, we'll consider any Python-named terminal as potentially reusable
179+
// In the future, we could add more sophisticated detection
180+
return terminal;
181+
}
182+
183+
// Check if the terminal's detected shell is Python
184+
if (terminal.state?.shell === 'python') {
185+
return terminal;
186+
}
187+
}
188+
189+
return undefined;
190+
}
127191
private getTerminalService(resource: Resource, options?: { newTerminalPerFile: boolean }): ITerminalService {
128192
return this.terminalServiceFactory.getTerminalService({
129193
resource,

src/test/terminals/codeExecution/terminalCodeExec.unit.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,4 +675,151 @@ suite('Terminal - Code Execution', () => {
675675
});
676676
});
677677
});
678+
679+
suite('Terminal Reuse', () => {
680+
let terminalSettings: TypeMoq.IMock<ITerminalSettings>;
681+
let terminalService: TypeMoq.IMock<ITerminalService>;
682+
let workspace: TypeMoq.IMock<IWorkspaceService>;
683+
let platform: TypeMoq.IMock<IPlatformService>;
684+
let workspaceFolder: TypeMoq.IMock<WorkspaceFolder>;
685+
let settings: TypeMoq.IMock<IPythonSettings>;
686+
let disposables: Disposable[] = [];
687+
let executor: ReplProvider;
688+
let terminalFactory: TypeMoq.IMock<ITerminalServiceFactory>;
689+
let commandManager: TypeMoq.IMock<ICommandManager>;
690+
let applicationShell: TypeMoq.IMock<IApplicationShell>;
691+
let interpreterService: TypeMoq.IMock<IInterpreterService>;
692+
let windowStub: sinon.SinonStub;
693+
let mockTerminals: any[];
694+
695+
setup(() => {
696+
terminalFactory = TypeMoq.Mock.ofType<ITerminalServiceFactory>();
697+
terminalService = TypeMoq.Mock.ofType<ITerminalService>();
698+
const configService = TypeMoq.Mock.ofType<IConfigurationService>();
699+
workspace = TypeMoq.Mock.ofType<IWorkspaceService>();
700+
commandManager = TypeMoq.Mock.ofType<ICommandManager>();
701+
applicationShell = TypeMoq.Mock.ofType<IApplicationShell>();
702+
platform = TypeMoq.Mock.ofType<IPlatformService>();
703+
interpreterService = TypeMoq.Mock.ofType<IInterpreterService>();
704+
705+
workspaceFolder = TypeMoq.Mock.ofType<WorkspaceFolder>();
706+
workspaceFolder.setup((w) => w.uri).returns(() => Uri.file(__dirname));
707+
708+
terminalSettings = TypeMoq.Mock.ofType<ITerminalSettings>();
709+
terminalSettings.setup((t) => t.reuseActiveTerminal).returns(() => true);
710+
terminalSettings.setup((t) => t.activateEnvironment).returns(() => false);
711+
terminalSettings.setup((t) => t.activateEnvInCurrentTerminal).returns(() => false);
712+
713+
settings = TypeMoq.Mock.ofType<IPythonSettings>();
714+
settings.setup((s) => s.terminal).returns(() => terminalSettings.object);
715+
716+
configService.setup((c) => c.getSettings(TypeMoq.It.isAny())).returns(() => settings.object);
717+
718+
terminalFactory
719+
.setup((f) => f.getTerminalService(TypeMoq.It.isAny()))
720+
.returns(() => terminalService.object);
721+
722+
// Mock window.terminals using a stub
723+
mockTerminals = [];
724+
const vscode = require('vscode');
725+
windowStub = sinon.stub(vscode, 'window').value({
726+
terminals: mockTerminals,
727+
onDidCloseTerminal: () => ({ dispose: () => {} })
728+
});
729+
730+
executor = new ReplProvider(
731+
terminalFactory.object,
732+
configService.object,
733+
workspace.object,
734+
disposables,
735+
platform.object,
736+
interpreterService.object,
737+
commandManager.object,
738+
applicationShell.object,
739+
);
740+
});
741+
742+
teardown(() => {
743+
disposables.forEach((d) => d.dispose());
744+
disposables = [];
745+
windowStub.restore();
746+
});
747+
748+
test('Should reuse existing Python terminal when reuseActiveTerminal is enabled', async () => {
749+
// Arrange
750+
const mockTerminal = {
751+
name: 'Python',
752+
exitStatus: undefined,
753+
show: sinon.stub(),
754+
sendText: sinon.stub(),
755+
state: { shell: 'python' }
756+
};
757+
mockTerminals.push(mockTerminal);
758+
759+
terminalSettings.setup((t) => t.reuseActiveTerminal).returns(() => true);
760+
761+
// Act
762+
await executor.execute('print("hello")', Uri.file('test.py'));
763+
764+
// Assert
765+
sinon.assert.calledOnce(mockTerminal.show);
766+
sinon.assert.calledWith(mockTerminal.sendText, 'print("hello")');
767+
terminalService.verify(async (t) => t.executeCommand(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.never());
768+
});
769+
770+
test('Should not reuse existing terminal when reuseActiveTerminal is disabled', async () => {
771+
// Arrange
772+
const mockTerminal = {
773+
name: 'Python',
774+
exitStatus: undefined,
775+
show: sinon.stub(),
776+
sendText: sinon.stub(),
777+
state: { shell: 'python' }
778+
};
779+
mockTerminals.push(mockTerminal);
780+
781+
terminalSettings.setup((t) => t.reuseActiveTerminal).returns(() => false);
782+
783+
// Mock required dependencies for creating new terminal
784+
interpreterService
785+
.setup((s) => s.getActiveInterpreter(TypeMoq.It.isAny()))
786+
.returns(() => Promise.resolve(({ path: '/usr/bin/python' } as unknown) as PythonEnvironment));
787+
terminalSettings.setup((t) => t.launchArgs).returns(() => []);
788+
platform.setup((p) => p.isWindows).returns(() => false);
789+
790+
// Act
791+
await executor.execute('print("hello")', Uri.file('test.py'));
792+
793+
// Assert
794+
sinon.assert.notCalled(mockTerminal.show);
795+
sinon.assert.notCalled(mockTerminal.sendText);
796+
});
797+
798+
test('Should skip closed terminals when looking for reusable terminal', async () => {
799+
// Arrange
800+
const closedTerminal = {
801+
name: 'Python',
802+
exitStatus: { code: 0 },
803+
show: sinon.stub(),
804+
sendText: sinon.stub()
805+
};
806+
const activeTerminal = {
807+
name: 'Python REPL',
808+
exitStatus: undefined,
809+
show: sinon.stub(),
810+
sendText: sinon.stub()
811+
};
812+
mockTerminals.push(closedTerminal, activeTerminal);
813+
814+
terminalSettings.setup((t) => t.reuseActiveTerminal).returns(() => true);
815+
816+
// Act
817+
await executor.execute('print("hello")', Uri.file('test.py'));
818+
819+
// Assert
820+
sinon.assert.notCalled(closedTerminal.show);
821+
sinon.assert.calledOnce(activeTerminal.show);
822+
sinon.assert.calledWith(activeTerminal.sendText, 'print("hello")');
823+
});
824+
});
678825
});

0 commit comments

Comments
 (0)