Skip to content

Commit f48d290

Browse files
committed
better sessions terminal tracking
1 parent c492347 commit f48d290

2 files changed

Lines changed: 176 additions & 8 deletions

File tree

src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { Codicon } from '../../../../base/common/codicons.js';
7+
import { isEqualOrParent } from '../../../../base/common/extpath.js';
78
import { Disposable } from '../../../../base/common/lifecycle.js';
89
import { autorun } from '../../../../base/common/observable.js';
910
import { URI } from '../../../../base/common/uri.js';
@@ -14,7 +15,7 @@ import { ILogService } from '../../../../platform/log/common/log.js';
1415
import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
1516
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
1617
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
17-
import { ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js';
18+
import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js';
1819
import { IPathService } from '../../../../workbench/services/path/common/pathService.js';
1920
import { Menus } from '../../../browser/menus.js';
2021
import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
@@ -83,16 +84,17 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben
8384
}
8485
}));
8586

86-
// When terminals are restored on startup, ensure visibility matches active session
87+
// When terminals are created externally, try to relate them to the active session
8788
this._register(this._terminalService.onDidCreateInstance(instance => {
8889
if (this._isCreatingTerminal || this._activeKey === undefined) {
8990
return;
9091
}
91-
// If this instance is not tracked by us, hide it
92+
// If this instance is already tracked by us, nothing to do
9293
const activeIds = this._pathToInstanceIds.get(this._activeKey);
93-
if (!activeIds?.has(instance.instanceId)) {
94-
this._terminalService.moveToBackground(instance);
94+
if (activeIds?.has(instance.instanceId)) {
95+
return;
9596
}
97+
this._tryAdoptTerminal(instance);
9698
}));
9799
}
98100

@@ -160,6 +162,58 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben
160162
ids.add(instanceId);
161163
}
162164

165+
/**
166+
* Attempts to associate an externally-created terminal with the active
167+
* session by checking whether its initial cwd falls within the active
168+
* session's worktree or repository. Hides the terminal if it cannot be
169+
* related.
170+
*/
171+
private async _tryAdoptTerminal(instance: ITerminalInstance): Promise<void> {
172+
let cwd: string | undefined;
173+
try {
174+
cwd = await instance.getInitialCwd();
175+
} catch {
176+
return;
177+
}
178+
179+
if (instance.isDisposed) {
180+
return;
181+
}
182+
183+
const activeKey = this._activeKey;
184+
if (!activeKey) {
185+
return;
186+
}
187+
188+
// Re-check tracking — the terminal may have been adopted while awaiting
189+
const activeIds = this._pathToInstanceIds.get(activeKey);
190+
if (activeIds?.has(instance.instanceId)) {
191+
return;
192+
}
193+
194+
const session = this._sessionsManagementService.activeSession.get();
195+
if (cwd && this._isRelatedToSession(cwd, session, activeKey)) {
196+
this._addInstanceToPath(activeKey, instance.instanceId);
197+
this._logService.trace(`[SessionsTerminal] Adopted terminal ${instance.instanceId} with cwd ${cwd}`);
198+
} else {
199+
this._terminalService.moveToBackground(instance);
200+
}
201+
}
202+
203+
/**
204+
* Returns whether the given cwd falls within the active session's
205+
* worktree, repository, or the current active key (home dir fallback).
206+
*/
207+
private _isRelatedToSession(cwd: string, session: IActiveSessionItem | undefined, activeKey: string): boolean {
208+
if (isEqualOrParent(cwd, activeKey, true)) {
209+
return true;
210+
}
211+
if (session?.providerType === AgentSessionProviders.Background && session.repository) {
212+
return isEqualOrParent(cwd, session.repository.fsPath, true);
213+
}
214+
return false;
215+
}
216+
163217
/**
164218
* Hides all foreground terminals that do not belong to the given active key
165219
* and shows all background terminals that do belong to it.
@@ -199,6 +253,32 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben
199253
this._pathToInstanceIds.delete(key);
200254
}
201255
}
256+
257+
async dumpTracking(): Promise<void> {
258+
const trackedInstanceIds = new Set<number>();
259+
260+
console.log('[SessionsTerminal] === Tracked Terminals ===');
261+
for (const [key, ids] of this._pathToInstanceIds) {
262+
for (const instanceId of ids) {
263+
trackedInstanceIds.add(instanceId);
264+
const instance = this._terminalService.getInstanceFromId(instanceId);
265+
let cwd = '<unknown>';
266+
if (instance) {
267+
try { cwd = await instance.getInitialCwd(); } catch { /* ignored */ }
268+
}
269+
console.log(` ${instanceId} - ${cwd} - ${key}`);
270+
}
271+
}
272+
273+
console.log('[SessionsTerminal] === Untracked Terminals ===');
274+
for (const instance of this._terminalService.instances) {
275+
if (!trackedInstanceIds.has(instance.instanceId)) {
276+
let cwd = '<unknown>';
277+
try { cwd = await instance.getInitialCwd(); } catch { /* ignored */ }
278+
console.log(` ${instance.instanceId} - ${cwd}`);
279+
}
280+
}
281+
}
202282
}
203283

