Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts
13401 views
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
import { Codicon } from '../../../../base/common/codicons.js';
7
import { Disposable } from '../../../../base/common/lifecycle.js';
8
import { autorun, derived } from '../../../../base/common/observable.js';
9
import { URI } from '../../../../base/common/uri.js';
10
import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';
11
import { localize, localize2 } from '../../../../nls.js';
12
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
13
import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js';
14
import { ILogService } from '../../../../platform/log/common/log.js';
15
import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
16
import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js';
17
import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js';
18
import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js';
19
import { IPathService } from '../../../../workbench/services/path/common/pathService.js';
20
import { Menus } from '../../../browser/menus.js';
21
import { isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js';
22
import { SessionsWelcomeVisibleContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js';
23
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
24
import { isWorkspaceAgentSessionType, ISession } from '../../../services/sessions/common/session.js';
25
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
26
import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js';
27
import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
28
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
29
import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js';
30
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
31
import { ITerminalProfileService, TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js';
32
import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js';
33
34
const SessionsTerminalViewVisibleContext = new RawContextKey<boolean>('sessionsTerminalViewVisible', false);
35
36
interface ISessionTerminalInfo {
37
/** The cwd to use for terminal matching/creation. For agent host sessions this is the unwrapped file URI. */
38
readonly cwd: URI;
39
/** When set, the terminal should be created on the agent host rather than locally. */
40
readonly agentHostCwd?: URI;
41
}
42
43
/**
44
* Returns terminal info for the given session: worktree or repository path for
45
* workspace-backed agent sessions. Returns `undefined` for sessions without a
46
* workspace (e.g. Cloud), or when no path is available.
47
*/
48
function getSessionTerminalInfo(session: ISession | undefined): ISessionTerminalInfo | undefined {
49
if (!session || !isWorkspaceAgentSessionType(session.sessionType)) {
50
return undefined;
51
}
52
const repo = session.workspace.get()?.repositories[0];
53
const cwd = repo?.workingDirectory ?? repo?.uri;
54
if (!cwd) {
55
return undefined;
56
}
57
if (cwd.scheme === AGENT_HOST_SCHEME) {
58
return { cwd: fromAgentHostUri(cwd), agentHostCwd: cwd };
59
}
60
return { cwd };
61
}
62
63
/**
64
* Manages terminal instances in the sessions window, ensuring:
65
* - A terminal exists for the active session's worktree (or repository if no worktree).
66
* - Terminals are shown/hidden based on their initial cwd matching the active path.
67
* - Terminals for an archived/removed session are closed only when no other
68
* live session still owns the same cwd (terminals are reused across sessions
69
* at the same worktree).
70
*/
71
export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution {
72
73
static readonly ID = 'workbench.contrib.sessionsTerminal';
74
75
private _activeKey: string | undefined;
76
77
constructor(
78
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
79
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
80
@ITerminalService private readonly _terminalService: ITerminalService,
81
@IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService,
82
@ILogService private readonly _logService: ILogService,
83
@IPathService private readonly _pathService: IPathService,
84
@ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService,
85
@IViewsService viewsService: IViewsService,
86
@IContextKeyService contextKeyService: IContextKeyService,
87
) {
88
super();
89
90
const profileOverride = derived(reader => {
91
const session = this._sessionsManagementService.activeSession.read(reader);
92
if (!session || session.providerId === LOCAL_AGENT_HOST_PROVIDER_ID) {
93
return; // no need to override local default profiles with the local AH
94
}
95
96
const address = this._getSessionAgentHostAddress(session);
97
if (!address) {
98
return;
99
}
100
101
const profiles = this._agentHostTerminalService.profiles.read(reader);
102
return profiles.find(p => p.address === address) ?? this._agentHostTerminalService.getProfileForConnection(address);
103
});
104
105
this._register(autorun(reader => {
106
const profile = profileOverride.read(reader);
107
if (profile) {
108
reader.store.add(this._terminalProfileService.overrideDefaultProfile(
109
profile.extensionIdentifier, profile.profileId,
110
));
111
}
112
}));
113
114
// Keep the default cwd in sync with the active session's working directory
115
// so that "New Terminal" uses it automatically.
116
// This is a little hacky but I don't see any better approach.
117
this._register(autorun(reader => {
118
const session = this._sessionsManagementService.activeSession.read(reader);
119
const info = getSessionTerminalInfo(session);
120
this._agentHostTerminalService.setDefaultCwd(info?.cwd);
121
}));
122
123
// Track whether the terminal view is visible so the titlebar toggle
124
// button shows the correct checked state.
125
const terminalViewVisible = SessionsTerminalViewVisibleContext.bindTo(contextKeyService);
126
terminalViewVisible.set(viewsService.isViewVisible(TERMINAL_VIEW_ID));
127
this._register(viewsService.onDidChangeViewVisibility(e => {
128
if (e.id === TERMINAL_VIEW_ID) {
129
terminalViewVisible.set(e.visible);
130
}
131
}));
132
133
// React to active session changes — use worktree/repo for background sessions, home dir otherwise
134
this._register(autorun(reader => {
135
const session = this._sessionsManagementService.activeSession.read(reader);
136
this._onActiveSessionChanged(session);
137
}));
138
139
// Hide restored terminals from a previous window session that don't
140
// belong to the current active session. These arrive asynchronously
141
// during reconnection and would otherwise flash in the foreground.
142
this._register(this._terminalService.onDidCreateInstance(instance => {
143
if (instance.shellLaunchConfig.attachPersistentProcess && this._activeKey) {
144
instance.getInitialCwd().then(cwd => {
145
if (cwd.toLowerCase() !== this._activeKey) {
146
const availableInstance = this._getAvailableTerminal(instance, `hide restored terminal for ${cwd}`);
147
if (!availableInstance) {
148
return;
149
}
150
this._terminalService.moveToBackground(availableInstance);
151
this._logService.trace(`[SessionsTerminal] Hid restored terminal ${availableInstance.instanceId} (cwd: ${cwd})`);
152
}
153
});
154
}
155
}));
156
157
// Close terminals for archived/removed sessions, but only when no other
158
// live session still owns that cwd. Terminals are reused across sessions
159
// at the same cwd, so a plain cwd match would kill a terminal still in use
160
// (e.g. the committed session from `onDidReplaceSession`).
161
// TODO: Consider removing the logic for trying to "delete/clean-up" terminal.
162
// Or consider tag terminals by sessionId + refcount instead of guarding here.
163
164
this._register(this._sessionsManagementService.onDidChangeSessions(e => {
165
const archivedChanged = e.changed.filter(s => s.isArchived.get());
166
if (e.removed.length === 0 && archivedChanged.length === 0) {
167
return;
168
}
169
const removedIds = new Set(e.removed.map(s => s.sessionId));
170
const liveCwdKeys = new Set<string>();
171
for (const session of this._sessionsManagementService.getSessions()) {
172
if (removedIds.has(session.sessionId) || session.isArchived.get()) {
173
continue;
174
}
175
const info = getSessionTerminalInfo(session);
176
if (info) {
177
liveCwdKeys.add(info.cwd.fsPath.toLowerCase());
178
}
179
}
180
for (const session of [...e.removed, ...archivedChanged]) {
181
const info = getSessionTerminalInfo(session);
182
if (info && !liveCwdKeys.has(info.cwd.fsPath.toLowerCase())) {
183
this._closeTerminalsForPath(info.cwd.fsPath);
184
}
185
}
186
}));
187
}
188
189
/**
190
* Ensures a terminal exists for the given cwd by scanning all terminal
191
* instances for a matching initial cwd. If none is found, creates a new
192
* one. Sets it as active and optionally focuses it.
193
*
194
* When {@link session} is provided and the session is backed by an agent
195
* host, the terminal is created on the agent host instead of locally.
196
*/
197
async ensureTerminal(cwd: URI, focus: boolean, session?: ISession): Promise<ITerminalInstance[]> {
198
const key = cwd.fsPath.toLowerCase();
199
let existing = await this._findTerminalsForKey(key);
200
201
if (existing.length === 0) {
202
try {
203
const instance = await this._createTerminalForSession(cwd, session);
204
const createdInstance = this._getAvailableTerminal(instance, `activate created terminal for ${cwd.fsPath}`);
205
if (!createdInstance) {
206
return [];
207
}
208
existing = [createdInstance];
209
this._terminalService.setActiveInstance(createdInstance);
210
this._logService.trace(`[SessionsTerminal] Created terminal ${createdInstance.instanceId} for ${cwd.fsPath}`);
211
} catch (e) {
212
this._logService.trace(`[SessionsTerminal] Cannot create terminal for ${cwd.fsPath}: ${e}`);
213
return [];
214
}
215
}
216
217
if (focus) {
218
await this._terminalService.focusActiveInstance();
219
}
220
221
return existing;
222
}
223
224
/**
225
* Creates a terminal for the given cwd. If the session is backed by an
226
* agent host, creates an agent host terminal; otherwise creates a local one.
227
*/
228
private async _createTerminalForSession(cwd: URI, session: ISession | undefined): Promise<ITerminalInstance> {
229
const address = session && this._getSessionAgentHostAddress(session);
230
if (address) {
231
const instance = await this._agentHostTerminalService.createTerminalForEntry(address, { cwd });
232
if (instance) {
233
return instance;
234
}
235
}
236
return this._terminalService.createTerminal({ config: { cwd } });
237
}
238
239
/**
240
* Returns the agent host address for the given session's provider,
241
* or `undefined` if the session is not backed by an agent host.
242
*/
243
private _getSessionAgentHostAddress(session: ISession | undefined): string | undefined {
244
if (!session) {
245
return undefined;
246
}
247
const provider = this._sessionsProvidersService.getProvider(session.providerId);
248
if (!provider || !isAgentHostProvider(provider)) {
249
return undefined;
250
}
251
return provider.remoteAddress ?? '__local__';
252
}
253
254
private async _onActiveSessionChanged(session: ISession | undefined): Promise<void> {
255
if (!session) {
256
return;
257
}
258
259
const info = getSessionTerminalInfo(session);
260
const targetPath = info?.cwd ?? await this._pathService.userHome();
261
const targetKey = targetPath.fsPath.toLowerCase();
262
if (this._activeKey === targetKey) {
263
return;
264
}
265
this._activeKey = targetKey;
266
267
const instances = await this.ensureTerminal(targetPath, false, info?.agentHostCwd ? session : undefined);
268
269
// If the active key changed while we were awaiting, a newer call has
270
// taken over — skip the visibility update to avoid flicker.
271
if (this._activeKey !== targetKey) {
272
return;
273
}
274
await this._updateTerminalVisibility(targetKey, instances.map(instance => instance.instanceId));
275
}
276
277
/**
278
* Finds the first terminal instance whose initial cwd (lower-cased) matches
279
* the given key.
280
*/
281
private async _findTerminalsForKey(key: string): Promise<ITerminalInstance[]> {
282
const result: ITerminalInstance[] = [];
283
for (const instance of this._terminalService.instances) {
284
try {
285
const cwd = await instance.getInitialCwd();
286
if (cwd.toLowerCase() === key) {
287
result.push(instance);
288
}
289
} catch {
290
// ignore terminals whose cwd cannot be resolved
291
}
292
}
293
return result;
294
}
295
296
private _getAvailableTerminal(instance: ITerminalInstance, action: string): ITerminalInstance | undefined {
297
const currentInstance = this._terminalService.getInstanceFromId(instance.instanceId);
298
if (!currentInstance || currentInstance.isDisposed) {
299
this._logService.trace(`[SessionsTerminal] Cannot ${action}; terminal ${instance.instanceId} is no longer available`);
300
return undefined;
301
}
302
return currentInstance;
303
}
304
305
/**
306
* Shows background terminals whose initial cwd matches the active key and
307
* hides foreground terminals whose initial cwd does not match.
308
*/
309
private async _updateTerminalVisibility(activeKey: string, forceForegroundTerminalIds: number[]): Promise<void> {
310
const toShow: ITerminalInstance[] = [];
311
const toHide: ITerminalInstance[] = [];
312
313
for (const instance of [...this._terminalService.instances]) {
314
let cwd: string | undefined;
315
try {
316
cwd = (await instance.getInitialCwd()).toLowerCase();
317
} catch {
318
continue;
319
}
320
const currentInstance = this._getAvailableTerminal(instance, `update visibility for ${cwd}`);
321
if (!currentInstance) {
322
continue;
323
}
324
325
const isForeground = this._terminalService.foregroundInstances.includes(currentInstance);
326
const isForceVisible = forceForegroundTerminalIds.includes(currentInstance.instanceId);
327
const belongsToActiveSession = cwd === activeKey;
328
if ((belongsToActiveSession || isForceVisible) && !isForeground) {
329
toShow.push(currentInstance);
330
} else if (!belongsToActiveSession && !isForceVisible && isForeground) {
331
toHide.push(currentInstance);
332
}
333
}
334
335
for (const instance of toShow) {
336
const availableInstance = this._getAvailableTerminal(instance, 'show background terminal');
337
if (availableInstance) {
338
await this._terminalService.showBackgroundTerminal(availableInstance, true);
339
}
340
}
341
for (const instance of toHide) {
342
const availableInstance = this._getAvailableTerminal(instance, 'move terminal to background');
343
if (availableInstance) {
344
this._terminalService.moveToBackground(availableInstance);
345
}
346
}
347
348
// Set the terminal with the most recent command as active
349
const foreground = this._terminalService.foregroundInstances;
350
let mostRecent: ITerminalInstance | undefined;
351
let mostRecentTimestamp = -1;
352
for (const instance of foreground) {
353
const cmdDetection = instance.capabilities.get(TerminalCapability.CommandDetection);
354
const lastCmd = cmdDetection?.commands.at(-1);
355
if (lastCmd && lastCmd.timestamp > mostRecentTimestamp) {
356
mostRecentTimestamp = lastCmd.timestamp;
357
mostRecent = instance;
358
}
359
}
360
if (mostRecent) {
361
this._terminalService.setActiveInstance(mostRecent);
362
}
363
}
364
365
private async _closeTerminalsForPath(fsPath: string): Promise<void> {
366
const key = fsPath.toLowerCase();
367
for (const instance of [...this._terminalService.instances]) {
368
try {
369
const cwd = (await instance.getInitialCwd()).toLowerCase();
370
if (cwd === key) {
371
const availableInstance = this._getAvailableTerminal(instance, `close archived terminal for ${fsPath}`);
372
if (!availableInstance) {
373
continue;
374
}
375
this._terminalService.safeDisposeTerminal(availableInstance);
376
this._logService.trace(`[SessionsTerminal] Closed archived terminal ${availableInstance.instanceId}`);
377
}
378
} catch {
379
// ignore
380
}
381
}
382
}
383
384
async dumpTracking(): Promise<void> {
385
console.log(`[SessionsTerminal] Active key: ${this._activeKey ?? '<none>'}`);
386
console.log('[SessionsTerminal] === All Terminals ===');
387
for (const instance of this._terminalService.instances) {
388
let cwd = '<unknown>';
389
try { cwd = await instance.getInitialCwd(); } catch { /* ignored */ }
390
const isForeground = this._terminalService.foregroundInstances.includes(instance);
391
console.log(` ${instance.instanceId} - ${cwd} - ${isForeground ? 'foreground' : 'background'}`);
392
}
393
}
394
395
async showAllTerminals(): Promise<void> {
396
for (const instance of this._terminalService.instances) {
397
if (!this._terminalService.foregroundInstances.includes(instance)) {
398
await this._terminalService.showBackgroundTerminal(instance, true);
399
this._logService.trace(`[SessionsTerminal] Moved terminal ${instance.instanceId} to foreground`);
400
}
401
}
402
}
403
}
404
405
registerWorkbenchContribution2(SessionsTerminalContribution.ID, SessionsTerminalContribution, WorkbenchPhase.AfterRestored);
406
407
class OpenSessionInTerminalAction extends Action2 {
408
409
constructor() {
410
super({
411
id: 'agentSession.openInTerminal',
412
title: localize2('openInTerminal', "Open Terminal"),
413
icon: Codicon.terminal,
414
toggled: {
415
condition: SessionsTerminalViewVisibleContext,
416
title: localize('hideTerminal', "Hide Terminal"),
417
},
418
menu: [{
419
id: Menus.TitleBarSessionMenu,
420
group: 'navigation',
421
order: 10,
422
when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()),
423
}]
424
});
425
}
426
427
override async run(_accessor: ServicesAccessor): Promise<void> {
428
const telemetryService = _accessor.get(ITelemetryService);
429
logSessionsInteraction(telemetryService, 'openTerminal');
430
431
const layoutService = _accessor.get(IWorkbenchLayoutService);
432
const viewsService = _accessor.get(IViewsService);
433
434
// Toggle: if panel is visible and the terminal view is active, hide it.
435
// If the panel is visible but showing another view, open the terminal instead.
436
if (layoutService.isVisible(Parts.PANEL_PART)) {
437
if (viewsService.isViewVisible(TERMINAL_VIEW_ID)) {
438
layoutService.setPartHidden(true, Parts.PANEL_PART);
439
return;
440
}
441
}
442
443
const contribution = getWorkbenchContribution<SessionsTerminalContribution>(SessionsTerminalContribution.ID);
444
const sessionsManagementService = _accessor.get(ISessionsManagementService);
445
const pathService = _accessor.get(IPathService);
446
447
const activeSession = sessionsManagementService.activeSession.get();
448
const info = getSessionTerminalInfo(activeSession);
449
const cwd = info?.cwd ?? await pathService.userHome();
450
await contribution.ensureTerminal(cwd, true, info?.agentHostCwd ? activeSession : undefined);
451
viewsService.openView(TERMINAL_VIEW_ID);
452
}
453
}
454
455
registerAction2(OpenSessionInTerminalAction);
456
457
class DumpTerminalTrackingAction extends Action2 {
458
459
constructor() {
460
super({
461
id: 'agentSession.dumpTerminalTracking',
462
title: localize2('dumpTerminalTracking', "Dump Terminal Tracking"),
463
f1: true,
464
});
465
}
466
467
override async run(): Promise<void> {
468
const contribution = getWorkbenchContribution<SessionsTerminalContribution>(SessionsTerminalContribution.ID);
469
await contribution.dumpTracking();
470
}
471
}
472
473
registerAction2(DumpTerminalTrackingAction);
474
475
class ShowAllTerminalsAction extends Action2 {
476
477
constructor() {
478
super({
479
id: 'agentSession.showAllTerminals',
480
title: localize2('showAllTerminals', "Show All Terminals"),
481
f1: true,
482
});
483
}
484
485
override async run(): Promise<void> {
486
const contribution = getWorkbenchContribution<SessionsTerminalContribution>(SessionsTerminalContribution.ID);
487
await contribution.showAllTerminals();
488
}
489
}
490
491
registerAction2(ShowAllTerminalsAction);
492
493