204284
registerWorkbenchContribution2(SessionsTerminalContribution.ID, SessionsTerminalContribution, WorkbenchPhase.AfterRestored);
@@ -231,3 +311,21 @@ class OpenSessionInTerminalAction extends Action2 {
231311
}
232312

233313
registerAction2(OpenSessionInTerminalAction);
314+
315+
class DumpTerminalTrackingAction extends Action2 {
316+
317+
constructor() {
318+
super({
319+
id: 'agentSession.dumpTerminalTracking',
320+
title: localize2('dumpTerminalTracking', "Dump Terminal Tracking"),
321+
f1: true,
322+
});
323+
}
324+
325+
override async run(): Promise<void> {
326+
const contribution = getWorkbenchContribution<SessionsTerminalContribution>(SessionsTerminalContribution.ID);
327+
await contribution.dumpTracking();
328+
}
329+
}
330+
331+
registerAction2(DumpTerminalTrackingAction);

src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT
5151
} as IActiveSessionItem;
5252
}
5353

54+
function makeTerminalInstance(id: number, cwd: string): ITerminalInstance {
55+
return {
56+
instanceId: id,
57+
isDisposed: false,
58+
getInitialCwd: () => Promise.resolve(cwd),
59+
} as unknown as ITerminalInstance;
60+
}
61+
5462
suite('SessionsTerminalContribution', () => {
5563

5664
const store = new DisposableStore();
@@ -102,7 +110,9 @@ suite('SessionsTerminalContribution', () => {
102110
}
103111
override async createTerminal(opts?: any): Promise<ITerminalInstance> {
104112
const id = nextInstanceId++;
105-
const instance = { instanceId: id } as ITerminalInstance;
113+
const cwdUri: URI | undefined = opts?.config?.cwd;
114+
const cwdStr = cwdUri?.fsPath ?? '';
115+
const instance = makeTerminalInstance(id, cwdStr);
106116
createdTerminals.push({ cwd: opts?.config?.cwd });
107117
terminalInstances.set(id, instance);
108118
onDidCreateInstance.fire(instance);
@@ -436,19 +446,21 @@ suite('SessionsTerminalContribution', () => {
436446
await tick();
437447

438448
// Simulate a terminal being restored (e.g. on startup) that is not tracked
439-
const restoredInstance = { instanceId: nextInstanceId++ } as ITerminalInstance;
449+
const restoredInstance = makeTerminalInstance(nextInstanceId++, '/some/other/path');
440450
terminalInstances.set(restoredInstance.instanceId, restoredInstance);
441451
onDidCreateInstance.fire(restoredInstance);
452+
await tick();
442453

443454
// The restored terminal should be moved to background
444455
assert.ok(moveToBackgroundCalls.includes(restoredInstance.instanceId), 'restored terminal should be backgrounded');
445456
});
446457

447458
test('does not hide restored terminals before any session is active', async () => {
448459
// Simulate a terminal being restored before any session is active
449-
const restoredInstance = { instanceId: nextInstanceId++ } as ITerminalInstance;
460+
const restoredInstance = makeTerminalInstance(nextInstanceId++, '/some/path');
450461
terminalInstances.set(restoredInstance.instanceId, restoredInstance);
451462
onDidCreateInstance.fire(restoredInstance);
463+
await tick();
452464

453465
assert.strictEqual(moveToBackgroundCalls.length, 0, 'should not background before any session is active');
454466
});
@@ -467,6 +479,64 @@ suite('SessionsTerminalContribution', () => {
467479
assert.strictEqual(createdTerminals.length, 1, 'should not create a new terminal');
468480
assert.ok(showBackgroundCalls.includes(instanceId), 'should show the backgrounded terminal');
469481
});
482+
483+
// --- Terminal adoption ---
484+
485+
test('adopts externally-created terminal whose cwd matches the active worktree', async () => {
486+
const worktree = URI.file('/worktree');
487+
activeSessionObs.set(makeAgentSession({ worktree, providerType: AgentSessionProviders.Background }), undefined);
488+
await tick();
489+
490+
const externalInstance = makeTerminalInstance(nextInstanceId++, worktree.fsPath);
491+
terminalInstances.set(externalInstance.instanceId, externalInstance);
492+
onDidCreateInstance.fire(externalInstance);
493+
await tick();
494+
495+
assert.ok(!moveToBackgroundCalls.includes(externalInstance.instanceId), 'should not be hidden');
496+
// Verify it was adopted — ensureTerminal should reuse it
497+
await contribution.ensureTerminal(worktree, false);
498+
assert.strictEqual(createdTerminals.length, 1, 'should reuse adopted terminal, not create a second');
499+
});
500+
501+
test('adopts externally-created terminal whose cwd is a subdirectory of the active worktree', async () => {
502+
const worktree = URI.file('/worktree');
503+
activeSessionObs.set(makeAgentSession({ worktree, providerType: AgentSessionProviders.Background }), undefined);
504+
await tick();
505+
506+
const externalInstance = makeTerminalInstance(nextInstanceId++, URI.file('/worktree/subdir').fsPath);
507+
terminalInstances.set(externalInstance.instanceId, externalInstance);
508+
onDidCreateInstance.fire(externalInstance);
509+
await tick();
510+
511+
assert.ok(!moveToBackgroundCalls.includes(externalInstance.instanceId), 'subdirectory terminal should not be hidden');
512+
});
513+
514+
test('adopts externally-created terminal whose cwd matches the session repository', async () => {
515+
const worktree = URI.file('/worktree');
516+
const repo = URI.file('/repo');
517+
activeSessionObs.set(makeAgentSession({ worktree, repository: repo, providerType: AgentSessionProviders.Background }), undefined);
518+
await tick();
519+
520+
const externalInstance = makeTerminalInstance(nextInstanceId++, repo.fsPath);
521+
terminalInstances.set(externalInstance.instanceId, externalInstance);
522+
onDidCreateInstance.fire(externalInstance);
523+
await tick();
524+
525+
assert.ok(!moveToBackgroundCalls.includes(externalInstance.instanceId), 'terminal at repository path should not be hidden');
526+
});
527+
528+
test('hides externally-created terminal whose cwd does not match the active session', async () => {
529+
const worktree = URI.file('/worktree');
530+
activeSessionObs.set(makeAgentSession({ worktree, providerType: AgentSessionProviders.Background }), undefined);
531+
await tick();
532+
533+
const externalInstance = makeTerminalInstance(nextInstanceId++, '/unrelated/path');
534+
terminalInstances.set(externalInstance.instanceId, externalInstance);
535+
onDidCreateInstance.fire(externalInstance);
536+
await tick();
537+
538+
assert.ok(moveToBackgroundCalls.includes(externalInstance.instanceId), 'unrelated terminal should be hidden');
539+
});
470540
});
471541

472542
function tick(): Promise<void> {

0 commit comments

Comments
 (0)